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.VizJSto render DOT diagrams inline andChoreo.Lab.Siren/Choreo.Lab.Sketchfor 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:
- 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. - Lab DSL (
Choreo.Lab.DSL.DecisionTree): An expressive sketch syntax usingdecision_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.