relationalai 0.11.2__py3-none-any.whl → 0.11.4__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 (42) hide show
  1. relationalai/clients/snowflake.py +44 -15
  2. relationalai/clients/types.py +1 -0
  3. relationalai/clients/use_index_poller.py +446 -178
  4. relationalai/early_access/builder/std/__init__.py +1 -1
  5. relationalai/early_access/dsl/bindings/csv.py +4 -4
  6. relationalai/semantics/internal/internal.py +22 -4
  7. relationalai/semantics/lqp/executor.py +69 -18
  8. relationalai/semantics/lqp/intrinsics.py +23 -0
  9. relationalai/semantics/lqp/model2lqp.py +16 -6
  10. relationalai/semantics/lqp/passes.py +3 -4
  11. relationalai/semantics/lqp/primitives.py +38 -14
  12. relationalai/semantics/metamodel/builtins.py +152 -11
  13. relationalai/semantics/metamodel/factory.py +3 -2
  14. relationalai/semantics/metamodel/helpers.py +78 -2
  15. relationalai/semantics/reasoners/graph/core.py +343 -40
  16. relationalai/semantics/reasoners/optimization/solvers_dev.py +20 -1
  17. relationalai/semantics/reasoners/optimization/solvers_pb.py +24 -3
  18. relationalai/semantics/rel/compiler.py +5 -17
  19. relationalai/semantics/rel/executor.py +2 -2
  20. relationalai/semantics/rel/rel.py +6 -0
  21. relationalai/semantics/rel/rel_utils.py +37 -1
  22. relationalai/semantics/rel/rewrite/extract_common.py +153 -242
  23. relationalai/semantics/sql/compiler.py +540 -202
  24. relationalai/semantics/sql/executor/duck_db.py +21 -0
  25. relationalai/semantics/sql/executor/result_helpers.py +7 -0
  26. relationalai/semantics/sql/executor/snowflake.py +9 -2
  27. relationalai/semantics/sql/rewrite/denormalize.py +4 -6
  28. relationalai/semantics/sql/rewrite/recursive_union.py +23 -3
  29. relationalai/semantics/sql/sql.py +120 -46
  30. relationalai/semantics/std/__init__.py +9 -4
  31. relationalai/semantics/std/datetime.py +363 -0
  32. relationalai/semantics/std/math.py +77 -0
  33. relationalai/semantics/std/re.py +83 -0
  34. relationalai/semantics/std/strings.py +1 -1
  35. relationalai/tools/cli_controls.py +445 -60
  36. relationalai/util/format.py +78 -1
  37. {relationalai-0.11.2.dist-info → relationalai-0.11.4.dist-info}/METADATA +3 -2
  38. {relationalai-0.11.2.dist-info → relationalai-0.11.4.dist-info}/RECORD +41 -39
  39. relationalai/semantics/std/dates.py +0 -213
  40. {relationalai-0.11.2.dist-info → relationalai-0.11.4.dist-info}/WHEEL +0 -0
  41. {relationalai-0.11.2.dist-info → relationalai-0.11.4.dist-info}/entry_points.txt +0 -0
  42. {relationalai-0.11.2.dist-info → relationalai-0.11.4.dist-info}/licenses/LICENSE +0 -0
@@ -2476,7 +2476,7 @@ class Graph():
2476
2476
 
2477
2477
 
2478
2478
  @include_in_docs
