|
14 | 14 | from cognite.neat._data_model.models.dms._constraints import RequiresConstraintDefinition |
15 | 15 | from cognite.neat._data_model.models.dms._container import ContainerPropertyDefinition, ContainerRequest |
16 | 16 | from cognite.neat._data_model.models.dms._data_types import DirectNodeRelation |
| 17 | +from cognite.neat._data_model.models.dms._indexes import BtreeIndex |
17 | 18 | from cognite.neat._data_model.models.dms._limits import SchemaLimits |
18 | 19 | from cognite.neat._data_model.models.dms._references import ( |
19 | 20 | ContainerDirectReference, |
@@ -622,10 +623,10 @@ def implements_graph(self) -> nx.DiGraph: |
622 | 623 | return graph |
623 | 624 |
|
624 | 625 | @cached_property |
625 | | - def implements_cycles(self) -> list[list[ViewReference]]: |
| 626 | + def implements_cycles(self) -> list[tuple[ViewReference, ...]]: |
626 | 627 | """Find all cycles in the implements graph. |
627 | 628 | Returns: |
628 | | - List of lists, where each list contains the ordered Views involved in forming the implements cycle. |
| 629 | + List of tuples, where each tuple contains the ordered Views involved in forming the implements cycle. |
629 | 630 | """ |
630 | 631 |
|
631 | 632 | return self.graph_cycles(self.implements_graph) |
@@ -731,14 +732,23 @@ def _user_intentional_constraints(self) -> set[tuple[ContainerReference, Contain |
731 | 732 |
|
732 | 733 | A constraint is user-intentional if: |
733 | 734 | 1. The constraint identifier does NOT have '__auto' postfix |
734 | | - 2. Neither src nor dst is part of a cycle (cyclic constraints are errors) |
| 735 | + 2. Neither src nor dst is part of a manually-created cycle |
735 | 736 |
|
736 | 737 | These constraints are preserved even if they're not in the optimal structure, because |
737 | 738 | they may be used for data integrity purposes. |
738 | | - We DON'T consider manual-created constraints as user-intended if they form part of a cycle, |
739 | | - because that indicates a problem with the data model where we likely can provide a better solution. |
| 739 | + We DON'T consider manual-created constraints as user-intended if they form part of a cycle |
| 740 | + consisting entirely of manual constraints. We ignore these because it indicates a problem with |
| 741 | + the data model where we likely can provide a better solution. We don't consider cycles formed |
| 742 | + only by __auto constraints. |
740 | 743 | """ |
741 | | - containers_in_cycles = {container for cycle in self.requires_constraint_cycles for container in cycle} |
| 744 | + containers_in_cycles: set[ContainerReference] = set() |
| 745 | + for cycle in self.requires_constraint_cycles: |
| 746 | + if all( |
| 747 | + self.requires_constraint_graph.edges[cycle[i], cycle[(i + 1) % len(cycle)]].get("is_auto", False) |
| 748 | + for i in range(len(cycle)) |
| 749 | + ): |
| 750 | + continue |
| 751 | + containers_in_cycles.update(cycle) |
742 | 752 |
|
743 | 753 | return { |
744 | 754 | (src, dst) |
@@ -786,17 +796,57 @@ def forms_directed_path(nodes: set[_NodeT], graph: nx.DiGraph) -> bool: |
786 | 796 | return False |
787 | 797 |
|
788 | 798 | @cached_property |
789 | | - def requires_constraint_cycles(self) -> list[list[ContainerReference]]: |
| 799 | + def requires_constraint_cycles(self) -> list[tuple[ContainerReference, ...]]: |
790 | 800 | """Find all cycles in the requires constraint graph. |
791 | 801 | Returns: |
792 | | - List of lists, where each list contains the ordered containers involved in forming the requires cycle. |
| 802 | + List of tuples, where each tuple contains the ordered containers involved in forming the requires cycle. |
793 | 803 | """ |
794 | 804 | return self.graph_cycles(self.requires_constraint_graph) |
795 | 805 |
|
796 | 806 | @staticmethod |
797 | | - def graph_cycles(graph: nx.DiGraph) -> list[list[T_Reference]]: |
| 807 | + def graph_cycles(graph: nx.DiGraph) -> list[tuple[T_Reference, ...]]: |
798 | 808 | """Returns cycles in the graph otherwise empty list""" |
799 | | - return [candidate for candidate in nx.simple_cycles(graph) if len(candidate) > 1] |
| 809 | + return [tuple(candidate) for candidate in nx.simple_cycles(graph) if len(candidate) > 1] |
| 810 | + |
| 811 | + def pick_cycle_constraint_to_remove( |
| 812 | + self, cycle: tuple[ContainerReference, ...] |
| 813 | + ) -> tuple[ContainerReference, ContainerReference]: |
| 814 | + """Pick the single best requires constraint to remove to break a cycle. |
| 815 | +
|
| 816 | + Selects a constraint edge from the cycle using a tiered preference: |
| 817 | + auto-generated constraints are preferred over user-defined ones, and |
| 818 | + edges that conflict with the direction considered optimal by the global optimizer |
| 819 | + are preferred over those that are only redundant and covered by other constraints. |
| 820 | + """ |
| 821 | + suboptimal_constraints: list[tuple[ContainerReference, ContainerReference]] = [] |
| 822 | + for i, source_container_ref in enumerate(cycle): |
| 823 | + required_container_ref = cycle[(i + 1) % len(cycle)] |
| 824 | + if (source_container_ref, required_container_ref) not in self.oriented_mst_edges: |
| 825 | + suboptimal_constraints.append((source_container_ref, required_container_ref)) |
| 826 | + |
| 827 | + # Tier 1: auto-generated and wrong direction from optimal solution |
| 828 | + for source_ref, required_ref in suboptimal_constraints: |
| 829 | + if self.requires_constraint_graph.edges[source_ref, required_ref].get("is_auto", False): |
| 830 | + if (required_ref, source_ref) in self.oriented_mst_edges: |
| 831 | + return source_ref, required_ref |
| 832 | + |
| 833 | + # Tier 2: auto-generated and redundant (covered by other constraints) |
| 834 | + for source_ref, required_ref in suboptimal_constraints: |
| 835 | + if self.requires_constraint_graph.edges[source_ref, required_ref].get("is_auto", False): |
| 836 | + return source_ref, required_ref |
| 837 | + |
| 838 | + # Tier 3: user-defined and wrong direction from optimal solution |
| 839 | + for source_ref, required_ref in suboptimal_constraints: |
| 840 | + if (required_ref, source_ref) in self.oriented_mst_edges: |
| 841 | + return source_ref, required_ref |
| 842 | + |
| 843 | + # Tier 4: user-defined and redundant (covered by other constraints) |
| 844 | + if suboptimal_constraints: |
| 845 | + return suboptimal_constraints[0] |
| 846 | + |
| 847 | + raise RuntimeError( |
| 848 | + f"{type(self).__name__}: No removable constraint found in cycle {cycle}. This is a bug in NEAT." |
| 849 | + ) |
800 | 850 |
|
801 | 851 | @cached_property |
802 | 852 | def optimized_requires_constraint_graph(self) -> nx.DiGraph: |
@@ -1000,6 +1050,80 @@ def _transitively_reduced_edges(self) -> set[tuple[ContainerReference, Container |
1000 | 1050 | # Return MST edges that survive reduction |
1001 | 1051 | return {e for e in reduced.edges() if e in self.oriented_mst_edges} |
1002 | 1052 |
|
| 1053 | + @cached_property |
| 1054 | + def missing_requires_constraints( |
| 1055 | + self, |
| 1056 | + ) -> list[tuple[ViewReference, ContainerReference, ContainerReference]]: |
| 1057 | + """Views with containers that are missing requires constraints needed for optimal query performance. |
| 1058 | +
|
| 1059 | + Each entry is a (view, source_container, required_container) tuple indicating that |
| 1060 | + source_container should have a requires constraint pointing to required_container. |
| 1061 | + """ |
| 1062 | + missing_requires_constraints: list[tuple[ViewReference, ContainerReference, ContainerReference]] = [] |
| 1063 | + for view_ref in self.merged.views: |
| 1064 | + changes = self.get_requires_changes_for_view(view_ref) |
| 1065 | + if changes.status != RequiresChangeStatus.CHANGES_AVAILABLE: |
| 1066 | + continue |
| 1067 | + for source_container_ref, required_container_ref in changes.to_add: |
| 1068 | + missing_requires_constraints.append((view_ref, source_container_ref, required_container_ref)) |
| 1069 | + return missing_requires_constraints |
| 1070 | + |
| 1071 | + @cached_property |
| 1072 | + def suboptimal_requires_constraints( |
| 1073 | + self, |
| 1074 | + ) -> list[tuple[ViewReference, ContainerReference, ContainerReference]]: |
| 1075 | + """Views with containers that have suboptimal requires constraints that should be removed. |
| 1076 | +
|
| 1077 | + Each entry is a (view, source_container, required_container) tuple indicating that |
| 1078 | + the existing requires constraint from source_container to required_container is |
| 1079 | + redundant or wrongly oriented relative to the optimal structure. |
| 1080 | + """ |
| 1081 | + results: list[tuple[ViewReference, ContainerReference, ContainerReference]] = [] |
| 1082 | + for view_ref in self.merged.views: |
| 1083 | + changes = self.get_requires_changes_for_view(view_ref) |
| 1084 | + if changes.status != RequiresChangeStatus.CHANGES_AVAILABLE: |
| 1085 | + continue |
| 1086 | + for source_container_ref, required_container_ref in changes.to_remove: |
| 1087 | + results.append((view_ref, source_container_ref, required_container_ref)) |
| 1088 | + return results |
| 1089 | + |
| 1090 | + @cached_property |
| 1091 | + def missing_reverse_relation_index_targets( |
| 1092 | + self, |
| 1093 | + ) -> list[tuple[ResolvedReverseDirectRelation, tuple[str, BtreeIndex] | None]]: |
| 1094 | + """Reverse direct relations missing a cursorable index on the target container property. |
| 1095 | +
|
| 1096 | + Returns: |
| 1097 | + List of tuples: (resolved_relation, existing_non_cursorable_index or None) |
| 1098 | + """ |
| 1099 | + targets: list[tuple[ResolvedReverseDirectRelation, tuple[str, BtreeIndex] | None]] = [] |
| 1100 | + |
| 1101 | + for resolved in self.resolved_reverse_direct_relations: |
| 1102 | + if not resolved.container or not resolved.container_property: |
| 1103 | + continue |
| 1104 | + if resolved.container_ref.space in COGNITE_SPACES: |
| 1105 | + continue |
| 1106 | + if not isinstance(resolved.container_property.type, DirectNodeRelation): |
| 1107 | + continue |
| 1108 | + if resolved.container_property.type.list: |
| 1109 | + continue |
| 1110 | + |
| 1111 | + for index_id, index in (resolved.container.indexes or {}).items(): |
| 1112 | + if not isinstance(index, BtreeIndex): |
| 1113 | + continue |
| 1114 | + if len(index.properties) != 1: |
| 1115 | + continue |
| 1116 | + if resolved.container_property_id not in index.properties: |
| 1117 | + continue |
| 1118 | + if index.cursorable: |
| 1119 | + break |
| 1120 | + targets.append((resolved, (index_id, index))) |
| 1121 | + break |
| 1122 | + else: |
| 1123 | + targets.append((resolved, None)) |
| 1124 | + |
| 1125 | + return targets |
| 1126 | + |
1003 | 1127 | def get_requires_changes_for_view(self, view: ViewReference) -> RequiresChangesForView: |
1004 | 1128 | """Get requires constraint changes needed to optimize a view. |
1005 | 1129 |
|
|
0 commit comments