use std::{collections::HashMap, sync::Arc};
use rand::Rng;
use crate::{
corpus::Corpus,
engine::{AethelError, GenerationContext, Rule, RuleBox, validation::validate_pool_ref},
};
use super::{
error::{PlanError, PlanErrorReport},
validation,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RuleKey(Arc<str>);
impl RuleKey {
pub fn new(name: impl AsRef<str>) -> Result<Self, PlanError> {
let name = name.as_ref().trim();
if name.is_empty() {
return Err(PlanError::InvalidIdentifier {
kind: "rule key".to_string(),
value: name.to_string(),
reason: "must not be empty".to_string(),
});
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return Err(PlanError::InvalidIdentifier {
kind: "rule key".to_string(),
value: name.to_string(),
reason: "allowed characters are ascii letters, digits, '_', '-', and '.'"
.to_string(),
});
}
Ok(Self(Arc::from(name)))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&RuleKey> for RuleKey {
fn from(value: &RuleKey) -> Self {
value.clone()
}
}
impl AsRef<RuleKey> for RuleKey {
fn as_ref(&self) -> &RuleKey {
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PoolRef {
section: Arc<str>,
field: Arc<str>,
}
impl PoolRef {
pub fn new(section: impl AsRef<str>, field: impl AsRef<str>) -> Result<Self, PlanError> {
let section = section.as_ref().trim();
let field = field.as_ref().trim();
validate_pool_ref(section, "section")?;
validate_pool_ref(field, "field")?;
Ok(Self {
section: Arc::from(section),
field: Arc::from(field),
})
}
pub fn section(&self) -> &str {
&self.section
}
pub fn field(&self) -> &str {
&self.field
}
}
pub struct PlanNode {
pub key: RuleKey,
pub rule: RuleBox,
}
pub struct PlanBuilder<'a> {
pub(crate) corpus: &'a Corpus,
pub(crate) nodes: Vec<PlanNode>,
}
impl<'a> PlanBuilder<'a> {
pub fn new(corpus: &'a Corpus) -> Self {
Self {
corpus,
nodes: Vec::new(),
}
}
pub fn rule(mut self, key: impl Into<RuleKey>, rule: impl Rule + 'static) -> Self {
self.nodes.push(PlanNode {
key: key.into(),
rule: Box::new(rule),
});
self
}
pub fn validate(self) -> Result<ValidatedPlan<'a>, PlanErrorReport> {
validation::validate_plan(self)
}
}
pub struct ValidatedPlan<'a> {
pub(crate) corpus: &'a Corpus,
pub(crate) nodes: Vec<PlanNode>,
}
pub struct CompiledPlan<'a> {
pub(crate) corpus: &'a Corpus,
pub(crate) nodes: Vec<PlanNode>,
pub(crate) order: Vec<usize>,
}
impl<'a> ValidatedPlan<'a> {
pub fn compile(self) -> Result<CompiledPlan<'a>, AethelError> {
let key_to_index: HashMap<RuleKey, usize> = self
.nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); self.nodes.len()];
let mut indegree: Vec<usize> = vec![0; self.nodes.len()];
for (idx, node) in self.nodes.iter().enumerate() {
for dep in node.rule.dependencies() {
if let Some(dep_idx) = key_to_index.get(&dep) {
adjacency[*dep_idx].push(idx);
indegree[idx] += 1;
}
}
}
let mut queue: Vec<usize> = indegree
.iter()
.enumerate()
.filter(|(_, degree)| **degree == 0)
.map(|(idx, _)| idx)
.collect();
let mut order = Vec::with_capacity(self.nodes.len());
while let Some(node) = queue.pop() {
order.push(node);
for next in &adjacency[node] {
indegree[*next] = indegree[*next].saturating_sub(1);
if indegree[*next] == 0 {
queue.push(*next);
}
}
}
if order.len() != self.nodes.len() {
return Err(AethelError::Custom(
"validation bug: cyclic plan reached compile".to_string(),
));
}
Ok(CompiledPlan {
corpus: self.corpus,
nodes: self.nodes,
order,
})
}
}
impl<'a> CompiledPlan<'a> {
pub fn generate(&self, rng: &mut dyn Rng) -> Result<GenerationContext<'a>, AethelError> {
let mut ctx = GenerationContext::new(self.corpus);
for idx in &self.order {
let node = &self.nodes[*idx];
let result = node.rule.execute(&ctx, rng)?;
ctx.insert_typed(node.key.clone(), result);
}
Ok(ctx)
}
}
#[cfg(test)]
mod tests {
use rand::{SeedableRng, rngs::StdRng};
use crate::{
corpus::Corpus,
engine::{
AethelError, ComposedValue, PlanError,
combinators::{
chance, choice, custom, fallback, join, lit, pick_range, pick_unique,
pick_unique_range, recall, weighted, when,
},
},
};
use super::{PlanBuilder, PoolRef, RuleKey};
#[test]
fn rule_key_validation_rejects_invalid_names() {
assert!(RuleKey::new("").is_err());
assert!(RuleKey::new("has space").is_err());
assert!(RuleKey::new("weapon-name_1").is_ok());
}
#[test]
fn validate_reports_missing_dependency() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let missing = RuleKey::new("missing").expect("rule key should be valid");
let result = PlanBuilder::new(&corpus)
.rule(&out, recall(&missing))
.rule(
RuleKey::new("literal").expect("rule key should be valid"),
lit("x"),
)
.validate();
let err = match result {
Ok(_) => panic!("validation should fail"),
Err(err) => err,
};
assert!(err.to_string().contains("missing dependency"));
}
#[test]
fn validate_reports_nested_invalid_chance_probability() {
let corpus = Corpus::builder("weapon")
.add_str(
"plan-test",
r#"
[header]
title = "plan-test"
target = "weapon"
"#,
)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let result = PlanBuilder::new(&corpus)
.rule(&out, join([chance(2.0, lit("x"))]))
.validate();
let err = match result {
Ok(_) => panic!("validation should fail"),
Err(err) => err,
};
assert!(err.errors.iter().any(|error| {
matches!(
error,
PlanError::InvalidChanceProbability { rule, value }
if rule == "out" && *value == 2.0
)
}));
}
#[test]
fn validate_reports_nested_zero_weighted_total() {
let corpus = Corpus::builder("weapon")
.add_str(
"plan-test",
r#"
[header]
title = "plan-test"
target = "weapon"
"#,
)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let result = PlanBuilder::new(&corpus)
.rule(&out, join([weighted([(0, lit("x"))])]))
.validate();
let err = match result {
Ok(_) => panic!("validation should fail"),
Err(err) => err,
};
assert!(err.errors.iter().any(|error| {
matches!(error, PlanError::WeightedTotalZero { rule } if rule == "out")
}));
}
#[test]
fn pick_unique_selects_without_replacement() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash", "birch", "cedar"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let pool = PoolRef::new("name", "prefix").expect("pool ref should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&out, pick_unique(pool, 3, "|"))
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(17);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
let generated = &ctx.require(&out).expect("out should exist").value;
let parts = generated.split('|').collect::<Vec<_>>();
let unique = parts
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
assert_eq!(parts.len(), 3);
assert_eq!(unique.len(), 3);
}
#[test]
fn pick_range_selects_amount_within_range() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash", "birch", "cedar"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let pool = PoolRef::new("name", "prefix").expect("pool ref should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&out, pick_range(pool, 1..=3, "|"))
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(18);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
let generated = &ctx.require(&out).expect("out should exist").value;
let count = if generated.is_empty() {
0
} else {
generated.split('|').count()
};
assert!((1..=3).contains(&count));
}
#[test]
fn pick_unique_range_caps_amount_to_pool_size() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash", "birch"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let pool = PoolRef::new("name", "prefix").expect("pool ref should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&out, pick_unique_range(pool, 3..=5, "|"))
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(19);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
let generated = &ctx.require(&out).expect("out should exist").value;
let parts = generated.split('|').collect::<Vec<_>>();
assert_eq!(parts.len(), 2);
}
#[test]
fn fallback_executes_secondary_when_primary_fails() {
let corpus = Corpus::builder("weapon")
.add_str(
"plan-test",
r#"
[header]
title = "plan-test"
target = "weapon"
"#,
)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(
&out,
fallback(
custom(Vec::<RuleKey>::new(), |_ctx, _rng, _deps| {
Err(AethelError::Custom("primary failed".to_string()))
}),
lit("backup"),
),
)
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(20);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
assert_eq!(ctx.require(&out).expect("out should exist").value, "backup");
}
#[test]
fn choice_selects_one_child_rule() {
let corpus = Corpus::builder("weapon")
.add_str(
"plan-test",
r#"
[header]
title = "plan-test"
target = "weapon"
"#,
)
.build()
.expect("corpus should build");
let out = RuleKey::new("out").expect("rule key should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&out, choice([lit("ash"), lit("birch"), lit("cedar")]))
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(21);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
let generated = &ctx.require(&out).expect("out should exist").value;
assert!(matches!(generated.as_str(), "ash" | "birch" | "cedar"));
}
#[test]
fn pool_ref_new_rejects_invalid_names() {
let err = PoolRef::new("", "prefix").expect_err("empty section should fail");
assert!(err.to_string().contains("section"));
let err = PoolRef::new("name", "prefix value").expect_err("space should fail");
assert!(err.to_string().contains("allowed characters"));
}
#[test]
fn pool_ref_new_accepts_valid_names() {
let pool = PoolRef::new("name", "prefix").expect("valid pool ref should build");
assert_eq!(pool.section(), "name");
assert_eq!(pool.field(), "prefix");
}
#[test]
fn custom_expression_supports_user_defined_combinator_logic() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let base = RuleKey::new("base").expect("rule key should be valid");
let out = RuleKey::new("out").expect("rule key should be valid");
let base_for_closure = base;
let compiled = PlanBuilder::new(&corpus)
.rule(&base_for_closure, lit("ash"))
.rule(
&out,
custom([base_for_closure.clone()], |ctx, _rng, deps| {
let prior = ctx.require(&deps[0])?;
Ok(ComposedValue {
value: format!("{} spear", prior.value),
provenance: prior.provenance.clone(),
})
}),
)
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(13);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
let generated = ctx.require(&out).expect("out should exist");
assert_eq!(generated.value, "ash spear");
}
#[test]
fn when_combinator_includes_inner_when_condition_is_non_empty() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let condition = RuleKey::new("condition").expect("rule key should be valid");
let out = RuleKey::new("out").expect("rule key should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&condition, lit("enabled"))
.rule(
&out,
join([
lit("start"),
when(recall(&condition), lit("-extra")),
lit("-end"),
]),
)
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(9);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
assert_eq!(
ctx.require(&out).expect("out should exist").value,
"start-extra-end"
);
}
#[test]
fn when_combinator_skips_inner_when_condition_is_empty() {
let raw = r#"
[header]
title = "plan-test"
target = "weapon"
[name]
prefix = ["ash"]
"#;
let corpus = Corpus::builder("weapon")
.add_str("plan-test", raw)
.build()
.expect("corpus should build");
let condition = RuleKey::new("condition").expect("rule key should be valid");
let out = RuleKey::new("out").expect("rule key should be valid");
let compiled = PlanBuilder::new(&corpus)
.rule(&condition, lit(""))
.rule(
&out,
join([
lit("start"),
when(recall(&condition), lit("-extra")),
lit("-end"),
]),
)
.validate()
.expect("plan should validate")
.compile()
.expect("plan should compile");
let mut rng = StdRng::seed_from_u64(10);
let ctx = compiled
.generate(&mut rng)
.expect("generation should succeed");
assert_eq!(
ctx.require(&out).expect("out should exist").value,
"start-end"
);
}
}