2479
- def weighted_degree(self):
2479
+ def weighted_degree(self, *, of: Optional[Relationship] = None):
2480
2480
  """Returns a binary relationship containing the weighted degree of each node.
2481
2481
 
2482
2482
  A node's weighted degree is the sum of the weights of all edges
@@ -2484,6 +2484,13 @@ class Graph():
2484
2484
  of both incoming and outgoing edges. For unweighted graphs, all edge
2485
2485
  weights are considered to be 1.0.
2486
2486
 
2487
+ Parameters
2488
+ ----------
2489
+ of : Relationship, optional
2490
+ A unary relationship containing a subset of the graph's nodes. When
2491
+ provided, constrains the domain of the weighted degree computation: only
2492
+ weighted degrees of nodes in this relationship are computed and returned.
2493
+
2487
2494
  Returns
2488
2495
  -------
2489
2496
  Relationship
@@ -2508,7 +2515,7 @@ class Graph():
2508
2515
 
2509
2516
  Examples
2510
2517
  --------
2511
- >>> from relationalai.semantics import Model, define, select, Float
2518
+ >>> from relationalai.semantics import Model, define, select, where, union, Float
2512
2519
  >>> from relationalai.semantics.reasoners.graph import Graph
2513
2520
  >>>
2514
2521
  >>> # 1. Set up a directed, weighted graph
@@ -2528,12 +2535,42 @@ class Graph():
2528
2535
  >>> # 3. Select the weighted degree of each node and inspect
2529
2536
  >>> node, node_weighted_degree = Node.ref("node"), Float.ref("node_weighted_degree")
2530
2537
  >>> weighted_degree = graph.weighted_degree()
2531
- >>> select(node.id, node_weighted_degree).where(weighted_degree(node, node_weighted_degree)).inspect()
2538
+ >>> select(
2539
+ ... node.id, node_weighted_degree
2540
+ ... ).where(
2541
+ ... weighted_degree(node, node_weighted_degree)
2542
+ ... ).inspect()
2532
2543
  ▰▰▰▰ Setup complete
2533
2544
  id node_weighted_degree
2534
2545
  0 1 0.0
2535
2546
  1 2 1.0
2536
2547
  2 3 1.0
2548
+ >>>
2549
+ >>> # 4. Use 'of' parameter to constrain the set of nodes to compute weighted degree of
2550
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
2551
+ >>> node = Node.ref()
2552
+ >>> where(union(node.id == 2, node.id == 3)).define(subset(node))
2553
+ >>> constrained_weighted_degree = graph.weighted_degree(of=subset)
2554
+ >>> select(
2555
+ ... node.id, node_weighted_degree
2556
+ ... ).where(
2557
+ ... constrained_weighted_degree(node, node_weighted_degree)
2558
+ ... ).inspect()
2559
+ ▰▰▰▰ Setup complete
2560
+ id node_weighted_degree
2561
+ 0 2 1.0
2562
+ 1 3 1.0
2563
+
2564
+ Notes
2565
+ -----
2566
+ The ``weighted_degree()`` method, called with no parameters, computes and caches
2567
+ the full weighted degree relationship, providing efficient reuse across multiple
2568
+ calls to ``weighted_degree()``. In contrast, ``weighted_degree(of=subset)`` computes a
2569
+ constrained relationship specific to the passed-in ``subset`` and that
2570
+ call site. When a significant fraction of the weighted degree relation is needed
2571
+ across a program, ``weighted_degree()`` is typically more efficient; this is the
2572
+ typical case. Use ``weighted_degree(of=subset)`` only when small subsets of the
2573
+ weighted degree relationship are needed collectively across the program.
2537
2574
 
2538
2575
  See Also
2539
2576
  --------
@@ -2541,23 +2578,55 @@ class Graph():
2541
2578
  weighted_outdegree
2542
2579
 
2543
2580
  """
2544
- return self._weighted_degree
2581
+ if of is None:
2582
+ return self._weighted_degree
2583
+ else:
2584
+ # Validate the 'of' parameter
2585
+ self._validate_node_subset_parameter(of)
2586
+ return self._weighted_degree_of(of)
2545
2587
 
2546
2588
  @cached_property
2547
2589
  def _weighted_degree(self):
2548
2590
  """Lazily define and cache the self._weighted_degree relationship."""
2591
+ return self._create_weighted_degree_relationship(nodes_subset=None)
2592
+
2593
+ def _weighted_degree_of(self, nodes_subset: Relationship):
2594
+ """
2595
+ Create a weighted degree relationship constrained to the subset of nodes
2596
+ in `nodes_subset`. Note this relationship is not cached; it is
2597
+ specific to the callsite.
2598
+ """
2599
+ return self._create_weighted_degree_relationship(nodes_subset=nodes_subset)
2600
+
2601
+ def _create_weighted_degree_relationship(self, *, nodes_subset: Optional[Relationship]):
2549
2602
  _weighted_degree_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} has weighted degree {{weight:Float}}")
2550
2603
 
2551
2604
  if self.directed:
2605
+ # For directed graphs, weighted degree is the sum of weighted indegree and weighted outdegree.
2606
+ if nodes_subset is None:
2607
+ weighted_indegree_rel = self._weighted_indegree
2608
+ weighted_outdegree_rel = self._weighted_outdegree
2609
+ else:
2610
+ weighted_indegree_rel = self._weighted_indegree_of(nodes_subset)
2611
+ weighted_outdegree_rel = self._weighted_outdegree_of(nodes_subset)
2612
+
2552
2613
  inweight, outweight = Float.ref(), Float.ref()
