///|
/// A source location used by parser and validation diagnostics.
pub(all) struct Span {
offset : Int
line : Int
column : Int
} derive(Debug, Eq)
///|
/// A human-readable, structured error. FlipSpec returns diagnostics instead of
/// aborting so callers can render them in a CLI, editor, or web application.
pub(all) struct Diagnostic {
code : String
message : String
span : Span
} derive(Debug, Eq)
///|
pub fn Diagnostic::render(self : Diagnostic) -> String {
"\{self.code} at \{self.span.line}:\{self.span.column}: \{self.message}"
}
///|
/// Scalar values accepted by the FlipSpec DSL.
pub(all) enum Value {
Integer(Int)
Boolean(Bool)
Text(String)
} derive(Debug, Eq)
///|
pub fn Value::render(self : Value) -> String {
match self {
Integer(value) => value.to_string()
Boolean(true) => "true"
Boolean(false) => "false"
Text(value) => "\"\{escape_text(value)}\""
}
}
///|
pub fn Value::plain(self : Value) -> String {
match self {
Integer(value) => value.to_string()
Boolean(true) => "true"
Boolean(false) => "false"
Text(value) => value
}
}
///|
pub fn Value::kind(self : Value) -> String {
match self {
Integer(_) => "integer"
Boolean(_) => "boolean"
Text(_) => "text"
}
}
///|
fn escape_text(value : String) -> String {
let out = StringBuilder::new()
for ch in value {
match ch {
'\\' => out.write_string("\\\\")
'"' => out.write_string("\\\"")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
/// Kleene three-valued truth. Unknown is deliberately preserved: a missing
/// input must not silently become false in policy evaluation.
pub(all) enum Truth {
Yes
No
Unknown
} derive(Debug, Eq)
///|
pub fn Truth::render(self : Truth) -> String {
match self {
Yes => "true"
No => "false"
Unknown => "unknown"
}
}
///|
pub fn Truth::not(self : Truth) -> Truth {
match self {
Yes => No
No => Yes
Unknown => Unknown
}
}
///|
pub fn Truth::conjunction(self : Truth, other : Truth) -> Truth {
match (self, other) {
(No, _) | (_, No) => No
(Yes, Yes) => Yes
_ => Unknown
}
}
///|
pub fn Truth::disjunction(self : Truth, other : Truth) -> Truth {
match (self, other) {
(Yes, _) | (_, Yes) => Yes
(No, No) => No
_ => Unknown
}
}
///|
pub(all) enum CompareOp {
Equal
NotEqual
Less
LessEqual
Greater
GreaterEqual
} derive(Debug, Eq)
///|
pub fn CompareOp::render(self : CompareOp) -> String {
match self {
Equal => "=="
NotEqual => "!="
Less => "<"
LessEqual => "<="
Greater => ">"
GreaterEqual => ">="
}
}
///|
/// Boolean expression evaluated against a set of named feature values.
pub(all) enum Expr {
Constant(Truth)
Compare(String, CompareOp, Value)
Present(String)
Not(Expr)
And(Expr, Expr)
Or(Expr, Expr)
} derive(Debug, Eq)
///|
pub fn Expr::render(self : Expr) -> String {
match self {
Constant(value) => value.render()
Compare(name, op, value) => "\{name} \{op.render()} \{value.render()}"
Present(name) => "present(\{name})"
Not(inner) => "not (\{inner.render()})"
And(left, right) => "(\{left.render()}) and (\{right.render()})"
Or(left, right) => "(\{left.render()}) or (\{right.render()})"
}
}
///|
/// A feature includes its current value, its feasible domain, and an integer
/// edit cost. Locked features participate in evaluation but never in repairs.
pub(all) struct Feature {
name : String
current : Value
domain : Array[Value]
cost : Int
locked : Bool
} derive(Debug, Eq)
///|
pub fn feature(
name : String,
current : Value,
domain : Array[Value],
cost? : Int = 1,
locked? : Bool = false,
) -> Feature {
{ name, current, domain, cost, locked }
}
///|
pub(all) struct Policy {
name : String
features : Array[Feature]
goal : Expr
} derive(Debug, Eq)
///|
pub fn policy(name : String, features : Array[Feature], goal : Expr) -> Policy {
{ name, features, goal }
}
///|
/// A lightweight ordered environment. Arrays keep reports deterministic across
/// compilation targets and are sufficient for the small policy models FlipSpec
/// is designed to explain.
pub(all) struct Binding {
name : String
value : Value
} derive(Debug, Eq)
///|
pub type Environment = Array[Binding]
///|
pub fn Policy::environment(self : Policy) -> Environment {
self.features.map(feature => Binding::{
name: feature.name,
value: feature.current,
})
}
///|
pub fn environment_get(environment : Environment, name : String) -> Value? {
for binding in environment {
if binding.name == name {
return Some(binding.value)
}
}
None
}
///|
pub fn environment_set(
environment : Environment,
name : String,
value : Value,
) -> Environment {
let result : Environment = []
let mut found = false
for binding in environment {
if binding.name == name {
result.push({ name, value })
found = true
} else {
result.push(binding)
}
}
if !found {
result.push({ name, value })
}
result
}
///|
pub fn Policy::find_feature(self : Policy, name : String) -> Feature? {
for item in self.features {
if item.name == name {
return Some(item)
}
}
None
}
///|
fn same_value_kind(left : Value, right : Value) -> Bool {
match (left, right) {
(Integer(_), Integer(_)) => true
(Boolean(_), Boolean(_)) => true
(Text(_), Text(_)) => true
_ => false
}
}
///|
/// Checks structural invariants before evaluation or planning.
pub fn Policy::validate(self : Policy) -> Array[Diagnostic] {
let errors : Array[Diagnostic] = []
let zero = Span::{ offset: 0, line: 1, column: 1 }
if self.name.is_empty() {
errors.push({
code: "E_MODEL_NAME",
message: "spec name must not be empty",
span: zero,
})
}
if self.features.is_empty() {
errors.push({
code: "E_MODEL_EMPTY",
message: "at least one feature is required",
span: zero,
})
}
for index, item in self.features {
if item.name.is_empty() {
errors.push({
code: "E_FEATURE_NAME",
message: "feature #\{index + 1} has an empty name",
span: zero,
})
}
for previous in 0.. Unit {
match expression {
Constant(_) => ()
Present(_) => ()
Compare(name, op, expected) =>
match model.find_feature(name) {
None =>
errors.push({
code: "E_UNKNOWN_FEATURE",
message: "goal references unknown feature '\{name}'",
span,
})
Some(item) => {
if !same_value_kind(item.current, expected) {
errors.push({
code: "E_TYPE_MISMATCH",
message: "feature '\{name}' is \{item.current.kind()} but goal compares it with \{expected.kind()}",
span,
})
}
match (op, expected) {
(Less | LessEqual | Greater | GreaterEqual, Boolean(_)) =>
errors.push({
code: "E_BOOLEAN_ORDER",
message: "boolean feature '\{name}' only supports == and !=",
span,
})
_ => ()
}
}
}
Not(inner) => collect_expression_errors(inner, model, errors, span)
And(left, right) | Or(left, right) => {
collect_expression_errors(left, model, errors, span)
collect_expression_errors(right, model, errors, span)
}
}
}