relationalai 0.12.4__py3-none-any.whl → 0.12.6__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 (101) hide show
  1. relationalai/__init__.py +4 -0
  2. relationalai/clients/snowflake.py +23 -11
  3. relationalai/{semantics/reasoners/graph → experimental}/paths/README.md +2 -2
  4. relationalai/experimental/paths/__init__.py +14 -309
  5. relationalai/{semantics/reasoners/graph → experimental}/paths/examples/basic_example.py +2 -2
  6. relationalai/{semantics/reasoners/graph → experimental}/paths/examples/paths_benchmark.py +2 -2
  7. relationalai/{semantics/reasoners/graph → experimental}/paths/examples/paths_example.py +2 -2
  8. relationalai/{semantics/reasoners/graph → experimental}/paths/examples/pattern_to_automaton.py +1 -1
  9. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/one_sided_ball_repetition.py +1 -1
  10. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/single.py +3 -3
  11. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/two_sided_balls_repetition.py +1 -1
  12. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/two_sided_balls_upto.py +2 -2
  13. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/usp-old.py +3 -3
  14. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/usp-tuple.py +3 -3
  15. relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/usp.py +3 -3
  16. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_limit_sp_max_length.py +2 -2
  17. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_limit_sp_multiple.py +2 -2
  18. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_limit_sp_single.py +2 -2
  19. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_limit_walks_multiple.py +2 -2
  20. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_limit_walks_single.py +2 -2
  21. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_one_sided_ball_repetition_multiple.py +2 -2
  22. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_one_sided_ball_repetition_single.py +2 -2
  23. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_one_sided_ball_upto_multiple.py +2 -2
  24. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_one_sided_ball_upto_single.py +2 -2
  25. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_single_paths.py +2 -2
  26. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_single_walks.py +2 -2
  27. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_single_walks_undirected.py +2 -2
  28. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_two_sided_balls_repetition_multiple.py +2 -2
  29. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_two_sided_balls_repetition_single.py +2 -2
  30. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_two_sided_balls_upto_multiple.py +2 -2
  31. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_two_sided_balls_upto_single.py +2 -2
  32. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_usp_nsp_multiple.py +2 -2
  33. relationalai/{semantics/reasoners/graph → experimental}/paths/tests/tests_usp_nsp_single.py +2 -2
  34. relationalai/semantics/__init__.py +4 -0
  35. relationalai/semantics/internal/annotations.py +1 -0
  36. relationalai/semantics/internal/internal.py +2 -0
  37. relationalai/semantics/lqp/builtins.py +1 -0
  38. relationalai/semantics/lqp/model2lqp.py +96 -3
  39. relationalai/semantics/lqp/primitives.py +3 -0
  40. relationalai/semantics/metamodel/builtins.py +50 -1
  41. relationalai/semantics/metamodel/typer/typer.py +3 -0
  42. relationalai/semantics/reasoners/__init__.py +4 -0
  43. relationalai/semantics/reasoners/experimental/__init__.py +7 -0
  44. relationalai/semantics/reasoners/graph/core.py +1154 -122
  45. relationalai/semantics/rel/builtins.py +3 -1
  46. relationalai/semantics/rel/rel_utils.py +5 -0
  47. relationalai/semantics/sql/compiler.py +6 -0
  48. {relationalai-0.12.4.dist-info → relationalai-0.12.6.dist-info}/METADATA +1 -1
  49. {relationalai-0.12.4.dist-info → relationalai-0.12.6.dist-info}/RECORD +84 -100
  50. {relationalai-0.12.4.dist-info → relationalai-0.12.6.dist-info}/WHEEL +1 -1
  51. relationalai/early_access/paths/__init__.py +0 -22
  52. relationalai/early_access/paths/api/__init__.py +0 -12
  53. relationalai/early_access/paths/benchmarks/__init__.py +0 -13
  54. relationalai/early_access/paths/graph/__init__.py +0 -12
  55. relationalai/early_access/paths/path_algorithms/find_paths/__init__.py +0 -12
  56. relationalai/early_access/paths/path_algorithms/one_sided_ball_repetition/__init__.py +0 -12
  57. relationalai/early_access/paths/path_algorithms/one_sided_ball_upto/__init__.py +0 -12
  58. relationalai/early_access/paths/path_algorithms/single/__init__.py +0 -12
  59. relationalai/early_access/paths/path_algorithms/two_sided_balls_repetition/__init__.py +0 -12
  60. relationalai/early_access/paths/path_algorithms/two_sided_balls_upto/__init__.py +0 -12
  61. relationalai/early_access/paths/path_algorithms/usp/__init__.py +0 -12
  62. relationalai/early_access/paths/rpq/__init__.py +0 -13
  63. relationalai/early_access/paths/utilities/iterators/__init__.py +0 -12
  64. relationalai/experimental/paths/pathfinder.rel +0 -2560
  65. relationalai/semantics/reasoners/graph/paths/__init__.py +0 -16
  66. relationalai/semantics/reasoners/graph/paths/path_algorithms/__init__.py +0 -3
  67. relationalai/semantics/reasoners/graph/paths/utilities/__init__.py +0 -3
  68. /relationalai/{semantics/reasoners/graph → experimental}/paths/api.py +0 -0
  69. /relationalai/{semantics/reasoners/graph → experimental}/paths/benchmarks/__init__.py +0 -0
  70. /relationalai/{semantics/reasoners/graph → experimental}/paths/benchmarks/grid_graph.py +0 -0
  71. /relationalai/{semantics/reasoners/graph → experimental}/paths/code_organization.md +0 -0
  72. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/Movies.ipynb +0 -0
  73. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/minimal_engine_warmup.py +0 -0
  74. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movie_example.py +0 -0
  75. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/actedin.csv +0 -0
  76. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/directed.csv +0 -0
  77. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/follows.csv +0 -0
  78. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/movies.csv +0 -0
  79. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/person.csv +0 -0
  80. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/produced.csv +0 -0
  81. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/ratings.csv +0 -0
  82. /relationalai/{semantics/reasoners/graph → experimental}/paths/examples/movies_data/wrote.csv +0 -0
  83. /relationalai/{semantics/reasoners/graph → experimental}/paths/find_paths_via_automaton.py +0 -0
  84. /relationalai/{semantics/reasoners/graph → experimental}/paths/graph.py +0 -0
  85. /relationalai/{early_access → experimental}/paths/path_algorithms/__init__.py +0 -0
  86. /relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/find_paths.py +0 -0
  87. /relationalai/{semantics/reasoners/graph → experimental}/paths/path_algorithms/one_sided_ball_upto.py +0 -0
  88. /relationalai/{semantics/reasoners/graph → experimental}/paths/product_graph.py +0 -0
  89. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/__init__.py +0 -0
  90. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/automaton.py +0 -0
  91. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/diagnostics.py +0 -0
  92. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/filter.py +0 -0
  93. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/glushkov.py +0 -0
  94. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/rpq.py +0 -0
  95. /relationalai/{semantics/reasoners/graph → experimental}/paths/rpq/transition.py +0 -0
  96. /relationalai/{early_access → experimental}/paths/utilities/__init__.py +0 -0
  97. /relationalai/{semantics/reasoners/graph → experimental}/paths/utilities/iterators.py +0 -0
  98. /relationalai/{semantics/reasoners/graph → experimental}/paths/utilities/prefix_sum.py +0 -0
  99. /relationalai/{semantics/reasoners/graph → experimental}/paths/utilities/utilities.py +0 -0
  100. {relationalai-0.12.4.dist-info → relationalai-0.12.6.dist-info}/entry_points.txt +0 -0
  101. {relationalai-0.12.4.dist-info → relationalai-0.12.6.dist-info}/licenses/LICENSE +0 -0
@@ -21,7 +21,9 @@ from relationalai.semantics import (
21
21
  )
22
22
  from relationalai.docutils import include_in_docs
23
23
  from relationalai.semantics.internal import annotations
24
+ from relationalai.semantics.internal import internal as builder_internal # For primitive graph algorithms.
24
25
  from relationalai.semantics.std.math import abs, isnan, isinf, maximum, natural_log, sqrt
26
+ from relationalai.semantics.std.integers import int64
25
27
 
26
28
  Numeric = Union[int, float, Decimal]
27
29
  NumericType = Type[Union[Numeric, Number]]
@@ -1067,6 +1069,7 @@ class Graph():
1067
1069
  def _validate_domain_constraint_parameters(
1068
1070
  self,
1069
1071
  method_name: str,
1072
+ symmetric: bool,
1070
1073
  full: Optional[bool],
1071
1074
  from_: Optional[Relationship],
1072
1075
  to: Optional[Relationship],
@@ -1076,13 +1079,15 @@ class Graph():
1076
1079
  Validate the domain constraint parameters for methods that accept
1077
1080
  `full`, `from_`, `to`, and `between` parameters.
1078
1081
 
1079
- This helper method performs common validation logic that applies
1080
- across multiple graph methods (e.g., common_neighbor, adamic_adar).
1081
-
1082
1082
  Parameters
1083
1083
  ----------
1084
1084
  method_name : str
1085
1085
  The name of the method being validated (for error messages).
1086
+ symmetric : bool
1087
+ Whether the relationship is symmetric in its first two positions.
1088
+ If True, enforces that 'to' can only be used with 'from_' (since
1089
+ 'to' alone would be redundant for symmetric relations).
1090
+ If False, allows 'to' without 'from_' (for asymmetric relations).
1086
1091
  full : bool, optional
1087
1092
  The full parameter value.
1088
1093
  from_ : Relationship, optional
@@ -1127,8 +1132,9 @@ class Graph():
1127
1132
  "or use 'from_'/'to' to constrain by position."
1128
1133
  )
1129
1134
 