2553
2614
  where(
2554
- self._weighted_indegree(self.Node, inweight),
2555
- self._weighted_outdegree(self.Node, outweight),
2615
+ weighted_indegree_rel(self.Node, inweight),
2616
+ weighted_outdegree_rel(self.Node, outweight),
2556
2617
  ).define(_weighted_degree_rel(self.Node, inweight + outweight))
2557
2618
  elif not self.directed:
2619
+ # Choose the appropriate node set
2620
+ if nodes_subset is None:
2621
+ # No constraint - use all nodes
2622
+ node_set = self.Node
2623
+ else:
2624
+ # Constrained to nodes in the subset
2625
+ node_set = nodes_subset
2626
+
2558
2627
  dst, weight = self.Node.ref(), Float.ref()
2559
2628
  where(
2560
- self.Node,
2629
+ node_set(self.Node),
2561
2630
  _weighted_degree := sum(dst, weight).per(self.Node).where(self._weight(self.Node, dst, weight)) | 0.0,
2562
2631
  ).define(_weighted_degree_rel(self.Node, _weighted_degree))
2563
2632
 
@@ -2565,13 +2634,20 @@ class Graph():
2565
2634
 
2566
2635
 
2567
2636
  @include_in_docs
2568
- def weighted_indegree(self):
2637
+ def weighted_indegree(self, *, of: Optional[Relationship] = None):
2569
2638
  """Returns a binary relationship containing the weighted indegree of each node.
2570
2639
 
2571
2640
  A node's weighted indegree is the sum of the weights of all incoming
2572
2641
  edges. For undirected graphs, this is identical to `weighted_degree`.
2573
2642
  For unweighted graphs, all edge weights are considered to be 1.0.
2574
2643
 
2644
+ Parameters
2645
+ ----------
2646
+ of : Relationship, optional
2647
+ A unary relationship containing a subset of the graph's nodes. When
2648
+ provided, constrains the domain of the weighted indegree computation: only
2649
+ weighted indegrees of nodes in this relationship are computed and returned.
2650
+
2575
2651
  Returns
2576
2652
  -------
2577
2653
  Relationship
@@ -2596,7 +2672,7 @@ class Graph():
2596
2672
 
2597
2673
  Examples
2598
2674
  --------
2599
- >>> from relationalai.semantics import Model, define, select, Float
2675
+ >>> from relationalai.semantics import Model, define, select, where, union, Float
2600
2676
  >>> from relationalai.semantics.reasoners.graph import Graph
2601
2677
  >>>
2602
2678
  >>> # 1. Set up a directed, weighted graph
@@ -2626,6 +2702,28 @@ class Graph():
2626
2702
  0 1 -1.0
2627
2703
  1 2 1.0
2628
2704
  2 3 1.0
2705
+ >>>
2706
+ >>> # 4. Use 'of' parameter to constrain the set of nodes to compute weighted indegree of
2707
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
2708
+ >>> node = Node.ref()
2709
+ >>> where(union(node.id == 2, node.id == 3)).define(subset(node))
2710
+ >>> constrained_weighted_indegree = graph.weighted_indegree(of=subset)
2711
+ >>> select(node.id, node_weighted_indegree).where(constrained_weighted_indegree(node, node_weighted_indegree)).inspect()
2712
+ ▰▰▰▰ Setup complete
2713
+ id node_weighted_indegree
2714
+ 0 2 1.0
2715
+ 1 3 1.0
2716
+
2717
+ Notes
2718
+ -----
2719
+ The ``weighted_indegree()`` method, called with no parameters, computes and caches
2720
+ the full weighted indegree relationship, providing efficient reuse across multiple
2721
+ calls to ``weighted_indegree()``. In contrast, ``weighted_indegree(of=subset)`` computes a
2722
+ constrained relationship specific to the passed-in ``subset`` and that
2723
+ call site. When a significant fraction of the weighted indegree relation is needed
2724
+ across a program, ``weighted_indegree()`` is typically more efficient; this is the
2725
+ typical case. Use ``weighted_indegree(of=subset)`` only when small subsets of the
2726
+ weighted indegree relationship are needed collectively across the program.
2629
2727
 
2630
2728
  See Also
2631
2729
  --------
@@ -2633,16 +2731,49 @@ class Graph():
2633
2731
  weighted_outdegree
2634
2732
 
2635
2733
  """
2636
- return self._weighted_indegree
2734
+ # TODO: It looks like the weights in the example in the docstring above
2735
+ # are holdovers from a version of the library that did not disallow
2736
+ # negative weights. Need to update the example to use only non-negative weights.
2737
+ if of is None:
2738
+ return self._weighted_indegree
2739
+ else:
2740
+ # Validate the 'of' parameter
2741
+ self._validate_node_subset_parameter(of)
2742
+ return self._weighted_indegree_of(of)
2637
2743
 
