Powered by AppSignal & Oban Pro

Choreo DecisionTree: Comprehensive Walkthrough

decision_tree_walkthrough.livemd

Choreo DecisionTree: Comprehensive Walkthrough

Mix.install([
  # For Hex publication/readers:
  {:choreo, "~> 0.14.1"},
  # For local development:
  # {:choreo, path: Path.expand("../..", __DIR__), force: true},
  {:kino_vizjs, "~> 0.9.0"}
])

Section

Rendering diagrams: This livebook uses Kino.VizJS to render DOT diagrams inline and Choreo.Lab.Siren / Choreo.Lab.Sketch for Mermaid previews.


What is Choreo.DecisionTree?

Choreo.DecisionTree models classification and choice trees where internal nodes are decisions (tests on features) and leaf nodes are outcomes (class labels or actions). Every path from root to leaf represents a complete decision chain.

Unlike free-form graphs, decision trees enforce structural invariants:

  • Exactly one root — the starting decision node
  • Single parent — every non-root node has exactly one parent (no merging)
  • No cycles — branches always flow downward

These invariants mean you can evaluate a tree deterministically: start at the root, read a feature value, follow the matching branch, repeat until you reach an outcome.

API Approaches: Programmatic Pipe API vs Lab DSL

Choreo provides two complementary approaches to modeling decision trees:

  1. Programmatic Pipe API (Choreo.DecisionTree): The canonical, explicit interface (DecisionTree.new() |> DecisionTree.set_root(...) |> DecisionTree.branch(...)). Best for runtime decision engines, dynamic rule loading, and strict validation pipelines.
  2. Lab DSL (Choreo.Lab.DSL.DecisionTree): An expressive sketch syntax using decision_tree do ... end, semantic node constructors (root, decision, outcome), direct connections (~>), and branch modifiers (|> when_("yes"), edge parent ~> child, "..."). Ideal for Livebooks, architectural documentation, and rapid prototyping.

The Legend below demonstrates the canonical programmatic pipe API, while subsequent examples leverage the concise Lab DSL.

Node Types

Type Shape Purpose
root 💎 diamond (double border) Starting decision — exactly one per tree
decision 💎 diamond Internal node testing a feature
outcome ⬭ rounded box Terminal leaf with class label or action
import Choreo.Lab.DSL.DecisionTree
alias Choreo.DecisionTree
alias Choreo.DecisionTree.Analysis

legend =
  DecisionTree.new()
  |> DecisionTree.set_root(:root, feature: "Root")
  |> DecisionTree.add_decision(:decision, feature: "Decision")
  |> DecisionTree.add_outcome(:outcome, label: "Outcome", class: "class_a")

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(legend)),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(legend)),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(legend))
)

Example 1: Troubleshooting Guide

A classic use case: your application's error page asks a series of questions and guides the user to a fix.

troubleshooting =
  decision_tree do
    has_power = root("Device powers on?", feature: "Device powers on?")
    has_network = decision("Network connected?", feature: "Network connected?")
    has_internet = decision("Internet reachable?", feature: "Internet reachable?")
    check_cable = outcome("Check power cable", class: "hardware")
    check_wifi = outcome("Check Wi-Fi settings", class: "network")
    check_dns = outcome("Check DNS config", class: "network")
    all_good = outcome("No issue detected", class: "ok")

    has_power ~> has_network |> when_("yes")
    has_power ~> check_cable |> when_("no")
    has_network ~> has_internet |> when_("yes")
    has_network ~> check_wifi |> when_("no")
    has_internet ~> all_good |> when_("yes")
    has_internet ~> check_dns |> when_("no")
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(troubleshooting), height: "600px"),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(troubleshooting), height: "600px"),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(troubleshooting))
)

Evaluating a Decision

Given feature values, walk the tree and return the path and outcome:

# User says: device powers on, network connected, internet reachable
Analysis.decide(troubleshooting, %{
  "Device powers on?" => "yes",
  "Network connected?" => "yes",
  "Internet reachable?" => "yes"
})
# User says: device doesn't power on
Analysis.decide(troubleshooting, %{
  "Device powers on?" => "no"
})

