Skip to content

Commit 93df880

Browse files
committed
feat(registry): add looped edges for graph view for analytical computations
1 parent bee93fa commit 93df880

5 files changed

Lines changed: 245 additions & 52 deletions

File tree

src/pysatl_core/distributions/registry/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
)
2626
from .graph_primitives import (
2727
DEFAULT_COMPUTATION_KEY,
28+
AnalyticalLoopEdgeMeta,
29+
ComputationEdgeMeta,
2830
EdgeMeta,
2931
GraphInvariantError,
3032
)
@@ -33,6 +35,8 @@
3335
# Graph primitives and constants
3436
"DEFAULT_COMPUTATION_KEY",
3537
"EdgeMeta",
38+
"ComputationEdgeMeta",
39+
"AnalyticalLoopEdgeMeta",
3640
"GraphInvariantError",
3741
# Constraint types
3842
"Constraint",

src/pysatl_core/distributions/registry/graph.py

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@
2929
from pysatl_core.distributions.registry.constraint import GraphPrimitiveConstraint
3030
from pysatl_core.distributions.registry.graph_primitives import (
3131
DEFAULT_COMPUTATION_KEY,
32+
AnalyticalLoopEdgeMeta,
33+
ComputationEdgeMeta,
3234
EdgeMeta,
3335
GraphInvariantError,
3436
)
3537

3638
if 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

src/pysatl_core/distributions/registry/graph_primitives.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,73 @@
88
__copyright__ = "Copyright (c) 2025 PySATL project"
99
__license__ = "SPDX-License-Identifier: MIT"
1010

11+
from abc import ABC, abstractmethod
1112
from dataclasses import dataclass, field
12-
from typing import TYPE_CHECKING
13+
from typing import Any
1314

15+
from pysatl_core.distributions.computation import (
16+
AnalyticalComputation,
17+
ComputationMethod,
18+
)
1419
from pysatl_core.distributions.registry.constraint import GraphPrimitiveConstraint
20+
from pysatl_core.types import LabelName
1521

16-
if TYPE_CHECKING:
17-
from typing import Any
22+
type EdgeMethod = ComputationMethod[Any, Any] | AnalyticalComputation[Any, Any]
1823

19-
from pysatl_core.distributions.computation import ComputationMethod
20-
21-
DEFAULT_COMPUTATION_KEY: str = "PySATL_default_computation"
24+
DEFAULT_COMPUTATION_KEY: LabelName = "PySATL_default_computation"
2225
"""Default label for computation edges when no specific label is provided."""
2326

2427

2528
@dataclass(frozen=True, slots=True)
26-
class EdgeMeta:
29+
class EdgeMeta(ABC):
2730
"""
2831
Metadata for a computation edge in the characteristic graph.
2932
3033
Parameters
3134
----------
32-
method : ComputationMethod
35+
method : EdgeMethod
3336
The computation method that defines the edge.
3437
constraint : GraphPrimitiveConstraint
3538
Constraint determining when this edge is applicable to a distribution.
3639
Defaults to a pass-through constraint that always allows.
40+
is_analytical : bool
41+
Whether this edge represents an analytical computation.
3742
"""
3843

39-
method: ComputationMethod[Any, Any]
44+
method: EdgeMethod
4045
constraint: GraphPrimitiveConstraint = field(default_factory=GraphPrimitiveConstraint)
46+
is_analytical: bool = field(default=False)
47+
48+
@abstractmethod
49+
def edge_kind(self) -> str:
50+
"""Return edge kind identifier."""
51+
...
52+
53+
54+
@dataclass(frozen=True, slots=True)
55+
class ComputationEdgeMeta(EdgeMeta):
56+
"""
57+
Edge metadata for conversion computations from the registry graph.
58+
"""
59+
60+
method: ComputationMethod[Any, Any]
61+
is_analytical: bool = field(default=False)
62+
63+
def edge_kind(self) -> str:
64+
return "computation"
65+
66+
67+
@dataclass(frozen=True, slots=True)
68+
class AnalyticalLoopEdgeMeta(EdgeMeta):
69+
"""
70+
Edge metadata for self-loop analytical computations from a distribution.
71+
"""
72+
73+
method: AnalyticalComputation[Any, Any]
74+
is_analytical: bool = field(default=True)
75+
76+
def edge_kind(self) -> str:
77+
return "analytical_loop"
4178

4279

4380
class GraphInvariantError(RuntimeError):

0 commit comments

Comments
 (0)