1130
- # Confirm that 'to' is only used with 'from_'.
1131
- if to is not None and from_ is None:
1135
+ # Confirm that 'to' is only used with 'from_' for symmetric relations.
1136
+ # For asymmetric relations, 'to' without 'from_' is meaningful.
1137
+ if symmetric and to is not None and from_ is None:
1132
1138
  raise ValueError(
1133
1139
  "The 'to' parameter can only be used together with the 'from_' parameter. "
1134
1140
  f"The 'from_' parameter constrains the first position in {method_name} tuples, "
@@ -1142,6 +1148,7 @@ class Graph():
1142
1148
  if (
1143
1149
  full is None and
1144
1150
  from_ is None and
1151
+ to is None and
1145
1152
  between is None
1146
1153
  ):
1147
1154
  raise ValueError(
@@ -2171,9 +2178,10 @@ class Graph():
2171
2178
  1 2 4 3
2172
2179
 
2173
2180
  """
2174
- # Validate domain constraint parameters.
2181
+ # Validate domain constraint parameters (common_neighbor is symmetric).
2182
+ symmetric = True
2175
2183
  self._validate_domain_constraint_parameters(
2176
- 'common_neighbor', full, from_, to, between
2184
+ 'common_neighbor', symmetric, full, from_, to, between
2177
2185
  )
2178
2186
 
2179
2187
  # At this point, exactly one of `full`, `from_`, or `between`
@@ -4941,6 +4949,11 @@ class Graph():
4941
4949
  def reachable_from(self):
4942
4950
  """Returns a binary relationship of all pairs of nodes (u, v) where v is reachable from u.
4943
4951
 
4952
+ .. deprecated::
4953
+ The ``reachable_from`` method is deprecated and will be removed in a
4954
+ future release. Please use :meth:`reachable` instead, which provides
4955
+ the same functionality with additional domain constraint options.
4956
+
4944
4957
  A node `v` is considered reachable from a node `u` if there is a path
4945
4958
  of edges from `u` to `v`.
4946
4959
 
@@ -5043,35 +5056,370 @@ class Graph():
5043
5056
  7 3 2
5044
5057
  8 3 3
5045
5058
 
5059
+ See Also
5060
+ --------
5061
+ reachable
5046
5062
 
5047
5063
  """
5048
5064
  warnings.warn(
5049
- (
5050
- "`reachable_from` presently always computes the full transitive closure "
5051
- "of the graph. To provide better control over the computed subset, "
5052
- "`reachable_from`'s interface will soon need to change."
5053
- ),
5054
- FutureWarning,
5065
+ "The 'reachable_from' method is deprecated and will be removed in a future release. "
5066
+ "Please use 'reachable' instead.",
5067
+ DeprecationWarning,
5055
5068
  stacklevel=2
5056
5069
  )
5057
- return self._reachable_from
5070
+ return self.reachable(full=True)
5071
+
5072
+
5073
+ @include_in_docs
5074
+ def reachable(
5075
+ self,
5076
+ *,
5077
+ full: Optional[bool] = None,
5078
+ from_: Optional[Relationship] = None,
5079
+ to: Optional[Relationship] = None,
5080
+ between: Optional[Relationship] = None,
5081
+ ):
5082
+ """Returns a binary relationship of pairs of nodes (u, v) where v is reachable from u.
5083
+
5084
+ A node `v` is considered reachable from a node `u` if there is a path
5085
+ of edges from `u` to `v`.
5086
+
5087
+ Parameters
5088
+ ----------
5089
+ full : bool, optional
5090
+ If ``True``, computes reachability for all pairs of nodes in the graph.
5091
+ This computation can be expensive for large graphs. Must be set to
5092
+ ``True`` to compute the full reachable relationship. Cannot be used
5093
+ with other parameters.
5094
+ from_ : Relationship, optional
5095
+ A unary relationship containing a subset of the graph's nodes. When
5096
+ provided, constrains the first position of the reachable
5097
+ computation: only reachability from nodes in this relationship are
5098
+ returned. Cannot be used with other parameters.
5099
+ to : Relationship, optional
5100
+ A unary relationship containing a subset of the graph's nodes. When
5101
+ provided, constrains the second position of the reachable
5102
+ computation: only reachability to nodes in this relationship are
5103
+ returned. Cannot be used with other parameters.
5104
+ between : Relationship, optional
5105
+ Not yet supported for reachable. If provided, raises an error. If you
5106
+ need this capability, please reach out.
5107
+
5108
+ Returns
5109
+ -------
5110
+ Relationship
5111
+ A binary relationship where each tuple represents a node and a
5112
+ node that is reachable from it.
5113
+
5114
+ Relationship Schema
5115
+ -------------------
5116
+ ``reachable(from_node, to_node)``
5117
+
5118
+ * **from_node** (*Node*): The node from which the path originates.
5119
+ * **to_node** (*Node*): The node that is reachable from the first node.
5120
+
5121
+ Supported Graph Types
5122
+ ---------------------
5123
+ | Graph Type | Supported | Notes |
5124
+ | :--------- | :-------- | :------------------- |
5125
+ | Undirected | Yes | |
5126
+ | Directed | Yes | |
5127
+ | Weighted | Yes | Weights are ignored. |
5128
+
5129
+ Notes
5130
+ -----
5131
+ The ``reachable(full=True)`` method computes and caches the full
5132
+ reachable relationship, providing efficient reuse across multiple calls. In
5133
+ contrast, ``reachable(from_=subset)`` or ``reachable(to=subset)``
5134
+ compute constrained relationships specific to the passed-in subset and call
5135
+ site. When a significant fraction of the reachable relation is needed,
5136
+ ``reachable(full=True)`` is typically more efficient. Use constrained
5137
+ variants only when small subsets are needed.
5138
+
5139
+ In directed graphs, the ``reachable`` relationship is asymmetric: node B
5140
+ may be reachable from node A without node A being reachable from node B. This
5141
+ asymmetry means that ``from_`` and ``to`` parameters have distinct,
5142
+ non-interchangeable meanings.
5143
+
5144
+ **Important:** Simultaneous use of ``from_`` and ``to`` parameters is not
5145
+ yet supported. The ``between`` parameter is also not yet supported. If
5146
+ you need these capabilities, please reach out.
5147
+
5148
+ There is a slight difference between `transitive closure` and
5149
+ `reachable`. The transitive closure of a binary relation E is the
5150
+ smallest relation that contains E and is transitive. When E is the
5151
+ edge set of a graph, the transitive closure of E does not include
5152
+ (u, u) if u is isolated. `reachable` is a different binary
5153
+ relation in which any node u is always reachable from u. In
5154
+ particular, `transitive closure` is a more general concept than
5155
+ `reachable`.
5156
+
5157
+ Examples
5158
+ --------
5159
+ **Directed Graph Example**
5160
+
5161
+ >>> from relationalai.semantics import Model, define, select
5162
+ >>> from relationalai.semantics.reasoners.graph import Graph
5163
+ >>>
5164
+ >>> # 1. Set up a directed graph
5165
+ >>> model = Model("test_model")
5166
+ >>> graph = Graph(model, directed=True, weighted=False)
5167
+ >>> Node, Edge = graph.Node, graph.Edge
5168
+ >>>
5169
+ >>> # 2. Define nodes and edges
5170
+ >>> n1, n2, n3 = [Node.new(id=i) for i in range(1, 4)]
5171
+ >>> define(n1, n2, n3)
5172
+ >>> define(
5173
+ ... Edge.new(src=n1, dst=n2),
5174
+ ... Edge.new(src=n3, dst=n2),
5175
+ ... )
5176
+ >>>
5177
+ >>> # 3. Select all reachable pairs and inspect
5178
+ >>> from_node, to_node = Node.ref("start"), Node.ref("end")
5179
+ >>> reachable = graph.reachable(full=True)
5180
+ >>> select(from_node.id, to_node.id).where(reachable(from_node, to_node)).inspect()
5181
+ ▰▰▰▰ Setup complete
5182
+ id id2
5183
+ 0 1 1
5184
+ 1 1 2
5185
+ 2 2 2
5186
+ 3 3 2
5187
+ 4 3 3
5188
+
5189
+ >>> # 4. Use 'from_' parameter to get reachability from specific nodes
5190
+ >>> # Define a subset containing nodes 1 and 3
5191
+ >>> from relationalai.semantics import union, where
5192
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in from subset")
5193
+ >>> node = Node.ref()
5194
+ >>> where(union(node.id == 1, node.id == 3)).define(subset(node))
5195
+ >>>
5196
+ >>> # Get reachability from nodes in the subset to all other nodes
5197
+ >>> reachable_from = graph.reachable(from_=subset)
5198
+ >>> select(from_node.id, to_node.id).where(
5199
+ ... reachable_from(from_node, to_node)
5200
+ ... ).inspect()
5201
+ ▰▰▰▰ Setup complete
5202
+ id id2
5203
+ 0 1 1
5204
+ 1 1 2
5205
+ 2 3 2
5206
+ 3 3 3
5207
+
5208
+ >>> # 5. Use 'to' parameter to get reachability to specific nodes
5209
+ >>> # Define a different subset containing node 2
5210
+ >>> to_subset = model.Relationship(f"{{node:{Node}}} is in to subset")
5211
+ >>> node = Node.ref()
5212
+ >>> where(node.id == 2).define(to_subset(node))
5213
+ >>>
5214
+ >>> # Get reachability from all nodes to node 2
5215
+ >>> reachable_to = graph.reachable(to=to_subset)
5216
+ >>> select(from_node.id, to_node.id).where(
5217
+ ... reachable_to(from_node, to_node)
5218
+ ... ).inspect()
5219
+ ▰▰▰▰ Setup complete
5220
+ id id2
5221
+ 0 1 2
5222
+ 1 2 2
5223
+ 2 3 2
5224
+
5225
+
5226
+ **Undirected Graph Example**
5227
+
5228
+ >>> from relationalai.semantics import Model, define, select
5229
+ >>> from relationalai.semantics.reasoners.graph import Graph
5230
+ >>>
5231
+ >>> # 1. Set up an undirected graph
5232
+ >>> model = Model("test_model")
5233
+ >>> graph = Graph(model, directed=False, weighted=False)
5234
+ >>> Node, Edge = graph.Node, graph.Edge
5235
+ >>>
5236
+ >>> # 2. Define the same nodes and edges
5237
+ >>> n1, n2, n3 = [Node.new(id=i) for i in range(1, 4)]
5238
+ >>> define(n1, n2, n3)
5239
+ >>> define(
5240
+ ... Edge.new(src=n1, dst=n2),
5241
+ ... Edge.new(src=n3, dst=n2),
5242
+ ... )
5243
+ >>>
5244
+ >>> # 3. Select all reachable pairs and inspect
5245
+ >>> from_node, to_node = Node.ref("start"), Node.ref("end")
5246
+ >>> reachable = graph.reachable(full=True)
5247
+ >>> select(from_node.id, to_node.id).where(reachable(from_node, to_node)).inspect()
5248
+ ▰▰▰▰ Setup complete
5249
+ id id2
5250
+ 0 1 1
5251
+ 1 1 2
5252
+ 2 1 3
5253
+ 3 2 1
5254
+ 4 2 2
5255
+ 5 2 3
5256
+ 6 3 1
5257
+ 7 3 2
5258
+ 8 3 3
5259
+
5260
+ """
5261
+ # Validate domain constraint parameters (reachable is asymmetric).
5262
+ symmetric = False
5263
+ self._validate_domain_constraint_parameters(
5264
+ 'reachable', symmetric, full, from_, to, between
5265
+ )
5266
+
5267
+ # Reachable-specific validation: between is not yet supported.
5268
+ if between is not None:
5269
+ raise ValueError(
5270
+ "The 'between' parameter is not yet supported for reachable. "
5271
+ "Use 'full=True' for all-pairs reachability, or 'from_'/'to' to "
5272
+ "constrain by position. If you need 'between' support for reachable, "
5273
+ "please reach out."
5274
+ )
5275
+
5276
+ # Reachable-specific validation: from_+to combination is not yet supported.
5277
+ if from_ is not None and to is not None:
5278
+ raise ValueError(
5279
+ "Simultaneous use of 'from_' and 'to' is not yet supported for reachable. "
5280
+ "Use 'from_=subset' to constrain start nodes, 'to=subset' to constrain "
5281
+ "end nodes, or 'full=True' for all pairs. If you need the 'from_' and 'to' "
5282
+ "combination for reachable, please reach out."
5283
+ )
5284
+
5285
+ # At this point, exactly one of `full`, `from_`, or `to` has been provided.
5286
+
5287
+ if full is not None:
5288
+ return self._reachable
5289
+
5290
+ if from_ is not None:
5291
+ self._validate_node_subset_parameter('from_', from_)
5292
+ return self._reachable_from(from_)
5293
+
5294
+ if to is not None:
5295
+ self._validate_node_subset_parameter('to', to)
5296
+ return self._reachable_to(to)
5058
5297
 
5059
5298
  @cached_property
5060
- def _reachable_from(self):
5061
- """Lazily define and cache the self._reachable_from relationship."""
5062
- _reachable_from_rel = self._model.Relationship(f"{{node_a:{self._NodeConceptStr}}} reaches {{node_b:{self._NodeConceptStr}}}")
5063
- _reachable_from_rel.annotate(annotations.track("graphs", "reachable_from"))
5299
+ def _reachable(self):
5300
+ """Lazily define and cache the self._reachable relationship."""
5301
+ _reachable_rel = self._create_reachable_relationship()
5302
+ _reachable_rel.annotate(annotations.track("graphs", "reachable"))
5303
+ return _reachable_rel
5304
+
5305
+ def _reachable_from(self, node_subset_from: Relationship):
5306
+ """
5307
+ Create a reachable relationship with the first position constrained to
5308
+ nodes in `node_subset_from`. This computes reachability from nodes in
5309
+ the subset to all other nodes. Note this relationship
5310
+ is not cached; it is specific to the callsite.
5311
+ """
5312
+ _reachable_rel = self._create_reachable_relationship(
5313
+ node_subset_from=node_subset_from,
5314
+ )
5315
+ _reachable_rel.annotate(annotations.track("graphs", "reachable_from"))
5316
+ return _reachable_rel
5064
5317
 
5318
+ def _reachable_to(self, node_subset_to: Relationship):
5319
+ """
5320
+ Create a reachable relationship with the second position constrained to
5321
+ nodes in `node_subset_to`. This computes reachability from all nodes to
5322
+ nodes in the subset. Note this relationship
5323
+ is not cached; it is specific to the callsite.
5324
+ """
5325
+ _reachable_rel = self._create_reachable_relationship(
5326
+ node_subset_to=node_subset_to
5327
+ )
5328
+ _reachable_rel.annotate(annotations.track("graphs", "reachable_to"))
5329
+ return _reachable_rel
5330
+
5331
+ def _create_reachable_relationship(
5332
+ self,
5333
+ *,
5334
+ node_subset_from: Optional[Relationship] = None,
5335
+ node_subset_to: Optional[Relationship] = None,
5336
+ ):
5337
+ """
5338
+ Create a reachable relationship, optionally constrained to node subsets.
5339
+
5340
+ Parameters
5341
+ ----------
5342
+ node_subset_from : Relationship or None
5343
+ If provided, constrains the first position to this subset.
5344
+ node_subset_to : Relationship or None
5345
+ If provided, constrains the second position to this subset.
5346
+
5347
+ Returns
5348
+ -------
5349
+ Relationship
5350
+ A binary relationship mapping (from_node, to_node).
5351
+ """
5352
+ # NOTE: In the constrained cases, we must compute over the full reach
5353
+ # {from xor to}, depending on the constraint, the nodes in the provided subset,
5354
+ # and _only_ over that reach.
5355
+
5356
+ # A reach relation over the reach {from xor to} the nodes in the subset.
5357
+ _reachable_rel = self._model.Relationship(
5358
+ f"{{node_a:{self._NodeConceptStr}}} reaches {{node_b:{self._NodeConceptStr}}}"
5359
+ )
5360
+
5361
+ # The logic below computes the reach by repeatedly extending
5362
+ # known reachability to neighbors; this snippet drives that,
5363
+ # with propagation direction depending on the constraint mode.
5065
5364
  node_a, node_b, node_c = self.Node.ref(), self.Node.ref(), self.Node.ref()
5066
- define(_reachable_from_rel(node_a, node_a))
5067
- define(_reachable_from_rel(node_a, node_c)).where(_reachable_from_rel(node_a, node_b), self._edge(node_b, node_c))
5365
+ if node_subset_to is None:
5366
+ # Either of `full` or `from_` modes; propagate reach forward for both.
5367
+ # (`full` mode uses forward propagation as it may be slightly more efficient,
5368
+ # though backward propagation also works in principle.)
5369
+ extension_rule = where(
5370
+ _reachable_rel(node_a, node_b),
5371
+ self._edge(node_b, node_c),
5372
+ ).select(node_a, node_c)
5373
+ else:
5374
+ # `to` mode; propagate reach backward.
5375
+ extension_rule = where(
5376
+ _reachable_rel(node_b, node_c),
5377
+ self._edge(node_a, node_b),
5378
+ ).select(node_a, node_c)
5379
+ # NOTE: The optimizer may generate an index on _edge for the above;
5380
+ # it may be best to use a cached reversed edge relationship instead,
5381
+ # given it's useful elsewhere and that may yield reuse (and more reliably
5382
+ # good query plans, possibly).
5383
+
5384
+ # The set of nodes from which to propagate reach.
5385
+ node = self.Node.ref() # node is used (coupled) below.
5386
+ # Binding origin_nodes_constraint unconditionally for
5387
+ # the unconstrained case and then overriding if necessary
5388
+ # appeases the type checker, which otherwise thinks
5389
+ # it may be unbound below.
5390
+ origin_nodes_constraint = node
5391
+ if node_subset_from is not None:
5392
+ origin_nodes_constraint = node_subset_from(node)
5393
+ elif node_subset_to is not None:
5394
+ origin_nodes_constraint = node_subset_to(node)
5395
+
5396
+ # Generate union of reach implications between node_x and node_y:
5397
+ node_x, node_y = union(
5398
+ # A node is always reachable from itself.
5399
+ where(origin_nodes_constraint).select(node, node),
5400
+ # Reachability can be extended to neighbors.
5401
+ # (Note that this part of the rule also drives the reach.)
5402
+ extension_rule,
5403
+ )
5404
+ # Define the reach relation recursively.
5405
+ define(
5406
+ _reachable_rel(node_x, node_y)
5407
+ )
5068
5408
 
5069
- return _reachable_from_rel
5409
+ return _reachable_rel
5070
5410
 
5071
5411
 
5072
5412
  @include_in_docs
5073
- def distance(self):
5074
- """Returns a ternary relationship containing the shortest path length between all pairs of nodes.
5413
+ def distance(
5414
+ self,
5415
+ *,
5416
+ full: Optional[bool] = None,
5417
+ from_: Optional[Relationship] = None,
5418
+ to: Optional[Relationship] = None,
5419
+ between: Optional[Relationship] = None,
5420
+ ):
5421
+ """Returns a ternary relationship containing
5422
+ the shortest path length between pairs of nodes.
5075
5423
 
5076
5424
  This method computes the shortest path length between all pairs of
5077
5425
  reachable nodes. The calculation depends on whether the graph is
@@ -5082,6 +5430,27 @@ class Graph():
5082
5430
  * For **weighted** graphs, the length is the sum of edge weights
5083
5431
  along the shortest path. Edge weights are assumed to be non-negative.
5084
5432
 
5433
+ Parameters
5434
+ ----------
5435
+ full : bool, optional
5436
+ If ``True``, computes distances for all pairs of nodes in the graph.
5437
+ This computation can be expensive for large graphs. Must be set to
5438
+ ``True`` to compute the full distance relationship. Cannot be used
5439
+ with other parameters.
5440
+ from_ : Relationship, optional
5441
+ A unary relationship containing a subset of the graph's nodes. When
5442
+ provided, constrains the first position (start_node) of the distance
5443
+ computation: only distances from nodes in this relationship are
5444
+ returned. Cannot be used with other parameters.
5445
+ to : Relationship, optional
5446
+ A unary relationship containing a subset of the graph's nodes. When
5447
+ provided, constrains the second position (end_node) of the distance
5448
+ computation: only distances to nodes in this relationship are
5449
+ returned. Cannot be used with other parameters.
5450
+ between : Relationship, optional
5451
+ Not yet supported for distance. If provided, raises an error. If you
5452
+ need this capability, please reach out.
5453
+
5085
5454
  Returns
5086
5455
  -------
5087
5456
  Relationship
@@ -5105,11 +5474,30 @@ class Graph():
5105
5474
  | Directed | Yes | |
5106
5475
  | Weighted | Yes | The calculation uses edge weights. |
5107
5476
 
5477
+ Notes
5478
+ -----
5479
+ The ``distance(full=True)`` method computes and caches the full distance
5480
+ relationship, providing efficient reuse across multiple calls. In contrast,
5481
+ ``distance(from_=subset)`` or ``distance(to=subset)`` compute constrained
5482
+ relationships specific to the passed-in subset and call site. When a
5483
+ significant fraction of the distance relation is needed, ``distance(full=True)``
5484
+ is typically more efficient. Use constrained variants only when small
5485
+ subsets are needed.
5486
+
5487
+ In directed graphs, the ``distance`` relationship is asymmetric: the
5488
+ distance from node A to node B may differ from the distance from node B
5489
+ to node A. This asymmetry means that ``from_`` and ``to`` parameters
5490
+ have distinct, non-interchangeable meanings.
5491
+
5492
+ **Important:** Simultaneous use of ``from_`` and ``to`` parameters is not
5493
+ yet supported. The ``between`` parameter is also not yet supported. If
5494
+ you need these capabilities, please reach out.
5495
+
5108
5496
  Examples
5109
5497
  --------
5110
5498
  **Unweighted Graph Example**
5111
5499
 
5112
- >>> from relationalai.semantics import Model, define, select, Integer
5500
+ >>> from relationalai.semantics import Model, define, select, union, where, Integer
5113
5501
  >>> from relationalai.semantics.reasoners.graph import Graph
5114
5502
  >>>
5115
5503
  >>> # 1. Set up an unweighted, undirected graph
@@ -5130,8 +5518,8 @@ class Graph():
5130
5518
  >>> # 3. Select the shortest path length between all pairs of nodes
5131
5519
  >>> start, end = Node.ref("start"), Node.ref("end")
5132
5520
  >>> length = Integer.ref("length")
5133
- >>> distance = graph.distance()
5134
- >>> select(start.id, end.id, length).where(distance(start, end, length)).inspect()
5521
+ >>> dist = graph.distance(full=True)
5522
+ >>> select(start.id, end.id, length).where(dist(start, end, length)).inspect()
5135
5523
  ▰▰▰▰ Setup complete
5136
5524
  id id2 length
5137
5525
  0 1 1 0
@@ -5151,6 +5539,46 @@ class Graph():
5151
5539
  14 4 3 2
5152
5540
  15 4 4 0
5153
5541
 
5542
+ >>> # 4. Use 'from_' parameter to get distances from specific nodes
5543
+ >>> # Define a subset containing nodes 1 and 3
5544
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in from subset")
5545
+ >>> node = Node.ref()
5546
+ >>> where(union(node.id == 1, node.id == 3)).define(subset(node))
5547
+ >>>
5548
+ >>> # Get distances from nodes in the subset to all other nodes
5549
+ >>> dist_from = graph.distance(from_=subset)
5550
+ >>> select(start.id, end.id, length).where(
5551
+ ... dist_from(start, end, length)
5552
+ ... ).inspect()
5553
+ ▰▰▰▰ Setup complete
5554
+ id id2 length
5555
+ 0 1 1 0
5556
+ 1 1 2 1
5557
+ 2 1 3 2
5558
+ 3 1 4 2
5559
+ 4 3 1 2
5560
+ 5 3 2 1
5561
+ 6 3 3 0
5562
+ 7 3 4 2
5563
+
5564
+ >>> # 5. Use 'to' parameter to get distances to specific nodes
5565
+ >>> # Define a different subset containing node 4
5566
+ >>> to_subset = model.Relationship(f"{{node:{Node}}} is in to subset")
5567
+ >>> node = Node.ref()
5568
+ >>> where(node.id == 4).define(to_subset(node))
5569
+ >>>
5570
+ >>> # Get distances from all nodes to node 4
5571
+ >>> dist_to = graph.distance(to=to_subset)
5572
+ >>> select(start.id, end.id, length).where(
5573
+ ... dist_to(start, end, length)
5574
+ ... ).inspect()
5575
+ ▰▰▰▰ Setup complete
5576
+ id id2 length
5577
+ 0 1 4 2
5578
+ 1 2 4 1
5579
+ 2 3 4 2
5580
+ 3 4 4 0
5581
+
5154
5582
 
5155
5583
  **Weighted Graph Example**
5156
5584
 
@@ -5174,8 +5602,8 @@ class Graph():
5174
5602
  >>> # 3. Select the shortest path length between all pairs of nodes
5175
5603
  >>> start, end = Node.ref("start"), Node.ref("end")
5176
5604
  >>> length = Float.ref("length")
5177
- >>> distance = graph.distance()
5178
- >>> select(start.id, end.id, length).where(distance(start, end, length)).inspect()
5605
+ >>> dist = graph.distance(full=True)
5606
+ >>> select(start.id, end.id, length).where(dist(start, end, length)).inspect()
5179
5607
  ▰▰▰▰ Setup complete
5180
5608
  id id2 length
5181
5609
  0 1 1 0.0
@@ -5187,75 +5615,190 @@ class Graph():
5187
5615
  6 3 3 0.0
5188
5616
 
5189
5617
  """
5190
- warnings.warn(
5191
- (
5192
- "`distance` presently always computes all-to-all distances "
5193
- "of the graph. To provide better control over the computed subset, "
5194
- "`distance`'s interface will soon need to change."
5195
- ),
5196
- FutureWarning,
5197
- stacklevel=2
5618
+ # Validate domain constraint parameters (distance is asymmetric).
5619
+ symmetric = False
5620
+ self._validate_domain_constraint_parameters(
5621
+ 'distance', symmetric, full, from_, to, between
5198
5622
  )
5199
5623
 
5200
- return self._distance
5624
+ # Distance-specific validation: between is not yet supported.
5625
+ if between is not None:
5626
+ raise ValueError(
5627
+ "The 'between' parameter is not yet supported for distance. "
5628
+ "Use 'full=True' for all-pairs distances, or 'from_'/'to' to "
5629
+ "constrain by position. If you need 'between' support for distance, "
5630
+ "please reach out."
5631
+ )
5632
+
5633
+ # Distance-specific validation: from_+to combination is not yet supported.
5634
+ if from_ is not None and to is not None:
5635
+ raise ValueError(
5636
+ "Simultaneous use of 'from_' and 'to' is not yet supported for distance. "
5637
+ "Use 'from_=subset' to constrain start nodes, 'to=subset' to constrain "
5638
+ "end nodes, or 'full=True' for all pairs. If you need the 'from_' and 'to' "
5639
+ "combination for distance, please reach out."
5640
+ )
5641
+
5642
+ # At this point, exactly one of `full`, `from_`, or `to` has been provided.
5643
+
5644
+ if full is not None:
5645
+ return self._distance
5646
+
5647
+ if from_ is not None:
5648
+ self._validate_node_subset_parameter('from_', from_)
5649
+ return self._distance_from(from_)
5650
+
5651
+ if to is not None:
5652
+ self._validate_node_subset_parameter('to', to)
5653
+ return self._distance_to(to)
5201
5654
 
5202
5655
  @cached_property
5203
5656
  def _distance(self):
5204
5657
  """Lazily define and cache the self._distance relationship."""
5205
- if not self.weighted:
5206
- _distance_rel = self._distance_non_weighted
5207
- else:
5208
- _distance_rel = self._distance_weighted
5209
-
5658
+ _distance_rel = self._create_distance_relationship()
5210
5659
  _distance_rel.annotate(annotations.track("graphs", "distance"))
5211
5660
  return _distance_rel
5212
5661
 
5213
- @cached_property
5214
- def _distance_weighted(self):
5215
- """Lazily define and cache the self._distance_weighted relationship, a non-public helper."""
5216
- _distance_weighted_rel = self._model.Relationship(f"{{node_u:{self._NodeConceptStr}}} and {{node_v:{self._NodeConceptStr}}} have a distance of {{d:Float}}")
5217
- node_u, node_v, node_n, w, d1 = self.Node.ref(), self.Node.ref(),\
5218
- self.Node.ref(), Float.ref(), Float.ref()
5219
- node_u, node_v, d = union(
5220
- where(node_u == node_v, d1 == 0.0).select(node_u, node_v, d1), # Base case.
5221
- where(self._weight(node_n, node_v, w), d2 := _distance_weighted_rel(node_u, node_n, Float) + abs(w))\
5222
- .select(node_u, node_v, d2) # Recursive case.
5662
+ def _distance_from(self, node_subset_from: Relationship):
5663
+ """
5664
+ Create a distance relationship with the first position constrained to
5665
+ nodes in `node_subset_from`. This computes distances from nodes in
5666
+ the subset to all other nodes. Note this relationship
5667
+ is not cached; it is specific to the callsite.
5668
+ """
5669
+ _distance_rel = self._create_distance_relationship(
5670
+ node_subset_from=node_subset_from,
5223
5671
  )
5224
- define(_distance_weighted_rel(node_u, node_v, min(d).per(node_u, node_v)))
5225
-
5226
- return _distance_weighted_rel
5672
+ _distance_rel.annotate(annotations.track("graphs", "distance_from"))
5673
+ return _distance_rel
5227
5674
 
5228
- @cached_property
5229
- def _distance_non_weighted(self):
5230
- """Lazily define and cache the self._distance_non_weighted relationship, a non-public helper."""
5231
- _distance_non_weighted_rel = self._model.Relationship(f"{{node_u:{self._NodeConceptStr}}} and {{node_v:{self._NodeConceptStr}}} have a distance of {{d:Integer}}")
5232
- node_u, node_v, node_n, d1 = self.Node.ref(), self.Node.ref(), self.Node.ref(), Integer.ref()
5233
- node_u, node_v, d = union(
5234
- where(node_u == node_v, d1 == 0).select(node_u, node_v, d1), # Base case.
5235
- where(self._edge(node_n, node_v),
5236
- d2 := _distance_non_weighted_rel(node_u, node_n, Integer) + 1).select(node_u, node_v, d2) # Recursive case.
5675
+ def _distance_to(self, node_subset_to: Relationship):
5676
+ """
5677
+ Create a distance relationship with the second position constrained to
5678
+ nodes in `node_subset_to`. This computes distances from all nodes to
5679
+ nodes in the subset. Note this relationship
5680
+ is not cached; it is specific to the callsite.
5681
+ """
5682
+ _distance_rel = self._create_distance_relationship(
5683
+ node_subset_to=node_subset_to
5237
5684
  )
5238
- define(_distance_non_weighted_rel(node_u, node_v, min(d).per(node_u, node_v)))
5685
+ _distance_rel.annotate(annotations.track("graphs", "distance_to"))
5686
+ return _distance_rel
5239
5687
 
5240
- return _distance_non_weighted_rel
5688
+ def _create_distance_relationship(
5689
+ self,
5690
+ *,
5691
+ node_subset_from: Optional[Relationship] = None,
5692
+ node_subset_to: Optional[Relationship] = None,
5693
+ weighted: Optional[bool] = None,
5694
+ ):
5695
+ """
5696
+ Create a distance relationship, weighted or unweighted per the graph,
5697
+ and optionally constrained to node subsets.
5241
5698
 
5242
- @cached_property
5243
- def _distance_reversed_non_weighted(self):
5244
- """Lazily define and cache the self._distance_reversed_non_weighted relationship, a non-public helper."""
5245
- _distance_reversed_non_weighted_rel = self._model.Relationship(f"{{node_u:{self._NodeConceptStr}}} and {{node_v:{self._NodeConceptStr}}} have a reversed distance of {{d:Integer}}")
5246
- node_u, node_v, node_n, d1 = self.Node.ref(), self.Node.ref(), self.Node.ref(), Integer.ref()
5247
- node_u, node_v, d = union(
5248
- where(node_u == node_v, d1 == 0).select(node_u, node_v, d1), # Base case.
5249
- where(self._edge(node_v, node_n),
5250
- d2 := _distance_reversed_non_weighted_rel(node_u, node_n, Integer) + 1).select(node_u, node_v, d2) # Recursive case.
5699
+ Parameters
5700
+ ----------
5701
+ node_subset_from : Relationship or None
5702
+ If provided, constrains the first position to this subset.
5703
+ node_subset_to : Relationship or None
5704
+ If provided, constrains the second position to this subset.
5705
+
5706
+ Returns
5707
+ -------
5708
+ Relationship
5709
+ A ternary relationship mapping (from_node, to_node, distance).
5710
+ """
5711
+ # NOTE: In the constrained cases, we must compute over the full reach
5712
+ # from xor to (depending on the constraint) the nodes in the provided subset,
5713
+ # and _only_ over that reach. To do so, the logic below simultaneously
5714
+ # computes the appropriate reach of the nodes in the provided subset
5715
+ # (in `_distance_reach_rel`) while computing distances.
5716
+
5717
+ if weighted is None:
5718
+ weighted = self.weighted
5719
+
5720
+ dist_type = Float if weighted else Integer
5721
+
5722
+ # A distance relation over the appropriate reach of the nodes in the subset.
5723
+ _distance_reach_rel = self._model.Relationship(
5724
+ f"{{node_u:{self._NodeConceptStr}}} and {{node_v:{self._NodeConceptStr}}} have a distance of {{d:{dist_type}}}"
5251
5725
  )
5252
- define(_distance_reversed_non_weighted_rel(node_u, node_v, min(d).per(node_u, node_v)))
5253
5726
 
5254
- return _distance_reversed_non_weighted_rel
5727
+ # The logic below computes the reach and distances by repeatedly extending
5728
+ # known distances to neighbors; this snippet drives that, with propagation
5729
+ # direction depending on the constraint mode.
5730
+ node_u, node_v, neighbor = self.Node.ref(), self.Node.ref(), self.Node.ref()
5731
+ uv_dist, step_dist, neighbor_dist = dist_type.ref(), dist_type.ref(), dist_type.ref()
5732
+ if node_subset_to is None:
5733
+ # Either of `full` or `from` modes; propagate reach forward for both.
5734
+ # (`full` mode uses forward propagation as it may be slightly more efficient,
5735
+ # though backward propagation also works in principle.)
5736
+ extension_rule = where(
5737
+ _distance_reach_rel(node_u, node_v, uv_dist),
5738
+ *(
5739
+ (
5740
+ self._weight(node_v, neighbor, step_dist),
5741
+ neighbor_dist == uv_dist + abs(step_dist),
5742
+ )
5743
+ if weighted else
5744
+ (
5745
+ self._edge(node_v, neighbor),
5746
+ neighbor_dist == uv_dist + 1,
5747
+ )
5748
+ ),
5749
+ ).select(node_u, neighbor, neighbor_dist)
5750
+ else:
5751
+ # `to` mode; propagate reach backward.
5752
+ extension_rule = where(
5753
+ _distance_reach_rel(node_u, node_v, uv_dist),
5754
+ *(
5755
+ (
5756
+ self._weight(neighbor, node_u, step_dist),
5757
+ neighbor_dist == uv_dist + abs(step_dist),
5758
+ )
5759
+ if weighted else
5760
+ (
5761
+ self._edge(neighbor, node_u),
5762
+ neighbor_dist == uv_dist + 1,
5763
+ )
5764
+ ),
5765
+ ).select(neighbor, node_v, neighbor_dist)
5766
+ # NOTE: The optimizer may generate an index on _edge for the above;
5767
+ # it may be best to use a cached reversed edge/weight relationship instead,
5768
+ # given it's useful elsewhere and that may yield reuse (and more reliably
5769
+ # good query plans, possibly).
5770
+
5771
+ # The set of nodes from which to propagate reach and distances.
5772
+ node = self.Node.ref() # node is used (coupled) below.
5773
+ # Binding origin_nodes_constraint unconditionally for
5774
+ # the unconstrained case and then overriding if necessary
5775
+ # appeases the type checker, which otherwise thinks
5776
+ # it may be unbound below.
5777
+ origin_nodes_constraint = node
5778
+ if node_subset_from is not None:
5779
+ origin_nodes_constraint = node_subset_from(node)
5780
+ elif node_subset_to is not None:
5781
+ origin_nodes_constraint = node_subset_to(node)
5782
+
5783
+ # Generate union of possible distances between node_a and node_b:
5784
+ node_a, node_b, ab_dist = union(
5785
+ # The distance from a node to itself is zero.
5786
+ where(origin_nodes_constraint).select(node, node, 0),
5787
+ # The distance from one node to another can be extended to its neighbors.
5788
+ # (Note that this part of the rule also drives the reach.)
5789
+ extension_rule,
5790
+ )
5791
+ # From the union of possible distances between node_a and node_b,
5792
+ # select the minimum as the distance.
5793
+ define(
5794
+ _distance_reach_rel(node_a, node_b, min(ab_dist).per(node_a, node_b))
5795
+ )
5796
+
5797
+ return _distance_reach_rel
5255
5798
 
5256
5799
 
5257
5800
  @include_in_docs
5258
- def weakly_connected_component(self):
5801
+ def weakly_connected_component(self, *, of: Optional[Relationship] = None):
5259
5802
  """Returns a binary relationship that maps each node to its weakly connected component.
5260
5803
 
5261
5804
  A weakly connected component is a subgraph where for every pair of
@@ -5263,6 +5806,14 @@ class Graph():
5263
5806
  For undirected graphs, this is equivalent to finding the connected
5264
5807
  components.
5265
5808
 
5809
+ Parameters
5810
+ ----------
5811
+ of : Relationship, optional
5812
+ A unary relationship containing a subset of the graph's nodes. When
5813
+ provided, constrains the domain of the weakly connected component
5814
+ computation: only component memberships of nodes in this relationship
5815
+ are returned.
5816
+
5266
5817
  Returns
5267
5818
  -------
5268
5819
  Relationship
@@ -5289,9 +5840,26 @@ class Graph():
5289
5840
  The ``component_id`` is the node with the minimum ID within each
5290
5841
  component.
5291
5842
 
5843
+ The ``weakly_connected_component()`` method, called with no parameters,
5844
+ computes and caches the full weakly connected component relationship,
5845
+ providing efficient reuse across multiple calls to
5846
+ ``weakly_connected_component()``.
5847
+
5848
+ In contrast, ``weakly_connected_component(of=subset)`` computes a constrained
5849
+ relationship specific to the passed-in ``subset`` and that call site.
5850
+
5851
+ Note that the constrained computation requires working over all nodes
5852
+ in the components containing the nodes in ``subset``. When that set
5853
+ of nodes constitutes an appreciable fraction of the graph, the constrained
5854
+ computation may be less efficient than computing the full relationship.
5855
+ Use ``weakly_connected_component(of=subset)`` only when small subsets of
5856
+ the weakly connected component relationship are needed collectively
5857
+ across the program, and the associated components cover only a small
5858
+ part of the graph.
5859
+
5292
5860
  Examples
5293
5861
  --------
5294
- >>> from relationalai.semantics import Model, define, select
5862
+ >>> from relationalai.semantics import Model, define, select, union, where
5295
5863
  >>> from relationalai.semantics.reasoners.graph import Graph
5296
5864
  >>>
5297
5865
  >>> # 1. Set up a directed graph
@@ -5317,36 +5885,141 @@ class Graph():
5317
5885
  1 2 1
5318
5886
  2 3 1
5319
5887
 
5320
- """
5321
- warnings.warn(
5322
- (
5323
- "`weakly_connected_component` presently always computes the component "
5324
- "for every node of the graph. To provide better control over the computed subset, "
5325
- "`weakly_connected_component`'s interface will soon need to change."
5326
- ),
5327
- FutureWarning,
5328
- stacklevel=2
5329
- )
5888
+ >>> # 4. Use 'of' parameter to constrain computation to subset of nodes
5889
+ >>> # Define a subset containing only nodes 1 and 3
5890
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
5891
+ >>> node = Node.ref()
5892
+ >>> where(union(node.id == 1, node.id == 3)).define(subset(node))
5893
+ >>>
5894
+ >>> # Get component membership only for nodes in the subset
5895
+ >>> constrained_wcc = graph.weakly_connected_component(of=subset)
5896
+ >>> select(node.id, component_id.id).where(
5897
+ ... constrained_wcc(node, component_id)
5898
+ ... ).inspect()
5899
+ ▰▰▰▰ Setup complete
5900
+ id id2
5901
+ 0 1 1
5902
+ 1 3 1
5330
5903
 
5331
- return self._weakly_connected_component
5904
+ """
5905
+ if of is None:
5906
+ return self._weakly_connected_component
5907
+ else:
5908
+ # Validate the 'of' parameter
5909
+ self._validate_node_subset_parameter('of', of)
5910
+ return self._weakly_connected_component_of(of)
5332
5911
 
5333
5912
  @cached_property
5334
5913
  def _weakly_connected_component(self):
5335
5914
  """Lazily define and cache the self._weakly_connected_component relationship."""
5336
- _weakly_connected_component_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} is in the connected component {{id:{self._NodeConceptStr}}}")
5915
+ _weakly_connected_component_rel = self._create_weakly_connected_component_relationship(
5916
+ node_subset=None
5917
+ )
5337
5918
  _weakly_connected_component_rel.annotate(annotations.track("graphs", "weakly_connected_component"))
5919
+ return _weakly_connected_component_rel
5338
5920
 
5339
- node, node_v, component = self.Node.ref(), self.Node.ref(), self.Node.ref()
5340
- node, component = union(
5341
- # A node starts with itself as the component id.
5342
- where(node == component).select(node, component),
5343
- # Recursive case.
5344
- where(_weakly_connected_component_rel(node, component), self._neighbor(node, node_v)).select(node_v, component)
5921
+ def _weakly_connected_component_of(self, node_subset: Relationship):
5922
+ """
5923
+ Create a weakly_connected_component relationship constrained to the
5924
+ subset of nodes in `node_subset`. Note this relationship is not cached;
5925
+ it is specific to the callsite.
5926
+ """
5927
+ _weakly_connected_component_rel = self._create_weakly_connected_component_relationship(
5928
+ node_subset=node_subset
5345
5929
  )
5346
- define(_weakly_connected_component_rel(node, min(component).per(node)))
5347
-
5930
+ _weakly_connected_component_rel.annotate(annotations.track("graphs", "weakly_connected_component_of"))
5348
5931
  return _weakly_connected_component_rel
5349
5932
 
5933
+ def _create_weakly_connected_component_relationship(
5934
+ self, *, node_subset: Optional[Relationship]
5935
+ ):
5936
+ """
5937
+ Create a weakly_connected_component relationship, optionally constrained
5938
+ to a subset of nodes.
5939
+
5940
+ Parameters
5941
+ ----------
5942
+ node_subset : Relationship or None
5943
+ If provided, a unary relationship defining the subset of nodes to
5944
+ compute component membership for. If None, compute for all nodes.
5945
+
5946
+ Returns
5947
+ -------
5948
+ Relationship
5949
+ A binary relationship mapping nodes to their weakly connected component IDs.
5950
+ """
5951
+ # NOTE: In the constrained case, we must compute over the full of
5952
+ # the weakly connected components containing the nodes in the
5953
+ # provided subset, and _only_ over those weakly connected components.
5954
+ # To do so, the logic below simultaneously computes the (weak) reach from
5955
+ # the nodes in the provided subset (in `_weakly_connected_component_reach_rel``)
5956
+ # while computing component membership.
5957
+
5958
+ # A weakly connected component relation over
5959
+ # those components reachable from the nodes in the subset.
5960
+ _weakly_connected_component_reach_rel = self._model.Relationship(
5961
+ f"{{node:{self._NodeConceptStr}}} is in the connected component {{id:{self._NodeConceptStr}}}"
5962
+ )
5963
+
5964
+ node, neighbor, component, dummy = \
5965
+ self.Node.ref(), self.Node.ref(), self.Node.ref(), self.Node.ref()
5966
+
5967
+ # `discovered_nodes` reflects the (weak) reach from the nodes in the provided
5968
+ # subset known at a given point in the recursion. In the unconstrained
5969
+ # case, the subset is implicitly the set of all nodes, and starting with
5970
+ # all such nodes "discovered" accelerates the computation.
5971
+ if node_subset is None:
5972
+ discovered_nodes = node
5973
+ else:
5974
+ discovered_nodes = union(
5975
+ node_subset(node),
5976
+ _weakly_connected_component_reach_rel(node, dummy)
5977
+ )
5978
+
5979
+ where(
5980
+ # Generate union of possible component identifiers for a given node:
5981
+ union(
5982
+ # A node's component identifier may be itself.
5983
+ where(discovered_nodes, component == node),
5984
+ # A node's component identifier may be those of its neighbors.
5985
+ # (Note that this part of the rule also drives the (weak) reach.)
5986
+ where(
5987
+ self._neighbor(neighbor, node),
5988
+ _weakly_connected_component_reach_rel(neighbor, component)
5989
+ )
5990
+ )
5991
+ ).define(
5992
+ # From the union of possible component identifiers for a given node,
5993
+ # select the minimum as the component identifier.
5994
+ _weakly_connected_component_reach_rel(node, min(component).per(node))
5995
+ )
5996
+ # NOTE: This logic, including in the constrained case, consumes
5997
+ # the (unconstrained, cached) self._neighbor relation, which is
5998
+ # ~O(nodes) to compute. Note that this seems to be about the best we can do:
5999
+ # To compute the (weak) reach, we either need: a) the edge relation, and
6000
+ # O(nodes) scans over the edge relation; b) the edge relation and
6001
+ # reverse edge relation, forming which is O(nodes); or c) the neighbor
6002
+ # relation over all nodes in the (weak) reach, computing which requires
6003
+ # one of (a) or (b) above. Given that, for simplicity here we use (c),
6004
+ # as there's a reasonable likelihood of re-/shared-use.
6005
+
6006
+ if node_subset is None:
6007
+ # The reach relation from all nodes is the full relation.
6008
+ return _weakly_connected_component_reach_rel
6009
+ else:
6010
+ # A weakly connected component relation constrained to
6011
+ # nodes in the subset (filtered from the reach relation above).
6012
+ _weakly_connected_component_constrained_rel = self._model.Relationship(
6013
+ f"{{node:{self._NodeConceptStr}}} is in the connected component {{id:{self._NodeConceptStr}}}"
6014
+ )
6015
+
6016
+ where(
6017
+ node_subset(node),
6018
+ _weakly_connected_component_reach_rel(node, component)
6019
+ ).define(_weakly_connected_component_constrained_rel(node, component))
6020
+
6021
+ return _weakly_connected_component_constrained_rel
6022
+
5350
6023
 
5351
6024
  @include_in_docs
5352
6025
  def diameter_range(self):
@@ -5517,6 +6190,28 @@ class Graph():
5517
6190
 
5518
6191
  return _reachable_from_min_node_rel
5519
6192
 
6193
+ @cached_property
6194
+ def _distance_non_weighted(self):
6195
+ """Lazily define and cache the self._distance relationship."""
6196
+ if not self.weighted:
6197
+ return self._distance
6198
+ else:
6199
+ return self._create_distance_relationship(weighted=False)
6200
+
6201
+ @cached_property
6202
+ def _distance_reversed_non_weighted(self):
6203
+ """Lazily define and cache the self._distance_reversed_non_weighted relationship, a non-public helper."""
6204
+ _distance_reversed_non_weighted_rel = self._model.Relationship(f"{{node_u:{self._NodeConceptStr}}} and {{node_v:{self._NodeConceptStr}}} have a reversed distance of {{d:Integer}}")
6205
+ node_u, node_v, node_n, d1 = self.Node.ref(), self.Node.ref(), self.Node.ref(), Integer.ref()
6206
+ node_u, node_v, d = union(
6207
+ where(node_u == node_v, d1 == 0).select(node_u, node_v, d1), # Base case.
6208
+ where(self._edge(node_v, node_n),
6209
+ d2 := _distance_reversed_non_weighted_rel(node_u, node_n, Integer) + 1).select(node_u, node_v, d2) # Recursive case.
6210
+ )
6211
+ define(_distance_reversed_non_weighted_rel(node_u, node_v, min(d).per(node_u, node_v)))
6212
+
6213
+ return _distance_reversed_non_weighted_rel
6214
+
5520
6215
 
5521
6216
  @include_in_docs
5522
6217
  def is_connected(self):
@@ -5901,9 +6596,10 @@ class Graph():
5901
6596
  doi: 10.1162/netn_a_00199. PMID: 34746624; PMCID: PMC8567827.
5902
6597
 
5903
6598
  """
5904
- # Validate domain constraint parameters.
6599
+ # Validate domain constraint parameters (jaccard_similarity is symmetric).
6600
+ symmetric = True
5905
6601
  self._validate_domain_constraint_parameters(
5906
- 'jaccard_similarity', full, from_, to, between
6602
+ 'jaccard_similarity', symmetric, full, from_, to, between
5907
6603
  )
5908
6604
 
5909
6605
  # At this point, exactly one of `full`, `from_`, or `between`
@@ -6476,9 +7172,10 @@ class Graph():
6476
7172
  1 3 4 0.707107
6477
7173
 
6478
7174
  """
6479
- # Validate domain constraint parameters.
7175
+ # Validate domain constraint parameters (cosine_similarity is symmetric).
7176
+ symmetric = True
6480
7177
  self._validate_domain_constraint_parameters(
6481
- 'cosine_similarity', full, from_, to, between
7178
+ 'cosine_similarity', symmetric, full, from_, to, between
6482
7179
  )
6483
7180
 
6484
7181
  # At this point, exactly one of `full`, `from_`, or `between`
@@ -6869,9 +7566,10 @@ class Graph():
6869
7566
  1 2 3 1.442695
6870
7567
 
6871
7568
  """
6872
- # Validate domain constraint parameters.
7569
+ # Validate domain constraint parameters (adamic_adar is symmetric).
7570
+ symmetric = True
6873
7571
  self._validate_domain_constraint_parameters(
6874
- 'adamic_adar', full, from_, to, between
7572
+ 'adamic_adar', symmetric, full, from_, to, between
6875
7573
  )
6876
7574
 
6877
7575
  # At this point, exactly one of `full`, `from_`, or `between`
@@ -7216,9 +7914,10 @@ class Graph():
7216
7914
  1 2 4 6
7217
7915
 
7218
7916
  """
7219
- # Validate domain constraint parameters.
7917
+ # Validate domain constraint parameters (preferential_attachment is symmetric).
7918
+ symmetric = True
7220
7919
  self._validate_domain_constraint_parameters(
7221
- 'preferential_attachment', full, from_, to, between
7920
+ 'preferential_attachment', symmetric, full, from_, to, between
7222
7921
  )
7223
7922
 
7224
7923
  # At this point, exactly one of `full`, `from_`, or `between`
@@ -7472,6 +8171,197 @@ class Graph():
7472
8171
  return _isolated_node_rel
7473
8172
 
7474
8173
 
8174
+ @cached_property
8175
+ def _non_isolated_node(self):
8176
+ """Lazily define and cache the self._non_isolated_node relationship."""
8177
+ # Primarily a helper for the primitive graph algorithms at this time.
8178
+ _non_isolated_node_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} is not isolated")
8179
+
8180
+ node, neighbor = self.Node.ref(), self.Node.ref()
8181
+ where(node, self._neighbor(node, neighbor)).define(_non_isolated_node_rel(node))
8182
+
8183
+ return _non_isolated_node_rel
8184
+
8185
+
8186
+ @cached_property
8187
+ def _primitive_node_to_index_rel(self):
8188
+ """
8189
+ The graph primitives operate over node indices, contiguous from one;
8190
+ compute a map from the identifier for each non-isolated node to such an index.
8191
+ Lazily define and cache that relationship for shared use across primitive algorithms.
8192
+ """
8193
+ _node_to_index_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} has {{index:Integer}}")
8194
+
8195
+ node = self.Node.ref()
8196
+ where(
8197
+ self._non_isolated_node(node),
8198
+ index := rank(node)
8199
+ ).define(
8200
+ _node_to_index_rel(node, index)
8201
+ )
8202
+
8203
+ return _node_to_index_rel
8204
+
8205
+ @cached_property
8206
+ def _primitive_weight_list(self):
8207
+ """
8208
+ The graph primitives operate over a normalized weight list, where
8209
+ the nodes are represented by their contiguous indices as Int64s.
8210
+ Lazily define and cache that normalized weight list for shared use
8211
+ across primitive algorithms.
8212
+ """
8213
+ _normalized_weight_list = self._model.Relationship("{u:Int64} {v:Int64} {w:Float}")
8214
+
8215
+ src_node, dst_node, weight = self.Node.ref(), self.Node.ref(), Float.ref()
8216
+ src_index, dst_index = Integer.ref(), Integer.ref()
8217
+ where(
8218
+ self._weight(src_node, dst_node, weight),
8219
+ self._primitive_node_to_index_rel(src_node, src_index),
8220
+ self._primitive_node_to_index_rel(dst_node, dst_index)
8221
+ ).define(
8222
+ _normalized_weight_list(int64(src_index), int64(dst_index), weight)
8223
+ )
8224
+
8225
+ return _normalized_weight_list
8226
+
8227
+ @cached_property
8228
+ def _primitive_node_count(self):
8229
+ """
8230
+ The graph primitives operate only over non-isolated nodes;
8231
+ compute the number of non-isolated nodes as an Int64.
8232
+ Lazily define and cache that count for shared use across primitive algorithms.
8233
+ """
8234
+ _normalized_node_count = self._model.Relationship("{node_count:Int64}")
8235
+
8236
+ define(_normalized_node_count(
8237
+ int64(count(self.Node).where(self._non_isolated_node(self.Node)))
8238
+ ))
8239
+
8240
+ return _normalized_node_count
8241
+
8242
+ @cached_property
8243
+ def _primitive_edge_count(self):
8244
+ """
8245
+ The graph primitives operate over a normalized weight list;
8246
+ compute the number of normalized edges as an Int64.
8247
+ Lazily define and cache that count for shared use across primitive algorithms.
8248
+
8249
+ (Note that the count of normalized edges does not in general match
8250
+ graph.num_edges(); it should match for directed graphs, but not for
8251
+ undirected graphs, where num_edges computes the number of
8252
+ undirected rather than directed edges.)
8253
+ """
8254
+ _normalized_edge_count = self._model.Relationship("{edge_count:Int64}")
8255
+
8256
+ u_index, v_index = builder_internal.Int64.ref(), builder_internal.Int64.ref()
8257
+ define(_normalized_edge_count(
8258
+ int64(count(u_index, v_index).where(self._primitive_weight_list(u_index, v_index, Float)))
8259
+ ))
8260
+
8261
+ return _normalized_edge_count
8262
+
8263
+ def _create_primitive_algorithm_relationship(
8264
+ self,
8265
+ primitive_name: str,
8266
+ primitive_params: list,
8267
+ ):
8268
+ """
8269
+ Helper method for infomap, louvain, and label propagation,
8270
+ i.e. the graph algorithsm that exercise graph primitives.
8271
+
8272
+ Create community assignment and diagnostic information relationships
8273
+ exercising the specified primitive algorithm with the provided parameters.
8274
+ """
8275
+ # Create relationship in which to store the final community assignments.
8276
+ _community_assignments_rel = self._model.Relationship(
8277
+ f"{{node:{self._NodeConceptStr}}} belongs to {{community:Integer}}")
8278
+
8279
+ # The graph primitives operate over a normalized form of the graph.
8280
+ # Most of the logic below transforms from the graph, to that normalized
8281
+ # form, and back again.
8282
+
8283
+ # The graph primitives operate over node indices, contiguous from one;
8284
+ # compute a map from the identifier for each non-isolated node to such an index.
8285
+ _primitive_node_to_index_rel = self._primitive_node_to_index_rel
8286
+
8287
+ # The graph primitives operate over a normalized weight list, where
8288
+ # the nodes are represented by their contiguous indices as Int64s.
8289
+ _primitive_weight_list = self._primitive_weight_list
8290
+
8291
+ # Compute the number of non-isolated nodes and normalized edges, as int64s.
8292
+ # (Note that the count of normalized edges does not in general match
8293
+ # graph.num_edges(); it should match for directed graphs, but not for
8294
+ # undirected graphs, where num_edges computes the number of
8295
+ # undirected rather than directed edges.)
8296
+ _primitive_node_count = self._primitive_node_count
8297
+ _primitive_edge_count = self._primitive_edge_count
8298
+
8299
+ # Invoke the graph primitive over the normalized data.
8300
+ primitive_expr = builder_internal.Expression(
8301
+ builder_internal.Relationship.builtins[primitive_name],
8302
+ builder_internal.TypeRef(_primitive_weight_list),
8303
+ builder_internal.TypeRef(_primitive_node_count),
8304
+ builder_internal.TypeRef(_primitive_edge_count),
8305
+ *primitive_params,
8306
+ builder_internal.String.ref("diagnostic_info"),
8307
+ builder_internal.Int64.ref("node_index"),
8308
+ builder_internal.Int64.ref("community")
8309
+ )
8310
+
8311
+ last_input_arg_offset = 3 + len(primitive_params) - 1
8312
+ prim_diagnostic_info = primitive_expr._arg_ref(last_input_arg_offset + 1)
8313
+ prim_node_index = primitive_expr._arg_ref(last_input_arg_offset + 2)
8314
+ prim_community = primitive_expr._arg_ref(last_input_arg_offset + 3)
8315
+
8316
+ # Extract the primitive's community assignments for
8317
+ # non-isolated nodes into a relation, mapping from
8318
+ # Int64s to Integers for smoother consumption downstream.
8319
+ _primitive_assignments_rel = self._model.Relationship("{node_index:Integer} has {community:Integer}")
8320
+ define(_primitive_assignments_rel(prim_node_index, prim_community))
8321
+ # TODO: May be possible to remove this intermediate relationship,
8322
+ # by directly mapping into `_infomap_result_rel` below.
8323
+ # But if so, need to take care not to introduce recursion in rules below.
8324
+
8325
+ # Transform the primitive's community assignments, which map
8326
+ # node indices to communities, to a map from nodes to communities.
8327
+ # Note that this covers only non-isolated nodes; isolated handled later.
8328
+ node = self.Node.ref()
8329
+ node_index = Integer.ref()
8330
+ community = Integer.ref()
8331
+ where(
8332
+ _primitive_node_to_index_rel(node, node_index),
8333
+ _primitive_assignments_rel(node_index, community),
8334
+ ).define(
8335
+ _community_assignments_rel(node, community)
8336
+ )
8337
+
8338
+ # Each isolated node must be assigned to its own unique community.
8339
+ # The primitive's community assignments are contiguous integers from one,
8340
+ # so we can assign isolated nodes to communities by offsetting their
8341
+ # enumeration index by the maximum community assigned by the primitive.
8342
+ isolated_node = self.Node.ref()
8343
+ nonisolated_index = Integer.ref()
8344
+ nonisolated_comm = Integer.ref()
8345
+ where(
8346
+ self._isolated_node(isolated_node),
8347
+ isolated_node_rank := rank(isolated_node),
8348
+ max_nonisolated_comm := (
8349
+ max(nonisolated_index, nonisolated_comm).where(
8350
+ _primitive_assignments_rel(nonisolated_index, nonisolated_comm)
8351
+ ) | 0
8352
+ ),
8353
+ isolated_comm := isolated_node_rank + max_nonisolated_comm
8354
+ ).define(_community_assignments_rel(isolated_node, isolated_comm))
8355
+
8356
+ # Extract diagnostic information from the primitive.
8357
+ _diagnostic_info_rel = self._model.Relationship("{diagnostic_info:String}")
8358
+ define(_diagnostic_info_rel(prim_diagnostic_info))
8359
+
8360
+ # Return both the community assignments and diagnostic information.
8361
+ return _community_assignments_rel, _diagnostic_info_rel
8362
+
8363
+
8364
+ @include_in_docs
7475
8365
  def infomap(
7476
8366
  self,
7477
8367
  max_levels: int = 1,
@@ -7481,6 +8371,7 @@ class Graph():
7481
8371
  teleportation_rate: float = 0.15,
7482
8372
  visit_rate_tolerance: float = 1e-15,
7483
8373
  randomization_seed: int = 8675309,
8374
+ diagnostic_info: bool = False,
7484
8375
  ):
7485
8376
  """Partitions nodes into communities using a variant of the Infomap algorithm.
7486
8377
 
@@ -7510,20 +8401,42 @@ class Graph():
7510
8401
  randomization_seed : int, optional
7511
8402
  The random number generator seed for the run. Must be a non-negative
7512
8403
  integer. Default is 8675309.
8404
+ diagnostic_info : bool, optional
8405
+ If ``True``, returns diagnostic information alongside
8406
+ community assignments. If ``False`` (default), returns only community
8407
+ assignments.
7513
8408
 
7514
8409
  Returns
7515
8410
  -------
7516
- Relationship
7517
- A binary relationship where each tuple represents a node and its
7518
- community assignment.
8411
+ Relationship or tuple of Relationships
8412
+ If ``diagnostic_info`` is ``False`` (default), returns a binary
8413
+ relationship where each tuple represents a node and its community
8414
+ assignment.
8415
+
8416
+ If ``diagnostic_info`` is ``True``, returns a tuple of
8417
+ ``(community_assignments, diagnostic_info)`` where:
8418
+
8419
+ - ``community_assignments`` is the binary relationship described above
8420
+ - ``diagnostic_info`` is a unary relationship containing a diagnostic
8421
+ string describing the algorithm's convergence and termination behavior
7519
8422
 
7520
8423
  Relationship Schema
7521
8424
  -------------------
8425
+ When ``diagnostic_info=False`` (default):
8426
+
7522
8427
  ``infomap(node, community_label)``
7523
8428
 
7524
8429
  * **node** (*Node*): The node.
7525
8430
  * **community_label** (*Integer*): The label of the community the node
7526
- belongs to.
8431
+ belongs to.
8432
+
8433
+ When ``diagnostic_info=True``, returns two relationships:
8434
+
8435
+ - ``infomap(node, community_label)`` as described above; and
8436
+ - ``diagnostic_info(diagnostic_string)``
8437
+
8438
+ * **diagnostic_string** (*String*): A diagnostic string describing the
8439
+ algorithm's convergence and termination behavior.
7527
8440
 
7528
8441
  Supported Graph Types
7529
8442
  ---------------------
@@ -7650,8 +8563,37 @@ class Graph():
7650
8563
  _assert_type("infomap:randomization_seed", randomization_seed, int)
7651
8564
  _assert_exclusive_lower_bound("infomap:randomization_seed", randomization_seed, 0)
7652
8565
 
7653
- raise NotImplementedError("`infomap` is not yet implemented.")
8566
+ # Collect parameters to rel_primitive_infomap,
8567
+ # appropriately typed and ordered for the primitive.
8568
+ infomap_parameters = [
8569
+ float(teleportation_rate),
8570
+ float(visit_rate_tolerance),
8571
+ float(level_tolerance),
8572
+ float(sweep_tolerance),
8573
+ int(max_levels),
8574
+ int(max_sweeps),
8575
+ int(randomization_seed),
8576
+ ]
8577
+
8578
+ # Create infomap community assignment and, if requested,
8579
+ # diagnostic information relationships.
8580
+ _infomap_assignments_rel, _infomap_diagnostic_rel = \
8581
+ self._create_primitive_algorithm_relationship(
8582
+ "infomap", infomap_parameters
8583
+ )
8584
+
8585
+ # Attach tracking information to the community assignment relationship.
8586
+ _infomap_assignments_rel.annotate(annotations.track("graphs", "infomap"))
8587
+
8588
+ # Return either just the community assignments, or both
8589
+ # the community assignments and diagnostic information, per request.
8590
+ if diagnostic_info:
8591
+ return _infomap_assignments_rel, _infomap_diagnostic_rel
8592
+ else:
8593
+ return _infomap_assignments_rel
8594
+
7654
8595
 
8596
+ @include_in_docs
7655
8597
  def louvain(
7656
8598
  self,
7657
8599
  max_levels: int = 1,
@@ -7659,6 +8601,7 @@ class Graph():
7659
8601
  level_tolerance: float = 0.01,
7660
8602
  sweep_tolerance: float = 0.0001,
7661
8603
  randomization_seed: int = 8675309,
8604
+ diagnostic_info: bool = False,
7662
8605
  ):
7663
8606
  """Partitions nodes into communities using the Louvain algorithm.
7664
8607
 
@@ -7682,12 +8625,23 @@ class Graph():
7682
8625
  randomization_seed : int, optional
7683
8626
  The random number generator seed for the run. Must be a
7684
8627
  non-negative integer. Default is 8675309.
8628
+ diagnostic_info : bool, optional
8629
+ If ``True``, returns diagnostic information alongside community
8630
+ assignments. If ``False`` (default), returns only community assignments.
7685
8631
 
7686
8632
  Returns
7687
8633
  -------
7688
- Relationship
7689
- A binary relationship where each tuple represents a node and its
7690
- community assignment.
8634
+ Relationship or tuple of Relationships
8635
+ If ``diagnostic_info`` is ``False`` (default), returns a binary
8636
+ relationship where each tuple represents a node and its community
8637
+ assignment.
8638
+
8639
+ If ``diagnostic_info`` is ``True``, returns a tuple of
8640
+ ``(community_assignments, diagnostic_info)`` where:
8641
+
8642
+ - ``community_assignments`` is the binary relationship described above
8643
+ - ``diagnostic_info`` is a unary relationship containing a diagnostic
8644
+ string describing the algorithm's termination behavior.
7691
8645
 
7692
8646
  Raises
7693
8647
  ------
@@ -7696,11 +8650,21 @@ class Graph():
7696
8650
 
7697
8651
  Relationship Schema
7698
8652
  -------------------
8653
+ When ``diagnostic_info=False`` (default):
8654
+
7699
8655
  ``louvain(node, community_label)``
7700
8656
 
7701
8657
  * **node** (*Node*): The node.
7702
8658
  * **community_label** (*Integer*): The label of the community the node
7703
- belongs to.
8659
+ belongs to.
8660
+
8661
+ When ``diagnostic_info=True``, returns two relationships:
8662
+
8663
+ - ``louvain(node, community_lable)`` as described above; and
8664
+ - ``diagnostic_info(diagnostic_string)``
8665
+
8666
+ * **diagnostic_string** (*String*): A diagnostic string describing the
8667
+ algorithm's convergence and termination behavior.
7704
8668
 
7705
8669
  Supported Graph Types
7706
8670
  ---------------------
@@ -7821,12 +8785,39 @@ class Graph():
7821
8785
  _assert_type("louvain:randomization_seed", randomization_seed, int)
7822
8786
  _assert_exclusive_lower_bound("louvain:randomization_seed", randomization_seed, 0)
7823
8787
 
7824
- raise NotImplementedError("`louvain` is not yet implemented.")
8788
+ # Collect parameters to rel_primitive_louvain,
8789
+ # appropriately typed and ordered for the primitive.
8790
+ louvain_parameters = [
8791
+ float(level_tolerance),
8792
+ float(sweep_tolerance),
8793
+ int(max_levels),
8794
+ int(max_sweeps),
8795
+ int(randomization_seed),
8796
+ ]
8797
+
8798
+ # Create louvain community assignment and, if requested,
8799
+ # diagnostic information relationships.
8800
+ _louvain_assignments_rel, _louvain_diagnostic_rel = \
8801
+ self._create_primitive_algorithm_relationship(
8802
+ "louvain", louvain_parameters
8803
+ )
8804
+
8805
+ # Attach tracking information to the community assignment relationship.
8806
+ _louvain_assignments_rel.annotate(annotations.track("graphs", "louvain"))
8807
+
8808
+ # Return either just the community assignments, or both
8809
+ # the community assignments and diagnostic information, per request.
8810
+ if diagnostic_info:
8811
+ return _louvain_assignments_rel, _louvain_diagnostic_rel
8812
+ else:
8813
+ return _louvain_assignments_rel
7825
8814
 
8815
+ @include_in_docs
7826
8816
  def label_propagation(
7827
8817
  self,
7828
8818
  max_sweeps: int = 20,
7829
8819
  randomization_seed: int = 8675309,
8820
+ diagnostic_info: bool = False,
7830
8821
  ):
7831
8822
  """Partitions nodes into communities using the Label Propagation algorithm.
7832
8823
 
@@ -7841,20 +8832,42 @@ class Graph():
7841
8832
  randomization_seed : int, optional
7842
8833
  The random number generator seed for the run. Must be a positive
7843
8834
  integer. Default is 8675309.
8835
+ diagnostic_info : bool, optional
8836
+ If ``True``, returns diagnostic information alongside
8837
+ community assignments. If ``False`` (default), returns only community
8838
+ assignments.
7844
8839
 
7845
8840
  Returns
7846
8841
  -------
7847
- Relationship
7848
- A binary relationship where each tuple represents a node and its
7849
- community assignment.
8842
+ Relationship or tuple of Relationships
8843
+ If ``diagnostic_info`` is ``False`` (default), returns a binary
8844
+ relationship where each tuple represents a node and its community
8845
+ assignment.
8846
+
8847
+ If ``diagnostic_info`` is ``True``, returns a tuple of
8848
+ ``(community_assignments, diagnostic_info)`` where:
8849
+
8850
+ - ``community_assignments`` is the binary relationship described above
8851
+ - ``diagnostic_info`` is a unary relationship containing a diagnostic
8852
+ string describing the algorithm's termination behavior.
7850
8853
 
7851
8854
  Relationship Schema
7852
8855
  -------------------
8856
+ When ``diagnostic_info=False`` (default):
8857
+
7853
8858
  ``label_propagation(node, community_label)``
7854
8859
 
7855
8860
  * **node** (*Node*): The node.
7856
8861
  * **community_label** (*Integer*): The label of the community the node
7857
- belongs to.
8862
+ belongs to.
8863
+
8864
+ When ``diagnostic_info=True``, returns two relationships:
8865
+
8866
+ - ``community_assignments(node, community_label)`` as described above; and
8867
+ - ``diagnostic_info(diganostic_string)``
8868
+
8869
+ * **diagnostic_string** (*String*): A diagnostic string describing the
8870
+ algorithm's convergence and termination behavior.
7858
8871
 
7859
8872
  Supported Graph Types
7860
8873
  ---------------------
@@ -7966,4 +8979,23 @@ class Graph():
7966
8979
  _assert_type("label_propagation:randomization_seed", randomization_seed, int)
7967
8980
  _assert_exclusive_lower_bound("label_propagation:randomization_seed", randomization_seed, 0)
7968
8981
 
7969
- raise NotImplementedError("`label_propagation` is not yet implemented.")
8982
+ # Collect parameters to rel_primitive_async_label_propagation,
8983
+ # appropriately typed and ordered for the primitive.
8984
+ label_propagation_parameters = [
8985
+ int(max_sweeps),
8986
+ int(randomization_seed),
8987
+ ]
8988
+
8989
+ # Invoke the helper method that handles normalization,
8990
+ # primitive invocation, and result transformation.
8991
+ _label_propagation_assignments_rel, _label_propagation_diagnostic_rel = \
8992
+ self._create_primitive_algorithm_relationship("label_propagation", label_propagation_parameters)
8993
+
8994
+ # Add tracking annotation.
8995
+ _label_propagation_assignments_rel.annotate(annotations.track("graphs", "label_propagation"))
8996
+
8997
+ # Return based on whether diagnostic information was requested.
8998
+ if diagnostic_info:
8999
+ return _label_propagation_assignments_rel, _label_propagation_diagnostic_rel
9000
+ else:
9001
+ return _label_propagation_assignments_rel