2638
2744
  @cached_property
2639
2745
  def _weighted_indegree(self):
2640
2746
  """Lazily define and cache the self._weighted_indegree relationship."""
2747
+ return self._create_weighted_indegree_relationship(nodes_subset=None)
2748
+
2749
+ def _weighted_indegree_of(self, nodes_subset: Relationship):
2750
+ """
2751
+ Create a weighted indegree relationship constrained to the subset of nodes
2752
+ in `nodes_subset`. Note this relationship is not cached; it is
2753
+ specific to the callsite.
2754
+ """
2755
+ return self._create_weighted_indegree_relationship(nodes_subset=nodes_subset)
2756
+
2757
+ def _create_weighted_indegree_relationship(self, *, nodes_subset: Optional[Relationship]):
2641
2758
  _weighted_indegree_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} has weighted indegree {{weight:Float}}")
2642
2759
 
2760
+ # Choose the appropriate node set
2761
+ if nodes_subset is None:
2762
+ # No constraint - use all nodes
2763
+ node_set = self.Node
2764
+ else:
2765
+ # Constrained to nodes in the subset
2766
+ node_set = nodes_subset
2767
+ # TODO: In a future cleanup pass, replace `node_set` with a `node_constraint`
2768
+ # that replaces the `node_set(self.Node)` in the where clause below,
2769
+ # and generates only `self.Node` (rather than `self.Node(self.Node)`)
2770
+ # in the `subset is None` case. This applies to a couple other
2771
+ # degree-of type relations as well.
2772
+
2773
+ # Apply the weighted indegree logic for both cases
2643
2774
  src, inweight = self.Node.ref(), Float.ref()
2644
2775
  where(
2645
- self.Node,
2776
+ node_set(self.Node),
2646
2777
  _weighted_indegree := sum(src, inweight).per(self.Node).where(self._weight(src, self.Node, inweight)) | 0.0,
2647
2778
  ).define(_weighted_indegree_rel(self.Node, _weighted_indegree))
2648
2779
 
@@ -2650,13 +2781,20 @@ class Graph():
2650
2781
 
2651
2782
 
2652
2783
  @include_in_docs
2653
- def weighted_outdegree(self):
2784
+ def weighted_outdegree(self, *, of: Optional[Relationship] = None):
2654
2785
  """Returns a binary relationship containing the weighted outdegree of each node.
2655
2786
 
2656
2787
  A node's weighted outdegree is the sum of the weights of all outgoing
2657
2788
  edges. For undirected graphs, this is identical to `weighted_degree`.
2658
2789
  For unweighted graphs, all edge weights are considered to be 1.0.
2659
2790
 
2791
+ Parameters
2792
+ ----------
2793
+ of : Relationship, optional
2794
+ A unary relationship containing a subset of the graph's nodes. When
2795
+ provided, constrains the domain of the weighted outdegree computation: only
2796
+ weighted outdegrees of nodes in this relationship are computed and returned.
2797
+
2660
2798
  Returns
2661
2799
  -------
2662
2800
  Relationship
@@ -2681,7 +2819,7 @@ class Graph():
2681
2819
 
2682
2820
  Examples
2683
2821
  --------
2684
- >>> from relationalai.semantics import Model, define, select, Float
2822
+ >>> from relationalai.semantics import Model, define, select, where, union, Float
2685
2823
  >>> from relationalai.semantics.reasoners.graph import Graph
2686
2824
  >>>
2687
2825
  >>> # 1. Set up a directed, weighted graph
@@ -2711,6 +2849,32 @@ class Graph():
2711
2849
  0 1 1.0
2712
2850
  1 2 0.0
2713
2851
  2 3 0.0
2852
+ >>>
2853
+ >>> # 4. Use 'of' parameter to constrain the set of nodes to compute weighted outdegree of
2854
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
2855
+ >>> node = Node.ref()
2856
+ >>> where(union(node.id == 1, node.id == 2)).define(subset(node))
2857
+ >>> constrained_weighted_outdegree = graph.weighted_outdegree(of=subset)
2858
+ >>> select(
2859
+ ... node.id, node_weighted_outdegree
2860
+ ... ).where(
2861
+ ... constrained_weighted_outdegree(node, node_weighted_outdegree)
2862
+ ... ).inspect()
2863
+ ▰▰▰▰ Setup complete
2864
+ id node_weighted_outdegree
2865
+ 0 1 1.0
2866
+ 1 2 0.0
2867
+
2868
+ Notes
2869
+ -----
2870
+ The ``weighted_outdegree()`` method, called with no parameters, computes and caches
2871
+ the full weighted outdegree relationship, providing efficient reuse across multiple
2872
+ calls to ``weighted_outdegree()``. In contrast, ``weighted_outdegree(of=subset)`` computes a
2873
+ constrained relationship specific to the passed-in ``subset`` and that
2874
+ call site. When a significant fraction of the weighted outdegree relation is needed
2875
+ across a program, ``weighted_outdegree()`` is typically more efficient; this is the
2876
+ typical case. Use ``weighted_outdegree(of=subset)`` only when small subsets of the
2877
+ weighted outdegree relationship are needed collectively across the program.
2714
2878
 
2715
2879
  See Also
2716
2880
  --------
@@ -2718,16 +2882,41 @@ class Graph():
2718
2882
  weighted_indegree
2719
2883
 
2720
2884
  """
