use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt;
use std::num::IntErrorKind;
use lgwks_std::hash::Hasher;
use lgwks_std::json::serde;
use lgwks_std::json::{Deserialize, Serialize};
use crate::error::BotError;
use crate::semantic::EmbedderIdentity;
pub const MAX_FLOW_BYTES: usize = 2_097_152;
pub const MAX_FLOW_SPEC_BYTES: usize = MAX_FLOW_BYTES;
pub const MAX_UTTERANCE_BYTES: usize = 8_192;
pub const MAX_VALUE_BYTES: usize = 65_536;
pub const MAX_RECORD_BYTES: usize = 1_048_576;
pub const MAX_SESSION_BYTES: usize = 8_388_608;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResourceAxis {
Utterance,
Value,
Record,
Session,
}
impl ResourceAxis {
#[must_use]
pub const fn label(&self) -> &'static str {
match *self {
Self::Utterance => "utterance",
Self::Value => "value",
Self::Record => "record",
Self::Session => "session",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields, default)]
#[non_exhaustive]
pub struct ResourceLimits {
utterance_bytes: usize,
value_bytes: usize,
record_bytes: usize,
session_bytes: usize,
}
impl ResourceLimits {
#[must_use]
pub const fn shipped() -> Self {
Self {
utterance_bytes: MAX_UTTERANCE_BYTES,
value_bytes: MAX_VALUE_BYTES,
record_bytes: MAX_RECORD_BYTES,
session_bytes: MAX_SESSION_BYTES,
}
}
#[must_use]
pub const fn get(self, axis: ResourceAxis) -> usize {
match axis {
ResourceAxis::Utterance => self.utterance_bytes,
ResourceAxis::Value => self.value_bytes,
ResourceAxis::Record => self.record_bytes,
ResourceAxis::Session => self.session_bytes,
}
}
#[must_use]
pub const fn tighten(mut self, axis: ResourceAxis, bytes: usize) -> Self {
match axis {
ResourceAxis::Utterance => self.utterance_bytes = bytes,
ResourceAxis::Value => self.value_bytes = bytes,
ResourceAxis::Record => self.record_bytes = bytes,
ResourceAxis::Session => self.session_bytes = bytes,
}
self
}
#[must_use]
pub const fn narrowed(self, other: Self) -> Self {
Self {
utterance_bytes: smaller(self.utterance_bytes, other.utterance_bytes),
value_bytes: smaller(self.value_bytes, other.value_bytes),
record_bytes: smaller(self.record_bytes, other.record_bytes),
session_bytes: smaller(self.session_bytes, other.session_bytes),
}
}
pub fn within_ceiling(self) -> Result<Self, BotError> {
let ceiling = Self::shipped();
for axis in [
ResourceAxis::Utterance,
ResourceAxis::Value,
ResourceAxis::Record,
ResourceAxis::Session,
] {
let requested = self.get(axis);
let allowed = ceiling.get(axis);
if requested > allowed {
let refusal = Err(BotError::ResourceLimitAboveCeiling {
axis,
requested,
ceiling: allowed,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "within_ceiling: returning an error to the caller");
return refusal;
}
}
Ok(self)
}
}
impl Default for ResourceLimits {
fn default() -> Self {
Self::shipped()
}
}
const SHIPPED_LIMITS: ResourceLimits = ResourceLimits::shipped();
const fn smaller(left: usize, right: usize) -> usize {
if left < right { left } else { right }
}
pub type NodeId = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Value {
String(String),
Integer(i64),
Boolean(bool),
Choice(String),
}
impl fmt::Display for Value {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::String(ref value) | Self::Choice(ref value) => formatter.write_str(value),
Self::Integer(value) => write!(formatter, "{value}"),
Self::Boolean(value) => write!(formatter, "{value}"),
}
}
}
impl Value {
#[must_use]
pub fn rendered_bytes(&self) -> usize {
match *self {
Self::String(ref value) | Self::Choice(ref value) => value.len(),
Self::Boolean(true) => 4,
Self::Boolean(false) => 5,
Self::Integer(value) => integer_bytes(value),
}
}
}
fn integer_bytes(value: i64) -> usize {
let magnitude = value.unsigned_abs();
let mut digits = 1_usize;
let mut threshold = 10_u64;
while magnitude >= threshold {
digits = digits.saturating_add(1);
threshold = threshold.saturating_mul(10);
}
if value < 0 {
digits.saturating_add(1)
} else {
digits
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum VarType {
String,
Integer,
Boolean,
Choice(Vec<String>),
}
impl VarType {
#[must_use]
pub const fn answer_domain(&self) -> AnswerDomain {
match *self {
Self::Integer => AnswerDomain::Integer,
Self::String | Self::Boolean | Self::Choice(_) => AnswerDomain::Label,
}
}
#[must_use]
pub const fn label(&self) -> &'static str {
match *self {
Self::String => "string",
Self::Integer => "integer",
Self::Boolean => "boolean",
Self::Choice(_) => "choice",
}
}
pub fn decode_answer(&self, answer: &str) -> Result<Value, AnswerRejection> {
let trimmed = answer.trim();
match *self {
Self::String => Ok(Value::String(answer.to_owned())),
Self::Integer => match trimmed.parse::<i64>() {
Ok(value) => Ok(Value::Integer(value)),
Err(error) => Err(match *error.kind() {
IntErrorKind::PosOverflow | IntErrorKind::NegOverflow => {
AnswerRejection::IntegerOutOfRange
}
_ => AnswerRejection::NotAnInteger,
}),
},
Self::Boolean => {
if trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("yes") {
Ok(Value::Boolean(true))
} else if trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
{
Ok(Value::Boolean(false))
} else {
Err(AnswerRejection::NotABoolean)
}
}
Self::Choice(ref options) => {
let Some(option) = options
.iter()
.find(|option| option.eq_ignore_ascii_case(trimmed))
else {
let refusal = Err(AnswerRejection::NotADeclaredChoice);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "decode_answer: returning an error to the caller");
return refusal;
};
Ok(Value::Choice(option.clone()))
}
}
}
}
pub(crate) fn decode_integer(raw: &str) -> Option<i64> {
raw.trim().parse::<i64>().ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnswerDomain {
Label,
Integer,
}
impl fmt::Display for AnswerDomain {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Label => formatter.write_str("label"),
Self::Integer => formatter.write_str("integer"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnswerRejection {
NotAnInteger,
IntegerOutOfRange,
NotABoolean,
NotADeclaredChoice,
}
impl fmt::Display for AnswerRejection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::NotAnInteger => formatter.write_str("it is not a decimal integer"),
Self::IntegerOutOfRange => {
formatter.write_str("it is outside the signed 64-bit integer range")
}
Self::NotABoolean => formatter.write_str("it is not true/false or yes/no"),
Self::NotADeclaredChoice => {
formatter.write_str("it is not one of the declared choice values")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValueExpr {
Literal(Value),
Var(String),
}
impl ValueExpr {
#[must_use]
pub fn variable(name: impl Into<String>) -> Self {
Self::Var(name.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Predicate {
Const(bool),
Eq(ValueExpr, ValueExpr),
Ne(ValueExpr, ValueExpr),
Lt(ValueExpr, ValueExpr),
Le(ValueExpr, ValueExpr),
Gt(ValueExpr, ValueExpr),
Ge(ValueExpr, ValueExpr),
And(Vec<Predicate>),
Or(Vec<Predicate>),
Not(Box<Predicate>),
}
impl Predicate {
pub fn evaluate(&self, scope: &VarScope) -> Result<bool, BotError> {
match *self {
Self::Const(value) => Ok(value),
Self::Eq(ref left, ref right) => same_value(left, right, scope, true),
Self::Ne(ref left, ref right) => same_value(left, right, scope, false),
Self::Lt(ref left, ref right) => {
compare_expr(left, right, scope, |ordering| ordering.is_lt())
}
Self::Le(ref left, ref right) => {
compare_expr(left, right, scope, |ordering| ordering.is_le())
}
Self::Gt(ref left, ref right) => {
compare_expr(left, right, scope, |ordering| ordering.is_gt())
}
Self::Ge(ref left, ref right) => {
compare_expr(left, right, scope, |ordering| ordering.is_ge())
}
Self::And(ref items) => all_true(items, scope),
Self::Or(ref items) => any_true(items, scope),
Self::Not(ref inner) => Ok(!inner.evaluate(scope)?),
}
}
}
fn same_value(
left: &ValueExpr,
right: &ValueExpr,
scope: &VarScope,
equal: bool,
) -> Result<bool, BotError> {
let left_value = resolve_expr(left, scope)?;
let right_value = resolve_expr(right, scope)?;
Ok((left_value == right_value) == equal)
}
fn all_true(items: &[Predicate], scope: &VarScope) -> Result<bool, BotError> {
for item in items {
if !item.evaluate(scope)? {
return Ok(false);
}
}
Ok(true)
}
fn any_true(items: &[Predicate], scope: &VarScope) -> Result<bool, BotError> {
for item in items {
if item.evaluate(scope)? {
return Ok(true);
}
}
Ok(false)
}
fn validate_branch_node(
spec: &FlowSpec,
node_id: &str,
var: &str,
when: &Predicate,
then: &str,
otherwise: &str,
) -> Result<(), BotError> {
validate_variable_reference(spec, node_id, var)?;
validate_predicate(spec, node_id, when)?;
check_edge_pair(&spec.nodes, node_id, then, otherwise)
}
fn validate_refer_node(
spec: &FlowSpec,
node_id: &str,
text: &str,
limits: ResourceLimits,
) -> Result<(), BotError> {
validate_template(spec, node_id, text, "refer.text", limits)?;
for target in spec.edge_targets(node_id) {
check_target(&spec.nodes, node_id, &target)?;
}
Ok(())
}
fn validate_say_node(
spec: &FlowSpec,
node_id: &str,
text: &str,
limits: ResourceLimits,
) -> Result<(), BotError> {
validate_template(spec, node_id, text, "say.text", limits)?;
if spec.edge_targets(node_id).len() > 1 {
let refusal = Err(BotError::MalformedFlow {
cause: format!("say node {node_id:?} has multiple continuations"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_say_node: returning an error to the caller");
return refusal;
}
if spec.edge_targets(node_id).is_empty() && !spec.terminals.contains_key(node_id) {
let refusal = Err(BotError::MissingTransition {
node: node_id.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_say_node: returning an error to the caller");
return refusal;
}
Ok(())
}
fn validate_ask_node(
spec: &FlowSpec,
node_id: &str,
var: &str,
options: &[String],
routes: &BTreeMap<String, NodeId>,
limits: ResourceLimits,
writers: &mut BTreeSet<String>,
) -> Result<(), BotError> {
let declared = declared_variable(spec, var)?;
writers.insert(var.to_owned());
if options.is_empty() || has_duplicate_strings(options) {
let refusal = Err(BotError::MalformedFlow {
cause: format!("ask node {node_id:?} has invalid options"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
}
if let Some((first, second)) = colliding_options(options, answer_domain_of(spec, var)) {
let refusal = Err(BotError::MalformedFlow {
cause: format!(
"ask node {node_id:?} offers {first:?} and {second:?} as the same \
{} answer",
answer_domain_of(spec, var)
),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
}
for option in options {
let Some(target) = routes.get(option) else {
let refusal = Err(BotError::MissingAskRoute {
node: node_id.to_owned(),
option: option.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
};
check_target(&spec.nodes, node_id, target)?;
let value = match declared.decode_answer(option) {
Ok(value) => value,
Err(rejection) => {
let refusal = Err(BotError::AskOptionNotAssignable {
node: node_id.to_owned(),
variable: var.to_owned(),
option: option.clone(),
expected: declared.label(),
cause: rejection.to_string(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
}
};
let value_bytes = limits.get(ResourceAxis::Value);
if value.rendered_bytes() > value_bytes {
let refusal = Err(BotError::AskOptionTooLarge {
node: node_id.to_owned(),
variable: var.to_owned(),
option: option.clone(),
bytes: value.rendered_bytes(),
limit: value_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
}
}
for (option, target) in routes {
if !options.iter().any(|candidate| candidate == option) {
let refusal = Err(BotError::MalformedFlow {
cause: format!("ask node {node_id:?} routes undeclared option {option:?}"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_ask_node: returning an error to the caller");
return refusal;
}
check_target(&spec.nodes, node_id, target)?;
}
Ok(())
}
fn check_edge_pair(
nodes: &BTreeMap<NodeId, NodeKind>,
node_id: &str,
first: &str,
second: &str,
) -> Result<(), BotError> {
check_target(nodes, node_id, first)?;
check_target(nodes, node_id, second)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum NodeKind {
Say {
text: String,
},
Ask {
var: String,
options: Vec<String>,
routes: BTreeMap<String, NodeId>,
},
Branch {
var: String,
when: Predicate,
then: NodeId,
otherwise: NodeId,
},
Handoff {
target: String,
},
Refer {
target: String,
text: String,
},
Route {
dispatch: NodeId,
fallback: NodeId,
},
End,
}
pub type FlowNodeKind = NodeKind;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum FlowEdge {
Next {
from: NodeId,
to: NodeId,
},
After {
from: NodeId,
to: NodeId,
},
}
impl FlowEdge {
#[must_use]
pub fn next(from: impl Into<String>, to: impl Into<String>) -> Self {
Self::Next {
from: from.into(),
to: to.into(),
}
}
#[must_use]
pub fn endpoints(&self) -> (&str, &str) {
match *self {
Self::Next { ref from, ref to } | Self::After { ref from, ref to } => (from, to),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Disposition {
Completed,
Referred,
HandedOff,
Refused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct EffectLedger {
confirmed: usize,
unsettled: usize,
}
impl EffectLedger {
#[must_use]
pub const fn new(confirmed: usize, unsettled: usize) -> Self {
Self {
confirmed,
unsettled,
}
}
#[must_use]
pub const fn confirmed(&self) -> usize {
self.confirmed
}
#[must_use]
pub const fn unsettled(&self) -> usize {
self.unsettled
}
#[must_use]
pub const fn needs_reconciliation(&self) -> bool {
self.unsettled > 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct Outcome {
disposition: Disposition,
effects: EffectLedger,
}
impl Outcome {
#[must_use]
pub const fn disposition(&self) -> Disposition {
self.disposition
}
#[must_use]
pub const fn effects(&self) -> EffectLedger {
self.effects
}
#[must_use]
pub const fn needs_reconciliation(&self) -> bool {
self.effects.needs_reconciliation()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct ChoiceArm {
when: Predicate,
then: NodeId,
}
impl ChoiceArm {
#[must_use]
pub fn new(when: Predicate, then: impl Into<String>) -> Self {
Self {
when,
then: then.into(),
}
}
#[must_use]
pub fn when(&self) -> &Predicate {
&self.when
}
#[must_use]
pub fn then(&self) -> &str {
&self.then
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Terminal {
Completed,
Referred {
target: String,
},
HandedOff {
target: String,
},
Refused {
reason: String,
},
}
impl Terminal {
#[must_use]
pub fn outcome(&self, effects: EffectLedger) -> Outcome {
let disposition = match *self {
Self::Completed => Disposition::Completed,
Self::Referred { .. } => Disposition::Referred,
Self::HandedOff { .. } => Disposition::HandedOff,
Self::Refused { .. } => Disposition::Refused,
};
Outcome {
disposition,
effects,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct FlowBounds {
budget: usize,
#[serde(default)]
resources: Option<ResourceLimits>,
}
impl FlowBounds {
#[must_use]
pub const fn new(budget: usize) -> Self {
Self {
budget,
resources: None,
}
}
#[must_use]
pub const fn budget(self) -> usize {
self.budget
}
#[must_use]
pub const fn resources(self) -> Option<ResourceLimits> {
self.resources
}
#[must_use]
pub fn effective_resources(self) -> ResourceLimits {
match self.resources {
Some(requested) => requested,
None => SHIPPED_LIMITS,
}
}
#[must_use]
pub const fn with_resources(mut self, resources: ResourceLimits) -> Self {
self.resources = Some(resources);
self
}
}
impl Default for FlowBounds {
fn default() -> Self {
Self::new(256)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct FlowSpec {
vars: BTreeMap<String, VarType>,
entry: NodeId,
nodes: BTreeMap<NodeId, NodeKind>,
#[serde(default)]
edges: Vec<FlowEdge>,
#[serde(default)]
terminals: BTreeMap<NodeId, Terminal>,
#[serde(default)]
bounds: FlowBounds,
}
impl FlowSpec {
pub fn new(
vars: BTreeMap<String, VarType>,
entry: impl Into<String>,
nodes: BTreeMap<NodeId, NodeKind>,
edges: Vec<FlowEdge>,
terminals: BTreeMap<NodeId, Terminal>,
bounds: FlowBounds,
) -> Result<Self, BotError> {
let spec = Self {
vars,
entry: entry.into(),
nodes,
edges,
terminals,
bounds,
};
spec.validate()?;
Ok(spec)
}
pub fn from_nodes(
vars: BTreeMap<String, VarType>,
entry: impl Into<String>,
nodes: BTreeMap<NodeId, NodeKind>,
bounds: FlowBounds,
) -> Result<Self, BotError> {
Self::new(vars, entry, nodes, Vec::new(), BTreeMap::new(), bounds)
}
pub fn to_json(&self) -> Result<String, lgwks_std::json::Error> {
crate::json::to_string_pretty(self)
}
pub fn to_ron(&self) -> Result<String, lgwks_std::ron::Error> {
lgwks_std::ron::to_string_pretty(self)
}
pub fn from_json(source: &str) -> Result<Self, BotError> {
check_flow_size(source)?;
reject_unknown_node_kinds(&merge_read_kinds(
parse_json::<DeclaredNodes<false>>(source),
parse_json::<DeclaredNodes<true>>(source),
))?;
let spec = parse_json::<Self>(source).map_err(malformed_flow)?;
spec.validate()?;
Ok(spec)
}
pub fn from_ron(source: &str) -> Result<Self, BotError> {
check_flow_size(source)?;
let spec = parse_ron::<Self>(source).map_err(malformed_flow)?;
spec.validate()?;
Ok(spec)
}
pub fn validate(&self) -> Result<(), BotError> {
flow::validate(self)
}
#[must_use]
pub fn vars(&self) -> &BTreeMap<String, VarType> {
&self.vars
}
#[must_use]
pub fn entry(&self) -> &str {
&self.entry
}
#[must_use]
pub fn nodes(&self) -> &BTreeMap<NodeId, NodeKind> {
&self.nodes
}
#[must_use]
pub fn edges(&self) -> &[FlowEdge] {
&self.edges
}
#[must_use]
pub fn terminals(&self) -> &BTreeMap<NodeId, Terminal> {
&self.terminals
}
#[must_use]
pub const fn bounds(&self) -> FlowBounds {
self.bounds
}
#[must_use]
pub fn node(&self, id: &str) -> Option<&NodeKind> {
self.nodes.get(id)
}
#[must_use]
pub fn effective_terminal(&self, node_id: &str) -> Option<Terminal> {
let intrinsic = self.intrinsic_terminal(node_id)?;
match self.terminals.get(node_id) {
Some(declared) => Some(declared.clone()),
None => Some(intrinsic),
}
}
fn intrinsic_terminal(&self, node_id: &str) -> Option<Terminal> {
match *self.nodes.get(node_id)? {
NodeKind::End => Some(Terminal::Completed),
NodeKind::Handoff { ref target } => Some(Terminal::HandedOff {
target: target.clone(),
}),
NodeKind::Refer { ref target, .. } => Some(Terminal::Referred {
target: target.clone(),
}),
_ => None,
}
}
fn edge_targets(&self, from: &str) -> Vec<NodeId> {
self.edges
.iter()
.filter_map(|edge| {
let (source, target) = edge.endpoints();
(source == from).then(|| target.to_owned())
})
.collect()
}
}
pub mod flow {
use super::{BotError, FlowSpec, validate_flow};
pub fn validate(spec: &FlowSpec) -> Result<(), BotError> {
validate_flow(spec)
}
}
fn validate_flow(spec: &FlowSpec) -> Result<(), BotError> {
validate_flow_within(spec, ResourceLimits::shipped())
}
fn validate_flow_within(spec: &FlowSpec, limits: ResourceLimits) -> Result<(), BotError> {
let requested = spec.bounds.effective_resources().within_ceiling()?;
let effective = requested.narrowed(limits);
if spec.nodes.is_empty() {
let refusal = Err(BotError::MalformedFlow {
cause: "flow declares no nodes".into(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_flow_within: returning an error to the caller");
return refusal;
}
if !spec.nodes.contains_key(&spec.entry) {
let refusal = Err(BotError::InvalidTransitionTarget {
from: "<entry>".into(),
target: spec.entry.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_flow_within: returning an error to the caller");
return refusal;
}
if spec.bounds.budget == 0 || spec.nodes.len() > spec.bounds.budget {
let refusal = Err(BotError::FlowBudgetExceeded {
steps: spec.nodes.len(),
budget: spec.bounds.budget,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_flow_within: returning an error to the caller");
return refusal;
}
validate_declarations(&spec.vars)?;
validate_terminals(spec)?;
let mut writers = BTreeSet::new();
for (node_id, kind) in &spec.nodes {
validate_node(spec, node_id, kind, effective, &mut writers)?;
}
for name in spec.vars.keys() {
if !writers.contains(name) {
let refusal = Err(BotError::VariableNeverWritten { name: name.clone() });
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_flow_within: returning an error to the caller");
return refusal;
}
}
for edge in &spec.edges {
let (from, to) = edge.endpoints();
if !spec.nodes.contains_key(from) {
let refusal = Err(BotError::InvalidTransitionTarget {
from: from.to_owned(),
target: from.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_flow_within: returning an error to the caller");
return refusal;
}
check_target(&spec.nodes, from, to)?;
}
reject_uninitialized_reads(spec)?;
reject_unreachable(spec)
}
fn reject_uninitialized_reads(spec: &FlowSpec) -> Result<(), BotError> {
let universe: BTreeSet<String> = spec.vars.keys().cloned().collect();
let mut available: BTreeMap<NodeId, BTreeSet<String>> = spec
.nodes
.keys()
.map(|node_id| (node_id.clone(), universe.clone()))
.collect();
available.insert(spec.entry.clone(), BTreeSet::new());
let mut pending: VecDeque<NodeId> = spec.nodes.keys().cloned().collect();
while let Some(node_id) = pending.pop_front() {
let Some(kind) = spec.nodes.get(&node_id) else {
continue;
};
let Some(incoming) = available.get(&node_id).cloned() else {
continue;
};
let mut outgoing = incoming;
if let NodeKind::Ask { ref var, .. } = *kind {
outgoing.insert(var.clone());
}
for target in successor_targets(spec, &node_id, kind) {
let Some(state) = available.get_mut(&target) else {
continue;
};
let before = state.len();
state.retain(|name| outgoing.contains(name));
if state.len() != before {
pending.push_back(target);
}
}
}
for (node_id, kind) in &spec.nodes {
let Some(state) = available.get(node_id) else {
continue;
};
for name in node_reads(spec, node_id, kind)? {
if !state.contains(&name) {
let refusal = Err(BotError::VariableReadBeforeInit {
node: node_id.clone(),
name,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "reject_uninitialized_reads: returning an error to the caller");
return refusal;
}
}
}
Ok(())
}
fn node_reads(
spec: &FlowSpec,
node_id: &str,
kind: &NodeKind,
) -> Result<BTreeSet<String>, BotError> {
match *kind {
NodeKind::Say { ref text } => declared_template_reads(spec, node_id, text, "say.text"),
NodeKind::Refer { ref text, .. } => {
declared_template_reads(spec, node_id, text, "refer.text")
}
NodeKind::Branch { ref when, .. } => {
let mut names = BTreeSet::new();
collect_predicate_variables(when, &mut names);
names.retain(|name| spec.vars.contains_key(name));
Ok(names)
}
NodeKind::Ask { .. }
| NodeKind::Handoff { .. }
| NodeKind::Route { .. }
| NodeKind::End => Ok(BTreeSet::new()),
}
}
fn compile_for_node<'a>(
node_id: &str,
field: &'static str,
text: &'a str,
) -> Result<CompiledTemplate<'a>, BotError> {
match CompiledTemplate::compile(text) {
Ok(compiled) => Ok(compiled),
Err(BotError::MalformedTemplate { .. }) => {
let refusal = Err(BotError::MalformedTemplate {
node: node_id.to_owned(),
field,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), node = node_id, field, "compile_for_node: the template is malformed");
refusal
}
Err(other) => {
lgwks_std::trace::debug!(error = ?other, node = node_id, field, "compile_for_node: the template did not compile");
Err(other)
}
}
}
fn bound<'s>(scope: &'s VarScope, name: &str) -> Result<&'s Value, BotError> {
match scope.get(name) {
Some(value) => Ok(value),
None => {
let refusal = Err(BotError::VariableUnset {
name: name.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), name, "bound: the marker has no value");
refusal
}
}
}
fn declared_template_reads(
spec: &FlowSpec,
node_id: &str,
text: &str,
field: &'static str,
) -> Result<BTreeSet<String>, BotError> {
let compiled = compile_for_node(node_id, field, text)?;
let mut names = BTreeSet::new();
for part in compiled.parts() {
if let TemplatePart::Variable(name) = *part
&& spec.vars.contains_key(name)
{
names.insert(name.to_owned());
}
}
Ok(names)
}
fn comparison_operands(predicate: &Predicate) -> Option<(&ValueExpr, &ValueExpr)> {
match *predicate {
Predicate::Eq(ref left, ref right)
| Predicate::Ne(ref left, ref right)
| Predicate::Lt(ref left, ref right)
| Predicate::Le(ref left, ref right)
| Predicate::Gt(ref left, ref right)
| Predicate::Ge(ref left, ref right) => Some((left, right)),
Predicate::Const(_) | Predicate::And(_) | Predicate::Or(_) | Predicate::Not(_) => None,
}
}
fn collect_predicate_variables(predicate: &Predicate, into: &mut BTreeSet<String>) {
if let Some((left, right)) = comparison_operands(predicate) {
collect_expr_variables(left, into);
collect_expr_variables(right, into);
} else {
match *predicate {
Predicate::Const(_) => {}
Predicate::And(ref items) | Predicate::Or(ref items) => {
for item in items {
collect_predicate_variables(item, into);
}
}
Predicate::Not(ref inner) => collect_predicate_variables(inner, into),
Predicate::Eq(..)
| Predicate::Ne(..)
| Predicate::Lt(..)
| Predicate::Le(..)
| Predicate::Gt(..)
| Predicate::Ge(..) => {}
}
}
}
fn collect_expr_variables(expression: &ValueExpr, into: &mut BTreeSet<String>) {
if let ValueExpr::Var(ref name) = *expression {
into.insert(name.clone());
}
}
fn validate_declarations(vars: &BTreeMap<String, VarType>) -> Result<(), BotError> {
for (name, kind) in vars {
if !valid_identifier(name) {
let refusal = Err(BotError::MalformedFlow {
cause: format!("invalid variable name {name:?}"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_declarations: returning an error to the caller");
return refusal;
}
match *kind {
VarType::Choice(ref options)
if options.is_empty() || has_duplicate_strings(options) =>
{
let refusal = Err(BotError::MalformedFlow {
cause: format!("choice variable {name:?} has invalid options"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_declarations: returning an error to the caller");
return refusal;
}
_ => {}
}
}
Ok(())
}
fn validate_terminals(spec: &FlowSpec) -> Result<(), BotError> {
for (node_id, declared) in &spec.terminals {
let Some(kind) = spec.nodes.get(node_id) else {
let refusal = Err(BotError::InvalidTransitionTarget {
from: "<terminal>".into(),
target: node_id.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_terminals: returning an error to the caller");
return refusal;
};
if !matches!(
*kind,
NodeKind::End | NodeKind::Handoff { .. } | NodeKind::Refer { .. }
) {
let refusal = Err(BotError::MalformedFlow {
cause: format!("terminal declaration {node_id:?} names a non-terminal node"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_terminals: returning an error to the caller");
return refusal;
}
if matches!(*kind, NodeKind::End) {
continue;
}
let Some(intrinsic) = spec.intrinsic_terminal(node_id) else {
let refusal = Err(BotError::MalformedFlow {
cause: format!("terminal declaration {node_id:?} has no intrinsic outcome"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_terminals: returning an error to the caller");
return refusal;
};
if intrinsic != *declared {
let refusal = Err(BotError::ConflictingTerminalDeclaration {
node: node_id.clone(),
intrinsic,
declared: declared.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_terminals: returning an error to the caller");
return refusal;
}
}
Ok(())
}
fn validate_node(
spec: &FlowSpec,
node_id: &str,
kind: &NodeKind,
limits: ResourceLimits,
writers: &mut BTreeSet<String>,
) -> Result<(), BotError> {
match *kind {
NodeKind::Say { ref text } => validate_say_node(spec, node_id, text, limits),
NodeKind::Ask {
ref var,
ref options,
ref routes,
} => validate_ask_node(spec, node_id, var, options, routes, limits, writers),
NodeKind::Branch {
ref var,
ref when,
ref then,
ref otherwise,
} => validate_branch_node(spec, node_id, var, when, then, otherwise),
NodeKind::Refer { ref text, .. } => validate_refer_node(spec, node_id, text, limits),
NodeKind::Route {
ref dispatch,
ref fallback,
} => {
check_target(&spec.nodes, node_id, dispatch)?;
check_target(&spec.nodes, node_id, fallback)
}
NodeKind::Handoff { .. } | NodeKind::End => Ok(()),
}
}
fn answer_domain_of(spec: &FlowSpec, name: &str) -> AnswerDomain {
spec.vars
.get(name)
.map_or(AnswerDomain::Label, VarType::answer_domain)
}
fn colliding_options(options: &[String], domain: AnswerDomain) -> Option<(String, String)> {
let mut seen: BTreeMap<String, String> = BTreeMap::new();
for option in options {
let identity = match domain {
AnswerDomain::Label => crate::language::normalize(option),
AnswerDomain::Integer => match decode_integer(option) {
Some(value) => value.to_string(),
None => continue,
},
};
if let Some(first) = seen.get(&identity) {
return Some((first.clone(), option.clone()));
}
seen.insert(identity, option.clone());
}
None
}
fn validate_variable_reference(spec: &FlowSpec, node_id: &str, name: &str) -> Result<(), BotError> {
if spec.vars.contains_key(name) {
Ok(())
} else {
let _ = node_id;
Err(BotError::UndeclaredVariable {
name: name.to_owned(),
})
}
}
fn declared_variable<'a>(spec: &'a FlowSpec, name: &str) -> Result<&'a VarType, BotError> {
spec.vars
.get(name)
.ok_or_else(|| BotError::UndeclaredVariable {
name: name.to_owned(),
})
}
fn validate_predicate(
spec: &FlowSpec,
node_id: &str,
predicate: &Predicate,
) -> Result<(), BotError> {
if let Some((left, right)) = comparison_operands(predicate) {
validate_expr(spec, node_id, left)?;
return validate_expr(spec, node_id, right);
}
match *predicate {
Predicate::Const(_) => Ok(()),
Predicate::And(ref items) | Predicate::Or(ref items) => {
if items.is_empty() {
let refusal = Err(BotError::MalformedFlow {
cause: format!("node {node_id:?} has an empty predicate"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_predicate: returning an error to the caller");
return refusal;
}
for item in items {
validate_predicate(spec, node_id, item)?;
}
Ok(())
}
Predicate::Not(ref inner) => validate_predicate(spec, node_id, inner),
Predicate::Eq(..)
| Predicate::Ne(..)
| Predicate::Lt(..)
| Predicate::Le(..)
| Predicate::Gt(..)
| Predicate::Ge(..) => Ok(()),
}
}
fn validate_expr(spec: &FlowSpec, _node_id: &str, expression: &ValueExpr) -> Result<(), BotError> {
if let ValueExpr::Var(ref name) = *expression
&& !spec.vars.contains_key(name)
{
let refusal = Err(BotError::UndeclaredVariable { name: name.clone() });
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_expr: returning an error to the caller");
return refusal;
}
Ok(())
}
fn validate_template(
spec: &FlowSpec,
node_id: &str,
template: &str,
field: &'static str,
limits: ResourceLimits,
) -> Result<(), BotError> {
let compiled = compile_for_node(node_id, field, template)?;
let mut literals = 0usize;
for part in compiled.parts() {
match *part {
TemplatePart::Literal(text) => {
literals = literals.checked_add(text.len()).ok_or(
BotError::TemplateExpansionTooLarge {
node: node_id.to_owned(),
bytes: usize::MAX,
limit: limits.get(ResourceAxis::Record),
},
)?;
}
TemplatePart::Variable(name) => {
if !spec.vars.contains_key(name) {
let refusal = Err(BotError::UndeclaredVariable {
name: name.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_template: returning an error to the caller");
return refusal;
}
}
}
}
let record_bytes = limits.get(ResourceAxis::Record);
if literals > record_bytes {
let refusal = Err(BotError::TemplateExpansionTooLarge {
node: node_id.to_owned(),
bytes: literals,
limit: record_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "validate_template: returning an error to the caller");
return refusal;
}
Ok(())
}
fn check_target(
nodes: &BTreeMap<NodeId, NodeKind>,
from: &str,
target: &str,
) -> Result<(), BotError> {
if nodes.contains_key(target) {
Ok(())
} else {
Err(BotError::InvalidTransitionTarget {
from: from.to_owned(),
target: target.to_owned(),
})
}
}
fn reject_unreachable(spec: &FlowSpec) -> Result<(), BotError> {
let mut seen = BTreeSet::new();
let mut pending = VecDeque::from([spec.entry.clone()]);
while let Some(node_id) = pending.pop_front() {
if !seen.insert(node_id.clone()) {
continue;
}
let Some(kind) = spec.nodes.get(&node_id) else {
continue;
};
for target in successor_targets(spec, &node_id, kind) {
if !seen.contains(&target) {
pending.push_back(target);
}
}
}
for node_id in spec.nodes.keys() {
if !seen.contains(node_id) {
let refusal = Err(BotError::UnreachableNode {
node: node_id.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "reject_unreachable: returning an error to the caller");
return refusal;
}
}
Ok(())
}
fn successor_targets(spec: &FlowSpec, node_id: &str, kind: &NodeKind) -> Vec<NodeId> {
let mut targets = spec.edge_targets(node_id);
match *kind {
NodeKind::Ask { ref routes, .. } => targets.extend(routes.values().cloned()),
NodeKind::Branch {
ref then,
ref otherwise,
..
} => {
targets.push(then.clone());
targets.push(otherwise.clone());
}
NodeKind::Route {
ref dispatch,
ref fallback,
} => {
targets.push(dispatch.clone());
targets.push(fallback.clone());
}
NodeKind::Say { .. }
| NodeKind::Handoff { .. }
| NodeKind::Refer { .. }
| NodeKind::End => {}
}
targets
}
fn has_duplicate_strings(values: &[String]) -> bool {
let mut seen = BTreeSet::new();
values.iter().any(|value| !seen.insert(value))
}
fn valid_identifier(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
}
fn parse_json<T>(text: &str) -> Result<T, String>
where
T: serde::de::DeserializeOwned,
{
crate::json::from_str(text).map_err(|error| error.to_string())
}
fn parse_ron<T>(text: &str) -> Result<T, String>
where
T: serde::de::DeserializeOwned,
{
lgwks_std::ron::from_str(text).map_err(|error| error.to_string())
}
fn check_flow_size(source: &str) -> Result<(), BotError> {
if source.len() > MAX_FLOW_BYTES {
let refusal = Err(BotError::FlowTooLarge {
bytes: source.len(),
limit: MAX_FLOW_BYTES,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "check_flow_size: returning an error to the caller");
return refusal;
}
Ok(())
}
fn malformed_flow(error: impl std::fmt::Display) -> BotError {
BotError::MalformedFlow {
cause: error.to_string().escape_debug().to_string(),
}
}
fn reject_unknown_node_kinds(declared: &BTreeMap<String, String>) -> Result<(), BotError> {
for (node_id, kind) in declared {
if !matches!(
kind.as_str(),
"say" | "ask" | "branch" | "handoff" | "refer" | "route" | "end"
) {
let refusal = Err(BotError::UnknownNodeKind {
node: node_id.clone(),
kind: kind.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "reject_unknown_node_kinds: returning an error to the caller");
return refusal;
}
}
Ok(())
}
#[derive(Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename = "FlowSpec")]
struct DeclaredNodes<const FIELDED: bool> {
#[serde(default)]
nodes: BTreeMap<String, VariantName<FIELDED>>,
}
struct VariantName<const FIELDED: bool>(String);
impl<'de, const FIELDED: bool> Deserialize<'de> for VariantName<FIELDED> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct NameVisitor<const FIELDED: bool>;
impl<'de, const FIELDED: bool> serde::de::Visitor<'de> for NameVisitor<FIELDED> {
type Value = VariantName<FIELDED>;
fn expecting(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str("a node kind")
}
fn visit_enum<A>(self, data: A) -> Result<VariantName<FIELDED>, A::Error>
where
A: serde::de::EnumAccess<'de>,
{
use serde::de::VariantAccess;
let (name, variant) = data.variant::<String>()?;
if FIELDED {
variant.newtype_variant::<serde::de::IgnoredAny>()?;
} else {
variant.unit_variant()?;
}
Ok(VariantName(name))
}
}
deserializer.deserialize_enum("NodeKind", &[], NameVisitor::<FIELDED>)
}
}
fn merge_read_kinds<const FIRST: bool, const SECOND: bool>(
first: Result<DeclaredNodes<FIRST>, String>,
second: Result<DeclaredNodes<SECOND>, String>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
if let Ok(declared) = first {
for (node_id, name) in declared.nodes {
merged.insert(node_id, name.0);
}
}
if let Ok(declared) = second {
for (node_id, name) in declared.nodes {
merged.insert(node_id, name.0);
}
}
merged
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct VarScope {
declarations: BTreeMap<String, VarType>,
values: BTreeMap<String, Value>,
}
impl VarScope {
pub fn new(declarations: BTreeMap<String, VarType>) -> Result<Self, BotError> {
validate_declarations(&declarations)?;
Ok(Self {
declarations,
values: BTreeMap::new(),
})
}
#[must_use]
pub fn declarations(&self) -> &BTreeMap<String, VarType> {
&self.declarations
}
#[must_use]
pub fn answer_domain(&self, name: &str) -> AnswerDomain {
self.declarations
.get(name)
.map_or(AnswerDomain::Label, VarType::answer_domain)
}
#[must_use]
pub fn values(&self) -> &BTreeMap<String, Value> {
&self.values
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&Value> {
self.values.get(name)
}
pub fn set(&mut self, name: &str, value: Value) -> Result<(), BotError> {
let Some(declared) = self.declarations.get(name) else {
let refusal = Err(BotError::UndeclaredVariable {
name: name.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "set: returning an error to the caller");
return refusal;
};
if value_matches_type(declared, &value) {
self.values.insert(name.to_owned(), value);
Ok(())
} else {
Err(BotError::VariableTypeMismatch {
name: name.to_owned(),
})
}
}
pub fn set_from_answer(&mut self, name: &str, answer: &str) -> Result<(), BotError> {
self.set_from_answer_within(name, answer, MAX_VALUE_BYTES)
}
pub fn set_from_answer_within(
&mut self,
name: &str,
answer: &str,
value_bytes: usize,
) -> Result<(), BotError> {
let Some(declared) = self.declarations.get(name) else {
let refusal = Err(BotError::UndeclaredVariable {
name: name.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "set_from_answer_within: returning an error to the caller");
return refusal;
};
let value =
declared
.decode_answer(answer)
.map_err(|rejection| BotError::InvalidVariableValue {
variable: name.to_owned(),
value: answer.to_owned(),
reason: rejection.to_string(),
})?;
let bytes = value.rendered_bytes();
if bytes > value_bytes {
let refusal = Err(BotError::ValueTooLarge {
variable: name.to_owned(),
bytes,
limit: value_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "set_from_answer_within: returning an error to the caller");
return refusal;
}
self.set(name, value)
}
pub fn interpolate(&self, template: &str) -> Result<String, BotError> {
self.interpolate_within(template, MAX_RECORD_BYTES)
}
pub fn interpolate_within(
&self,
template: &str,
record_bytes: usize,
) -> Result<String, BotError> {
TemplateInterpolator::new().interpolate(template, self, "<scope>", record_bytes)
}
}
fn value_matches_type(declared: &VarType, value: &Value) -> bool {
match *declared {
VarType::String => matches!(value, &Value::String(_)),
VarType::Integer => matches!(value, &Value::Integer(_)),
VarType::Boolean => matches!(value, &Value::Boolean(_)),
VarType::Choice(ref options) => match *value {
Value::Choice(ref value) => options.contains(value),
_ => false,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TemplatePart<'a> {
Literal(&'a str),
Variable(&'a str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CompiledTemplate<'a> {
parts: Vec<TemplatePart<'a>>,
}
impl<'a> CompiledTemplate<'a> {
pub fn compile(template: &'a str) -> Result<Self, BotError> {
let mut parts = Vec::new();
let mut cursor = 0;
while let Some(relative_start) = template[cursor..].find("${") {
let start = cursor.saturating_add(relative_start);
if start > cursor {
parts.push(TemplatePart::Literal(&template[cursor..start]));
}
let name_start = start.saturating_add(2);
let Some(relative_end) = template[name_start..].find('}') else {
let refusal = Err(BotError::MalformedTemplate {
node: "<runtime>".into(),
field: "template",
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "compile: returning an error to the caller");
return refusal;
};
let end = name_start.saturating_add(relative_end);
let name = &template[name_start..end];
if !valid_identifier(name) {
let refusal = Err(BotError::MalformedTemplate {
node: "<runtime>".into(),
field: "template",
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "compile: returning an error to the caller");
return refusal;
}
parts.push(TemplatePart::Variable(name));
cursor = end.saturating_add(1);
}
if cursor < template.len() {
parts.push(TemplatePart::Literal(&template[cursor..]));
}
Ok(Self { parts })
}
#[must_use]
pub fn parts(&self) -> &[TemplatePart<'a>] {
&self.parts
}
pub fn expanded_bytes(&self, scope: &VarScope) -> Result<usize, BotError> {
let mut total = 0usize;
for part in &self.parts {
let bytes = match *part {
TemplatePart::Literal(text) => text.len(),
TemplatePart::Variable(name) => bound(scope, name)?.rendered_bytes(),
};
total = total
.checked_add(bytes)
.ok_or(BotError::TemplateExpansionTooLarge {
node: "<runtime>".into(),
bytes: usize::MAX,
limit: usize::MAX,
})?;
}
Ok(total)
}
pub fn render(&self, scope: &VarScope) -> Result<String, BotError> {
let mut output = String::new();
for part in &self.parts {
match *part {
TemplatePart::Literal(text) => output.push_str(text),
TemplatePart::Variable(name) => {
output.push_str(&bound(scope, name)?.to_string());
}
}
}
Ok(output)
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct Question<'a> {
id: &'a str,
options: &'a [String],
domain: AnswerDomain,
}
impl<'a> Question<'a> {
#[must_use]
pub const fn new(id: &'a str, options: &'a [String]) -> Self {
Self {
id,
options,
domain: AnswerDomain::Label,
}
}
#[must_use]
pub const fn with_domain(self, domain: AnswerDomain) -> Self {
Self { domain, ..self }
}
#[must_use]
pub const fn id(&self) -> &'a str {
self.id
}
#[must_use]
pub const fn options(&self) -> &'a [String] {
self.options
}
#[must_use]
pub const fn domain(&self) -> AnswerDomain {
self.domain
}
}
pub trait Interpolate {
fn interpolate(
&self,
template: &str,
scope: &VarScope,
node: &str,
limit: usize,
) -> Result<String, BotError>;
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct TemplateInterpolator;
impl TemplateInterpolator {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Interpolate for TemplateInterpolator {
fn interpolate(
&self,
template: &str,
scope: &VarScope,
node: &str,
limit: usize,
) -> Result<String, BotError> {
let compiled = CompiledTemplate::compile(template).map_err(|error| match error {
BotError::MalformedTemplate { field, .. } => BotError::MalformedTemplate {
node: node.to_owned(),
field,
},
other => other,
})?;
let bytes = compiled.expanded_bytes(scope)?;
if bytes > limit {
let refusal = Err(BotError::TemplateExpansionTooLarge {
node: node.to_owned(),
bytes,
limit,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "interpolate: returning an error to the caller");
return refusal;
}
compiled.render(scope)
}
}
pub trait Resolver {
fn resolve(&self, utterance: &str, question: &Question<'_>) -> Verdict;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct PolicyVersion {
label: String,
revision: String,
}
impl PolicyVersion {
#[must_use]
pub fn new(label: impl Into<String>, parameters: &[f64]) -> Self {
let label = label.into();
let mut hasher = Hasher::new();
hasher.update(label.as_bytes());
for parameter in parameters {
hasher.update(¶meter.to_bits().to_le_bytes());
}
Self {
revision: hasher.finalize().to_hex(),
label,
}
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub fn revision(&self) -> &str {
&self.revision
}
}
impl fmt::Display for PolicyVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}/{}", self.label, self.revision)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct Provenance {
policy: PolicyVersion,
model: Option<EmbedderIdentity>,
}
impl Provenance {
#[must_use]
pub fn without_model(policy: PolicyVersion) -> Self {
Self {
policy,
model: None,
}
}
#[must_use]
pub fn with_model(policy: PolicyVersion, model: EmbedderIdentity) -> Self {
Self {
policy,
model: Some(model),
}
}
#[must_use]
pub fn policy(&self) -> &PolicyVersion {
&self.policy
}
#[must_use]
pub fn model(&self) -> Option<&EmbedderIdentity> {
self.model.as_ref()
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Verdict {
resolution: Resolution,
provenance: Provenance,
}
impl Verdict {
#[must_use]
pub const fn new(resolution: Resolution, provenance: Provenance) -> Self {
Self {
resolution,
provenance,
}
}
#[must_use]
pub const fn resolution(&self) -> &Resolution {
&self.resolution
}
#[must_use]
pub const fn provenance(&self) -> &Provenance {
&self.provenance
}
#[must_use]
pub fn into_resolution(self) -> Resolution {
self.resolution
}
}
pub(crate) fn by_score_descending(
left: &(usize, MatchTier, f64),
right: &(usize, MatchTier, f64),
) -> Ordering {
match (left.2.is_nan(), right.2.is_nan()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => right.2.total_cmp(&left.2),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MatchTier {
Exact,
Phonetic,
Fuzzy,
Semantic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DegradedReason {
EmbedderUnavailable,
UnmeasurableEmbedding,
}
impl fmt::Display for DegradedReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::EmbedderUnavailable => formatter.write_str("the embedder is unavailable"),
Self::UnmeasurableEmbedding => {
formatter.write_str("an embedding could not be measured")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Resolution {
Resolved {
index: usize,
tier: MatchTier,
score: f64,
lead: f64,
},
Ambiguous {
tied: Vec<usize>,
tier: MatchTier,
score: f64,
},
Absent {
best_score: f64,
},
StaleAlias {
question: String,
option: String,
},
Degraded {
reason: DegradedReason,
},
}
pub const RECEIPT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct DecisionReceipt {
version: u32,
session: SessionId,
flow: String,
node: NodeId,
options: String,
resolution: Resolution,
provenance: Provenance,
selected: Option<String>,
route: Option<NodeId>,
}
impl DecisionReceipt {
#[must_use]
pub const fn version(&self) -> u32 {
self.version
}
#[must_use]
pub fn session(&self) -> &SessionId {
&self.session
}
#[must_use]
pub fn flow(&self) -> &str {
&self.flow
}
#[must_use]
pub fn node(&self) -> &str {
&self.node
}
#[must_use]
pub fn options(&self) -> &str {
&self.options
}
#[must_use]
pub fn resolution(&self) -> &Resolution {
&self.resolution
}
#[must_use]
pub fn provenance(&self) -> &Provenance {
&self.provenance
}
#[must_use]
pub fn selected(&self) -> Option<&str> {
self.selected.as_deref()
}
#[must_use]
pub fn route(&self) -> Option<&str> {
self.route.as_deref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReceiptAcceptance {
InMemory,
Durable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct JournalError {
sink: String,
cause: String,
}
impl JournalError {
#[must_use]
pub fn new(sink: impl Into<String>, cause: impl Into<String>) -> Self {
Self {
sink: sink.into(),
cause: cause.into(),
}
}
#[must_use]
pub fn sink(&self) -> &str {
&self.sink
}
#[must_use]
pub fn cause(&self) -> &str {
&self.cause
}
}
impl fmt::Display for JournalError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"journal {} refused a decision receipt: {}",
self.sink, self.cause
)
}
}
impl std::error::Error for JournalError {}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RecordedDecision {
receipt: DecisionReceipt,
acceptance: ReceiptAcceptance,
}
impl RecordedDecision {
#[must_use]
pub fn receipt(&self) -> &DecisionReceipt {
&self.receipt
}
#[must_use]
pub const fn acceptance(&self) -> ReceiptAcceptance {
self.acceptance
}
}
pub trait Journal {
fn record(&mut self, path_node: &str, role: &str, text: &str);
fn record_decision(
&mut self,
receipt: &DecisionReceipt,
) -> Result<ReceiptAcceptance, JournalError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde", deny_unknown_fields)]
#[non_exhaustive]
pub struct TranscriptEntry {
path_node: String,
role: String,
text: String,
}
impl TranscriptEntry {
#[must_use]
pub fn path_node(&self) -> &str {
&self.path_node
}
#[must_use]
pub fn role(&self) -> &str {
&self.role
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct MemoryJournal {
records: Vec<TranscriptEntry>,
receipts: Vec<DecisionReceipt>,
}
impl MemoryJournal {
#[must_use]
pub const fn new() -> Self {
Self {
records: Vec::new(),
receipts: Vec::new(),
}
}
#[must_use]
pub fn records(&self) -> &[TranscriptEntry] {
&self.records
}
#[must_use]
pub fn receipts(&self) -> &[DecisionReceipt] {
&self.receipts
}
#[must_use]
pub fn into_records(self) -> Vec<TranscriptEntry> {
self.records
}
#[must_use]
pub fn into_receipts(self) -> Vec<DecisionReceipt> {
self.receipts
}
}
impl Journal for MemoryJournal {
fn record(&mut self, path_node: &str, role: &str, text: &str) {
self.records.push(TranscriptEntry {
path_node: path_node.to_owned(),
role: role.to_owned(),
text: text.to_owned(),
});
}
fn record_decision(
&mut self,
receipt: &DecisionReceipt,
) -> Result<ReceiptAcceptance, JournalError> {
self.receipts.push(receipt.clone());
Ok(ReceiptAcceptance::InMemory)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(crate = "lgwks_std::json::serde")]
#[non_exhaustive]
pub struct SessionId(String);
impl SessionId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SessionId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for SessionId {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl fmt::Display for SessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[non_exhaustive]
pub struct Session {
id: SessionId,
flow: FlowSpec,
flow_revision: String,
current: Option<NodeId>,
scope: VarScope,
visited: Vec<NodeId>,
transcript: Vec<TranscriptEntry>,
resolver: Box<dyn Resolver>,
journal: Box<dyn Journal>,
decisions: Vec<RecordedDecision>,
terminal: Option<Terminal>,
steps: usize,
last_utterance: Option<String>,
limits: ResourceLimits,
retained: usize,
}
struct Turn<'a> {
node_id: &'a str,
var: &'a str,
options: &'a [String],
routes: &'a BTreeMap<String, NodeId>,
verdict: &'a Verdict,
utterance: &'a str,
}
impl Session {
pub fn new(id: impl Into<SessionId>, flow: FlowSpec) -> Result<Self, BotError> {
Self::with_components(
id,
flow,
crate::language::LanguageResolver::new(),
MemoryJournal::new(),
)
}
pub fn with_resolver<R>(
id: impl Into<SessionId>,
flow: FlowSpec,
resolver: R,
) -> Result<Self, BotError>
where
R: Resolver + 'static,
{
Self::with_components(id, flow, resolver, MemoryJournal::new())
}
pub fn with_components_and_limits<R, J>(
id: impl Into<SessionId>,
flow: FlowSpec,
resolver: R,
journal: J,
limits: ResourceLimits,
) -> Result<Self, BotError>
where
R: Resolver + 'static,
J: Journal + 'static,
{
let operator = limits.within_ceiling()?;
let requested = flow.bounds.effective_resources().within_ceiling()?;
let effective = operator.narrowed(requested);
validate_flow_within(&flow, effective)?;
let scope = VarScope::new(flow.vars.clone())?;
let flow_revision = revision_of(&flow)?;
let mut session = Self {
id: id.into(),
current: Some(flow.entry.clone()),
flow,
flow_revision,
scope,
visited: Vec::new(),
transcript: Vec::new(),
resolver: Box::new(resolver),
journal: Box::new(journal),
decisions: Vec::new(),
terminal: None,
steps: 0,
last_utterance: None,
limits: effective,
retained: 0,
};
session.drive()?;
Ok(session)
}
pub fn with_limits(
id: impl Into<SessionId>,
flow: FlowSpec,
limits: ResourceLimits,
) -> Result<Self, BotError> {
Self::with_components_and_limits(
id,
flow,
crate::language::LanguageResolver::new(),
MemoryJournal::new(),
limits,
)
}
pub fn with_components<R, J>(
id: impl Into<SessionId>,
flow: FlowSpec,
resolver: R,
journal: J,
) -> Result<Self, BotError>
where
R: Resolver + 'static,
J: Journal + 'static,
{
Self::with_components_and_limits(id, flow, resolver, journal, ResourceLimits::shipped())
}
#[must_use]
pub fn id(&self) -> &SessionId {
&self.id
}
#[must_use]
pub fn current(&self) -> Option<&str> {
self.current.as_deref()
}
#[must_use]
pub fn current_node(&self) -> Option<&NodeKind> {
self.current.as_deref().and_then(|id| self.flow.node(id))
}
#[must_use]
pub fn scope(&self) -> &VarScope {
&self.scope
}
#[must_use]
pub fn visited(&self) -> &[NodeId] {
&self.visited
}
#[must_use]
pub fn transcript(&self) -> &[TranscriptEntry] {
&self.transcript
}
#[must_use]
pub fn decisions(&self) -> &[RecordedDecision] {
&self.decisions
}
#[must_use]
pub fn flow_revision(&self) -> &str {
&self.flow_revision
}
#[must_use]
pub fn terminal(&self) -> Option<&Terminal> {
self.terminal.as_ref()
}
#[must_use]
pub fn steps(&self) -> usize {
self.steps
}
#[must_use]
pub const fn limits(&self) -> ResourceLimits {
self.limits
}
#[must_use]
pub const fn retained_bytes(&self) -> usize {
self.retained
}
fn receipt_and_record(&mut self, turn: &Turn<'_>) -> Result<(), BotError> {
self.write_receipt(turn.node_id, turn.options, turn.verdict, None, None)?;
self.record(turn.node_id, "user", turn.utterance)
}
fn accept_resolved(&mut self, turn: &Turn<'_>, index: usize) -> Result<(), BotError> {
let Turn {
node_id,
var,
options,
routes,
verdict,
utterance,
} = *turn;
let Some(option) = options.get(index) else {
let refusal = Err(BotError::ResolverReturnedInvalidOption {
node: node_id.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "accept_resolved: returning an error to the caller");
return refusal;
};
let Some(target) = routes.get(option).cloned() else {
let refusal = Err(BotError::MissingAskRoute {
node: node_id.to_owned(),
option: option.clone(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "accept_resolved: returning an error to the caller");
return refusal;
};
self.write_receipt(
node_id,
options,
verdict,
Some(option.clone()),
Some(&target),
)?;
self.scope
.set_from_answer_within(var, option, self.limits.get(ResourceAxis::Value))?;
self.last_utterance = Some(utterance.to_owned());
self.record(node_id, "user", utterance)?;
self.current = Some(target);
self.drive()
}
fn reask(&mut self, turn: &Turn<'_>, ask: &[String]) -> Result<(), BotError> {
self.receipt_and_record(turn)?;
self.record_prompt(turn.node_id, ask)
}
fn apply_resolution(
&mut self,
turn: &Turn<'_>,
resolution: Resolution,
) -> Result<(), BotError> {
let options = turn.options;
match resolution {
Resolution::Resolved { index, .. } => self.accept_resolved(turn, index),
Resolution::Ambiguous { tied, .. } => {
let narrowed: Vec<String> = tied
.iter()
.filter_map(|candidate| options.get(*candidate).cloned())
.collect();
let ask: &[String] = if narrowed.len() >= 2 {
&narrowed
} else {
options
};
self.reask(turn, ask)
}
Resolution::Absent { .. } => self.reask(turn, options),
Resolution::StaleAlias {
question: bound_question,
option,
} => {
self.record_stale_alias(turn.node_id, &bound_question, &option)?;
self.reask(turn, options)
}
Resolution::Degraded { reason } => {
self.record_degraded(turn.node_id, reason)?;
self.reask(turn, options)
}
}
}
pub fn answer(&mut self, utterance: &str) -> Result<(), BotError> {
if self.terminal.is_some() {
let refusal = Err(BotError::SessionTerminated);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "answer: returning an error to the caller");
return refusal;
}
let Some(node_id) = self.current.clone() else {
let refusal = Err(BotError::SessionNotAwaitingAnswer);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "answer: returning an error to the caller");
return refusal;
};
let Some(NodeKind::Ask {
var,
options,
routes,
}) = self.flow.node(&node_id).cloned()
else {
let refusal = Err(BotError::SessionNotAwaitingAnswer);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "answer: returning an error to the caller");
return refusal;
};
let utterance_limit = self.limits.get(ResourceAxis::Utterance);
if utterance.len() > utterance_limit {
let refusal = Err(BotError::UtteranceTooLarge {
bytes: utterance.len(),
limit: utterance_limit,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "answer: returning an error to the caller");
return refusal;
}
self.charge_step()?;
let question =
Question::new(&node_id, &options).with_domain(self.scope.answer_domain(&var));
let verdict = self.resolver.resolve(utterance, &question);
let turn = Turn {
node_id: &node_id,
var: &var,
options: &options,
routes: &routes,
verdict: &verdict,
utterance,
};
self.apply_resolution(&turn, verdict.resolution().clone())
}
fn write_receipt(
&mut self,
node_id: &str,
options: &[String],
verdict: &Verdict,
selected: Option<String>,
route: Option<&str>,
) -> Result<(), BotError> {
let receipt = DecisionReceipt {
version: RECEIPT_VERSION,
session: self.id.clone(),
flow: self.flow_revision.clone(),
node: node_id.to_owned(),
options: options_revision(options),
resolution: verdict.resolution().clone(),
provenance: verdict.provenance().clone(),
selected,
route: route.map(str::to_owned),
};
let acceptance = self.journal.record_decision(&receipt).map_err(|cause| {
BotError::ReceiptNotRecorded {
node: node_id.to_owned(),
cause,
}
})?;
self.decisions.push(RecordedDecision {
receipt,
acceptance,
});
Ok(())
}
fn charge_step(&mut self) -> Result<(), BotError> {
let attempted = self
.steps
.checked_add(1)
.ok_or(BotError::SessionBudgetExceeded {
steps: usize::MAX,
budget: self.flow.bounds.budget,
})?;
if attempted > self.flow.bounds.budget {
let refusal = Err(BotError::SessionBudgetExceeded {
steps: attempted,
budget: self.flow.bounds.budget,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "charge_step: returning an error to the caller");
return refusal;
}
self.steps = attempted;
Ok(())
}
fn speak(&mut self, node_id: &str, text: &str) -> Result<(), BotError> {
let rendered = self.render(node_id, text)?;
self.record(node_id, "assistant", &rendered)
}
fn drive(&mut self) -> Result<(), BotError> {
loop {
let Some(node_id) = self.current.clone() else {
return Ok(());
};
self.charge_step()?;
self.charge_retention(node_id.len())?;
self.visited.push(node_id.clone());
let Some(kind) = self.flow.node(&node_id).cloned() else {
let refusal = Err(BotError::InvalidTransitionTarget {
from: "<session>".into(),
target: node_id,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "drive: returning an error to the caller");
return refusal;
};
if self.step(&node_id, kind)? {
return Ok(());
}
}
}
fn speak_then_follow(&mut self, node_id: &str, text: &str) -> Result<(), BotError> {
self.speak(node_id, text)?;
let Some(target) = self.flow.edge_targets(node_id).into_iter().next() else {
let refusal = Err(BotError::MissingTransition {
node: node_id.to_owned(),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "speak_then_follow: returning an error to the caller");
return refusal;
};
self.current = Some(target);
Ok(())
}
fn take_branch(
&mut self,
when: &Predicate,
then: NodeId,
otherwise: NodeId,
) -> Result<(), BotError> {
self.current = Some(if when.evaluate(&self.scope)? {
then
} else {
otherwise
});
Ok(())
}
fn refer(&mut self, node_id: &str, text: &str) -> Result<(), BotError> {
self.speak(node_id, text)?;
self.finish(node_id)
}
fn step(&mut self, node_id: &str, kind: NodeKind) -> Result<bool, BotError> {
match kind {
NodeKind::Say { text } => self.speak_then_follow(node_id, &text),
NodeKind::Ask { options, .. } => {
self.record_prompt(node_id, &options)?;
return Ok(true);
}
NodeKind::Branch {
when,
then,
otherwise,
..
} => self.take_branch(&when, then, otherwise),
NodeKind::Handoff { .. } | NodeKind::End => self.finish(node_id),
NodeKind::Refer { text, .. } => self.refer(node_id, &text),
NodeKind::Route { dispatch, fallback } => {
self.current = Some(if self.last_utterance.is_some() {
dispatch
} else {
fallback
});
Ok(())
}
}?;
Ok(false)
}
fn finish(&mut self, node_id: &str) -> Result<(), BotError> {
let Some(outcome) = self.flow.effective_terminal(node_id) else {
let refusal = Err(BotError::MalformedFlow {
cause: format!("node {node_id:?} reached with no terminal outcome"),
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "finish: returning an error to the caller");
return refusal;
};
self.terminal = Some(outcome);
self.current = None;
Ok(())
}
fn render(&self, node_id: &str, template: &str) -> Result<String, BotError> {
TemplateInterpolator::new().interpolate(
template,
&self.scope,
node_id,
self.limits.get(ResourceAxis::Record),
)
}
fn charge_retention(&mut self, bytes: usize) -> Result<(), BotError> {
let limit = self.limits.get(ResourceAxis::Session);
let total = self
.retained
.checked_add(bytes)
.ok_or(BotError::SessionRetentionExceeded {
bytes: usize::MAX,
limit,
})?;
if total > limit {
let refusal = Err(BotError::SessionRetentionExceeded {
bytes: total,
limit,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "charge_retention: returning an error to the caller");
return refusal;
}
self.retained = total;
Ok(())
}
fn record(&mut self, node_id: &str, role: &str, text: &str) -> Result<(), BotError> {
let record_bytes = self.limits.get(ResourceAxis::Record);
if text.len() > record_bytes {
let refusal = Err(BotError::RecordTooLarge {
node: node_id.to_owned(),
bytes: text.len(),
limit: record_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "record: returning an error to the caller");
return refusal;
}
let cost = text
.len()
.saturating_add(node_id.len())
.saturating_add(role.len());
self.charge_retention(cost)?;
self.journal.record(node_id, role, text);
self.transcript.push(TranscriptEntry {
path_node: node_id.to_owned(),
role: role.to_owned(),
text: text.to_owned(),
});
Ok(())
}
fn record_prompt(&mut self, node_id: &str, options: &[String]) -> Result<(), BotError> {
let bytes = prompt_bytes(options);
let record_bytes = self.limits.get(ResourceAxis::Record);
if bytes > record_bytes {
let refusal = Err(BotError::RecordTooLarge {
node: node_id.to_owned(),
bytes,
limit: record_bytes,
});
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "record_prompt: returning an error to the caller");
return refusal;
}
let prompt = format!("Choose one: {}", options.join(", "));
self.record(node_id, "assistant", &prompt)
}
fn record_degraded(&mut self, node_id: &str, reason: DegradedReason) -> Result<(), BotError> {
let text = format!("Resolver unavailable: {reason}");
self.record(node_id, "resolver-degraded", &text)
}
fn record_stale_alias(
&mut self,
node_id: &str,
question: &str,
option: &str,
) -> Result<(), BotError> {
let text = format!("Superseded alias: question {question} no longer offers \"{option}\"");
self.record(node_id, "resolver-stale-alias", &text)
}
}
fn prompt_bytes(options: &[String]) -> usize {
const PREFIX: &str = "Choose one: ";
const SEPARATOR: &str = ", ";
let separators = options
.len()
.saturating_sub(1)
.saturating_mul(SEPARATOR.len());
let candidates = options.iter().fold(PREFIX.len(), |total, option| {
total.saturating_add(option.len())
});
candidates.saturating_add(separators)
}
fn revision_of(flow: &FlowSpec) -> Result<String, BotError> {
crate::json::to_string(flow)
.map(|rendered| lgwks_std::hash::blake3(rendered.as_bytes()).to_hex())
.map_err(|error| BotError::MalformedFlow {
cause: error.to_string().escape_debug().to_string(),
})
}
fn options_revision(options: &[String]) -> String {
let mut hasher = Hasher::new();
for option in options {
hasher.update(&option.len().to_le_bytes());
hasher.update(option.as_bytes());
}
hasher.finalize().to_hex()
}
fn resolve_expr(expression: &ValueExpr, scope: &VarScope) -> Result<Value, BotError> {
match *expression {
ValueExpr::Literal(ref value) => Ok(value.clone()),
ValueExpr::Var(ref name) => scope
.get(name)
.cloned()
.ok_or_else(|| BotError::VariableUnset { name: name.clone() }),
}
}
fn compare_expr<F>(
left: &ValueExpr,
right: &ValueExpr,
scope: &VarScope,
predicate: F,
) -> Result<bool, BotError>
where
F: FnOnce(std::cmp::Ordering) -> bool,
{
let left = resolve_expr(left, scope)?;
let right = resolve_expr(right, scope)?;
let ordering = match (left, right) {
(Value::String(left), Value::String(right))
| (Value::Choice(left), Value::Choice(right)) => left.cmp(&right),
(Value::Integer(left), Value::Integer(right)) => left.cmp(&right),
(Value::Boolean(left), Value::Boolean(right)) => left.cmp(&right),
_ => {
let refusal = Err(BotError::PredicateTypeMismatch);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "compare_expr: returning an error to the caller");
return refusal;
}
};
Ok(predicate(ordering))
}
impl core::fmt::Debug for Session {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Session")
.field("id", &self.id)
.field("current", &self.current)
.field("terminal", &self.terminal)
.field("steps", &self.steps)
.field("visited", &self.visited.len())
.field("transcript", &self.transcript.len())
.field("decisions", &self.decisions.len())
.field("retained", &self.retained)
.field("limits", &self.limits)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn end_flow() -> Result<FlowSpec, BotError> {
FlowSpec::new(
BTreeMap::new(),
"end",
BTreeMap::from([(String::from("end"), NodeKind::End)]),
Vec::new(),
BTreeMap::new(),
FlowBounds::new(4),
)
}
#[test]
fn predicates_compare_typed_values() -> Result<(), BotError> {
let mut declarations = BTreeMap::new();
declarations.insert(String::from("answer"), VarType::Integer);
let mut scope = VarScope::new(declarations)?;
scope.set_from_answer("answer", "7")?;
let predicate = Predicate::Gt(
ValueExpr::Var(String::from("answer")),
ValueExpr::Literal(Value::Integer(3)),
);
assert!(predicate.evaluate(&scope)?);
Ok(())
}
#[test]
fn templates_use_scope_values() -> Result<(), BotError> {
let mut declarations = BTreeMap::new();
declarations.insert(String::from("name"), VarType::String);
let mut scope = VarScope::new(declarations)?;
scope.set_from_answer("name", "Ada")?;
let rendered = scope.interpolate("Hello ${name}")?;
assert_eq!(rendered, "Hello Ada");
Ok(())
}
fn decode_cases() -> Vec<(VarType, &'static str, Result<Value, AnswerRejection>)> {
let choice = || VarType::Choice(vec![String::from("yes"), String::from("no")]);
vec![
(
VarType::String,
"Continue",
Ok(Value::String(String::from("Continue"))),
),
(
VarType::String,
" padded ",
Ok(Value::String(String::from(" padded "))),
),
(VarType::Integer, "7", Ok(Value::Integer(7))),
(VarType::Integer, " -12 ", Ok(Value::Integer(-12))),
(VarType::Integer, "007", Ok(Value::Integer(7))),
(VarType::Integer, "7.5", Err(AnswerRejection::NotAnInteger)),
(VarType::Integer, "", Err(AnswerRejection::NotAnInteger)),
(
VarType::Integer,
"99999999999999999999",
Err(AnswerRejection::IntegerOutOfRange),
),
(VarType::Boolean, "yes", Ok(Value::Boolean(true))),
(VarType::Boolean, " TRUE ", Ok(Value::Boolean(true))),
(VarType::Boolean, "No", Ok(Value::Boolean(false))),
(
VarType::Boolean,
"Continue",
Err(AnswerRejection::NotABoolean),
),
(choice(), "yes", Ok(Value::Choice(String::from("yes")))),
(choice(), " No ", Ok(Value::Choice(String::from("no")))),
(
choice(),
"approve",
Err(AnswerRejection::NotADeclaredChoice),
),
]
}
#[test]
fn the_answer_decoder_is_one_function_for_validation_and_assignment() {
for (declared, answer, expected) in decode_cases() {
assert_eq!(
declared.decode_answer(answer),
expected,
"decoding {answer:?} as {} disagreed with the table",
declared.label()
);
}
}
#[test]
fn assignment_uses_the_same_decoder_validation_does() -> Result<(), BotError> {
for (declared, answer, expected) in decode_cases() {
let mut declarations = BTreeMap::new();
declarations.insert(String::from("slot"), declared.clone());
let mut scope = VarScope::new(declarations)?;
let assigned = scope.set_from_answer("slot", answer);
match expected {
Ok(value) => {
assigned?;
assert_eq!(
scope.get("slot"),
Some(&value),
"assigning {answer:?} as {} stored the wrong value",
declared.label()
);
}
Err(rejection) => {
assert!(
matches!(assigned, Err(BotError::InvalidVariableValue { .. })),
"assigning {answer:?} as {} must refuse with the typed value error, got \
{assigned:?} ({rejection})",
declared.label()
);
assert!(
scope.get("slot").is_none(),
"a refused assignment must not write the variable"
);
}
}
}
Ok(())
}
#[test]
fn every_rejection_renders_a_cause_a_reader_can_act_on() {
let rejections = [
AnswerRejection::NotAnInteger,
AnswerRejection::IntegerOutOfRange,
AnswerRejection::NotABoolean,
AnswerRejection::NotADeclaredChoice,
];
for rejection in rejections {
let rendered = rejection.to_string();
assert!(
!rendered.is_empty() && rendered.chars().any(char::is_alphabetic),
"a rejection is rendered into a diagnostic, so it cannot be empty: {rendered:?}"
);
}
}
#[test]
fn an_ask_whose_variable_cannot_hold_its_options_is_refused_at_load() -> Result<(), BotError> {
let outcome = FlowSpec::new(
BTreeMap::from([(String::from("decision"), VarType::Boolean)]),
"ask",
BTreeMap::from([
(
String::from("ask"),
NodeKind::Ask {
var: String::from("decision"),
options: vec![String::from("Continue"), String::from("Cancel")],
routes: BTreeMap::from([
(String::from("Continue"), String::from("done")),
(String::from("Cancel"), String::from("done")),
]),
},
),
(String::from("done"), NodeKind::End),
]),
Vec::new(),
BTreeMap::new(),
FlowBounds::new(8),
);
let BotError::AskOptionNotAssignable {
node,
variable,
option,
expected,
cause,
} = (match outcome {
Err(error) => error,
Ok(spec) => {
return Err(BotError::MalformedFlow {
cause: format!("accepted a flow with {} nodes", spec.nodes().len()),
});
}
})
else {
return Err(BotError::MalformedFlow {
cause: String::from("refused for the wrong reason"),
});
};
assert_eq!(node, "ask", "the refusal names the ask node");
assert_eq!(variable, "decision", "the refusal names the variable");
assert_eq!(
option, "Continue",
"the refusal names the candidate, not just the node"
);
assert_eq!(expected, "boolean", "the refusal names the expected type");
assert_eq!(
cause,
AnswerRejection::NotABoolean.to_string(),
"the refusal carries the decoder's own reason"
);
Ok(())
}
#[test]
fn an_ask_the_variable_can_hold_is_still_accepted() -> Result<(), BotError> {
let accepted = [
(
VarType::Boolean,
vec![String::from("yes"), String::from("no")],
),
(
VarType::Integer,
vec![String::from("0"), String::from("-1")],
),
(
VarType::Choice(vec![String::from("yes"), String::from("no")]),
vec![String::from("Yes"), String::from("NO")],
),
(
VarType::String,
vec![String::from("anything"), String::from("")],
),
];
for (declared, options) in accepted {
let routes = options
.iter()
.map(|option| (option.clone(), String::from("done")))
.collect();
let spec = FlowSpec::new(
BTreeMap::from([(String::from("slot"), declared.clone())]),
"ask",
BTreeMap::from([
(
String::from("ask"),
NodeKind::Ask {
var: String::from("slot"),
options,
routes,
},
),
(String::from("done"), NodeKind::End),
]),
Vec::new(),
BTreeMap::new(),
FlowBounds::new(8),
)?;
assert_eq!(
spec.nodes().len(),
2,
"a {} ask over storable candidates must load",
declared.label()
);
}
Ok(())
}
#[test]
fn the_default_resolver_has_an_explicit_unrecognized_case() {
let options = vec![String::from("yes"), String::from("no")];
let question = Question::new("ask", &options);
let resolver = crate::language::LanguageResolver::new();
assert!(
matches!(
resolver.resolve("maybe", &question).into_resolution(),
Resolution::Absent { .. }
),
"an unrecognized answer is Absent, which is not the same as Ambiguous"
);
assert_eq!(
resolver.resolve("yes", &question).into_resolution(),
Resolution::Resolved {
index: 0,
tier: MatchTier::Exact,
score: 1.0,
lead: 1.0,
}
);
}
#[test]
fn end_session_stops_at_terminal() -> Result<(), BotError> {
let session = Session::new("test", end_flow()?)?;
assert_eq!(session.current(), None);
assert_eq!(session.terminal(), Some(&Terminal::Completed));
Ok(())
}
fn terminals() -> [(Terminal, Disposition); 4] {
[
(Terminal::Completed, Disposition::Completed),
(
Terminal::Referred {
target: String::from("tier-2"),
},
Disposition::Referred,
),
(
Terminal::HandedOff {
target: String::from("agent"),
},
Disposition::HandedOff,
),
(
Terminal::Refused {
reason: String::from("out of scope"),
},
Disposition::Refused,
),
]
}
#[test]
fn disposition_and_effect_knowledge_are_reported_independently() {
let histories = [
EffectLedger::new(0, 0), EffectLedger::new(1, 0), EffectLedger::new(0, 1), EffectLedger::new(2, 1), EffectLedger::new(3, 2),
];
for (terminal, expected) in terminals() {
for ledger in histories {
let report = terminal.outcome(ledger);
assert_eq!(
report.disposition(),
expected,
"no effect history may change what the workflow decided"
);
assert_eq!(
report.effects(),
ledger,
"no disposition may change what is known about the effects"
);
assert_eq!(
report.needs_reconciliation(),
ledger.unsettled() > 0,
"reconciliation follows the unsettled count and nothing else"
);
}
}
}
#[test]
fn a_refusal_no_longer_erases_an_unsettled_effect() {
let refused = Terminal::Refused {
reason: String::from("later step denied"),
};
let settled = refused.outcome(EffectLedger::new(0, 0));
let unknown = refused.outcome(EffectLedger::new(0, 1));
assert_eq!(
settled.disposition(),
unknown.disposition(),
"both runs were refused, and the report says so in both cases"
);
assert!(
!settled.needs_reconciliation(),
"nothing was attempted, so there is nothing to reconcile"
);
assert!(
unknown.needs_reconciliation(),
"an earlier effect may be live; a refusal does not settle it"
);
assert_ne!(
settled, unknown,
"histories differing on a possibly-live effect must stay distinguishable"
);
}
#[test]
fn an_unsettled_effect_does_not_imply_a_confirmed_one() {
let report = Terminal::Completed.outcome(EffectLedger::new(0, 1));
assert_eq!(
report.effects().confirmed(),
0,
"nothing was confirmed, so nothing may be claimed to exist"
);
assert_eq!(
report.effects().unsettled(),
1,
"the unknown effect is still counted"
);
assert!(
report.needs_reconciliation(),
"the run needs reconciling, which is not the same statement as having \
partly succeeded"
);
}
#[test]
fn an_indeterminate_effect_is_what_puts_a_run_in_doubt() {
let errors = [BotError::EffectIndeterminate {
domain: String::from("gh::merge"),
cause: String::from("request timed out"),
}];
let unsettled = errors
.iter()
.filter(|error| matches!(**error, BotError::EffectIndeterminate { .. }))
.count();
assert_eq!(unsettled, 1, "the run left one effect unsettled");
let report = Terminal::Completed.outcome(EffectLedger::new(0, unsettled));
assert_eq!(
report.disposition(),
Disposition::Completed,
"an unsettled effect does not change what the workflow decided"
);
assert!(
report.needs_reconciliation(),
"an indeterminate effect is what puts a completed run in doubt"
);
}
#[test]
fn a_report_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
let report = Terminal::Refused {
reason: String::from("denied"),
}
.outcome(EffectLedger::new(2, 1));
let json = crate::json::to_string(&report)?;
let back: Outcome = crate::json::from_str(&json)?;
assert_eq!(
back, report,
"a report has to survive the journal it is written to"
);
assert_eq!(
back.disposition(),
Disposition::Refused,
"the refusal survives the round trip"
);
assert!(back.needs_reconciliation(), "so does the unresolved effect");
Ok(())
}
}