diff --git a/.changes/unreleased/Under the Hood-20250801-144219.yaml b/.changes/unreleased/Under the Hood-20250801-144219.yaml new file mode 100644 index 0000000000..96f6d8acc0 --- /dev/null +++ b/.changes/unreleased/Under the Hood-20250801-144219.yaml @@ -0,0 +1,6 @@ +kind: Under the Hood +body: Add path weight-function for the semantic graph +time: 2025-08-01T14:42:19.117781-07:00 +custom: + Author: plypaul + Issue: "1804" diff --git a/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mf_graph.py b/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mf_graph.py index 1ac1c8fa00..8be1b734c0 100644 --- a/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mf_graph.py +++ b/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mf_graph.py @@ -4,6 +4,7 @@ """ from __future__ import annotations +import itertools import logging from abc import ABC, abstractmethod from functools import cached_property @@ -29,6 +30,7 @@ from metricflow_semantics.experimental.mf_graph.node_descriptor import MetricflowGraphNodeDescriptor from metricflow_semantics.experimental.ordered_set import FrozenOrderedSet, MutableOrderedSet, OrderedSet from metricflow_semantics.mf_logging.format_option import PrettyFormatOption +from metricflow_semantics.mf_logging.lazy_formattable import LazyFormat from metricflow_semantics.mf_logging.pretty_formattable import MetricFlowPrettyFormattable from metricflow_semantics.mf_logging.pretty_formatter import ( MetricFlowPrettyFormatter, @@ -160,10 +162,24 @@ def nodes(self) -> OrderedSet[NodeT_co]: raise NotImplementedError() @abstractmethod - def nodes_with_label(self, graph_label: MetricflowGraphLabel) -> OrderedSet[NodeT_co]: - """Return nodes in the graph with the given label.""" + def nodes_with_labels(self, *graph_labels: MetricflowGraphLabel) -> OrderedSet[NodeT_co]: + """Return nodes in the graph with any one of the given labels.""" raise NotImplementedError() + def node_with_label(self, label: MetricflowGraphLabel) -> NodeT_co: + """Finds the node with the given label. If not exactly one if found, an error is raised.""" + nodes = self.nodes_with_labels(label) + matching_node_count = len(nodes) + if matching_node_count != 1: + raise KeyError( + LazyFormat( + "Did not find exactly one node with the given label", + matching_node_count=matching_node_count, + first_10_nodes=list(itertools.islice(nodes, 10)), + ) + ) + return next(iter(nodes)) + @property @abstractmethod def edges(self) -> OrderedSet[EdgeT_co]: @@ -221,7 +237,7 @@ def _intersect_edges(self, other: MetricflowGraph[NodeT_co, EdgeT_co]) -> Ordere return self.edges.intersection(other.edges) @abstractmethod - def intersection(self, other: MetricflowGraph[NodeT_co, EdgeT_co]) -> Self: # noqa: D102 + def intersection(self, other: Self) -> Self: # noqa: D102 raise NotImplementedError() @abstractmethod diff --git a/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mutable_graph.py b/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mutable_graph.py index 1bbb4b9c26..f8b567885d 100644 --- a/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mutable_graph.py +++ b/metricflow-semantics/metricflow_semantics/experimental/mf_graph/mutable_graph.py @@ -7,6 +7,7 @@ from typing_extensions import override +from metricflow_semantics.collection_helpers.syntactic_sugar import mf_flatten from metricflow_semantics.experimental.mf_graph.graph_id import MetricflowGraphId, SequentialGraphId from metricflow_semantics.experimental.mf_graph.graph_labeling import MetricflowGraphLabel from metricflow_semantics.experimental.mf_graph.mf_graph import ( @@ -15,7 +16,7 @@ MetricflowGraphNode, NodeT, ) -from metricflow_semantics.experimental.ordered_set import MutableOrderedSet, OrderedSet +from metricflow_semantics.experimental.ordered_set import FrozenOrderedSet, MutableOrderedSet, OrderedSet logger = logging.getLogger(__name__) @@ -42,41 +43,41 @@ class MutableGraph(Generic[NodeT, EdgeT], MetricflowGraph[NodeT, EdgeT], ABC): _node_to_successor_nodes: DefaultDict[MetricflowGraphNode, MutableOrderedSet[NodeT]] def add_node(self, node: NodeT) -> None: # noqa: D102 - self._nodes.add(node) - for node_property in node.labels: - self._label_to_nodes[node_property].add(node) - self._graph_id = SequentialGraphId.create() + self.add_nodes((node,)) def add_nodes(self, nodes: Iterable[NodeT]) -> None: # noqa: D102 + self._nodes.update(nodes) for node in nodes: - self.add_node(node) + for node_label in node.labels: + self._label_to_nodes[node_label].add(node) + self._graph_id = SequentialGraphId.create() def add_edge(self, edge: EdgeT) -> None: # noqa: D102 - tail_node = edge.tail_node - head_node = edge.head_node - graph_nodes = self._nodes - - if tail_node not in graph_nodes: - self.add_node(tail_node) - if head_node not in graph_nodes: - self.add_node(head_node) - - self._tail_node_to_edges[tail_node].add(edge) - self._head_node_to_edges[head_node].add(edge) - self._node_to_successor_nodes[tail_node].add(head_node) - self._node_to_predecessor_nodes[head_node].add(tail_node) - self._edges.add(edge) - self._graph_id = SequentialGraphId.create() + self.add_edges((edge,)) def add_edges(self, edges: Iterable[EdgeT]) -> None: # noqa: D102 + tail_nodes = [edge.tail_node for edge in edges] + head_nodes = [edge.head_node for edge in edges] + + nodes_to_add: MutableOrderedSet[NodeT] = MutableOrderedSet() + nodes_to_add.update(tail_nodes, head_nodes) + nodes_to_add.difference_update(self.nodes) + self.add_nodes(nodes_to_add) + for edge in edges: - self.add_edge(edge) + tail_node = edge.tail_node + head_node = edge.head_node + + self._tail_node_to_edges[tail_node].add(edge) + self._head_node_to_edges[head_node].add(edge) + self._node_to_successor_nodes[tail_node].add(head_node) + self._node_to_predecessor_nodes[head_node].add(tail_node) + + self._edges.update(edges) + self._graph_id = SequentialGraphId.create() def update(self, other: MetricflowGraph[NodeT, EdgeT]) -> None: """Add the nodes and edges to this graph.""" - if len(other.nodes) == 0 and len(other.edges) == 0: - return - self.add_nodes(other.nodes) self.add_edges(other.edges) self._graph_id = SequentialGraphId.create() @@ -87,8 +88,8 @@ def nodes(self) -> OrderedSet[NodeT]: # noqa: D102 return self._nodes @override - def nodes_with_label(self, graph_label: MetricflowGraphLabel) -> OrderedSet[NodeT]: - return self._label_to_nodes[graph_label] + def nodes_with_labels(self, *graph_labels: MetricflowGraphLabel) -> OrderedSet[NodeT]: + return FrozenOrderedSet(mf_flatten(self._label_to_nodes[label] for label in graph_labels)) @override @property diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/attribute_recipe.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/attribute_recipe.py new file mode 100644 index 0000000000..8a0f74e0b4 --- /dev/null +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/attribute_recipe.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import logging +from functools import cached_property +from typing import Optional, Set + +from dbt_semantic_interfaces.enum_extension import assert_values_exhausted +from dbt_semantic_interfaces.type_enums import DatePart, TimeGranularity + +from metricflow_semantics.collection_helpers.mf_type_aliases import AnyLengthTuple +from metricflow_semantics.experimental.dataclass_helpers import fast_frozen_dataclass +from metricflow_semantics.experimental.metricflow_exception import MetricflowInternalError +from metricflow_semantics.experimental.ordered_set import FrozenOrderedSet, MutableOrderedSet, OrderedSet +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.attribute_recipe_step import ( + AttributeRecipeStep, +) +from metricflow_semantics.experimental.semantic_graph.model_id import SemanticModelId +from metricflow_semantics.mf_logging.lazy_formattable import LazyFormat +from metricflow_semantics.model.linkable_element_property import LinkableElementProperty +from metricflow_semantics.model.semantics.linkable_element import LinkableElementType +from metricflow_semantics.time.granularity import ExpandedTimeGranularity + +logger = logging.getLogger(__name__) + + +IndexedDunderName = AnyLengthTuple[str] + + +@fast_frozen_dataclass() +class AttributeRecipe: + """The recipe for computing an attribute by following a path in the semantic graph.""" + + indexed_dunder_name: IndexedDunderName = () + joined_model_ids: AnyLengthTuple[SemanticModelId] = () + element_properties: FrozenOrderedSet[LinkableElementProperty] = FrozenOrderedSet() + entity_link_names: AnyLengthTuple[str] = () + + element_type: Optional[LinkableElementType] = None + # Maps to the time grain set for a time dimension in a semantic model + source_time_grain: Optional[TimeGranularity] = None + # Maps to the attribute's time grain or date part. + recipe_time_grain: Optional[ExpandedTimeGranularity] = None + recipe_date_part: Optional[DatePart] = None + + @cached_property + def dunder_name_elements_set(self) -> Set[str]: + """The elements of a dunder name as a set for fast repeated-element checks.""" + return set(self.indexed_dunder_name) + + @cached_property + def joined_model_id_set(self) -> Set[SemanticModelId]: + """The joined semantic model IDs as a set for fast repeated-model checks.""" + return set(self.joined_model_ids) + + @staticmethod + def create(initial_step: AttributeRecipeStep) -> AttributeRecipe: # noqa: D102 + dunder_name_elements: AnyLengthTuple[str] = () + if initial_step.add_dunder_name_element is not None: + dunder_name_elements = (initial_step.add_dunder_name_element,) + entity_link_names: AnyLengthTuple[str] = () + if initial_step.add_entity_link is not None: + entity_link_names = (initial_step.add_entity_link,) + + models_in_join: AnyLengthTuple[SemanticModelId] = () + + add_model_join = initial_step.add_model_join + if add_model_join is not None: + models_in_join = models_in_join + (add_model_join,) + + return AttributeRecipe( + indexed_dunder_name=dunder_name_elements, + joined_model_ids=models_in_join, + element_properties=FrozenOrderedSet(initial_step.add_properties or ()), + element_type=initial_step.set_element_type, + entity_link_names=entity_link_names, + source_time_grain=initial_step.set_source_time_grain, + recipe_time_grain=initial_step.set_time_grain_access, + recipe_date_part=initial_step.set_date_part_access, + ) + + @cached_property + def last_model_id(self) -> Optional[SemanticModelId]: + """The last model ID that was added to the join.""" + if self.joined_model_ids: + return None + + return tuple(self.joined_model_ids)[-1] + + def append_step(self, recipe_step: AttributeRecipeStep) -> AttributeRecipe: + """Add a step to the end of the recipe.""" + dundered_name_elements = self.indexed_dunder_name + if recipe_step.add_dunder_name_element is not None: + dundered_name_elements = dundered_name_elements + (recipe_step.add_dunder_name_element,) + entity_link_names = self.entity_link_names + if recipe_step.add_entity_link is not None: + entity_link_names = entity_link_names + (recipe_step.add_entity_link,) + + models_in_join = self.joined_model_ids + join_model = recipe_step.add_model_join + + if join_model is not None: + models_in_join = models_in_join + (join_model,) + + return AttributeRecipe( + indexed_dunder_name=dundered_name_elements, + joined_model_ids=models_in_join, + element_properties=self.element_properties.union(recipe_step.add_properties) + if recipe_step.add_properties is not None + else self.element_properties, + element_type=recipe_step.set_element_type or self.element_type, + entity_link_names=entity_link_names, + source_time_grain=recipe_step.set_source_time_grain or self.source_time_grain, + recipe_time_grain=recipe_step.set_time_grain_access or self.recipe_time_grain, + recipe_date_part=recipe_step.set_date_part_access or self.recipe_date_part, + ) + + def push_step(self, recipe_step: AttributeRecipeStep) -> AttributeRecipe: + """Add a step to the beginning of the recipe.""" + dundered_name_elements = self.indexed_dunder_name + if recipe_step.add_dunder_name_element is not None: + dundered_name_elements = (recipe_step.add_dunder_name_element,) + dundered_name_elements + entity_link_names = self.entity_link_names + if recipe_step.add_entity_link is not None: + entity_link_names = (recipe_step.add_entity_link,) + entity_link_names + models_in_join = self.joined_model_ids + add_model_join = recipe_step.add_model_join + if add_model_join is not None: + if len(models_in_join) == 0: + models_in_join = (add_model_join,) + else: + models_in_join = (add_model_join,) + models_in_join + + return AttributeRecipe( + indexed_dunder_name=dundered_name_elements, + joined_model_ids=models_in_join, + element_properties=FrozenOrderedSet(recipe_step.add_properties).union(self.element_properties) + if recipe_step.add_properties is not None + else self.element_properties, + element_type=self.element_type or recipe_step.set_element_type, + entity_link_names=entity_link_names, + source_time_grain=self.source_time_grain or recipe_step.set_source_time_grain, + recipe_time_grain=self.recipe_time_grain or recipe_step.set_time_grain_access, + recipe_date_part=self.recipe_date_part or recipe_step.set_date_part_access, + ) + + def push_steps(self, *updates: AttributeRecipeStep) -> AttributeRecipe: + """See `push_step`.""" + result = self + for update in updates: + result = result.push_step(update) + return result + + def resolve_complete_properties(self) -> OrderedSet[LinkableElementProperty]: + """Resolve the complete set of `LinkableElementProperty` for this recipe. + + While many properties were set by recipe steps during traversal, some need to be resolved at the end as it + is easier / faster to determine at the end. + """ + element_type = self.element_type + + if element_type is None: + raise ValueError(LazyFormat("Recipe is missing the element type", recipe=self)) + + properties = MutableOrderedSet(self.element_properties) + + model_ids = self.joined_model_ids + model_id_count = len(model_ids) + if model_id_count == 0: + if LinkableElementProperty.METRIC_TIME not in properties: + raise ValueError(LazyFormat("Recipe is missing context on accessed semantic models", recipe=self)) + elif model_id_count == 1: + if element_type is not LinkableElementType.METRIC and LinkableElementProperty.METRIC_TIME not in properties: + properties.add(LinkableElementProperty.LOCAL) + elif model_id_count == 2: + properties.add(LinkableElementProperty.JOINED) + elif model_id_count >= 3: + properties.update( + ( + LinkableElementProperty.JOINED, + LinkableElementProperty.MULTI_HOP, + ) + ) + else: + raise MetricflowInternalError( + LazyFormat("Reached unhandled case", model_id_count=model_id_count, recipe=self) + ) + + # Add `DERIVED_TIME_GRANULARITY` if the grain is different from the element's grain. + source_time_grain = self.source_time_grain + recipe_time_grain = self.recipe_time_grain + if source_time_grain is not None: + if recipe_time_grain is None and self.recipe_date_part is None: + raise ValueError( + LazyFormat( + "Recipe has a source time-grain, but no recipe time-grain or recipe date-part", recipe=self + ) + ) + if recipe_time_grain is not None and source_time_grain is not recipe_time_grain.base_granularity: + properties.add(LinkableElementProperty.DERIVED_TIME_GRANULARITY) + + return properties + + def resolve_element_name(self) -> Optional[str]: + """Resolve the element name. + + Currently, the recipe stores the dunder-name elements (e.g. ["metric_time", "day"]), but not the element name. + Since the position of the element name in the list depends on the type of element, this method helps to resolve + that. + """ + element_type = self.element_type + + # Incomplete recipe. + if element_type is None: + return None + + dunder_name_elements = self.indexed_dunder_name + dunder_name_element_count = len(dunder_name_elements) + + # Incomplete recipe. + if dunder_name_element_count == 0: + return None + + if element_type is LinkableElementType.TIME_DIMENSION: + # e.g. ['metric_time'] + if dunder_name_element_count == 1: + return dunder_name_elements[-1] + # e.g. ['metric_time', 'day'] + else: + return dunder_name_elements[-2] + elif ( + element_type is LinkableElementType.ENTITY + or element_type is LinkableElementType.DIMENSION + or element_type is LinkableElementType.TIME_DIMENSION + or element_type is LinkableElementType.METRIC + ): + return dunder_name_elements[-1] + else: + assert_values_exhausted(element_type) diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_path.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_path.py new file mode 100644 index 0000000000..ab7b972333 --- /dev/null +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_path.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from typing_extensions import override + +from metricflow_semantics.experimental.mf_graph.path_finding.graph_path import MutableGraphPath +from metricflow_semantics.experimental.mf_graph.path_finding.pathfinder import MetricflowPathfinder +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.attribute_recipe import ( + AttributeRecipe, +) +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.attribute_recipe_step import ( + AttributeRecipeStep, +) +from metricflow_semantics.experimental.semantic_graph.sg_interfaces import SemanticGraphEdge, SemanticGraphNode +from metricflow_semantics.mf_logging.pretty_formattable import MetricFlowPrettyFormattable +from metricflow_semantics.mf_logging.pretty_formatter import PrettyFormatContext + +logger = logging.getLogger(__name__) + +_EMPTY_RECIPE = AttributeRecipe() + + +@dataclass +class AttributeRecipeWriterPath(MutableGraphPath[SemanticGraphNode, SemanticGraphEdge], MetricFlowPrettyFormattable): + """An implementation of a path in the semantic graph that writes recipes. + + The nodes and edges in the semantic graph are annotated with recipe steps that describe the computation of + attributes. This path takes those steps and merges them into a single recipe as nodes are added to the path. + + In DFS traversal, nodes are added and popped at the end. To support this operation, recipe versions are + stored in a list so that consistent state can be maintained. + """ + + # Every time a node / edge is added, the updated recipe is added to this list. + _recipe_versions: list[AttributeRecipe] + + @staticmethod + def create(start_node: Optional[SemanticGraphNode] = None) -> AttributeRecipeWriterPath: # noqa: D102 + path = AttributeRecipeWriterPath( + _nodes=[], + _edges=[], + _weight_addition_order=[], + _current_weight=0, + _current_node_set=set(), + _node_set_addition_order=[], + _recipe_versions=[_EMPTY_RECIPE], + ) + if start_node: + path._append_node(start_node) + path._append_step(start_node.recipe_step_to_append) + return path + + @staticmethod + def create_from_edge(start_edge: SemanticGraphEdge, weight: int) -> AttributeRecipeWriterPath: # noqa: D102 + path = AttributeRecipeWriterPath( + _nodes=[], + _edges=[], + _weight_addition_order=[], + _current_weight=0, + _current_node_set=set(), + _node_set_addition_order=[], + _recipe_versions=[_EMPTY_RECIPE], + ) + path.append_edge(start_edge, weight) + return path + + def _append_step(self, recipe_step: AttributeRecipeStep) -> None: + previous_recipe = self._recipe_versions[-1] + self._recipe_versions.append(previous_recipe.append_step(recipe_step)) + + @property + def latest_recipe(self) -> AttributeRecipe: # noqa: D102 + return self._recipe_versions[-1] + + @override + def append_edge(self, edge: SemanticGraphEdge, weight: int) -> None: + """Add an edge with the given weight to this path.""" + if self.is_empty: + self._append_step(edge.tail_node.recipe_step_to_append) + self._append_step(edge.recipe_step_to_append) + self._append_step(edge.head_node.recipe_step_to_append) + + super().append_edge(edge, weight) + + @override + def pretty_format(self, format_context: PrettyFormatContext) -> Optional[str]: + return format_context.formatter.pretty_format(self._nodes) + + @override + def pop_end(self) -> None: + if self._edges: + self._recipe_versions.pop() + self._recipe_versions.pop() + else: + self._recipe_versions.pop() + super().pop_end() + + @override + def copy(self) -> AttributeRecipeWriterPath: + return AttributeRecipeWriterPath( + _nodes=self._nodes.copy(), + _edges=self._edges.copy(), + _current_weight=self._current_weight, + _current_node_set=self._current_node_set.copy(), + _weight_addition_order=self._weight_addition_order.copy(), + _node_set_addition_order=self._node_set_addition_order.copy(), + _recipe_versions=self._recipe_versions.copy(), + ) + + +RecipeWriterPathfinder = MetricflowPathfinder[SemanticGraphNode, SemanticGraphEdge, AttributeRecipeWriterPath] diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_weight.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_weight.py new file mode 100644 index 0000000000..b474b63720 --- /dev/null +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/attribute_resolution/recipe_writer_weight.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import itertools +import logging +from functools import cached_property +from typing import Mapping, Optional, Set + +from dbt_semantic_interfaces.enum_extension import assert_values_exhausted +from dbt_semantic_interfaces.type_enums import DatePart, TimeGranularity +from typing_extensions import override + +from metricflow_semantics.collection_helpers.mf_type_aliases import AnyLengthTuple +from metricflow_semantics.experimental.mf_graph.path_finding.weight_function import WeightFunction +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.attribute_recipe import AttributeRecipe +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.attribute_recipe_step import ( + AttributeRecipeStep, +) +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.recipe_writer_path import ( + AttributeRecipeWriterPath, +) +from metricflow_semantics.experimental.semantic_graph.nodes.node_labels import ( + GroupByAttributeLabel, + TimeClusterLabel, + TimeDimensionLabel, +) +from metricflow_semantics.experimental.semantic_graph.sg_exceptions import SemanticGraphTraversalError +from metricflow_semantics.experimental.semantic_graph.sg_interfaces import SemanticGraphEdge, SemanticGraphNode +from metricflow_semantics.mf_logging.lazy_formattable import LazyFormat +from metricflow_semantics.model.linkable_element_property import LinkableElementProperty +from metricflow_semantics.model.semantics.element_filter import LinkableElementFilter +from metricflow_semantics.model.semantics.linkable_element import LinkableElementType +from metricflow_semantics.model.semantics.semantic_model_join_evaluator import MAX_JOIN_HOPS + +logger = logging.getLogger(__name__) + + +class AttributeRecipeWriterWeightFunction( + WeightFunction[SemanticGraphNode, SemanticGraphEdge, AttributeRecipeWriterPath] +): + """The weight function to use with `AttributeRecipeWriterPath`. + + The weight function maps a path in the semantic graph to the number of entity links in the dunder name associated + with the path / attribute. + + In addition, this weight function models the characteristics of the query interface by returning `None` in certain + cases. `None` signals to the pathfinder that the edge is blocked. This weight function returns `None` to prohibit: + + * Repeated name elements in a dunder name (e.g. `listing__listing`). + * Repeated semantic models in a join. + * Querying a time dimension at a time grain that's smaller than the one configured in the semantic model. + + In addition, this can be initialized with a `LinkableElementFilter` to handle filtering of results as required by + the MF engine API. + + As the methods in this class are called repeatedly in a relatively tight loop during resolution, the performance of + this class is critical. Hence, there are a number of early-exit checks. + """ + + def __init__( # noqa: D107 + self, element_filter: Optional[LinkableElementFilter] = None, max_path_model_count: Optional[int] = None + ) -> None: + """Initializer. + + Args: + element_filter: If specified, limits paths to align with the given filter. + max_path_model_count: To handle some edge cases, this can be specified to limit the number of semantic + models in the path. + """ + self._element_filter = element_filter + self._group_by_attribute_label = GroupByAttributeLabel.get_instance() + self._time_dimension_label = TimeDimensionLabel.get_instance() + self._time_cluster_label = TimeClusterLabel.get_instance() + self._max_path_model_count = max_path_model_count + + self._verbose_debug_logs = False + + @override + def incremental_weight( + self, + path_to_node: AttributeRecipeWriterPath, + next_edge: SemanticGraphEdge, + ) -> Optional[int]: + next_node = next_edge.head_node + current_recipe = path_to_node.latest_recipe + # First run checks that can be done without the next recipe (the profiler showed non-insignificant time spent + # generating recipes). + next_edge_step = next_edge.recipe_step_to_append + next_node_step = next_node.recipe_step_to_append + + element_filter = self._element_filter + + # We do not allow repeated element names in the dundered name (e.g. `listing__listing`). + if AttributeRecipeWriterWeightFunction.repeated_dunder_name_elements( + current_recipe, next_edge_step, next_node_step + ): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to a repeated name element.", + next_edge_step=next_edge_step, + next_node_step=next_node_step, + ) + ) + return None + + # Don't allow joining a semantic model multiple times. + if AttributeRecipeWriterWeightFunction.repeated_model_join(current_recipe, next_edge_step, next_node_step): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to a repeated model.", + models_in_join=current_recipe.joined_model_ids, + next_edge_step=next_edge_step, + next_node_step=next_node_step, + ) + ) + return None + + # A quick check to see if the filter will block the edge. + if element_filter and self._filter_denies_edge(element_filter, next_node, (next_edge_step, next_node_step)): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to the element filter.", + next_edge=next_edge, + next_edge_step=next_edge_step, + next_node_step=next_node_step, + element_filter=self._element_filter, + ) + ) + return None + + weight_added_by_taking_edge = 0 + if next_edge.recipe_step_to_append.add_entity_link is not None: + weight_added_by_taking_edge += 1 + if next_edge.head_node.recipe_step_to_append.add_entity_link is not None: + weight_added_by_taking_edge += 1 + + if len(current_recipe.entity_link_names) + weight_added_by_taking_edge > MAX_JOIN_HOPS: + return None + + # Check if this needs to limit joins. + if self._max_path_model_count is not None: + current_path_model_count = len(current_recipe.joined_model_ids) + if next_edge_step.add_model_join: + current_path_model_count += 1 + if next_node_step.add_model_join: + current_path_model_count += 1 + if current_path_model_count > self._max_path_model_count: + return None + + # If the current path is not yet at an attribute node, we can't run the checks below so return early. + next_node_is_attribute = self._group_by_attribute_label in next_node.labels + if not next_node_is_attribute: + return weight_added_by_taking_edge + + next_edge_update = next_edge.recipe_step_to_append + next_node_update = next_edge.head_node.recipe_step_to_append + next_recipe = current_recipe.append_step(next_edge_update).append_step(next_node_update) + + # Require entity links for dimensions / time dimensions, except for metric time + if self._invalid_entity_links(next_recipe): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to entity links being outside of range.", + element_type=next_recipe.element_type, + entity_link_names=next_recipe.entity_link_names, + models_in_join=next_recipe.joined_model_ids, + ) + ) + return None + + if self._source_time_grain_mismatch(next_recipe): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to source time-grain mismatch.", + next_edge=next_edge, + next_recipe=next_recipe, + ) + ) + return None + + if element_filter and self._filter_denies_recipe(element_filter, next_recipe): + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Blocking edge due to the element filter.", + next_edge=next_edge, + next_recipe=next_recipe, + element_filter=self._element_filter, + ) + ) + return None + + return weight_added_by_taking_edge + + def _filter_denies_edge( + self, + element_filter: LinkableElementFilter, + next_node: SemanticGraphNode, + steps: AnyLengthTuple[AttributeRecipeStep], + ) -> bool: + property_deny_set = element_filter.without_any_of + if property_deny_set and any( + element_property in property_deny_set + for element_property in itertools.chain.from_iterable( + step.add_properties for step in steps if step.add_properties + ) + ): + return True + + name_element_allow_set = element_filter.element_names + # Generally, element name should be checked at the attribute node, but it can be done at time dimension nodes + # as they are an entity node that adds an element name. This can speed up traversal by pruning edges. + if ( + name_element_allow_set + and self._time_dimension_label in next_node.labels + and not all( + name_element in name_element_allow_set + for name_element in ( + step.add_dunder_name_element for step in steps if step.add_dunder_name_element is not None + ) + ) + ): + return True + + return False + + def _filter_denies_recipe( + self, + element_filter: LinkableElementFilter, + next_recipe: AttributeRecipe, + ) -> bool: + """Check if the element filter denies the recipe associated with the path. + + As per call in the incremental weight function, this should only be called when the next node is an attribute + node. + """ + next_property_set = next_recipe.resolve_complete_properties() + next_element_name = next_recipe.resolve_element_name() + if next_property_set is None or next_element_name is None: + raise SemanticGraphTraversalError( + LazyFormat( + "Expected the recipe at an attribute node or a time-attribute node to have complete" + " properties and an element name, but it is missing at least one of them.", + next_property_set=next_property_set, + element_name=next_element_name, + next_recipe=next_recipe, + ) + ) + + return not element_filter.allow(next_element_name, next_property_set) + + @staticmethod + def repeated_dunder_name_elements( + recipe: AttributeRecipe, + step: AttributeRecipeStep, + other_step: AttributeRecipeStep, + ) -> bool: + """Return true if the recipe + steps would cause a repeated element in the dunder name.""" + next_edge_add_dunder_name_element = step.add_dunder_name_element + next_node_add_dunder_name_element = other_step.add_dunder_name_element + if next_edge_add_dunder_name_element is None and next_node_add_dunder_name_element is None: + return False + + current_dunder_name_elements = recipe.dunder_name_elements_set + return ( + next_node_add_dunder_name_element + and next_node_add_dunder_name_element in current_dunder_name_elements + or next_edge_add_dunder_name_element + and next_edge_add_dunder_name_element in current_dunder_name_elements + or next_edge_add_dunder_name_element == next_node_add_dunder_name_element + ) + + @staticmethod + def repeated_model_join( + recipe: AttributeRecipe, + step: AttributeRecipeStep, + other_step: AttributeRecipeStep, + ) -> bool: + """Return true if the recipe + steps would cause a repeated semantic model in the join.""" + edge_add_model_join = step.add_model_join + node_add_model_join = other_step.add_model_join + if edge_add_model_join is None and node_add_model_join is None: + return False + if edge_add_model_join == node_add_model_join: + return True + + for model_in_join in recipe.joined_model_ids: + if model_in_join == edge_add_model_join or model_in_join == node_add_model_join: + return True + return False + + def _invalid_entity_links(self, next_recipe: AttributeRecipe) -> bool: + # Require entity links for dimensions / time dimensions, except for metric time + next_recipe_element_type = next_recipe.element_type + if next_recipe_element_type is None: + return False + + # Doing a max for the join count to allow this weight function to be used for paths that haven't yet + # included a semantic model. + join_count = max(0, len(next_recipe.joined_model_ids) - 1) + min_entity_link_length = 1 + if join_count == 0: + max_entity_link_length = 1 + else: + max_entity_link_length = join_count + + if next_recipe_element_type is LinkableElementType.ENTITY: + min_entity_link_length = 0 + elif ( + next_recipe_element_type is LinkableElementType.TIME_DIMENSION + or next_recipe_element_type is LinkableElementType.DIMENSION + ): + if LinkableElementProperty.METRIC_TIME in next_recipe.element_properties: + min_entity_link_length = 0 + + elif next_recipe_element_type is LinkableElementType.METRIC: + pass + else: + assert_values_exhausted(next_recipe_element_type) + + next_entity_link_length = len(next_recipe.entity_link_names) + return not (min_entity_link_length <= next_entity_link_length <= max_entity_link_length) + + def _source_time_grain_mismatch(self, next_recipe: AttributeRecipe) -> bool: + source_time_grain = next_recipe.source_time_grain + recipe_time_grain = next_recipe.recipe_time_grain + if source_time_grain is None: + return False + + if recipe_time_grain is not None and recipe_time_grain.base_granularity.to_int() < source_time_grain.to_int(): + return True + + date_part = next_recipe.recipe_date_part + if date_part is None: + return False + + return source_time_grain not in self._date_part_to_min_time_grain[date_part] + + @cached_property + def _date_part_to_min_time_grain(self) -> Mapping[DatePart, Set[TimeGranularity]]: + return {date_part: set(date_part.compatible_granularities) for date_part in DatePart} diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/builder/graph_builder.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/builder/graph_builder.py new file mode 100644 index 0000000000..295c371637 --- /dev/null +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/builder/graph_builder.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import logging +import time +from typing import Type + +from typing_extensions import override + +from metricflow_semantics.collection_helpers.mf_type_aliases import AnyLengthTuple +from metricflow_semantics.experimental.dsi.manifest_object_lookup import ManifestObjectLookup +from metricflow_semantics.experimental.semantic_graph.builder.categorical_dimension_subgraph import ( + CategoricalDimensionSubgraphGenerator, +) +from metricflow_semantics.experimental.semantic_graph.builder.entity_join_subgraph import EntityJoinSubgraphGenerator +from metricflow_semantics.experimental.semantic_graph.builder.entity_key_subgraph import ( + EntityKeySubgraphGenerator, +) +from metricflow_semantics.experimental.semantic_graph.builder.measure_subgraph import ( + MeasureSubgraphGenerator, +) +from metricflow_semantics.experimental.semantic_graph.builder.metric_subgraph import MetricSubgraphGenerator +from metricflow_semantics.experimental.semantic_graph.builder.subgraph_generator import SemanticSubgraphGenerator +from metricflow_semantics.experimental.semantic_graph.builder.time_dimension_subgraph import ( + TimeDimensionSubgraphGenerator, +) +from metricflow_semantics.experimental.semantic_graph.builder.time_entity_subgraph import TimeEntitySubgraphGenerator +from metricflow_semantics.experimental.semantic_graph.sg_interfaces import MutableSemanticGraph, SemanticGraph +from metricflow_semantics.mf_logging.lazy_formattable import LazyFormat + +logger = logging.getLogger(__name__) + + +class SemanticGraphBuilder: + """Convenience class that builds a semantic graph using all subgraph generators. + + This will replace `PartialSemanticGraphBuilder` once the PRs that depend on it are merged. + """ + + _ALL_SUBGRAPH_GENERATORS: AnyLengthTuple[Type[SemanticSubgraphGenerator]] = ( + CategoricalDimensionSubgraphGenerator, + EntityKeySubgraphGenerator, + EntityJoinSubgraphGenerator, + MeasureSubgraphGenerator, + TimeDimensionSubgraphGenerator, + TimeEntitySubgraphGenerator, + MetricSubgraphGenerator, + ) + + @override + def __init__(self, manifest_object_lookup: ManifestObjectLookup) -> None: + self._manifest_object_lookup = manifest_object_lookup + self._verbose_debug_logs = True + + def build(self) -> SemanticGraph: # noqa: D102 + current_graph = MutableSemanticGraph.create() + for generator in self._ALL_SUBGRAPH_GENERATORS: + start_time = time.perf_counter() + generator_instance = generator(self._manifest_object_lookup) + + start_node_count = len(current_graph.nodes) + start_edge_count = len(current_graph.edges) + generated_edges = generator_instance.generate_edges() + generated_edge_count = len(generated_edges) + current_graph.add_edges(generated_edges) + added_node_count = len(current_graph.nodes) - start_node_count + added_edge_count = len(current_graph.edges) - start_edge_count + runtime = time.perf_counter() - start_time + if self._verbose_debug_logs: + logger.debug( + LazyFormat( + "Generated subgraph", + generator=generator.__name__, + runtime=f"{runtime:.2f}s", + added_node_count=added_node_count, + added_edge_count=added_edge_count, + generated_edge_count=generated_edge_count, + ) + ) + + return current_graph diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_exceptions.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_exceptions.py new file mode 100644 index 0000000000..4ca21800c0 --- /dev/null +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_exceptions.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import logging + +from metricflow_semantics.experimental.metricflow_exception import MetricflowInternalError + +logger = logging.getLogger(__name__) + + +class SemanticGraphTraversalError(MetricflowInternalError): + """Raised when an unexpected condition is encountered during semantic-graph traversal.""" + + pass diff --git a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_interfaces.py b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_interfaces.py index 140c504b53..07bd8f9b34 100644 --- a/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_interfaces.py +++ b/metricflow-semantics/metricflow_semantics/experimental/semantic_graph/sg_interfaces.py @@ -8,12 +8,10 @@ from typing_extensions import Optional, override from metricflow_semantics.collection_helpers.mf_type_aliases import AnyLengthTuple -from metricflow_semantics.collection_helpers.syntactic_sugar import mf_flatten from metricflow_semantics.dag.mf_dag import DisplayedProperty from metricflow_semantics.experimental.dataclass_helpers import fast_frozen_dataclass from metricflow_semantics.experimental.mf_graph.formatting.dot_attributes import DotGraphAttributeSet from metricflow_semantics.experimental.mf_graph.graph_id import MetricflowGraphId, SequentialGraphId -from metricflow_semantics.experimental.mf_graph.graph_labeling import MetricflowGraphLabel from metricflow_semantics.experimental.mf_graph.mf_graph import ( MetricflowGraph, MetricflowGraphEdge, @@ -131,10 +129,6 @@ def as_dot_graph(self, include_graphical_attributes: bool) -> DotGraphAttributeS ) ) - def nodes_with_labels(self, *labels: MetricflowGraphLabel) -> MutableOrderedSet[SemanticGraphNode]: - """Return nodes in the graph with any of the given labels.""" - return MutableOrderedSet(mf_flatten(self.nodes_with_label(label) for label in labels)) - @dataclass class MutableSemanticGraph(MutableGraph[SemanticGraphNode, SemanticGraphEdge], SemanticGraph): diff --git a/metricflow-semantics/metricflow_semantics/model/semantics/element_filter.py b/metricflow-semantics/metricflow_semantics/model/semantics/element_filter.py index f4f1bd0ed2..6ecaf87cb0 100644 --- a/metricflow-semantics/metricflow_semantics/model/semantics/element_filter.py +++ b/metricflow-semantics/metricflow_semantics/model/semantics/element_filter.py @@ -1,15 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import FrozenSet, Optional +from typing import FrozenSet, Iterable, Optional from typing_extensions import Self, override from metricflow_semantics.collection_helpers.merger import Mergeable +from metricflow_semantics.experimental.dataclass_helpers import fast_frozen_dataclass from metricflow_semantics.model.linkable_element_property import LinkableElementProperty -@dataclass(frozen=True) +@fast_frozen_dataclass() class LinkableElementFilter(Mergeable): """Describes a way to filter the `LinkableElements` in a `LinkableElementSet`.""" @@ -19,6 +19,21 @@ class LinkableElementFilter(Mergeable): without_any_of: FrozenSet[LinkableElementProperty] = frozenset() without_all_of: FrozenSet[LinkableElementProperty] = frozenset() + def copy( + self, + element_names: Optional[FrozenSet[str]] = None, + with_any_of: Optional[FrozenSet[LinkableElementProperty]] = None, + without_any_of: Optional[FrozenSet[LinkableElementProperty]] = None, + without_all_of: Optional[FrozenSet[LinkableElementProperty]] = None, + ) -> LinkableElementFilter: + """Create a copy of this with the given non-None fields replaced.""" + return LinkableElementFilter( + element_names=element_names if element_names is not None else self.element_names, + with_any_of=with_any_of if with_any_of is not None else self.with_any_of, + without_any_of=without_any_of if without_any_of is not None else self.without_any_of, + without_all_of=without_all_of if without_all_of is not None else self.without_all_of, + ) + @override def merge(self: Self, other: LinkableElementFilter) -> LinkableElementFilter: if self.element_names is None and other.element_names is None: @@ -44,3 +59,31 @@ def without_element_names(self) -> LinkableElementFilter: without_any_of=self.without_any_of, without_all_of=self.without_all_of, ) + + def allow( + self, element_name: Optional[str], element_properties: Optional[Iterable[LinkableElementProperty]] + ) -> bool: + """Return true if this allows an item with the given name and properties. + + `None` can be specified in cases of incomplete context. + """ + if element_name is not None: + allowed_element_name_set = self.element_names + if allowed_element_name_set is not None and element_name not in allowed_element_name_set: + return False + + if element_properties is not None: + if len(self.with_any_of.intersection(element_properties)) == 0: + return False + denied_property_set = self.without_any_of + if denied_property_set and len(denied_property_set.intersection(element_properties)) > 0: + return False + denied_full_match_property_set = self.without_all_of + if denied_full_match_property_set: + denied_full_match_property_set_length = len(denied_full_match_property_set) + if denied_full_match_property_set_length > 0 and denied_full_match_property_set_length == len( + denied_full_match_property_set.intersection(element_properties) + ): + return False + + return True diff --git a/metricflow-semantics/tests_metricflow_semantics/experimental/semantic_graph/attribute_resolver/test_recipe_writer.py b/metricflow-semantics/tests_metricflow_semantics/experimental/semantic_graph/attribute_resolver/test_recipe_writer.py new file mode 100644 index 0000000000..0283b61319 --- /dev/null +++ b/metricflow-semantics/tests_metricflow_semantics/experimental/semantic_graph/attribute_resolver/test_recipe_writer.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging + +import tabulate +from _pytest.fixtures import FixtureRequest +from dbt_semantic_interfaces.naming.keywords import DUNDER +from dbt_semantic_interfaces.protocols import SemanticManifest +from metricflow_semantics.collection_helpers.mf_type_aliases import AnyLengthTuple +from metricflow_semantics.experimental.dsi.manifest_object_lookup import ManifestObjectLookup +from metricflow_semantics.experimental.mf_graph.path_finding.pathfinder import MetricflowPathfinder +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.recipe_writer_path import ( + AttributeRecipeWriterPath, + RecipeWriterPathfinder, +) +from metricflow_semantics.experimental.semantic_graph.attribute_resolution.recipe_writer_weight import ( + AttributeRecipeWriterWeightFunction, +) +from metricflow_semantics.experimental.semantic_graph.builder.graph_builder import SemanticGraphBuilder +from metricflow_semantics.experimental.semantic_graph.nodes.node_labels import GroupByAttributeLabel, MeasureLabel +from metricflow_semantics.mf_logging.pretty_print import mf_pformat +from metricflow_semantics.test_helpers.config_helpers import MetricFlowTestConfiguration +from metricflow_semantics.test_helpers.snapshot_helpers import ( + assert_str_snapshot_equal, +) + +logger = logging.getLogger(__name__) + + +def test_recipe_writer_path( + request: FixtureRequest, + mf_test_configuration: MetricFlowTestConfiguration, + sg_02_single_join_manifest: SemanticManifest, +) -> None: + """Test generating recipes by traversing the semantic graph.""" + semantic_graph = SemanticGraphBuilder(ManifestObjectLookup(sg_02_single_join_manifest)).build() + path_finder: RecipeWriterPathfinder = MetricflowPathfinder() + + # Find all valid paths from the `booking_count` measure to any group-by attribute node. + source_node = semantic_graph.node_with_label(MeasureLabel.get_instance("booking_count")) + target_nodes = semantic_graph.nodes_with_labels(GroupByAttributeLabel.get_instance()) + + # found_paths = tuple( + # path_finder.find_paths_dfs( + # graph=semantic_graph, + # initial_path=AttributeRecipeWriterPath.create(source_node), + # target_nodes=target_nodes, + # weight_function=AttributeRecipeWriterWeightFunction(), + # max_path_weight=2, + # ) + # ) + found_paths: list[AttributeRecipeWriterPath] = [] + for path in path_finder.find_paths_dfs( + graph=semantic_graph, + initial_path=AttributeRecipeWriterPath.create(source_node), + target_nodes=target_nodes, + weight_function=AttributeRecipeWriterWeightFunction(), + max_path_weight=2, + ): + found_paths.append(path.copy()) + + # Produce a table showing how the path relates to the dunder name and the recipe. + table_headers = ("Path", "Dunder Name", "Recipe") + table_rows: list[AnyLengthTuple[str]] = [] + + for path in found_paths: + path_str = "\n-> ".join(node.node_descriptor.node_name for node in path.nodes) + dunder_name = DUNDER.join(path.latest_recipe.indexed_dunder_name) + table_rows.append((path_str, dunder_name, mf_pformat(path.latest_recipe))) + + assert_str_snapshot_equal( + request=request, + snapshot_configuration=mf_test_configuration, + snapshot_str=tabulate.tabulate( + headers=table_headers, + tabular_data=table_rows, + ), + ) diff --git a/metricflow-semantics/tests_metricflow_semantics/snapshots/test_recipe_writer.py/str/test_recipe_writer_path__result.txt b/metricflow-semantics/tests_metricflow_semantics/snapshots/test_recipe_writer.py/str/test_recipe_writer_path__result.txt new file mode 100644 index 0000000000..641c441421 --- /dev/null +++ b/metricflow-semantics/tests_metricflow_semantics/snapshots/test_recipe_writer.py/str/test_recipe_writer_path__result.txt @@ -0,0 +1,115 @@ +test_name: test_recipe_writer_path +test_filename: test_recipe_writer.py +docstring: + Test generating recipes by traversing the semantic graph. +--- +Path Dunder Name Recipe +--------------------------------- -------------------------------------- --------------------------------------------------------------------------------------- +Measure(booking_count) metric_time__quarter AttributeRecipe( +-> MetricTime indexed_dunder_name=('metric_time', 'quarter'), +-> TimeEntity joined_model_ids=(bookings_source,), +-> TimeAttribute(quarter) element_properties={METRIC_TIME}, + element_type=TIME_DIMENSION, + source_time_grain=QUARTER, + recipe_time_grain=ExpandedTimeGranularity(name='quarter', base_granularity=QUARTER), + ) +Measure(booking_count) metric_time__year AttributeRecipe( +-> MetricTime indexed_dunder_name=('metric_time', 'year'), +-> TimeEntity joined_model_ids=(bookings_source,), +-> TimeAttribute(year) element_properties={METRIC_TIME}, + element_type=TIME_DIMENSION, + source_time_grain=QUARTER, + recipe_time_grain=ExpandedTimeGranularity(name='year', base_granularity=YEAR), + ) +Measure(booking_count) metric_time__extract_year AttributeRecipe( +-> MetricTime indexed_dunder_name=('metric_time', 'extract_year'), +-> TimeEntity joined_model_ids=(bookings_source,), +-> TimeAttribute(extract_year) element_properties={METRIC_TIME, DATE_PART}, + element_type=TIME_DIMENSION, + source_time_grain=QUARTER, + recipe_date_part=YEAR, + ) +Measure(booking_count) metric_time__extract_quarter AttributeRecipe( +-> MetricTime indexed_dunder_name=('metric_time', 'extract_quarter'), +-> TimeEntity joined_model_ids=(bookings_source,), +-> TimeAttribute(extract_quarter) element_properties={METRIC_TIME, DATE_PART}, + element_type=TIME_DIMENSION, + source_time_grain=QUARTER, + recipe_date_part=QUARTER, + ) +Measure(booking_count) metric_time__custom_year AttributeRecipe( +-> MetricTime indexed_dunder_name=('metric_time', 'custom_year'), +-> TimeEntity joined_model_ids=(bookings_source,), +-> TimeAttribute(custom_year) element_properties={METRIC_TIME, DERIVED_TIME_GRANULARITY}, + element_type=TIME_DIMENSION, + source_time_grain=QUARTER, + recipe_time_grain=ExpandedTimeGranularity(name='custom_year', base_granularity=YEAR), + ) +Measure(booking_count) booking AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking',), +-> KeyAttribute(booking) joined_model_ids=(bookings_source,), + element_properties={ENTITY}, + element_type=ENTITY, + ) +Measure(booking_count) listing AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('listing',), +-> KeyAttribute(listing) joined_model_ids=(bookings_source,), + element_properties={ENTITY}, + element_type=ENTITY, + ) +Measure(booking_count) listing__country_latest AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('listing', 'country_latest'), +-> listings_source.listing joined_model_ids=(bookings_source, listings_source), +-> JoinedModel(listings_source) entity_link_names=('listing',), +-> Dimension(country_latest) element_type=DIMENSION, + ) +Measure(booking_count) booking__listing AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'listing'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) element_properties={ENTITY}, +-> KeyAttribute(listing) entity_link_names=('booking',), + element_type=ENTITY, + ) +Measure(booking_count) booking__booking_time__quarter AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'booking_time', 'quarter'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) entity_link_names=('booking',), +-> TimeDimension(booking_time) element_type=TIME_DIMENSION, +-> TimeEntity source_time_grain=QUARTER, +-> TimeAttribute(quarter) recipe_time_grain=ExpandedTimeGranularity(name='quarter', base_granularity=QUARTER), + ) +Measure(booking_count) booking__booking_time__year AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'booking_time', 'year'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) entity_link_names=('booking',), +-> TimeDimension(booking_time) element_type=TIME_DIMENSION, +-> TimeEntity source_time_grain=QUARTER, +-> TimeAttribute(year) recipe_time_grain=ExpandedTimeGranularity(name='year', base_granularity=YEAR), + ) +Measure(booking_count) booking__booking_time__extract_year AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'booking_time', 'extract_year'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) element_properties={DATE_PART}, +-> TimeDimension(booking_time) entity_link_names=('booking',), +-> TimeEntity element_type=TIME_DIMENSION, +-> TimeAttribute(extract_year) source_time_grain=QUARTER, + recipe_date_part=YEAR, + ) +Measure(booking_count) booking__booking_time__extract_quarter AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'booking_time', 'extract_quarter'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) element_properties={DATE_PART}, +-> TimeDimension(booking_time) entity_link_names=('booking',), +-> TimeEntity element_type=TIME_DIMENSION, +-> TimeAttribute(extract_quarter) source_time_grain=QUARTER, + recipe_date_part=QUARTER, + ) +Measure(booking_count) booking__booking_time__custom_year AttributeRecipe( +-> LocalModel(bookings_source) indexed_dunder_name=('booking', 'booking_time', 'custom_year'), +-> bookings_source.booking joined_model_ids=(bookings_source,), +-> JoinedModel(bookings_source) element_properties={DERIVED_TIME_GRANULARITY}, +-> TimeDimension(booking_time) entity_link_names=('booking',), +-> TimeEntity element_type=TIME_DIMENSION, +-> TimeAttribute(custom_year) source_time_grain=QUARTER, + recipe_time_grain=ExpandedTimeGranularity(name='custom_year', base_granularity=YEAR), + )