What if a feature is missing?

# Missing "Network connected?" — decision cannot proceed
Analysis.decide(troubleshooting, %{
  "Device powers on?" => "yes"
})

Example 2: ML-Style Classification Tree

Model a simplified version of the classic Iris dataset decision tree. Features are petal length and width; outcomes are species labels.

iris =
  decision_tree do
    petal_length = root("petal length (cm)", feature: "petal length (cm)")
    petal_width = decision("petal width (cm)", feature: "petal width (cm)")

    setosa = outcome("Iris-setosa", class: "setosa", probability: 0.98)
    versicolor = outcome("Iris-versicolor", class: "versicolor", probability: 0.94)
    virginica = outcome("Iris-virginica", class: "virginica", probability: 0.91)

    petal_length ~> setosa |> when_("< 2.45")
    petal_length ~> petal_width |> when_(">= 2.45")
    petal_width ~> versicolor |> when_("< 1.75")
    petal_width ~> virginica |> when_(">= 1.75")
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(iris)),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(iris)),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(iris))
)

Path Enumeration

What are all possible root-to-leaf paths?

Analysis.paths(iris)
|> Enum.each(fn path ->
  IO.puts(Enum.join(path, " → "))
end)

Paths with Conditions

Get the full decision logic for each path:

Analysis.paths_with_conditions(iris)
|> Enum.each(fn {path, branches} ->
  conditions = Enum.map(branches, fn {_p, _c, cond} -> cond end)
  outcome = List.last(path)
  IO.puts("#{outcome}: #{Enum.join(conditions, " AND ")}")
end)

Tree Metrics

IO.puts("Depth: #{Analysis.depth(iris)}")
IO.puts("Breadth (leaves): #{Analysis.breadth(iris)}")
IO.inspect(Analysis.feature_importance(iris), label: "Feature importance")
IO.inspect(Analysis.reachable_outcomes(iris), label: "Reachable outcomes")

Depth tells you the maximum number of decisions before classification. Breadth tells you how many distinct classes the tree can emit. Feature importance shows which attributes drive the most splits — useful for feature selection in ML pipelines.

Rule Extraction

Convert every root-to-leaf path into a human-readable IF-THEN rule. This is useful for documentation, regulatory audits, or exporting business logic.

Analysis.rules(iris)
|> Enum.each(fn %{conditions: conditions, outcome: outcome} ->
  conds = Enum.map(conditions, fn {f, v} -> "#{f} = #{v}" end) |> Enum.join(" AND ")
  IO.puts("IF #{conds} THEN #{outcome.label} (#{outcome.class})")
end)

Test Case Generation

Generate the smallest set of feature maps that exercises every reachable leaf. Each map can be fed directly back into Analysis.decide/2.

Analysis.generate_test_cases(iris)
|> Enum.each(fn features ->
  {:ok, path, label} = Analysis.decide(iris, features)
  IO.puts("#{inspect(features)} -> #{label} via #{inspect(path)}")
end)

Example 3: Business Rules Engine

Loan approval decisions based on income, credit score, and employment status. Some paths should never coexist — let's detect logical inconsistencies.

loan =
  decision_tree do
    income = root("annual income", feature: "annual income")
    credit_score = decision("credit score", feature: "credit score")
    employment = decision("employment status", feature: "employment status")

    approved_fast = outcome("Fast Track Approved", class: "approved")
    approved_standard = outcome("Standard Approved", class: "approved")
    rejected_risk = outcome("Rejected — High Risk", class: "rejected")
    rejected_income = outcome("Rejected — Low Income", class: "rejected")
    manual_review_credit = outcome("Manual Review (Credit)", class: "pending")
    manual_review_employment = outcome("Manual Review (Employment)", class: "pending")

    income ~> credit_score |> when_(">= 50k")
    income ~> rejected_income |> when_("< 50k")
    credit_score ~> employment |> when_(">= 700")
    credit_score ~> manual_review_credit |> when_("600-699")
    credit_score ~> rejected_risk |> when_("< 600")
    employment ~> approved_fast |> when_("full-time")
    employment ~> approved_standard |> when_("part-time")
    employment ~> manual_review_employment |> when_("self-employed")
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(loan)),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(loan), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(loan))
)

