thundergraph-model 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. tg_model/__init__.py +46 -0
  2. tg_model/analysis/__init__.py +38 -0
  3. tg_model/analysis/_coherence.py +91 -0
  4. tg_model/analysis/compare_variants.py +197 -0
  5. tg_model/analysis/impact.py +101 -0
  6. tg_model/analysis/sweep.py +199 -0
  7. tg_model/execution/__init__.py +122 -0
  8. tg_model/execution/behavior.py +826 -0
  9. tg_model/execution/configured_model.py +754 -0
  10. tg_model/execution/connection_bindings.py +87 -0
  11. tg_model/execution/dependency_graph.py +217 -0
  12. tg_model/execution/evaluator.py +419 -0
  13. tg_model/execution/external_ops.py +153 -0
  14. tg_model/execution/graph_compiler.py +1110 -0
  15. tg_model/execution/instances.py +274 -0
  16. tg_model/execution/requirements.py +104 -0
  17. tg_model/execution/rollups.py +60 -0
  18. tg_model/execution/run_context.py +304 -0
  19. tg_model/execution/solve_groups.py +104 -0
  20. tg_model/execution/validation.py +211 -0
  21. tg_model/execution/value_slots.py +63 -0
  22. tg_model/export/__init__.py +7 -0
  23. tg_model/integrations/__init__.py +36 -0
  24. tg_model/integrations/external_compute.py +122 -0
  25. tg_model/model/__init__.py +39 -0
  26. tg_model/model/compile_types.py +896 -0
  27. tg_model/model/declarations/__init__.py +8 -0
  28. tg_model/model/declarations/behavior.py +37 -0
  29. tg_model/model/declarations/values.py +53 -0
  30. tg_model/model/definition_context.py +1742 -0
  31. tg_model/model/elements.py +159 -0
  32. tg_model/model/expr.py +75 -0
  33. tg_model/model/identity.py +63 -0
  34. tg_model/model/refs.py +319 -0
  35. thundergraph_model-1.0.0.dist-info/METADATA +82 -0
  36. thundergraph_model-1.0.0.dist-info/RECORD +38 -0
  37. thundergraph_model-1.0.0.dist-info/WHEEL +4 -0
  38. thundergraph_model-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1110 @@