2721
- return self._weighted_outdegree
2885
+ if of is None:
2886
+ return self._weighted_outdegree
2887
+ else:
2888
+ # Validate the 'of' parameter
2889
+ self._validate_node_subset_parameter(of)
2890
+ return self._weighted_outdegree_of(of)
2722
2891
 
2723
2892
  @cached_property
2724
2893
  def _weighted_outdegree(self):
2725
2894
  """Lazily define and cache the self._weighted_outdegree relationship."""
2895
+ return self._create_weighted_outdegree_relationship(nodes_subset=None)
2896
+
2897
+ def _weighted_outdegree_of(self, nodes_subset: Relationship):
2898
+ """
2899
+ Create a weighted outdegree relationship constrained to the subset of nodes
2900
+ in `nodes_subset`. Note this relationship is not cached; it is
2901
+ specific to the callsite.
2902
+ """
2903
+ return self._create_weighted_outdegree_relationship(nodes_subset=nodes_subset)
2904
+
2905
+ def _create_weighted_outdegree_relationship(self, *, nodes_subset: Optional[Relationship]):
2726
2906
  _weighted_outdegree_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} has weighted outdegree {{weight:Float}}")
2727
2907
 
2908
+ # Choose the appropriate node set
2909
+ if nodes_subset is None:
2910
+ # No constraint - use all nodes
2911
+ node_set = self.Node
2912
+ else:
2913
+ # Constrained to nodes in the subset
2914
+ node_set = nodes_subset
2915
+
2916
+ # Apply the weighted outdegree logic for both cases
2728
2917
  dst, outweight = self.Node.ref(), Float.ref()
2729
2918
  where(
2730
- self.Node,
2919
+ node_set(self.Node),
2731
2920
  _weighted_outdegree := sum(dst, outweight).per(self.Node).where(self._weight(self.Node, dst, outweight)) | 0.0,
2732
2921
  ).define(_weighted_outdegree_rel(self.Node, _weighted_outdegree))
2733
2922
 
@@ -2735,14 +2924,24 @@ class Graph():
2735
2924
 
2736
2925
 
2737
2926
  @include_in_docs