Inconsistent Path Detection

A logically inconsistent path is one where the same feature is checked against mutually exclusive conditions along the path. For example, a path that requires both income >= 50k and income < 50k is impossible.

Analysis.inconsistent_paths(loan)
|> IO.inspect(label: "Inconsistent paths")

In this well-formed tree, there are no inconsistencies. But if someone accidentally branched the same feature twice with overlapping ranges, this analysis catches it immediately:

inconsistent =
  decision_tree do
    color = root("color", feature: "color")
    shade = decision("color", feature: "color")
    stop = outcome("Stop")
    go1 = outcome("Go")
    go2 = outcome("Go")

    color ~> shade |> when_("red")
    color ~> go1 |> when_("green")
    shade ~> stop |> when_("dark")
    shade ~> go2 |> when_("light")
  end

Analysis.inconsistent_paths(inconsistent)
|> IO.inspect(label: "Inconsistent paths in broken tree")

Validation

Analysis.validate(loan)
|> Enum.each(fn {sev, msg} ->
  icon = if sev == :error, do: "❌", else: "⚠️"
  IO.puts("#{icon} #{msg}")
end)

Example 4: Configuration Selector

Help a user pick the right AWS EC2 instance type based on workload characteristics. This tree is deeper and demonstrates how decision trees scale to many levels.

ec2_selector =
  decision_tree do
    workload = root("workload type", feature: "workload type")
    memory = decision("memory needs", feature: "memory needs")
    gpu = decision("needs GPU?", feature: "needs GPU?")
    io = decision("I/O intensity", feature: "I/O intensity")
    budget = decision("budget constraint", feature: "budget constraint")

    t3_micro = outcome("t3.micro", class: "general")
    t3_large = outcome("t3.large", class: "general")
    m6i_xlarge = outcome("m6i.xlarge", class: "general")
    r6i_large = outcome("r6i.large", class: "memory")
    r6i_2xlarge = outcome("r6i.2xlarge", class: "memory")
    c6i_xlarge = outcome("c6i.xlarge", class: "compute")
    i4i_large = outcome("i4i.large", class: "storage")
    g5_xlarge = outcome("g5.xlarge", class: "gpu")
    inf2_xlarge = outcome("inf2.xlarge", class: "ml")

    # Workload branches
    workload ~> memory |> when_("database")
    workload ~> gpu |> when_("ml/training")
    workload ~> io |> when_("analytics")
    workload ~> budget |> when_("web server")

    # Memory branches
    memory ~> r6i_large |> when_("< 64GB")
    memory ~> r6i_2xlarge |> when_(">= 64GB")

    # GPU branches
    gpu ~> g5_xlarge |> when_("yes")
    gpu ~> inf2_xlarge |> when_("no")

    # I/O branches
    io ~> i4i_large |> when_("high")
    io ~> c6i_xlarge |> when_("medium")

    # Budget branches
    budget ~> t3_micro |> when_("tight")
    budget ~> t3_large |> when_("moderate")
    budget ~> m6i_xlarge |> when_("flexible")
  end

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(ec2_selector)),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(ec2_selector), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(ec2_selector))
)

Interactive Evaluation

# A database workload with high memory needs
Analysis.decide(ec2_selector, %{
  "workload type" => "database",
  "memory needs" => ">= 64GB"
})
# A web server on a tight budget
Analysis.decide(ec2_selector, %{
  "workload type" => "web server",
  "budget constraint" => "tight"
})

Feature Importance in Configuration Trees

Analysis.feature_importance(ec2_selector)
|> Enum.sort_by(fn {_feature, count} -> count end, :desc)
|> Enum.each(fn {feature, count} ->
  IO.puts("#{feature}: #{count} split(s)")
end)

