///|
/// Plain-language rule explanations.
///
/// The explain_rules operation renders each rule condition as an
/// English sentence derived from the compiled tree — comparisons read
/// naturally (" must be at least "), conjunctions join
/// with "and", disjunctions with "or", negations with "not", and
/// builtin calls expand to their intent (exists -> " is
/// present"). Deterministic from the tree, no facts involved; a
/// reviewer can hand the output to a non-technical stakeholder.
///|
fn phrase(expr : Expression) -> String {
match expr {
Literal(value) => Value::stringify(value)
Variable(path) => path
Unary("!", operand) => "not (" + phrase(operand) + ")"
Unary("-", operand) => "negative " + phrase(operand)
Unary(op, operand) => op + " applied to (" + phrase(operand) + ")"
Binary(op, left, right) => {
let left_text = phrase(left)
let right_text = phrase(right)
match op {
"&&" => "(" + left_text + ") and (" + right_text + ")"
"||" => "(" + left_text + ") or (" + right_text + ")"
">" => left_text + " must be greater than " + right_text
">=" => left_text + " must be at least " + right_text
"<" => left_text + " must be less than " + right_text
"<=" => left_text + " must be at most " + right_text
"==" => left_text + " must equal " + right_text
"!=" => left_text + " must not equal " + right_text
"+" => left_text + " plus " + right_text
"-" => left_text + " minus " + right_text
"*" => left_text + " times " + right_text
"/" => left_text + " divided by " + right_text
"in" => left_text + " is listed in " + right_text
_ => left_text + " " + op + " " + right_text
}
}
Call(name, args) =>
match (name, args) {
("exists", [Variable(path)]) => path + " is present"
("len", [arg]) => "the size of (" + phrase(arg) + ")"
("contains", [Variable(path), item]) =>
path + " contains " + phrase(item)
("lower", [arg]) => "the lowercase form of (" + phrase(arg) + ")"
("upper", [arg]) => "the uppercase form of (" + phrase(arg) + ")"
("abs", [arg]) => "the absolute value of (" + phrase(arg) + ")"
("floor", [arg]) => phrase(arg) + " rounded down"
("ceil", [arg]) => phrase(arg) + " rounded up"
("round", [arg]) => phrase(arg) + " rounded"
(_, _) => name + " over its arguments"
}
}
}
///|
fn explain_rules_request(request : Value) -> Value raise {
let rules = arr(get(request, "rules"))
let explanations : Array[Value] = []
let mut index = 0
for item in rules {
let id = str(get(item, "id"))
let condition = compile(str(get(item, "when")))
let sentence = phrase(condition)
// Capitalize the first alphabetic character (the sentence may
// start with punctuation such as a grouping parenthesis).
let chars = sentence.to_array()
let mut cut = 0
let mut upper_prefix = ""
while cut < chars.length() {
let piece = chars[cut].to_string()
let upper_piece = piece.to_upper()
if piece == upper_piece {
// not a lowercase letter: keep scanning
upper_prefix += piece
cut += 1
} else {
upper_prefix += upper_piece
cut += 1
break
}
}
let capitalized = upper_prefix + sentence[cut:].to_owned()
explanations.push(
record([
("id", String(id)),
("canonical", String(condition.render())),
("explanation", String(capitalized + ".")),
]),
)
index += 1
}
ignore(index)
record([
("explanations", Array(explanations)),
("rule_count", Number(explanations.length().to_double())),
])
}