2738
- def degree_centrality(self):
2927
+ def degree_centrality(self, *, of: Optional[Relationship] = None):
2739
2928
  """Returns a binary relationship containing the degree centrality of each node.
2740
2929
 
2741
2930
  Degree centrality is a measure of a node's importance, defined as its
2742
2931
  degree (or weighted degree for weighted graphs) divided by the number
2743
- of other nodes in the graph. For simple graphs without self-loops, this
2744
- value will be at most 1.0; graphs with self-loops might have nodes
2745
- with a degree centrality greater than 1.0.
2932
+ of other nodes in the graph.
2933
+
2934
+ For unewighted graphs without self-loops, this value will be at most 1.0;
2935
+ unweighted graphs with self-loops might have nodes with a degree centrality
2936
+ greater than 1.0. Weighted graphs may have degree centralities
2937
+ greater than 1.0 as well.
2938
+
2939
+ Parameters
2940
+ ----------
2941
+ of : Relationship, optional
2942
+ A unary relationship containing a subset of the graph's nodes. When
2943
+ provided, constrains the domain of the degree centrality computation: only
2944
+ degree centralities of nodes in this relationship are computed and returned.
2746
2945
 
2747
2946
  Returns
2748
2947
  -------
@@ -2798,6 +2997,20 @@ class Graph():
2798
2997
  2 3 1.000000
2799
2998
  3 4 0.666667
2800
2999
 
3000
+ >>> # 4. Use 'of' parameter to constrain the set of nodes to compute degree centrality of
3001
+ >>> # Define a subset containing only nodes 2 and 3
3002
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
3003
+ >>> node = Node.ref()
3004
+ >>> where(union(node.id == 2, node.id == 3)).define(subset(node))
3005
+ >>>
3006
+ >>> # Get degree centralities only of nodes in the subset
3007
+ >>> constrained_degree_centrality = graph.degree_centrality(of=subset)
3008
+ >>> select(node.id, centrality).where(constrained_degree_centrality(node, centrality)).inspect()
3009
+ ▰▰▰▰ Setup complete
3010
+ id centrality
3011
+ 0 2 1.0
3012
+ 1 3 1.0
3013
+
2801
3014
  **Weighted Graph Example**
2802
3015
 
2803
3016
  >>> from relationalai.semantics import Model, define, select, Float
@@ -2827,52 +3040,90 @@ class Graph():
2827
3040
  1 2 1.75
2828
3041
  2 3 1.00
2829
3042
 
3043
+ Notes
3044
+ -----
3045
+ The ``degree_centrality()`` method, called with no parameters, computes and caches
3046
+ the full degree centrality relationship, providing efficient reuse across multiple
3047
+ calls to ``degree_centrality()``. In contrast, ``degree_centrality(of=subset)`` computes a
3048
+ constrained relationship specific to the passed-in ``subset`` and that
3049
+ call site. When a significant fraction of the degree centrality relation is needed
3050
+ across a program, ``degree_centrality()`` is typically more efficient; this is the
3051
+ typical case. Use ``degree_centrality(of=subset)`` only when small subsets of the
3052
+ degree centrality relationship are needed collectively across the program.
3053
+
2830
3054
  See Also
2831
3055
  --------
2832
3056
  degree
2833
3057
  weighted_degree
2834
3058
 
2835
3059
  """
2836
- return self._degree_centrality
3060
+ if of is None:
3061
+ return self._degree_centrality
3062
+ else:
3063
+ # Validate the 'of' parameter
3064
+ self._validate_node_subset_parameter(of)
3065
+ return self._degree_centrality_of(of)
2837
3066
 
2838
3067
  @cached_property
2839
3068
  def _degree_centrality(self):
2840
3069
  """Lazily define and cache the self._degree_centrality relationship."""
3070
+ return self._create_degree_centrality_relationship(nodes_subset=None)
3071
+
3072
+ def _degree_centrality_of(self, nodes_subset: Relationship):
3073
+ """
3074
+ Create a degree centrality relationship constrained to the subset of nodes
3075
+ in `nodes_subset`. Note this relationship is not cached; it is
3076
+ specific to the callsite.
3077
+ """
3078
+ return self._create_degree_centrality_relationship(nodes_subset=nodes_subset)
3079
+
3080
+ def _create_degree_centrality_relationship(self, *, nodes_subset: Optional[Relationship]):
3081
+ """Create a degree centrality relationship, optionally constrained to a subset of nodes."""
2841
3082
  _degree_centrality_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} has {{degree_centrality:Float}}")
2842
3083
 
2843
- degree = Integer.ref()
2844
- weighted_degree = Float.ref()
3084
+ if nodes_subset is None:
3085
+ degree_rel = self._degree
3086
+ node_constraint = [] # No constraint on nodes.
3087
+ else:
3088
+ degree_rel = self._degree_of(nodes_subset)
3089
+ node_constraint = [nodes_subset(self.Node)] # Nodes constrained to given subset.
2845
3090
 
2846
- # TODO: Collapse the logic for these edge cases using a match.
3091
+ degree = Integer.ref()
2847
3092
 
2848
3093
  # A single isolated node has degree centrality zero.
2849
3094
  where(
2850
3095
  self._num_nodes(1),
2851
- self._degree(self.Node, 0)
3096
+ *node_constraint,
3097
+ degree_rel(self.Node, 0)
2852
3098
  ).define(_degree_centrality_rel(self.Node, 0.0))
2853
3099
 
2854
3100
  # A single non-isolated node has degree centrality one.
2855
3101
  where(
2856
3102
  self._num_nodes(1),
2857
- self._degree(self.Node, degree),
3103
+ *node_constraint,
3104
+ degree_rel(self.Node, degree),
2858
3105
  degree > 0
2859
3106
  ).define(_degree_centrality_rel(self.Node, 1.0))
2860
3107
 