In this tree, every feature appears exactly once — a balanced tree. In real-world datasets, skewed importance tells you which feature provides the most information gain.


Example 5: Pruning Redundant Decisions

Sometimes a decision doesn't actually change the outcome. If all branches under a decision lead to the same class, that decision is redundant and can be removed.

redundant =
  decision_tree do
    color = root("color", feature: "color")
    size_red = decision("size", feature: "size")
    size_green = decision("size", feature: "size")
    stop_1 = outcome("Stop", class: "stop")
    stop_2 = outcome("Stop", class: "stop")
    stop_3 = outcome("Stop", class: "stop")
    stop_4 = outcome("Stop", class: "stop")

    # Both colors lead to distinct size decisions, which always lead to Stop
    color ~> size_red |> when_("red")
    color ~> size_green |> when_("green")
    size_red ~> stop_1 |> when_("small")
    size_red ~> stop_2 |> when_("large")
    size_green ~> stop_3 |> when_("small")
    size_green ~> stop_4 |> when_("large")
  end

before_pruning =
  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(redundant)),
    Graphviz: Kino.VizJS.render(DecisionTree.to_dot(redundant)),
    Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(redundant))
  )

pruned = Analysis.prune_redundant(redundant)

after_pruning =
  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(pruned)),
    Graphviz: Kino.VizJS.render(DecisionTree.to_dot(pruned)),
    Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(pruned))
  )

Kino.Layout.grid([
  Kino.HTML.new("<h4>Before Pruning</h4>"),
  before_pruning,
  Kino.HTML.new("<h4>After Pruning</h4>"),
  after_pruning
])

The :color and :size decisions were both redundant because every path ended at Stop. After pruning, the tree collapses to a single outcome node — the simplest possible representation.


Example 6: Fixing a Broken Tree

The builder enforces invariants at construction time, but let's see what validation catches when a node is left orphaned.

# Simulate a malformed tree by building one with issues
broken =
  decision_tree do
    weather = root("weather", feature: "weather")
    decision("wind", feature: "wind")
    play = outcome("Play")
    stay = outcome("Stay")

    # Missing branch from root — decision node :wind has no parent
    weather ~> play |> when_("sunny")
    weather ~> stay |> when_("rainy")
  end

Analysis.validate(broken)
|> Enum.each(fn {sev, msg} ->
  icon = if sev == :error, do: "❌", else: "⚠️"
  IO.puts("#{icon} #{msg}")
end)

The validator catches:

  • Decision nodes with no branches (orphan :wind)
  • Outcome nodes with outgoing branches (if any)
  • Duplicate conditions from the same parent
  • Orphan nodes that are unreachable from the root

Detecting Orphan Nodes

Use Analysis.orphan_nodes/1 to find declared nodes that are not reachable from the root:

Analysis.orphan_nodes(broken)
|> IO.inspect(label: "Unreachable nodes")

Detecting Missing Branches

Use Analysis.missing_branches/2 to compare the tree against an expected domain of feature values. This is especially useful when the expected values are defined by a schema, dataset, or business contract.

expected_domain = %{"weather" => ["sunny", "cloudy", "rainy"]}

Analysis.missing_branches(broken, expected_domain)
|> IO.inspect(label: "Missing branches")

In this case, :cloudy is missing from the root, so a real-world input could fall through without reaching an outcome.

Tree Invariants at Build Time

Even before validation, the builder raises on invariant violations:

# Cannot add a second root
try do
  DecisionTree.new()
  |> DecisionTree.set_root(:a, feature: "a")
  |> DecisionTree.set_root(:b, feature: "b")
rescue
  e -> IO.puts("Error: #{e.message}")
end
# Cannot create a cycle
try do
  DecisionTree.new()
  |> DecisionTree.set_root(:a, feature: "a")
  |> DecisionTree.add_decision(:b, feature: "b")
  |> DecisionTree.branch(:a, :b, "yes")
  |> DecisionTree.branch(:b, :a, "no")
rescue
  e -> IO.puts("Error: #{e.message}")