1
+ """Compile a DependencyGraph from a ConfiguredModel's authored semantics.
2
+
3
+ Walks the configured topology, inspects authored expressions and constraints,
4
+ and builds the bipartite dependency graph automatically.
5
+
6
+ Edges are kept so compute nodes depend on **value** nodes (with ``slot_id``);
7
+ ``Evaluator._check_dependencies_ready`` only inspects those value dependencies.
8
+ The only public entry point is ``compile_graph()``. All other functions
9
+ are internal compilation helpers.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from unitflow import Quantity
18
+
19
+ from tg_model.execution.configured_model import ConfiguredModel
20
+ from tg_model.execution.connection_bindings import AllocationBinding
21
+ from tg_model.execution.dependency_graph import DependencyGraph, DependencyNode, NodeKind
22
+ from tg_model.execution.external_ops import (
23
+ ExternalOpsError,
24
+ materialize_external_result,
25
+ )
26
+ from tg_model.execution.external_ops import (
27
+ resolve_attribute_ref_to_slot as _resolve_attr_ref_core,
28
+ )
29
+ from tg_model.execution.instances import ElementInstance, PartInstance, RequirementPackageInstance
30
+ from tg_model.execution.value_slots import ValueSlot
31
+ from tg_model.integrations.external_compute import (
32
+ ExternalComputeBinding,
33
+ ExternalComputeResult,
34
+ assert_sync_external,
35
+ )
36
+ from tg_model.model.refs import AttributeRef
37
+
38
+
39
+ def _alloc_target_as_part(target: ElementInstance, *, where: str) -> PartInstance:
40
+ """Narrow allocation targets to :class:`PartInstance` with a single graph-level check."""
41
+ if not isinstance(target, PartInstance):
42
+ raise GraphCompilationError(f"{where}: allocation target must be PartInstance, got {type(target).__name__}")
43
+ return target
44
+
45
+
46
+ def _first_value_slot_under_requirement_package(
47
+ pkg: RequirementPackageInstance,
48
+ ) -> ValueSlot | None:
49
+ """First package :class:`ValueSlot` in stable name order (including nested packages)."""
50
+ for key in sorted(pkg._members.keys()):
51
+ m = pkg._members[key]
52
+ if isinstance(m, ValueSlot):
53
+ return m
54
+ if isinstance(m, RequirementPackageInstance):
55
+ inner = _first_value_slot_under_requirement_package(m)
56
+ if inner is not None:
57
+ return inner
58
+ return None
59
+
60
+
61
+ class GraphCompilationError(Exception):
62
+ """Raised when graph compilation cannot resolve symbols, slots, or bindings."""
63
+
64
+
65
+ def compile_graph(model: ConfiguredModel) -> tuple[DependencyGraph, dict[str, Callable]]:
66
+ """Compile dependency graph and per-node compute handlers from a configured model.
67
+
68
+ This is the **only** supported public entry point for graph compilation.
69
+
70
+ Parameters
71
+ ----------
72
+ model : ConfiguredModel
73
+ Frozen topology from :func:`~tg_model.execution.configured_model.instantiate`.
74
+
75
+ Returns
76
+ -------
77
+ graph : DependencyGraph
78
+ Bipartite value/compute graph in topological-evaluable form.
79
+ handlers : dict[str, Callable]
80
+ Sync callables keyed by compute ``node_id`` (expressions, roll-ups, externals, constraints).
81
+
82
+ Raises
83
+ ------
84
+ GraphCompilationError
85
+ On unresolvable references, binding errors, or other compile failures.
86
+
87
+ Notes
88
+ -----
89
+ Walks value slots, requirement acceptance, constraints, solve groups, and external nodes.
90
+ Async externals are still scheduled from sync :meth:`~tg_model.execution.evaluator.Evaluator.evaluate_async`.
91
+
92
+ Successful results are **cached** on ``model._compiled_graph`` so repeated calls and
93
+ :meth:`~tg_model.execution.configured_model.ConfiguredModel.evaluate` reuse the same
94
+ ``(graph, handlers)`` tuple without recompilation.
95
+ """
96
+ cached = getattr(model, "_compiled_graph", None)
97
+ if cached is not None:
98
+ return cached
99
+
100
+ graph = DependencyGraph()
101
+ handlers: dict[str, Callable] = {}
102
+
103
+ _compile_part(model.root, graph, handlers, model)
104
+ _compile_requirement_packages_from_parts(model.root, graph, handlers, model)
105
+ _compile_constraints_for_part(model.root, graph, handlers, model)
106
+ _compile_requirement_derived_slots(model, graph, handlers)
107
+ _compile_requirement_acceptance(model, graph, handlers)
108
+ _compile_solve_groups_for_part(model.root, graph, handlers, model)
109
+
110
+ model._compiled_graph = (graph, handlers)
111
+ return graph, handlers
112
+
113
+
114
+ def _compile_part(
115
+ part: PartInstance,
116
+ graph: DependencyGraph,
117
+ handlers: dict[str, Callable],
118
+ model: ConfiguredModel,
119
+ ) -> None:
120
+ for slot in part.value_slots:
121
+ _compile_slot(slot, part, graph, handlers, model)
122
+ _compile_external_for_part(part, graph, handlers, model)
123
+ for child in part.children:
124
+ _compile_part(child, graph, handlers, model)
125
+
126
+
127
+ def _compile_slot(
128
+ slot: ValueSlot,
129
+ owner: PartInstance,
130
+ graph: DependencyGraph,
131
+ handlers: dict[str, Callable],
132
+ model: ConfiguredModel,
133
+ ) -> None:
134
+ slot_node_id = f"val:{slot.path_string}"
135
+
136
+ if slot.is_parameter:
137
+ graph.add_node(
138
+ DependencyNode(
139
+ slot_node_id,
140
+ NodeKind.INPUT_PARAMETER,
141
+ slot_id=slot.stable_id,
142
+ )
143
+ )
144
+ return
145
+
146
+ expr = slot.metadata.get("_expr")
147
+
148
+ if expr is None:
149
+ graph.add_node(
150
+ DependencyNode(
151
+ slot_node_id,
152
+ NodeKind.ATTRIBUTE_VALUE,
153
+ slot_id=slot.stable_id,
154
+ )
155
+ )
156
+ return
157
+
158
+ from tg_model.model.declarations.values import RollupDecl
159
+
160
+ if isinstance(expr, RollupDecl):
161
+ _compile_rollup(slot, slot_node_id, expr, owner, graph, handlers)
162
+ return
163
+
164
+ graph.add_node(
165
+ DependencyNode(
166
+ slot_node_id,
167
+ NodeKind.ATTRIBUTE_VALUE,
168
+ slot_id=slot.stable_id,
169
+ )
170
+ )
171
+
172
+ expr_node_id = f"expr:{slot.path_string}"
173
+ graph.add_node(
174
+ DependencyNode(
175
+ expr_node_id,
176
+ NodeKind.LOCAL_EXPRESSION,
177
+ slot_id=slot.stable_id,
178
+ )
179
+ )
180
+ graph.add_edge(expr_node_id, slot_node_id)
181
+
182
+ if isinstance(expr, AttributeRef):
183
+ dep_slot = _resolve_attribute_ref_to_slot(expr, owner, model)
184
+ dep_node_id = f"val:{dep_slot.path_string}"
185
+ graph.add_edge(dep_node_id, expr_node_id)
186
+
187
+ def make_ref_passthrough_handler(dnid: str) -> Callable[..., Any]:
188
+ def handler(dep_values: dict[str, Any]) -> Any:
189
+ return dep_values[dnid]
190
+
191
+ return handler
192
+
193
+ handlers[expr_node_id] = make_ref_passthrough_handler(dep_node_id)
194
+ return
195
+
196
+ if hasattr(expr, "free_symbols") and expr.free_symbols:
197
+ for sym in expr.free_symbols:
198
+ dep_slot = _resolve_symbol_to_slot(sym, owner, model)
199
+ dep_node_id = f"val:{dep_slot.path_string}"
200
+ graph.add_edge(dep_node_id, expr_node_id)
201
+
202
+ def make_expr_handler(expression: Any, owner_part: PartInstance, cm: ConfiguredModel) -> Callable:
203
+ def handler(dep_values: dict[str, Any]) -> Any:
204
+ context = {}
205
+ for sym in expression.free_symbols:
206
+ dep_slot = _resolve_symbol_to_slot(sym, owner_part, cm)
207
+ dep_node_id = f"val:{dep_slot.path_string}"
208
+ if dep_node_id in dep_values:
209
+ context[sym] = dep_values[dep_node_id]
210
+ return expression.evaluate(context)
211
+
212
+ return handler
213
+
214
+ handlers[expr_node_id] = make_expr_handler(expr, owner, model)
215
+ elif hasattr(expr, "evaluate"):
216
+ handlers[expr_node_id] = lambda dep_values, e=expr: e.evaluate({})
217
+ elif callable(expr):
218
+ handlers[expr_node_id] = lambda dep_values, fn=expr: fn(dep_values)
219
+ else:
220
+ handlers[expr_node_id] = lambda dep_values, val=expr: val
221
+
222
+
223
+ def _resolve_attribute_ref_to_slot(
224
+ ref: Any,
225
+ owner: PartInstance,
226
+ model: ConfiguredModel,
227
+ ) -> ValueSlot:
228
+ try:
229
+ return _resolve_attr_ref_core(ref, owner, model)
230
+ except ExternalOpsError as e:
231
+ raise GraphCompilationError(str(e)) from e
232
+
233
+
234
+ def _compile_external_for_part(
235
+ part: PartInstance,
236
+ graph: DependencyGraph,
237
+ handlers: dict[str, Callable],
238
+ model: ConfiguredModel,
239
+ ) -> None:
240
+ groups: dict[int, list[ValueSlot]] = {}
241
+ for slot in part.value_slots:
242
+ cb = slot.metadata.get("_computed_by")
243
+ if cb is None:
244
+ continue
245
+ if not isinstance(cb, ExternalComputeBinding):
246
+ raise GraphCompilationError(
247
+ f"computed_by must be an ExternalComputeBinding at '{slot.path_string}', got {type(cb).__name__}"
248
+ )
249
+ if slot.metadata.get("_expr") is not None:
250
+ raise GraphCompilationError(f"Attribute '{slot.path_string}' cannot combine expr= with computed_by=")
251
+ groups.setdefault(id(cb), []).append(slot)
252
+
253
+ for slots in groups.values():
254
+ _build_external_compute_node(slots, part, graph, handlers, model)
255
+
256
+
257
+ def _build_external_compute_node(
258
+ slots: list[ValueSlot],
259
+ owner: PartInstance,
260
+ graph: DependencyGraph,
261
+ handlers: dict[str, Callable],
262
+ model: ConfiguredModel,
263
+ ) -> None:
264
+ binding: ExternalComputeBinding = slots[0].metadata["_computed_by"]
265
+ for s in slots[1:]:
266
+ if s.metadata.get("_computed_by") is not binding:
267
+ raise GraphCompilationError("External binding identity mismatch within compute group")
268
+
269
+ routes = binding.output_routes
270
+ slot_ids = {s.stable_id for s in slots}
271
+
272
+ if routes is None:
273
+ if len(slots) != 1:
274
+ raise GraphCompilationError(
275
+ "Single-slot ExternalComputeBinding (output_routes is None) requires exactly "
276
+ "one attribute with that binding on this part"
277
+ )
278
+ output_slot_ids = [slots[0].stable_id]
279
+ else:
280
+ resolved_by_key: dict[str, ValueSlot] = {}
281
+ for key, ref in routes.items():
282
+ resolved_by_key[key] = _resolve_attribute_ref_to_slot(ref, owner, model)
283
+ route_ids = {vs.stable_id for vs in resolved_by_key.values()}
284
+ if slot_ids != route_ids:
285
+ raise GraphCompilationError(
286
+ f"external output_routes target slots {route_ids!r} must match exactly "
287
+ f"attributes carrying the same computed_by ({slot_ids!r}) "
288
+ f"(part '{owner.path_string}')"
289
+ )
290
+ output_slot_ids = [resolved_by_key[k].stable_id for k in sorted(routes.keys())]
291
+
292
+ node_id = f"ext:{id(binding)}:{owner.path_string}"
293
+ if node_id in graph.nodes:
294
+ raise GraphCompilationError(f"Duplicate external compute node '{node_id}'")
295
+
296
+ input_name_to_dep: dict[str, str] = {}
297
+ for _iname, ref in binding.inputs.items():
298
+ in_slot = _resolve_attribute_ref_to_slot(ref, owner, model)
299
+ dep_node = f"val:{in_slot.path_string}"
300
+ input_name_to_dep[_iname] = dep_node
301
+
302
+ # Live binding reference: treat as frozen after compile_graph(); mutating it afterward is UB.
303
+ graph.add_node(
304
+ DependencyNode(
305
+ node_id,
306
+ NodeKind.EXTERNAL_COMPUTATION,
307
+ metadata={
308
+ "output_slot_ids": tuple(output_slot_ids),
309
+ "binding_id": id(binding),
310
+ "binding": binding,
311
+ "owner_path": tuple(owner.instance_path),
312
+ "input_name_to_dep": dict(input_name_to_dep),
313
+ },
314
+ )
315
+ )
316
+
317
+ for _dep in input_name_to_dep.values():
318
+ graph.add_edge(_dep, node_id)
319
+
320
+ for s in slots:
321
+ graph.add_edge(node_id, f"val:{s.path_string}")
322
+
323
+ handlers[node_id] = _make_external_handler(
324
+ binding=binding,
325
+ owner=owner,
326
+ model=model,
327
+ input_name_to_dep=input_name_to_dep,
328
+ slots=slots,
329
+ node_id=node_id,
330
+ )
331
+
332
+
333
+ def _make_external_handler(
334
+ *,
335
+ binding: ExternalComputeBinding,
336
+ owner: PartInstance,
337
+ model: ConfiguredModel,
338
+ input_name_to_dep: dict[str, str],
339
+ slots: list[ValueSlot],
340
+ node_id: str,
341
+ ) -> Callable[..., None]:
342
+ from tg_model.execution.evaluator import RunResult
343
+ from tg_model.execution.run_context import RunContext
344
+
345
+ def handler(dep_values: dict[str, Any], ctx: RunContext, run_result: RunResult) -> None:
346
+ try:
347
+ assert_sync_external(binding.external, context=node_id)
348
+ except TypeError as e:
349
+ msg = str(e)
350
+ for s in slots:
351
+ ctx.get_or_create_record(s.stable_id).block(msg)
352
+ run_result.failures.append(msg)
353
+ return
354
+
355
+ inputs_dict: dict[str, Quantity] = {}
356
+ try:
357
+ for name, dep_node_id in input_name_to_dep.items():
358
+ if dep_node_id not in dep_values:
359
+ raise KeyError(f"missing dependency {dep_node_id}")
360
+ inputs_dict[name] = dep_values[dep_node_id]
361
+ compute_fn = getattr(binding.external, "compute", None)
362
+ if compute_fn is None:
363
+ raise TypeError("external object has no compute()")
364
+ res = compute_fn(inputs_dict)
365
+ if not isinstance(res, ExternalComputeResult):
366
+ raise TypeError(f"External compute must return ExternalComputeResult, got {type(res).__name__}")
367
+ materialize_external_result(binding, res, owner, model, ctx, slots)
368
+ except Exception as e:
369
+ msg = str(e)
370
+ for s in slots:
371
+ ctx.get_or_create_record(s.stable_id).fail(msg)
372
+ run_result.failures.append(f"External compute '{node_id}' failed: {msg}")
373
+
374
+ return handler
375
+
376
+
377
+ def _compile_rollup(
378
+ slot: ValueSlot,
379
+ slot_node_id: str,
380
+ expr: Any,
381
+ owner: PartInstance,
382
+ graph: DependencyGraph,
383
+ handlers: dict[str, Callable],
384
+ ) -> None:
385
+ graph.add_node(
386
+ DependencyNode(
387
+ slot_node_id,
388
+ NodeKind.ATTRIBUTE_VALUE,
389
+ slot_id=slot.stable_id,
390
+ )
391
+ )
392
+
393
+ expr_node_id = f"rollup:{slot.path_string}"
394
+ graph.add_node(
395
+ DependencyNode(
396
+ expr_node_id,
397
+ NodeKind.ROLLUP_COMPUTATION,
398
+ slot_id=slot.stable_id,
399
+ )
400
+ )
401
+ graph.add_edge(expr_node_id, slot_node_id)
402
+
403
+ child_slots: list[str] = []
404
+ for child in owner.children:
405
+ try:
406
+ target_slot = expr.value_func(child)
407
+ if isinstance(target_slot, ValueSlot):
408
+ dep_node_id = f"val:{target_slot.path_string}"
409
+ graph.add_edge(dep_node_id, expr_node_id)
410
+ child_slots.append(dep_node_id)
411
+ except AttributeError:
412
+ pass
413
+
414
+ from tg_model.execution.rollups import build_rollup_handler
415
+
416
+ handlers[expr_node_id] = build_rollup_handler(expr.kind, expr.value_func, child_slots)
417
+
418
+
419
+ def _resolve_symbol_for_requirement_acceptance(
420
+ sym: Any,
421
+ allocate_target: PartInstance,
422
+ requirement_definition_type: type,
423
+ _model: ConfiguredModel,
424
+ alloc: AllocationBinding,
425
+ ) -> ValueSlot:
426
+ """Map a unitflow symbol to a :class:`ValueSlot` for requirement acceptance (Phase 7).
427
+
428
+ If :class:`~tg_model.execution.connection_bindings.AllocationBinding` carries
429
+ ``input_bindings`` (from ``allocate(..., inputs=…)``), symbols owned by the configured root
430
+ whose path matches ``<requirement instance path tail> + (input_name,)`` resolve to the bound
431
+ part slot.
432
+
433
+ Otherwise: symbols declared on the **same type as the allocate() call site** use paths from
434
+ that type's root (e.g. ``('motor', 'shaft_power')``); we strip the allocate target's path
435
+ under the configured root and resolve the remainder from ``allocate_target``.
436
+
437
+ Symbols declared on the **allocate target's part type** (e.g. attributes in ``Motor.define``)
438
+ use paths relative to that part (e.g. ``('shaft_power',)``) and resolve directly from
439
+ ``allocate_target``.
440
+
441
+ Requirement-local :meth:`~tg_model.model.definition_context.ModelDefinitionContext.requirement_attribute`
442
+ values are registered as :class:`~tg_model.execution.value_slots.ValueSlot` objects on the
443
+ configured root and resolved via ``path_registry`` when not covered by ``inputs=`` bindings.
444
+ """
445
+ from tg_model.model.refs import _symbol_id_to_path
446
+
447
+ result = _symbol_id_to_path.get(id(sym))
448
+ if result is not None:
449
+ sym_owner, tg_path = result
450
+ req_inst = alloc.requirement
451
+ if sym_owner is requirement_definition_type and isinstance(req_inst, ElementInstance):
452
+ rtail = tuple(req_inst.instance_path[1:])
453
+ if len(tg_path) == len(rtail) + 1 and tg_path[: len(rtail)] == rtail:
454
+ iname = tg_path[-1]
455
+ if alloc.input_bindings:
456
+ bound = alloc.input_bindings.get(iname)
457
+ if bound is not None:
458
+ return bound
459
+ full_key = ".".join((req_inst.instance_path[0], *tg_path))
460
+ hit = _model.path_registry.get(full_key)
461
+ if isinstance(hit, ValueSlot):
462
+ return hit
463
+ raise GraphCompilationError(
464
+ f"Requirement acceptance symbol {iname!r} at path {tg_path!r} has no "
465
+ f"matching entry in allocate(..., inputs=...) or requirement_attribute slot"
466
+ )
467
+ current: Any = allocate_target
468
+ if sym_owner is allocate_target.definition_type:
469
+ rel = tg_path
470
+ elif sym_owner is requirement_definition_type:
471
+ anchor = tuple(allocate_target.instance_path[1:])
472
+ if len(tg_path) < len(anchor) or tg_path[: len(anchor)] != anchor:
473
+ raise GraphCompilationError(
474
+ f"Symbol '{getattr(sym, 'name', '?')}' path {tg_path!r} is not under "
475
+ f"allocate target path {anchor!r} from configured root"
476
+ )
477
+ rel = tg_path[len(anchor) :]
478
+ else:
479
+ raise GraphCompilationError(
480
+ f"Symbol '{getattr(sym, 'name', '?')}' is owned by {sym_owner.__name__}; "
481
+ f"requirement acceptance allows symbols from {requirement_definition_type.__name__} "
482
+ f"or from allocate target type {allocate_target.definition_type.__name__} only."
483
+ )
484
+ try:
485
+ for segment in rel:
486
+ current = getattr(current, segment)
487
+ if isinstance(current, ValueSlot):
488
+ return current
489
+ except AttributeError:
490
+ pass
491
+ raise GraphCompilationError(
492
+ f"Symbol '{getattr(sym, 'name', '?')}' path {tg_path!r} "
493
+ f"could not be resolved under '{allocate_target.path_string}' for requirement acceptance"
494
+ )
495
+
496
+ raise GraphCompilationError(
497
+ f"Symbol '{getattr(sym, 'name', '?')}' is not a canonical AttributeRef-derived symbol. "
498
+ f"All expression symbols must originate from model.attribute() or model.parameter() refs."
499
+ )
500
+
501
+
502
+ def _resolve_symbol_to_slot(
503
+ sym: Any,
504
+ owner: PartInstance,
505
+ model: ConfiguredModel,
506
+ ) -> ValueSlot:
507
+ """Resolve a unitflow Symbol to its corresponding ValueSlot.
508
+
509
+ Uses the canonical symbol-id registry from AttributeRef.sym.
510
+ When the symbol belongs to a different type than ``owner`` (e.g. a root
511
+ system parameter referenced via :func:`~tg_model.model.definition_context.parameter_ref`),
512
+ resolution falls back to ``model.root``.
513
+
514
+ Fails loudly if the symbol cannot be resolved — silent misbinding
515
+ is not acceptable in a safety-critical context.
516
+ """
517
+ from tg_model.model.refs import _symbol_id_to_path
518
+
519
+ result = _symbol_id_to_path.get(id(sym))
520
+ if result is not None:
521
+ _owner_type, tg_path = result
522
+ current: Any = owner
523
+ try:
524
+ for segment in tg_path:
525
+ current = getattr(current, segment)
526
+ if isinstance(current, ValueSlot):
527
+ return current
528
+ except AttributeError:
529
+ pass
530
+ if _owner_type is not owner.definition_type and _owner_type is model.root.definition_type:
531
+ current = model.root
532
+ try:
533
+ for segment in tg_path:
534
+ current = getattr(current, segment)
535
+ if isinstance(current, ValueSlot):
536
+ return current
537
+ except AttributeError:
538
+ pass
539
+ raise GraphCompilationError(
540
+ f"Symbol '{getattr(sym, 'name', '?')}' has registered path {tg_path} "
541
+ f"but could not be resolved under '{owner.path_string}'"
542
+ )
543
+
544
+ raise GraphCompilationError(
545
+ f"Symbol '{getattr(sym, 'name', '?')}' is not a canonical AttributeRef-derived symbol. "
546
+ f"All expression symbols must originate from model.attribute() or model.parameter() refs."
547
+ )
548
+
549
+
550
+ def _resolve_sym_for_requirement_expr(
551
+ sym: Any,
552
+ allocate_target: PartInstance,
553
+ requirement_definition_type: type,
554
+ model: ConfiguredModel,
555
+ alloc: AllocationBinding,
556
+ ) -> ValueSlot:
557
+ """Resolve a symbol for :meth:`requirement_attribute` expressions (inputs, derived, root, part)."""
558
+ from tg_model.model.refs import _symbol_id_to_path
559
+
560
+ info = _symbol_id_to_path.get(id(sym))
561
+ if info is None:
562
+ raise GraphCompilationError(
563
+ f"Symbol '{getattr(sym, 'name', '?')}' is not a canonical AttributeRef-derived symbol."
564
+ )
565
+ sym_owner, tg_path = info
566
+ req_inst = alloc.requirement
567
+ if isinstance(req_inst, ElementInstance) and sym_owner is requirement_definition_type:
568
+ rtail = tuple(req_inst.instance_path[1:])
569
+ if len(tg_path) == len(rtail) + 1 and tg_path[: len(rtail)] == rtail:
570
+ return _resolve_symbol_for_requirement_acceptance(
571
+ sym,
572
+ allocate_target,
573
+ requirement_definition_type,
574
+ model,
575
+ alloc,
576
+ )
577
+ if sym_owner is model.root.definition_type:
578
+ return _resolve_symbol_to_slot(sym, model.root, model)
579
+ if sym_owner is allocate_target.definition_type:
580
+ return _resolve_symbol_to_slot(sym, allocate_target, model)
581
+ raise GraphCompilationError(
582
+ f"Symbol '{getattr(sym, 'name', '?')}' cannot be resolved for requirement_attribute "
583
+ f"(owner {sym_owner.__name__} is not root, allocate target, or requirement namespace)."
584
+ )
585
+
586
+
587
+ def _compile_requirement_attribute_slot(
588
+ slot: ValueSlot,
589
+ alloc: AllocationBinding,
590
+ graph: DependencyGraph,
591
+ handlers: dict[str, Callable],
592
+ model: ConfiguredModel,
593
+ ) -> None:
594
+ """Compile one derived requirement slot (same graph pattern as :func:`_compile_slot`)."""
595
+ target = alloc.target
596
+ if not isinstance(target, PartInstance):
597
+ raise GraphCompilationError(
598
+ f"requirement_attribute needs allocate target PartInstance; got {type(target).__name__}"
599
+ )
600
+ req = alloc.requirement
601
+ if not isinstance(req, ElementInstance) or req.kind != "requirement":
602
+ raise GraphCompilationError("requirement_attribute allocation must reference a requirement instance")
603
+ req_owner_type = req.definition_type
604
+
605
+ expr = slot.metadata.get("_expr")
606
+ if expr is None:
607
+ graph.add_node(
608
+ DependencyNode(
609
+ f"val:{slot.path_string}",
610
+ NodeKind.ATTRIBUTE_VALUE,
611
+ slot_id=slot.stable_id,
612
+ )
613
+ )
614
+ return
615
+
616
+ slot_node_id = f"val:{slot.path_string}"
617
+ graph.add_node(
618
+ DependencyNode(
619
+ slot_node_id,
620
+ NodeKind.ATTRIBUTE_VALUE,
621
+ slot_id=slot.stable_id,
622
+ )
623
+ )
624
+ expr_node_id = f"expr:{slot.path_string}"
625
+ graph.add_node(
626
+ DependencyNode(
627
+ expr_node_id,
628
+ NodeKind.LOCAL_EXPRESSION,
629
+ slot_id=slot.stable_id,
630
+ )
631
+ )
632
+ graph.add_edge(expr_node_id, slot_node_id)
633
+
634
+ if hasattr(expr, "free_symbols") and expr.free_symbols:
635
+ for sym in expr.free_symbols:
636
+ dep_slot = _resolve_sym_for_requirement_expr(sym, target, req_owner_type, model, alloc)
637
+ dep_node_id = f"val:{dep_slot.path_string}"
638
+ graph.add_edge(dep_node_id, expr_node_id)
639
+
640
+ def make_handler(expression: Any, a: AllocationBinding, cm: ConfiguredModel) -> Callable:
641
+ def handler(dep_values: dict[str, Any]) -> Any:
642
+ context: dict[Any, Any] = {}
643
+ tgt = _alloc_target_as_part(a.target, where="requirement_attribute expression")
644
+ for sym in expression.free_symbols:
645
+ dep_slot = _resolve_sym_for_requirement_expr(
646
+ sym,
647
+ tgt,
648
+ a.requirement.definition_type,
649
+ cm,
650
+ a,
651
+ )
652
+ dep_node_id = f"val:{dep_slot.path_string}"
653
+ if dep_node_id in dep_values:
654
+ context[sym] = dep_values[dep_node_id]
655
+ return expression.evaluate(context)
656
+
657
+ return handler
658
+
659
+ handlers[expr_node_id] = make_handler(expr, alloc, model)
660
+ elif hasattr(expr, "evaluate"):
661
+ handlers[expr_node_id] = lambda dep_values, e=expr: e.evaluate({})
662
+ elif callable(expr):
663
+ handlers[expr_node_id] = lambda dep_values, fn=expr: fn(dep_values)
664
+ else:
665
+ handlers[expr_node_id] = lambda dep_values, val=expr: val
666
+
667
+
668
+ def _compile_requirement_derived_slots(
669
+ model: ConfiguredModel,
670
+ graph: DependencyGraph,
671
+ handlers: dict[str, Callable],
672
+ ) -> None:
673
+ """Compile :meth:`requirement_attribute` value slots (before requirement acceptance checks)."""
674
+ from collections import defaultdict
675
+
676
+ if not model.requirement_value_slots:
677
+ return
678
+
679
+ by_req: dict[str, list[AllocationBinding]] = defaultdict(list)
680
+ for a in model.allocations:
681
+ r = a.requirement
682
+ if isinstance(r, ElementInstance) and r.kind == "requirement":
683
+ by_req[r.path_string].append(a)
684
+
685
+ grouped: dict[str, list[ValueSlot]] = defaultdict(list)
686
+ for slot in model.requirement_value_slots:
687
+ parent = ".".join(slot.instance_path[:-1])
688
+ grouped[parent].append(slot)
689
+
690
+ for req_path, slots in grouped.items():
691
+ allocs = by_req.get(req_path)
692
+ if not allocs:
693
+ raise GraphCompilationError(
694
+ f"requirement_attribute slot(s) under {req_path!r} require a matching allocate(...) edge"
695
+ )
696
+ if len(allocs) > 1:
697
+ raise GraphCompilationError(
698
+ f"requirement {req_path!r} has requirement_attribute-derived slots but multiple "
699
+ f"allocate(...) edges; use a single allocation for derived requirement attributes."
700
+ )
701
+ alloc = allocs[0]
702
+ req = alloc.requirement
703
+ if not isinstance(req, ElementInstance):
704
+ raise GraphCompilationError("requirement allocation invalid for requirement_attribute compile")
705
+ order = list(req.metadata.get("_requirement_attribute_names") or [])
706
+ rank = {n: i for i, n in enumerate(order)}
707
+ slots_sorted = sorted(slots, key=lambda s: rank.get(s.instance_path[-1], 10_000))
708
+ for slot in slots_sorted:
709
+ _compile_requirement_attribute_slot(slot, alloc, graph, handlers, model)
710
+
711
+
712
+ def _compile_requirement_packages_from_parts(
713
+ part: PartInstance,
714
+ graph: DependencyGraph,
715
+ handlers: dict[str, Callable],
716
+ model: ConfiguredModel,
717
+ ) -> None:
718
+ """Compile value slots and constraints declared on composable requirement packages."""
719
+ compiled = part.definition_type.compile()
720
+ tr = compiled.get("_type_registry", {})
721
+ for name, node in compiled.get("nodes", {}).items():
722
+ if node.get("kind") != "requirement_block" or tr.get(name) is None:
723
+ continue
724
+ sub = getattr(part, name, None)
725
+ if isinstance(sub, RequirementPackageInstance):
726
+ _compile_requirement_package_tree(sub, graph, handlers, model)
727
+ for child in part.children:
728
+ _compile_requirement_packages_from_parts(child, graph, handlers, model)
729
+
730
+
731
+ def _compile_requirement_package_tree(
732
+ pkg: RequirementPackageInstance,
733
+ graph: DependencyGraph,
734
+ handlers: dict[str, Callable],
735
+ model: ConfiguredModel,
736
+ ) -> None:
737
+ from tg_model.model.declarations.values import RollupDecl
738
+
739
+ compiled = pkg.package_type.compile()
740
+ # Declaration order matches Python 3.7+ dict insertion order (same as compile_type recording).
741
+ for name, node in compiled["nodes"].items():
742
+ kind = node["kind"]
743
+ if kind in ("parameter", "attribute"):
744
+ slot = getattr(pkg, name)
745
+ if not isinstance(slot, ValueSlot):
746
+ raise GraphCompilationError(
747
+ f"Expected ValueSlot for {pkg.path_string}.{name}, got {type(slot).__name__}"
748
+ )
749
+ if slot.metadata.get("_computed_by") is not None:
750
+ raise GraphCompilationError(
751
+ f"computed_by= is not supported on requirement package slot '{slot.path_string}'"
752
+ )
753
+ expr_m = slot.metadata.get("_expr")
754
+ if isinstance(expr_m, RollupDecl):
755
+ raise GraphCompilationError(
756
+ f"RollupDecl is not supported on requirement package slot '{slot.path_string}'"
757
+ )
758
+ _compile_slot(slot, model.root, graph, handlers, model)
759
+ elif kind == "constraint":
760
+ _compile_requirement_package_constraint(pkg, name, node, graph, handlers, model)
761
+ elif kind == "requirement_block":
762
+ inner = getattr(pkg, name, None)
763
+ if isinstance(inner, RequirementPackageInstance):
764
+ _compile_requirement_package_tree(inner, graph, handlers, model)
765
+
766
+
767
+ def _compile_requirement_package_constraint(
768
+ pkg: RequirementPackageInstance,
769
+ name: str,
770
+ node: dict[str, Any],
771
+ graph: DependencyGraph,
772
+ handlers: dict[str, Callable],
773
+ model: ConfiguredModel,
774
+ ) -> None:
775
+ constraint_node_id = f"check:{pkg.path_string}.{name}"
776
+ graph.add_node(
777
+ DependencyNode(
778
+ constraint_node_id,
779
+ NodeKind.CONSTRAINT_CHECK,
780
+ metadata={"name": f"{pkg.path_string}.{name}"},
781
+ )
782
+ )
783
+ expr = node.get("metadata", {}).get("_expr")
784
+ if expr is None:
785
+ raise GraphCompilationError(
786
+ f"Requirement package constraint '{pkg.path_string}.{name}' has no expr "
787
+ f"(expected compile-time validation to reject this)."
788
+ )
789
+
790
+ def make_pkg_constraint_handler(
791
+ constraint_expr: Any,
792
+ cm: ConfiguredModel,
793
+ ) -> Callable[..., bool]:
794
+ def handler(dep_values: dict[str, Any]) -> bool:
795
+ context: dict[Any, Any] = {}
796
+ for sym in constraint_expr.free_symbols:
797
+ dep_slot = _resolve_symbol_to_slot(sym, cm.root, cm)
798
+ dep_node_id = f"val:{dep_slot.path_string}"
799
+ if dep_node_id in dep_values:
800
+ context[sym] = dep_values[dep_node_id]
801
+ return constraint_expr.evaluate(context)
802
+
803
+ return handler
804
+
805
+ if hasattr(expr, "free_symbols") and expr.free_symbols:
806
+ for sym in expr.free_symbols:
807
+ dep_slot = _resolve_symbol_to_slot(sym, model.root, model)
808
+ dep_node_id = f"val:{dep_slot.path_string}"
809
+ graph.add_edge(dep_node_id, constraint_node_id)
810
+ handlers[constraint_node_id] = make_pkg_constraint_handler(expr, model)
811
+ elif hasattr(expr, "evaluate"):
812
+ handlers[constraint_node_id] = lambda dep_values, e=expr: bool(e.evaluate({}))
813
+ elif callable(expr):
814
+ handlers[constraint_node_id] = lambda dep_values, fn=expr: bool(fn(dep_values))
815
+ else:
816
+ handlers[constraint_node_id] = lambda dep_values, val=expr: bool(val)
817
+
818
+ # Constant / symbol-free constraints need at least one incoming edge so validation does not
819
+ # treat the check node as orphaned, and so evaluation runs after package inputs exist.
820
+ if not graph.dependencies_of(constraint_node_id):
821
+ anchor = _first_value_slot_under_requirement_package(pkg)
822
+ if anchor is not None:
823
+ graph.add_edge(f"val:{anchor.path_string}", constraint_node_id)
824
+
825
+
826
+ def _compile_constraints_for_part(
827
+ part: PartInstance,
828
+ graph: DependencyGraph,
829
+ handlers: dict[str, Callable],
830
+ model: ConfiguredModel,
831
+ ) -> None:
832
+ compiled = part.definition_type.compile()
833
+
834
+ for name, node in compiled.get("nodes", {}).items():
835
+ if node["kind"] != "constraint":
836
+ continue
837
+
838
+ constraint_node_id = f"check:{part.path_string}.{name}"
839
+ graph.add_node(
840
+ DependencyNode(
841
+ constraint_node_id,
842
+ NodeKind.CONSTRAINT_CHECK,
843
+ metadata={"name": f"{part.path_string}.{name}"},
844
+ )
845
+ )
846
+
847
+ expr = node["metadata"].get("_expr")
848
+ if expr is not None and hasattr(expr, "free_symbols"):
849
+ for sym in expr.free_symbols:
850
+ dep_slot = _resolve_symbol_to_slot(sym, part, model)
851
+ dep_node_id = f"val:{dep_slot.path_string}"
852
+ graph.add_edge(dep_node_id, constraint_node_id)
853
+
854
+ def make_constraint_handler(
855
+ constraint_expr: Any,
856
+ owner_part: PartInstance,
857
+ cm: ConfiguredModel,
858
+ ) -> Callable[..., bool]:
859
+ def handler(dep_values: dict[str, Any]) -> bool:
860
+ context = {}
861
+ for sym in constraint_expr.free_symbols:
862
+ dep_slot = _resolve_symbol_to_slot(sym, owner_part, cm)
863
+ dep_node_id = f"val:{dep_slot.path_string}"
864
+ if dep_node_id in dep_values:
865
+ context[sym] = dep_values[dep_node_id]
866
+ return constraint_expr.evaluate(context)
867
+
868
+ return handler
869
+
870
+ handlers[constraint_node_id] = make_constraint_handler(expr, part, model)
871
+
872
+ for child in part.children:
873
+ _compile_constraints_for_part(child, graph, handlers, model)
874
+
875
+
876
+ def _compile_requirement_acceptance(
877
+ model: ConfiguredModel,
878
+ graph: DependencyGraph,
879
+ handlers: dict[str, Callable],
880
+ ) -> None:
881
+ """Add constraint-check nodes for requirements with ``_accept_expr`` per allocation (Phase 7).
882
+
883
+ Uses :func:`_resolve_symbol_for_requirement_acceptance` (not :func:`_resolve_symbol_to_slot`):
884
+ symbols may be authored on the requirement owner type (system path prefix stripped to the
885
+ allocate target) or on the allocate target's part type. Same ``Evaluator`` / graph kinds as
886
+ part constraints; resolution rules differ.
887
+ """
888
+ for alloc in model.allocations:
889
+ req = alloc.requirement
890
+ if not isinstance(req, ElementInstance) or req.kind != "requirement":
891
+ continue
892
+ expr = req.metadata.get("_accept_expr")
893
+ if expr is None:
894
+ continue
895
+ target = alloc.target
896
+ if not isinstance(target, PartInstance):
897
+ raise GraphCompilationError(
898
+ f"Requirement acceptance for '{req.path_string}' needs allocate target to be a "
899
+ f"PartInstance; got {type(target).__name__} at '{target.path_string}'"
900
+ )
901
+ req_def_type = req.definition_type
902
+ node_id = f"reqcheck:{req.path_string}@{alloc.stable_id}"
903
+ graph.add_node(
904
+ DependencyNode(
905
+ node_id,
906
+ NodeKind.CONSTRAINT_CHECK,
907
+ metadata={
908
+ "name": node_id,
909
+ "requirement_path": req.path_string,
910
+ "allocation_target_path": target.path_string,
911
+ "check_kind": "requirement_acceptance",
912
+ },
913
+ )
914
+ )
915
+ if not hasattr(expr, "free_symbols"):
916
+ raise GraphCompilationError(
917
+ f"Requirement '{req.path_string}' acceptance expr has no free_symbols; "
918
+ "use the same expression types as model.constraint(expr=...)."
919
+ )
920
+ for sym in expr.free_symbols:
921
+ dep_slot = _resolve_symbol_for_requirement_acceptance(
922
+ sym,
923
+ target,
924
+ req_def_type,
925
+ model,
926
+ alloc,
927
+ )
928
+ dep_node_id = f"val:{dep_slot.path_string}"
929
+ graph.add_edge(dep_node_id, node_id)
930
+ if not expr.free_symbols:
931
+ anchor = _first_value_graph_node_id_under_part(target)
932
+ if anchor is None:
933
+ raise GraphCompilationError(
934
+ f"Requirement '{req.path_string}' has a constant acceptance expr but allocate "
935
+ f"target '{target.path_string}' has no value slots to order evaluation after."
936
+ )
937
+ graph.add_edge(anchor, node_id)
938
+
939
+ def make_req_accept_handler(
940
+ constraint_expr: Any,
941
+ owner_part: PartInstance,
942
+ cm: ConfiguredModel,
943
+ req_owner_type: type,
944
+ binding: AllocationBinding,
945
+ ) -> Callable[..., bool]:
946
+ def handler(dep_values: dict[str, Any]) -> bool:
947
+ context: dict[Any, Any] = {}
948
+ for sym in constraint_expr.free_symbols:
949
+ dep_slot = _resolve_symbol_for_requirement_acceptance(
950
+ sym,
951
+ owner_part,
952
+ req_owner_type,
953
+ cm,
954
+ binding,
955
+ )
956
+ dep_node_id = f"val:{dep_slot.path_string}"
957
+ if dep_node_id in dep_values:
958
+ context[sym] = dep_values[dep_node_id]
959
+ return constraint_expr.evaluate(context)
960
+
961
+ return handler
962
+
963
+ handlers[node_id] = make_req_accept_handler(expr, target, model, req_def_type, alloc)
964
+
965
+
966
+ def _first_value_graph_node_id_under_part(part: PartInstance) -> str | None:
967
+ """First ``val:...`` node id in a deterministic DFS under ``part`` (for ordering-only edges)."""
968
+ for slot in part.value_slots:
969
+ return f"val:{slot.path_string}"
970
+ for child in part.children:
971
+ found = _first_value_graph_node_id_under_part(child)
972
+ if found is not None:
973
+ return found
974
+ return None
975
+
976
+
977
+ def _resolve_path_to_slot(
978
+ path: list[str],
979
+ part: PartInstance,
980
+ group_name: str,
981
+ ) -> ValueSlot:
982
+ """Resolve a declaration path to its corresponding ValueSlot under a part."""
983
+ current: Any = part
984
+ try:
985
+ for seg in path:
986
+ current = getattr(current, seg)
987
+ if isinstance(current, ValueSlot):
988
+ return current
989
+ except AttributeError:
990
+ pass
991
+ raise GraphCompilationError(
992
+ f"Solve group '{group_name}': path {list(path)} could not be resolved to a ValueSlot under '{part.path_string}'"
993
+ )
994
+
995
+
996
+ def _compile_solve_groups_for_part(
997
+ part: PartInstance,
998
+ graph: DependencyGraph,
999
+ handlers: dict[str, Callable],
1000
+ model: ConfiguredModel,
1001
+ ) -> None:
1002
+ compiled = part.definition_type.compile()
1003
+
1004
+ for name, node in compiled.get("nodes", {}).items():
1005
+ if node["kind"] != "solve_group":
1006
+ continue
1007
+
1008
+ sg_node_id = f"solve:{part.path_string}.{name}"
1009
+ meta = node.get("metadata", {})
1010
+
1011
+ unknown_paths = meta.get("_unknowns", [])
1012
+ given_paths = meta.get("_givens", [])
1013
+ equations = meta.get("_equations", [])
1014
+
1015
+ unknown_slot_ids: set[str] = set()
1016
+ unknown_slot_by_path: dict[tuple[str, ...], str] = {}
1017
+ for upath in unknown_paths:
1018
+ slot = _resolve_path_to_slot(upath, part, name)
1019
+ if slot.stable_id in unknown_slot_ids:
1020
+ raise GraphCompilationError(f"Solve group '{name}': duplicate unknown '{'.'.join(upath)}'")
1021
+ unknown_slot_ids.add(slot.stable_id)
1022
+ unknown_slot_by_path[tuple(upath)] = slot.stable_id
1023
+
1024
+ given_slot_ids: set[str] = set()
1025
+ for gpath in given_paths:
1026
+ slot = _resolve_path_to_slot(gpath, part, name)
1027
+ if slot.stable_id in given_slot_ids:
1028
+ raise GraphCompilationError(f"Solve group '{name}': duplicate given '{'.'.join(gpath)}'")
1029
+ if slot.stable_id in unknown_slot_ids:
1030
+ raise GraphCompilationError(
1031
+ f"Solve group '{name}': '{'.'.join(gpath)}' declared as both unknown and given"
1032
+ )
1033
+ given_slot_ids.add(slot.stable_id)
1034
+
1035
+ target_slots = {sid: sid for sid in unknown_slot_ids}
1036
+ graph.add_node(
1037
+ DependencyNode(
1038
+ sg_node_id,
1039
+ NodeKind.SOLVE_GROUP,
1040
+ metadata={"name": name, "target_slots": target_slots},
1041
+ )
1042
+ )
1043
+
1044
+ unknown_syms: list[Any] = []
1045
+ given_syms: list[Any] = []
1046
+ given_to_node_id: dict[Any, str] = {}
1047
+ found_unknown_ids: set[str] = set()
1048
+ found_given_ids: set[str] = set()
1049
+
1050
+ for eq in equations:
1051
+ if not hasattr(eq, "free_symbols"):
1052
+ continue
1053
+ for sym in eq.free_symbols:
1054
+ if any(s is sym for s in unknown_syms) or any(s is sym for s in given_syms):
1055
+ continue
1056
+ slot = _resolve_symbol_to_slot(sym, part, model)
1057
+ dep_node_id = f"val:{slot.path_string}"
1058
+
1059
+ if slot.stable_id in unknown_slot_ids:
1060
+ unknown_syms.append(sym)
1061
+ found_unknown_ids.add(slot.stable_id)
1062
+ graph.add_edge(sg_node_id, dep_node_id)
1063
+ elif slot.stable_id in given_slot_ids:
1064
+ given_syms.append(sym)
1065
+ found_given_ids.add(slot.stable_id)
1066
+ given_to_node_id[sym] = dep_node_id
1067
+ graph.add_edge(dep_node_id, sg_node_id)
1068
+ else:
1069
+ raise GraphCompilationError(
1070
+ f"Solve group '{name}': symbol '{getattr(sym, 'name', '?')}' "
1071
+ f"resolves to slot '{slot.path_string}' which is not declared "
1072
+ f"as unknown or given."
1073
+ )
1074
+
1075
+ missing_unknowns = unknown_slot_ids - found_unknown_ids
1076
+ if missing_unknowns:
1077
+ raise GraphCompilationError(
1078
+ f"Solve group '{name}': declared unknowns not found in any equation. "
1079
+ f"Missing slot IDs: {missing_unknowns}"
1080
+ )
1081
+
1082
+ missing_givens = given_slot_ids - found_given_ids
1083
+ if missing_givens:
1084
+ raise GraphCompilationError(
1085
+ f"Solve group '{name}': declared givens not found in any equation. Missing slot IDs: {missing_givens}"
1086
+ )
1087
+
1088
+ sym_to_slot_id: dict[int, str] = {}
1089
+ for sym in unknown_syms:
1090
+ from tg_model.model.refs import _symbol_id_to_path
1091
+
1092
+ result = _symbol_id_to_path.get(id(sym))
1093
+ if result is not None:
1094
+ _, sym_path = result
1095
+ slot_id = unknown_slot_by_path.get(tuple(sym_path))
1096
+ if slot_id is not None:
1097
+ sym_to_slot_id[id(sym)] = slot_id
1098
+
1099
+ from tg_model.execution.solve_groups import build_solve_group_handler
1100
+
1101
+ handlers[sg_node_id] = build_solve_group_handler(
1102
+ equations,
1103
+ unknown_syms,
1104
+ given_syms,
1105
+ given_to_node_id,
1106
+ sym_to_slot_id,
1107
+ )
1108
+
1109
+ for child in part.children:
1110
+ _compile_solve_groups_for_part(child, graph, handlers, model)