2861
3108
  # General case, i.e. with more than one node.
2862
- num_nodes = Integer.ref()
2863
3109
  if self.weighted:
2864
- where(
2865
- self._num_nodes(num_nodes),
2866
- num_nodes > 1,
2867
- self._weighted_degree(self.Node, weighted_degree)
2868
- ).define(_degree_centrality_rel(self.Node, weighted_degree / (num_nodes - 1.0)))
2869
- elif not self.weighted:
2870
- where(
2871
- self._num_nodes(num_nodes),
2872
- num_nodes > 1,
2873
- self._degree(self.Node, degree)
2874
- ).define(_degree_centrality_rel(self.Node, degree / (num_nodes - 1.0)))
2875
- # TODO: .to_df() second-column dtype becomes `object` rather than `Float`.
3110
+ maybe_weighted_degree = Float.ref()
3111
+ if nodes_subset is None:
3112
+ maybe_weighted_degree_rel = self._weighted_degree
3113
+ else:
3114
+ maybe_weighted_degree_rel = self._weighted_degree_of(nodes_subset)
3115
+ else: # not self.weighted
3116
+ maybe_weighted_degree = Integer.ref()
3117
+ maybe_weighted_degree_rel = degree_rel
3118
+
3119
+ num_nodes = Integer.ref()
3120
+
3121
+ where(
3122
+ self._num_nodes(num_nodes),
3123
+ num_nodes > 1,
3124
+ *node_constraint,
3125
+ maybe_weighted_degree_rel(self.Node, maybe_weighted_degree)
3126
+ ).define(_degree_centrality_rel(self.Node, maybe_weighted_degree / (num_nodes - 1.0)))
2876
3127
 
2877
3128
  return _degree_centrality_rel
2878
3129
 
@@ -3614,12 +3865,19 @@ class Graph():
3614
3865
 
3615
3866
 
3616
3867
  @include_in_docs
3617
- def triangle_count(self):
3868
+ def triangle_count(self, *, of: Optional[Relationship] = None):
3618
3869
  """Returns a binary relationship containing the number of unique triangles each node belongs to.
3619
3870
 
3620
3871
  A triangle is a set of three nodes where each node has a directed
3621
3872
  or undirected edge to the other two nodes, forming a 3-cycle.
3622
3873
 
3874
+ Parameters
3875
+ ----------
3876
+ of : Relationship, optional
3877
+ A unary relationship containing a subset of the graph's nodes. When
3878
+ provided, constrains the domain of the triangle count computation: only
3879
+ triangle counts of nodes in this relationship are computed and returned.
3880
+
3623
3881
  Returns
3624
3882
  -------
3625
3883
  Relationship
@@ -3675,6 +3933,31 @@ class Graph():
3675
3933
  3 4 0
3676
3934
  4 5 0
3677
3935
 
3936
+ >>> # 4. Use 'of' parameter to constrain the set of nodes to compute triangle counts of
3937
+ >>> # Define a subset containing only nodes 1 and 3
3938
+ >>> subset = model.Relationship(f"{{node:{Node}}} is in subset")
3939
+ >>> node = Node.ref()
3940
+ >>> where(union(node.id == 1, node.id == 3)).define(subset(node))
3941
+ >>>
3942
+ >>> # Get triangle counts only of nodes in the subset
3943
+ >>> constrained_triangle_count = graph.triangle_count(of=subset)
3944
+ >>> select(node.id, count).where(constrained_triangle_count(node, count)).inspect()
3945
+ ▰▰▰▰ Setup complete
3946
+ id count
3947
+ 0 1 1
3948
+ 1 3 1
3949
+
3950
+ Notes
3951
+ -----
3952
+ The ``triangle_count()`` method, called with no parameters, computes and caches
3953
+ the full triangle count relationship, providing efficient reuse across multiple
3954
+ calls to ``triangle_count()``. In contrast, ``triangle_count(of=subset)`` computes a
3955
+ constrained relationship specific to the passed-in ``subset`` and that
3956
+ call site. When a significant fraction of the triangle count relation is needed
3957
+ across a program, ``triangle_count()`` is typically more efficient; this is the
3958
+ typical case. Use ``triangle_count(of=subset)`` only when small subsets of the
3959
+ triangle count relationship are needed collectively across the program.
3960
+
3678
3961
  See Also
3679
3962
  --------
3680
3963
  triangle
@@ -3682,15 +3965,35 @@ class Graph():
3682
3965
  num_triangles
3683
3966
 
3684
3967
  """