end
# Cannot give a node two parents
try do
  DecisionTree.new()
  |> DecisionTree.set_root(:a, feature: "a")
  |> DecisionTree.add_decision(:b, feature: "b")
  |> DecisionTree.add_outcome(:c, label: "C")
  |> DecisionTree.branch(:a, :c, "yes")
  |> DecisionTree.branch(:b, :c, "no")
rescue
  e -> IO.puts("Error: #{e.message}")
end

Advanced: Custom Theming

brand_theme =
  Choreo.DecisionTree.theme(
    :default,
    colors: %{
      root: "#8b5cf6",
      decision: "#3b82f6",
      outcome: "#10b981"
    },
    node_fontcolor: "white",
    edge_color: "#94a3b8",
    graph_bgcolor: "lightgray"
  )

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(iris, theme: brand_theme)),
  Graphviz: Kino.VizJS.render(DecisionTree.to_dot(iris, theme: brand_theme), height: "500px"),
  Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(iris, theme: brand_theme))
)

Example 8: Zooming and Architectural Views

When dealing with deep, complex decision trees, displaying every single condition can be overwhelming. Choreo.Viewable allows you to "zoom out" to see the big picture, or "zoom in" for detail.

For Decision Tree graphs, zoom levels are semantic:

  • Level 0: Root node only (What is the primary decision?)
  • Level 1: Root and internal decisions (The decision structure, without outcomes)
  • Level 2+: Everything (including all outcomes)
alias Choreo.View

zoom_tree =
  decision_tree do
    weather = root("Outlook", feature: "Outlook")
    temp = decision("Temperature", feature: "Temperature")
    wind = decision("Wind", feature: "Wind")
    play1 = outcome("Play")
    play2 = outcome("Play")
    play3 = outcome("Play")
    stop1 = outcome("Don't Play")
    stop2 = outcome("Don't Play")

    weather ~> play1 |> when_("Overcast")
    weather ~> temp |> when_("Sunny")
    weather ~> wind |> when_("Rain")
    temp ~> stop1 |> when_("High")
    temp ~> play2 |> when_("Normal")
    wind ~> stop2 |> when_("Strong")
    wind ~> play3 |> when_("Weak")
  end

# Zoom out to Level 0 (Root only)
root_only = View.zoom(zoom_tree, level: 0)

level_0 =
  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(root_only)),
    Graphviz: Kino.VizJS.render(DecisionTree.to_dot(root_only)),
    Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(root_only))
  )

decision_view = View.zoom(zoom_tree, level: 1)

level_1 =
  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(decision_view)),
    Graphviz: Kino.VizJS.render(DecisionTree.to_dot(decision_view)),
    Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(decision_view))
  )

full_view = View.zoom(zoom_tree, level: 2)

level_2 =
  Kino.Layout.tabs(
    Siren: Choreo.Lab.Siren.new(DecisionTree.to_mermaid(full_view)),
    Graphviz: Kino.VizJS.render(DecisionTree.to_dot(full_view)),
    Sketch: Choreo.Lab.Sketch.new(DecisionTree.to_mermaid(full_view))
  )

Kino.Layout.tabs(
  "Zoom Level 0": level_0,
  "Zoom Level 1": level_1,
  "Full View": level_2
)

When you zoom out, intermediate nodes are hidden, but their transitive dependencies are preserved as virtual edges (dashed light-grey lines) so you don't lose the structural relationship between the visible components!


Cheat Sheet

Lab DSL Syntax (Choreo.Lab.DSL.DecisionTree)

Syntax Description
decision_tree do ... end Define a decision tree model
r = root("Feature", feature: "...") Set root decision node (exactly one per tree)
d = decision("Feature", feature: "...") Declare an internal decision node
d = question("Feature") Alias for decision constructor
o = outcome("Label", class: "...") Declare a terminal leaf outcome node
o = result("Label") / leaf("Label") Aliases for outcome constructor
a ~> b Connect parent to child (requires branch condition)
`a ~> b > when_("condition")`
`a ~> b > condition("condition")/on(...)`
`a ~> b > when_("condition", probability: 0.9)`
edge a ~> b, "condition" Explicit branch declaration with condition
branch a ~> b, "condition", opts Explicit branch declaration with condition and options

