///|
/// Leaf-level causes that currently prevent a trace from being true. This is
/// intentionally structural: callers receive the exact evaluated predicates,
/// not a second heuristic explanation of the policy.
pub fn Trace::blocking_leaves(self : Trace) -> Array[Trace] {
let result : Array[Trace] = []
collect_blocking_leaves(self, result)
result
}
///|
fn collect_blocking_leaves(trace : Trace, result : Array[Trace]) -> Unit {
if trace.children.is_empty() {
if trace.truth != Yes {
result.push(trace)
}
return
}
match trace.truth {
Yes => ()
No | Unknown => {
let before = result.length()
for child in trace.children {
if child.truth != Yes {
collect_blocking_leaves(child, result)
}
}
if result.length() == before {
result.push(trace)
}
}
}
}
///|
pub(all) struct DecisionRow {
environment : Environment
outcome : Truth
} derive(Debug, Eq)
///|
pub(all) struct DecisionTable {
policy_name : String
rows : Array[DecisionRow]
combinations : Int
truncated : Bool
} derive(Debug, Eq)
///|
priv struct TableState {
model : Policy
limit : Int
rows : Array[DecisionRow]
mut combinations : Int
mut truncated : Bool
}
///|
/// Enumerate the feasible decision surface. Mutable feature domains are
/// expanded; locked features stay at their current value. `limit` bounds the
/// materialized rows so the API is safe for interactive tools.
pub fn Policy::decision_table(
self : Policy,
limit? : Int = 256,
) -> Result[DecisionTable, Diagnostic] {
if limit <= 0 {
return Err({
code: "E_TABLE_LIMIT",
message: "decision table limit must be positive",
span: { offset: 0, line: 1, column: 1 },
})
}
let validation = self.validate()
if !validation.is_empty() {
return Err(validation[0])
}
let state = TableState::{
model: self,
limit,
rows: [],
combinations: 0,
truncated: false,
}
enumerate_table(state, 0, self.environment())
Ok({
policy_name: self.name,
rows: state.rows,
combinations: state.combinations,
truncated: state.truncated,
})
}
///|
fn enumerate_table(
state : TableState,
index : Int,
environment : Environment,
) -> Unit {
if index >= state.model.features.length() {
state.combinations = state.combinations + 1
if state.rows.length() < state.limit {
state.rows.push({
environment,
outcome: evaluate(state.model.goal, environment).truth,
})
} else {
state.truncated = true
}
return
}
let item = state.model.features[index]
if item.locked {
enumerate_table(state, index + 1, environment)
return
}
for candidate in item.domain {
enumerate_table(
state,
index + 1,
environment_set(environment, item.name, candidate),
)
}
}
///|
pub fn DecisionTable::count(self : DecisionTable, truth : Truth) -> Int {
self.rows.count_if(row => row.outcome == truth)
}
///|
pub fn DecisionTable::to_markdown(self : DecisionTable) -> String {
let out = StringBuilder::new()
out.write_string("# Decision table: \{self.policy_name}\n\n")
if self.rows.is_empty() {
out.write_string("No rows.\n")
return out.to_string()
}
for binding in self.rows[0].environment {
out.write_string("| \{binding.name} ")
}
out.write_string("| outcome |\n")
for _ in self.rows[0].environment {
out.write_string("| --- ")
}
out.write_string("| --- |\n")
for row in self.rows {
for binding in row.environment {
out.write_string("| \{binding.value.render()} ")
}
out.write_string("| \{row.outcome.render()} |\n")
}
if self.truncated {
out.write_string("\n_Table truncated after \{self.rows.length()} rows._\n")
}
out.to_string()
}
///|
/// Serialize a programmatically assembled policy back to canonical FlipSpec.
/// The result can be parsed again, which makes it useful for editors and model
/// migration tools.
pub fn Policy::to_source(self : Policy) -> String {
let out = StringBuilder::new()
out.write_string("spec \{self.name}\n")
for item in self.features {
out.write_string("feature \{item.name} = \{item.current.render()} in [")
for index, value in item.domain {
if index > 0 {
out.write_string(", ")
}
out.write_string(value.render())
}
out.write_string("] cost \{item.cost}")
if item.locked {
out.write_string(" locked")
}
out.write_char('\n')
}
out.write_string("goal \{self.goal.render()}\n")
out.to_string()
}