3968
+ if of is not None:
3969
+ self._validate_node_subset_parameter(of)
3970
+ return self._triangle_count_of(of)
3685
3971
  return self._triangle_count
3686
3972
 
3687
3973
  @cached_property
3688
3974
  def _triangle_count(self):
3689
3975
  """Lazily define and cache the self._triangle_count relationship."""
3976
+ return self._create_triangle_count_relationship(nodes_subset=None)
3977
+
3978
+ def _triangle_count_of(self, nodes_subset: Relationship):
3979
+ """
3980
+ Create a triangle count relationship constrained to the subset of nodes
3981
+ in `nodes_subset`. Note this relationship is not cached; it is
3982
+ specific to the callsite.
3983
+ """
3984
+ return self._create_triangle_count_relationship(nodes_subset=nodes_subset)
3985
+
3986
+ def _create_triangle_count_relationship(self, *, nodes_subset: Optional[Relationship]):
3987
+ """Create a triangle count relationship, optionally constrained to a subset of nodes."""
3690
3988
  _triangle_count_rel = self._model.Relationship(f"{{node:{self._NodeConceptStr}}} belongs to {{count:Integer}} triangles")
3691
3989
 
3990
+ if nodes_subset is None:
3991
+ node_constraint = self.Node # No constraint on nodes.
3992
+ else:
3993
+ node_constraint = nodes_subset(self.Node) # Nodes constrained to given subset.
3994
+
3692
3995
  where(
3693
- self.Node,
3996
+ node_constraint,
3694
3997
  _count := self._nonzero_triangle_count_fragment(self.Node) | 0
3695
3998
  ).define(_triangle_count_rel(self.Node, _count))
3696
3999
 
@@ -2,12 +2,15 @@ from __future__ import annotations
2
2
  from typing import Union
3
3
  import textwrap
4
4
  import uuid
5
+ import time
5
6
 
6
7
  from relationalai.semantics.snowflake import Table
7
8
  from relationalai.semantics import std
8
9
  from relationalai.semantics.internal import internal as b # TODO(coey) change b name or remove b.?
9
10
  from relationalai.semantics.rel.executor import RelExecutor
10
11
  from relationalai.semantics.lqp.executor import LQPExecutor
12
+ from relationalai.tools.constants import DEFAULT_QUERY_TIMEOUT_MINS
13
+ from relationalai.util.timeout import calc_remaining_timeout_minutes
11
14
 
12
15
  from .common import make_name
13
16
  from relationalai.experimental.solvers import Solver
@@ -243,6 +246,17 @@ class SolverModelDev:
243
246
  app_name = resources.get_app_name()
244
247
  print(app_name)
245
248
 
249
+ # Note: currently the query timeout is not propagated to the steps 'export model
250
+ # relations', and 'import result relations'. For those steps the default query
251
+ # timeout value defined in the config will apply.
252
+ # TODO: propagate the query timeout to those steps as well.
253
+ query_timeout_mins = kwargs.get("query_timeout_mins", None)
254
+ config = self._model._config
255
+ if query_timeout_mins is None and (timeout_value := config.get("query_timeout_mins", DEFAULT_QUERY_TIMEOUT_MINS)) is not None:
256
+ query_timeout_mins = int(timeout_value)
257
+ config_file_path = getattr(config, 'file_path', None)
258
+ start_time = time.monotonic()
259
+
246
260
  # 1. export model relations
247
261
  print("export model relations")
248
262
  # TODO(coey) perf: only export the relations that are actually used in the model
@@ -266,6 +280,9 @@ class SolverModelDev:
266
280
  b.select(*rel._field_refs).where(rel(*rel._field_refs)).into(table)
267
281
 
268
282
  # 2. execute solver job and wait for completion
283
+ remaining_timeout_minutes = calc_remaining_timeout_minutes(
284
+ start_time, query_timeout_mins, config_file_path=config_file_path,
285
+ )
269
286
  print("execute solver job")
270
287
  payload = {
271
288
  "solver": solver.solver_name.lower(),
@@ -273,7 +290,9 @@ class SolverModelDev:
273
290
  "input_id": input_id,
274
291
  "data_type": self._data_type
275
292
  }
276
- job_id = solver._exec_job(payload, log_to_console=log_to_console)
293
+ job_id = solver._exec_job(
294
+ payload, log_to_console=log_to_console, query_timeout_mins=remaining_timeout_minutes,
295
+ )
277
296
  print(f"job id: {job_id}") # TODO(coey) maybe job_id is not useful
278
297
 
279
298
  # 3. import result relations