Analysis functions for Choreo.DecisionTree.
Provides path enumeration, evaluation, depth metrics, and pruning.
Further reading
Summary
Functions
Returns the number of leaf / outcome nodes.
Evaluates the decision tree against a map of feature values.
Returns the maximum depth of the tree (number of edges from root to deepest leaf).
Returns a map of feature frequencies across all decision nodes.
Finds logically impossible paths where a feature is checked against mutually exclusive conditions.
Enumerates all root-to-leaf paths.
Returns all root-to-leaf paths with their branch conditions.
Prunes redundant decision nodes.
Returns the unique set of all possible outcome classes the tree can produce.
Validates tree completeness.
Functions
@spec breadth(Choreo.DecisionTree.t()) :: non_neg_integer()
Returns the number of leaf / outcome nodes.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop)
...> |> Choreo.DecisionTree.add_outcome(:go)
...> |> Choreo.DecisionTree.add_outcome(:caution)
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
...> |> Choreo.DecisionTree.branch(:color, :caution, "yellow")
iex> Choreo.DecisionTree.Analysis.breadth(tree)
3This analysis answers the question: "How many leaf outcomes exist?"
@spec decide(Choreo.DecisionTree.t(), %{required(String.t()) => String.t()}) :: {:ok, [Yog.node_id()], String.t()} | {:error, String.t()}
Evaluates the decision tree against a map of feature values.
Walks from the root, at each decision node reading the corresponding feature value and following the branch whose condition matches.
Returns {:ok, path, outcome_label} or {:error, reason}.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...> |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Choreo.DecisionTree.Analysis.decide(tree, %{"color" => "red"})
{:ok, [:color, :stop], "Stop"}
iex> Choreo.DecisionTree.Analysis.decide(tree, %{"color" => "blue"})
{:error, "No branch for 'blue' from node :color"}
iex> Choreo.DecisionTree.Analysis.decide(Choreo.DecisionTree.new(), %{})
{:error, "Tree has no root"}This analysis answers the question: "Given feature values, what outcome does the tree predict?"
@spec depth(Choreo.DecisionTree.t()) :: non_neg_integer()
Returns the maximum depth of the tree (number of edges from root to deepest leaf).
A single-node tree has depth 0.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:a, feature: "a")
...> |> Choreo.DecisionTree.add_decision(:b, feature: "b")
...> |> Choreo.DecisionTree.add_outcome(:x)
...> |> Choreo.DecisionTree.add_outcome(:y)
...> |> Choreo.DecisionTree.branch(:a, :b, "1")
...> |> Choreo.DecisionTree.branch(:b, :x, "2")
...> |> Choreo.DecisionTree.branch(:b, :y, "3")
iex> Choreo.DecisionTree.Analysis.depth(tree)
2
iex> Choreo.DecisionTree.Analysis.depth(Choreo.DecisionTree.new())
0This analysis answers the question: "How deep is the tree?"
@spec feature_importance(Choreo.DecisionTree.t()) :: %{ required(String.t()) => non_neg_integer() }
Returns a map of feature frequencies across all decision nodes.
Useful for understanding which features drive the most splits.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:weather, feature: "weather")
...> |> Choreo.DecisionTree.add_decision(:wind, feature: "wind")
...> |> Choreo.DecisionTree.add_outcome(:play)
...> |> Choreo.DecisionTree.branch(:weather, :wind, "cloudy")
...> |> Choreo.DecisionTree.branch(:wind, :play, "calm")
iex> Choreo.DecisionTree.Analysis.feature_importance(tree)
%{"weather" => 1, "wind" => 1}This analysis answers the question: "Which features drive the most splits?"
@spec inconsistent_paths(Choreo.DecisionTree.t()) :: [{[Yog.node_id()], [String.t()]}]
Finds logically impossible paths where a feature is checked against mutually exclusive conditions.
Returns a list of tuples {path, [features_with_conflicts]}.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_decision(:shade, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...> |> Choreo.DecisionTree.add_outcome(:go1, label: "Go")
...> |> Choreo.DecisionTree.add_outcome(:go2, label: "Go")
...> |> Choreo.DecisionTree.branch(:color, :shade, "red")
...> |> Choreo.DecisionTree.branch(:color, :go1, "green")
...> |> Choreo.DecisionTree.branch(:shade, :stop, "dark")
...> |> Choreo.DecisionTree.branch(:shade, :go2, "light")
iex> inconsistencies = Choreo.DecisionTree.Analysis.inconsistent_paths(tree)
iex> length(inconsistencies)
2
iex> Enum.any?(inconsistencies, fn {_path, features} -> "color" in features end)
trueThis analysis answers the question: "Are there logically impossible paths?"
@spec paths(Choreo.DecisionTree.t()) :: [[Yog.node_id()]]
Enumerates all root-to-leaf paths.
Each path is a list of node IDs from root to outcome.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...> |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Enum.sort(Choreo.DecisionTree.Analysis.paths(tree))
[[:color, :go], [:color, :stop]]This analysis answers the question: "What are all possible root-to-leaf paths?"
@spec paths_with_conditions(Choreo.DecisionTree.t()) :: [ {[Yog.node_id()], [{Yog.node_id(), Yog.node_id(), String.t()}]} ]
Returns all root-to-leaf paths with their branch conditions.
Each result is {path, [{parent, child, condition}]}.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop")
...> |> Choreo.DecisionTree.add_outcome(:go, label: "Go")
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> paths = Choreo.DecisionTree.Analysis.paths_with_conditions(tree)
iex> {[:color, :stop], [{:color, :stop, "red"}]} in paths
true
iex> {[:color, :go], [{:color, :go, "green"}]} in paths
trueThis analysis answers the question: "What are all paths with their branch conditions?"
@spec prune_redundant(Choreo.DecisionTree.t()) :: Choreo.DecisionTree.t()
Prunes redundant decision nodes.
A decision is redundant when all of its descendant leaves share the same class label. The decision node is replaced by an outcome node with that label.
Returns a new tree.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_decision(:shade, feature: "shade")
...> |> Choreo.DecisionTree.add_outcome(:stop_light, label: "Stop", class: "stop")
...> |> Choreo.DecisionTree.add_outcome(:stop_dark, label: "Stop", class: "stop")
...> |> Choreo.DecisionTree.branch(:color, :shade, "red")
...> |> Choreo.DecisionTree.branch(:shade, :stop_light, "light")
...> |> Choreo.DecisionTree.branch(:shade, :stop_dark, "dark")
iex> pruned = Choreo.DecisionTree.Analysis.prune_redundant(tree)
iex> :shade in Choreo.DecisionTree.outcomes(pruned)
true
iex> :shade in Choreo.DecisionTree.decisions(pruned)
falseThis analysis answers the question: "Which decision nodes can be simplified?"
@spec reachable_outcomes(Choreo.DecisionTree.t()) :: [String.t()]
Returns the unique set of all possible outcome classes the tree can produce.
Only considers outcomes that are actually reachable from the root.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, class: "stop")
...> |> Choreo.DecisionTree.add_outcome(:go, class: "go")
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Enum.sort(Choreo.DecisionTree.Analysis.reachable_outcomes(tree))
["go", "stop"]This analysis answers the question: "What are all possible outcome classes?"
@spec validate(Choreo.DecisionTree.t()) :: [{:error | :warning, String.t()}]
Validates tree completeness.
Checks for:
- missing root
- decision nodes with no branches
- outcome nodes with branches (should be leaves)
- duplicate conditions from the same parent
Returns a list of {severity, message} tuples.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop)
...> |> Choreo.DecisionTree.add_outcome(:go)
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> Choreo.DecisionTree.Analysis.validate(tree)
[]
iex> tree = Choreo.DecisionTree.new()
iex> Choreo.DecisionTree.Analysis.validate(tree)
[{:error, "Tree has no root"}]This analysis answers the question: "Is the tree structurally valid?"