Programmatic Pipe API & Analysis (Choreo.DecisionTree)

Task / Feature Command
Create Tree DecisionTree.new/1 (Opts: :strict)
Set Root Node DecisionTree.set_root/3 (Opts: :feature, :label, :shape, :fillcolor)
Add Decision Node DecisionTree.add_decision/3 (Opts: :feature, :label, :fillcolor)
Add Outcome Node DecisionTree.add_outcome/3 (Opts: :label, :class, :probability, :fillcolor)
Add Branch DecisionTree.branch/5 (tree, parent, child, condition, opts \\ [])
Query Branches / Outcomes DecisionTree.branches/1, DecisionTree.outcomes/1, DecisionTree.decisions/1
Query Condition DecisionTree.condition/3 (tree, parent, child)
Render Mermaid Flowchart DecisionTree.to_mermaid/2 (Opts: :theme, :direction, :highlighted_nodes, :highlighted_edges)
Render DOT Graphviz DecisionTree.to_dot/2 (Opts: :theme, :rankdir, :highlighted_nodes, :highlighted_edges)
Themes DecisionTree.theme/2 (:default, :dark, :minimal, :warm, :forest, :ocean)
Cycle Detection Analysis.cyclic?/1 (Detects loops in graph structure)
Dead-End Stages Analysis.dead_ends/1 (Finds decision paths that cannot reach any outcome)
Outcome Distribution Analysis.outcome_distribution/1 (Frequency of reachable outcomes by class)
Evaluate Inputs Analysis.decide/2 (Walks tree given feature values and returns outcome)
Enumerate Paths Analysis.paths/1, Analysis.paths_with_conditions/1
Depth & Breadth Metrics Analysis.depth/1, Analysis.breadth/1
Feature Importance Analysis.feature_importance/1 (Ranks features by split count)
Extract IF-THEN Rules Analysis.rules/1 (Converts paths into executable IF-THEN rules)
Test Case Generation Analysis.generate_test_cases/1 (Generates minimal feature set covering all outcomes)
Inconsistent Path Detection Analysis.inconsistent_paths/1 (Finds logically contradictory branches)
Detect Missing Branches Analysis.missing_branches/2 (Finds missing feature values against domain schema)
Redundant Branch Pruning Analysis.prune_redundant/1 (Collapses decisions that produce identical outcomes)
Tree Invariant Validation Analysis.validate/1 (Validates root, single parent, no cycles, orphan nodes)
Semantic Zoom Views Choreo.View.zoom/2 (Level 0: root, Level 1: root + decisions, Level 2+: all)

Summary

Question Function
"Given features, what's the outcome?" Analysis.decide/2
"What are all possible paths?" Analysis.paths/1
"What conditions lead to each outcome?" Analysis.paths_with_conditions/1
"How deep is the tree?" Analysis.depth/1
"How many outcomes?" Analysis.breadth/1
"Which features matter most?" Analysis.feature_importance/1
"What IF-THEN rules does the tree encode?" Analysis.rules/1
"What inputs exercise every path?" Analysis.generate_test_cases/1
"Which nodes are unreachable?" Analysis.orphan_nodes/1
"Does the tree contain cycles?" Analysis.cyclic?/1
"Which paths lead nowhere?" Analysis.dead_ends/1
"How are outcomes distributed?" Analysis.outcome_distribution/1
"Which expected branches are missing?" Analysis.missing_branches/2
"Are there impossible paths?" Analysis.inconsistent_paths/1
"Can I simplify the tree?" Analysis.prune_redundant/1
"Is the tree structurally valid?" Analysis.validate/1
"Render to DOT" DecisionTree.to_dot/2
"Render to Mermaid" DecisionTree.to_mermaid/2

Decision trees as code mean your business rules, troubleshooting guides, and ML models are version-controlled, diffable, and automatically validated. Every branch addition is checked for cycles, duplicate conditions, and logical inconsistencies — before it ever reaches production.