2929from pysatl_core .distributions .registry .constraint import GraphPrimitiveConstraint
3030from pysatl_core .distributions .registry .graph_primitives import (
3131 DEFAULT_COMPUTATION_KEY ,
32+ AnalyticalLoopEdgeMeta ,
33+ ComputationEdgeMeta ,
3234 EdgeMeta ,
3335 GraphInvariantError ,
3436)
3537
3638if TYPE_CHECKING :
3739 from pysatl_core .distributions .computation import ComputationMethod
3840 from pysatl_core .distributions .distribution import Distribution
39- from pysatl_core .types import GenericCharacteristicName
41+ from pysatl_core .types import GenericCharacteristicName , LabelName
4042
4143
4244# --------------------------------------------------------------------------- #
@@ -89,7 +91,7 @@ def __init__(self) -> None:
8991 # Adjacency: src → dst → label → [EdgeMeta]
9092 self ._adj : dict [
9193 GenericCharacteristicName ,
92- dict [GenericCharacteristicName , dict [str , list [EdgeMeta ]]],
94+ dict [GenericCharacteristicName , dict [LabelName , list [EdgeMeta ]]],
9395 ] = {}
9496 self ._all_nodes : set [GenericCharacteristicName ] = set ()
9597
@@ -98,7 +100,7 @@ def __init__(self) -> None:
98100 self ._def_rules : dict [GenericCharacteristicName , GraphPrimitiveConstraint ] = {}
99101
100102 # Label preference for path finding
101- self .label_preference : tuple [str , ...] = (DEFAULT_COMPUTATION_KEY ,)
103+ self .label_preference : tuple [LabelName , ...] = (DEFAULT_COMPUTATION_KEY ,)
102104
103105 self ._initialized = True
104106
@@ -128,6 +130,11 @@ def _ensure_node(self, node: GenericCharacteristicName) -> bool:
128130 """Check if a node has been declared via add_characteristic()."""
129131 return node in self ._all_nodes
130132
133+ @property
134+ def declared_characteristics (self ) -> set [GenericCharacteristicName ]:
135+ """Return declared registry characteristics."""
136+ return set (self ._all_nodes )
137+
131138 def _add_presence_rule (
132139 self , name : GenericCharacteristicName , constraint : GraphPrimitiveConstraint | None
133140 ) -> None :
@@ -168,7 +175,7 @@ def add_computation(
168175 self ,
169176 method : ComputationMethod [Any , Any ],
170177 * ,
171- label : str = DEFAULT_COMPUTATION_KEY ,
178+ label : LabelName = DEFAULT_COMPUTATION_KEY ,
172179 constraint : GraphPrimitiveConstraint | None = None ,
173180 ) -> None :
174181 """
@@ -178,7 +185,7 @@ def add_computation(
178185 ----------
179186 method : ComputationMethod
180187 Computation object with exactly one source and one target.
181- label : str , default=DEFAULT_COMPUTATION_KEY
188+ label : LabelName , default=DEFAULT_COMPUTATION_KEY
182189 Variant label for the edge.
183190 constraint : GraphPrimitiveConstraint, optional
184191 Edge applicability constraint. If None, a pass-through constraint is used.
@@ -208,7 +215,7 @@ def add_computation(
208215 # constraints
209216 self ._adj [src ][dst ].setdefault (label , [])
210217 self ._adj [src ][dst ][label ].append (
211- EdgeMeta (
218+ ComputationEdgeMeta (
212219 method = method ,
213220 constraint = constraint or GraphPrimitiveConstraint (),
214221 )
@@ -292,6 +299,33 @@ def _compute_definitive_nodes(self, distr: Distribution) -> set[GenericCharacter
292299 definitive .add (name )
293300 return definitive
294301
302+ @staticmethod
303+ def _attach_analytical_loops (
304+ adj : dict [
305+ GenericCharacteristicName ,
306+ dict [GenericCharacteristicName , dict [LabelName , EdgeMeta ]],
307+ ],
308+ distr : Distribution ,
309+ present_nodes : set [GenericCharacteristicName ],
310+ ) -> None :
311+ """
312+ Attach analytical self-loops for distribution-provided computations.
313+
314+ Notes
315+ -----
316+ Analytical loops are only added for characteristics present in this view.
317+ Each labeled analytical computation becomes one loop edge ``char -> char``.
318+ """
319+ for characteristic_name , labeled_methods in distr .analytical_computations .items ():
320+ if characteristic_name not in present_nodes :
321+ continue
322+
323+ loop_variants = adj .setdefault (characteristic_name , {}).setdefault (
324+ characteristic_name , {}
325+ )
326+ for label_name , analytical_method in labeled_methods .items ():
327+ loop_variants [label_name ] = AnalyticalLoopEdgeMeta (method = analytical_method )
328+
295329 def view (self , distr : Distribution ) -> RegistryView :
296330 """
297331 Create a filtered view of the graph for the given distribution.
@@ -310,16 +344,17 @@ def view(self, distr: Distribution) -> RegistryView:
310344 -----
311345 1. Filters edges by their constraints
312346 2. Removes edges touching absent nodes
313- 3. Computes definitive nodes from the remaining present nodes
314- 4. Validates graph invariants
347+ 3. Adds analytical self-loops from distribution analytical computations
348+ 4. Computes definitive nodes from the remaining present nodes
349+ 5. Validates graph invariants
315350 """
316351 # 1) Filter edges by applicability
317352 adj : dict [
318- GenericCharacteristicName , dict [GenericCharacteristicName , dict [str , EdgeMeta ]]
353+ GenericCharacteristicName , dict [GenericCharacteristicName , dict [LabelName , EdgeMeta ]]
319354 ] = {}
320355 for src , d in self ._adj .items ():
321356 for dst , variants in d .items ():
322- kept : dict [str , EdgeMeta ] = {}
357+ kept : dict [LabelName , EdgeMeta ] = {}
323358 for label , metas in variants .items ():
324359 for edge in metas :
325360 if edge .constraint .allows (distr ):
@@ -343,7 +378,10 @@ def view(self, distr: Distribution) -> RegistryView:
343378 for node in present_nodes :
344379 adj .setdefault (node , {})
345380
346- # 3) Compute definitive nodes (must be present)
381+ # 3) Attach analytical loops
382+ self ._attach_analytical_loops (adj , distr , present_nodes )
383+
384+ # 4) Compute definitive nodes (must be present)
347385 definitive_nodes = self ._compute_definitive_nodes (distr ) & present_nodes
348386
349387 return RegistryView (adj , definitive_nodes , present_nodes )
@@ -387,14 +425,15 @@ def __init__(
387425 self ,
388426 adj : Mapping [
389427 GenericCharacteristicName ,
390- Mapping [GenericCharacteristicName , Mapping [str , EdgeMeta ]],
428+ Mapping [GenericCharacteristicName , Mapping [LabelName , EdgeMeta ]],
391429 ],
392430 definitive_nodes : set [GenericCharacteristicName ],
393431 present_nodes : set [GenericCharacteristicName ],
394432 ) -> None :
395433 # Deep copy adjacency to ensure immutability
396434 self ._adj : dict [
397- GenericCharacteristicName , dict [GenericCharacteristicName , dict [str , EdgeMeta ]]
435+ GenericCharacteristicName ,
436+ dict [GenericCharacteristicName , dict [LabelName , EdgeMeta ]],
398437 ] = {}
399438 for src , d in adj .items ():
400439 self ._adj [src ] = {dst : dict (variants ) for dst , variants in d .items ()}
@@ -419,7 +458,7 @@ def indefinitive_characteristics(self) -> set[GenericCharacteristicName]:
419458
420459 def successors (
421460 self , v : GenericCharacteristicName
422- ) -> Mapping [GenericCharacteristicName , Mapping [str , EdgeMeta ]]:
461+ ) -> Mapping [GenericCharacteristicName , Mapping [LabelName , EdgeMeta ]]:
423462 """
424463 Get outgoing edges from a characteristic.
425464
@@ -430,7 +469,7 @@ def successors(
430469
431470 Returns
432471 -------
433- Mapping[str, Mapping[str , EdgeMeta]]
472+ Mapping[str, Mapping[LabelName , EdgeMeta]]
434473 Destination → label → edge metadata.
435474 """
436475 return self ._adj .get (v , {})
@@ -473,7 +512,7 @@ def predecessors(self, v: GenericCharacteristicName) -> set[GenericCharacteristi
473512
474513 def variants (
475514 self , src : GenericCharacteristicName , dst : GenericCharacteristicName
476- ) -> Mapping [str , EdgeMeta ]:
515+ ) -> Mapping [LabelName , EdgeMeta ]:
477516 """
478517 Get all labeled edges between two characteristics.
479518
@@ -484,17 +523,35 @@ def variants(
484523
485524 Returns
486525 -------
487- Mapping[str , EdgeMeta]
526+ Mapping[LabelName , EdgeMeta]
488527 Label → edge metadata mapping.
489528 """
490529 return self ._adj .get (src , {}).get (dst , {})
491530
531+ def analytical_variants (self , state : GenericCharacteristicName ) -> Mapping [LabelName , EdgeMeta ]:
532+ """
533+ Get analytical self-loop variants for a characteristic.
534+
535+ Parameters
536+ ----------
537+ state : str
538+ Characteristic name.
539+
540+ Returns
541+ -------
542+ Mapping[LabelName, EdgeMeta]
543+ Label → analytical loop metadata for ``state -> state``.
544+ """
545+ return {
546+ label : edge for label , edge in self .variants (state , state ).items () if edge .is_analytical
547+ }
548+
492549 def find_path (
493550 self ,
494551 src : GenericCharacteristicName ,
495552 dst : GenericCharacteristicName ,
496553 * ,
497- prefer_label : str | None = None ,
554+ prefer_label : LabelName | None = None ,
498555 ) -> list [Any ] | None :
499556 """
500557 Find a computation path from src to dst using BFS.
@@ -503,12 +560,12 @@ def find_path(
503560 ----------
504561 src, dst : str
505562 Source and destination characteristics.
506- prefer_label : str , optional
563+ prefer_label : LabelName , optional
507564 Preferred edge label to use when multiple options exist.
508565
509566 Returns
510567 -------
511- list of ComputationMethod or None
568+ list of Any or None
512569 List of computation methods forming the path, or None if no path exists.
513570
514571 Notes
@@ -668,17 +725,17 @@ def _reachable_from(
668725
669726 @staticmethod
670727 def _pick_method (
671- variants : Mapping [str , EdgeMeta ],
672- prefer_label : str | None ,
728+ variants : Mapping [LabelName , EdgeMeta ],
729+ prefer_label : LabelName | None ,
673730 ) -> Any :
674731 """
675732 Select a method from label variants.
676733
677734 Parameters
678735 ----------
679- variants : Mapping[str , EdgeMeta]
736+ variants : Mapping[LabelName , EdgeMeta]
680737 Available edge variants.
681- prefer_label : str , optional
738+ prefer_label : LabelName , optional
682739 Preferred label.
683740
684741 Returns
0 commit comments