Analysis functions for Choreo.DecisionTree.
Provides path enumeration, evaluation, rule extraction, test-case generation, completeness checks, 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.
Generates feature maps that cover every reachable leaf path.
Finds logically impossible paths where a feature is checked against mutually exclusive conditions.
Finds decision nodes whose outgoing branches do not cover an expected set of feature values.
Returns nodes that are not reachable from the root.
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.
Extracts IF-THEN rules from the decision tree.
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 generate_test_cases(Choreo.DecisionTree.t()) :: [ %{required(String.t()) => String.t()} ]
Generates feature maps that cover every reachable leaf path.
Each generated map can be passed to decide/2 to reach a distinct
outcome. This is useful for testing or for validating that every
rule in the tree is exercisable.
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> test_cases = Choreo.DecisionTree.Analysis.generate_test_cases(tree)
iex> length(test_cases)
2
iex> %{"color" => "red"} in test_cases
true
iex> %{"color" => "green"} in test_cases
trueThis analysis answers the question: "What inputs exercise every path?"
@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 missing_branches(Choreo.DecisionTree.t(), %{ required(String.t()) => [String.t()] }) :: [ {Yog.node_id(), String.t(), [String.t()]} ]
Finds decision nodes whose outgoing branches do not cover an expected set of feature values.
Accepts a map of feature => [expected_values]. For each decision node
testing that feature, returns {node_id, feature, missing_values} when
values are absent.
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.missing_branches(tree, %{"color" => ["red", "green", "blue"]})
[{:color, "color", ["blue"]}]
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:a, feature: "a")
...> |> Choreo.DecisionTree.add_outcome(:x)
...> |> Choreo.DecisionTree.branch(:a, :x, "1")
iex> Choreo.DecisionTree.Analysis.missing_branches(tree, %{"a" => ["1"]})
[]This analysis answers the question: "Which expected branches are missing?"
@spec orphan_nodes(Choreo.DecisionTree.t()) :: [Yog.node_id()]
Returns nodes that are not reachable from the root.
In a well-formed tree every declared node should be reachable. Nodes added without a connecting branch are reported as orphans.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:a, feature: "a")
...> |> Choreo.DecisionTree.add_outcome(:x)
...> |> Choreo.DecisionTree.add_outcome(:y)
...> |> Choreo.DecisionTree.branch(:a, :x, "1")
iex> Choreo.DecisionTree.Analysis.orphan_nodes(tree)
[:y]This analysis answers the question: "Which declared nodes are unreachable?"
@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> Choreo.DecisionTree.outcomes(pruned)
[:color]
iex> :color 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 rules(Choreo.DecisionTree.t()) :: [ %{ conditions: %{required(String.t()) => String.t()}, outcome: %{class: String.t() | nil, label: String.t() | nil} } ]
Extracts IF-THEN rules from the decision tree.
Each rule maps the conditions along a root-to-leaf path to the outcome reached at that leaf.
Examples
iex> tree = Choreo.DecisionTree.new()
iex> tree = tree
...> |> Choreo.DecisionTree.set_root(:color, feature: "color")
...> |> Choreo.DecisionTree.add_outcome(:stop, label: "Stop", class: "stop")
...> |> Choreo.DecisionTree.add_outcome(:go, label: "Go", class: "go")
...> |> Choreo.DecisionTree.branch(:color, :stop, "red")
...> |> Choreo.DecisionTree.branch(:color, :go, "green")
iex> rules = Choreo.DecisionTree.Analysis.rules(tree)
iex> length(rules)
2
iex> Enum.find(rules, fn r -> r.outcome.class == "stop" end)
%{conditions: %{"color" => "red"}, outcome: %{class: "stop", label: "Stop"}}This analysis answers the question: "What IF-THEN rules does the tree encode?"
@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?"