Skip to main content

narrative_engine/core/
grammar.rs

1/// Stochastic grammar runtime — types, parsing, loading, and expansion.
2use rand::distributions::WeightedIndex;
3use rand::prelude::Distribution;
4use rand::rngs::StdRng;
5use rustc_hash::FxHashSet;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9use thiserror::Error;
10
11use crate::core::markov::MarkovModel;
12use crate::schema::entity::{Entity, Value};
13
14const MAX_EXPANSION_DEPTH: u32 = 20;
15
16#[derive(Debug, Error)]
17pub enum GrammarError {
18    #[error("template parse error: {0}")]
19    TemplateParse(String),
20    #[error("IO error: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("RON deserialization error: {0}")]
23    Ron(#[from] ron::error::SpannedError),
24    #[error("rule not found: {0}")]
25    RuleNotFound(String),
26    #[error("max expansion depth ({0}) exceeded")]
27    MaxDepthExceeded(u32),
28    #[error("no matching alternatives for rule '{0}'")]
29    NoAlternatives(String),
30    #[error("entity binding not found for role: {0}")]
31    EntityBindingNotFound(String),
32    #[error("entity field not found: {0}")]
33    EntityFieldNotFound(String),
34    #[error("markov generation error: {0}")]
35    MarkovError(String),
36}
37
38/// Accumulated state during grammar expansion.
39pub struct SelectionContext<'a> {
40    pub tags: FxHashSet<String>,
41    pub entity_bindings: HashMap<String, &'a Entity>,
42    pub depth: u32,
43    /// Optional voice grammar weight overrides (rule_name → multiplier).
44    pub voice_weights: Option<&'a HashMap<String, f32>>,
45    /// Loaded Markov models keyed by corpus_id.
46    pub markov_models: HashMap<String, &'a MarkovModel>,
47}
48
49impl<'a> Default for SelectionContext<'a> {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl<'a> SelectionContext<'a> {
56    pub fn new() -> Self {
57        Self {
58            tags: FxHashSet::default(),
59            entity_bindings: HashMap::new(),
60            depth: 0,
61            voice_weights: None,
62            markov_models: HashMap::new(),
63        }
64    }
65
66    pub fn with_tags(mut self, tags: impl IntoIterator<Item = String>) -> Self {
67        self.tags.extend(tags);
68        self
69    }
70
71    pub fn with_entity(mut self, role: &str, entity: &'a Entity) -> Self {
72        self.entity_bindings.insert(role.to_string(), entity);
73        self
74    }
75
76    pub fn with_markov(mut self, corpus_id: &str, model: &'a MarkovModel) -> Self {
77        self.markov_models.insert(corpus_id.to_string(), model);
78        self
79    }
80}
81
82/// A segment of a parsed template.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub enum TemplateSegment {
85    /// Literal text, emitted as-is.
86    Literal(String),
87    /// Reference to another grammar rule: `{rule_name}`.
88    RuleRef(String),
89    /// Reference to a Markov generator: `{markov:corpus:tag}`.
90    MarkovRef { corpus: String, tag: String },
91    /// Entity field interpolation: `{entity.field}`.
92    EntityField { field: String },
93    /// Pronoun-aware entity reference: `{subject}`, `{object}`, `{possessive}`.
94    PronounRef { role: String },
95}
96
97/// A parsed template — a sequence of segments.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Template {
100    pub segments: Vec<TemplateSegment>,
101}
102
103impl Template {
104    /// Parse a template string into a sequence of segments.
105    ///
106    /// Syntax:
107    /// - `{rule_name}` → `RuleRef`
108    /// - `{markov:corpus:tag}` → `MarkovRef`
109    /// - `{entity.field}` → `EntityField`
110    /// - `{subject}` / `{object}` / `{possessive}` → `PronounRef`
111    /// - `{{` → literal `{`
112    /// - Everything else → `Literal`
113    pub fn parse(input: &str) -> Result<Template, GrammarError> {
114        let mut segments = Vec::new();
115        let mut literal_buf = String::new();
116        let chars: Vec<char> = input.chars().collect();
117        let len = chars.len();
118        let mut i = 0;
119
120        while i < len {
121            if chars[i] == '{' {
122                // Escaped brace
123                if i + 1 < len && chars[i + 1] == '{' {
124                    literal_buf.push('{');
125                    i += 2;
126                    continue;
127                }
128
129                // Flush any accumulated literal
130                if !literal_buf.is_empty() {
131                    segments.push(TemplateSegment::Literal(literal_buf.clone()));
132                    literal_buf.clear();
133                }
134
135                // Find the closing brace
136                let start = i + 1;
137                let mut depth = 1;
138                let mut end = start;
139                while end < len {
140                    if chars[end] == '{' {
141                        return Err(GrammarError::TemplateParse(
142                            "nested braces are not allowed".to_string(),
143                        ));
144                    }
145                    if chars[end] == '}' {
146                        depth -= 1;
147                        if depth == 0 {
148                            break;
149                        }
150                    }
151                    end += 1;
152                }
153
154                if depth != 0 {
155                    return Err(GrammarError::TemplateParse("unclosed brace".to_string()));
156                }
157
158                let content: String = chars[start..end].iter().collect();
159                if content.is_empty() {
160                    return Err(GrammarError::TemplateParse("empty braces".to_string()));
161                }
162
163                segments.push(Self::parse_segment(&content)?);
164                i = end + 1;
165            } else if chars[i] == '}' {
166                // Escaped closing brace
167                if i + 1 < len && chars[i + 1] == '}' {
168                    literal_buf.push('}');
169                    i += 2;
170                    continue;
171                }
172                return Err(GrammarError::TemplateParse(
173                    "unmatched closing brace".to_string(),
174                ));
175            } else {
176                literal_buf.push(chars[i]);
177                i += 1;
178            }
179        }
180
181        if !literal_buf.is_empty() {
182            segments.push(TemplateSegment::Literal(literal_buf));
183        }
184
185        Ok(Template { segments })
186    }
187
188    fn parse_segment(content: &str) -> Result<TemplateSegment, GrammarError> {
189        // Check for pronoun refs
190        match content {
191            "subject" | "object" | "possessive" => {
192                return Ok(TemplateSegment::PronounRef {
193                    role: content.to_string(),
194                });
195            }
196            _ => {}
197        }
198
199        // Check for markov ref: markov:corpus:tag
200        if let Some(rest) = content.strip_prefix("markov:") {
201            let parts: Vec<&str> = rest.splitn(2, ':').collect();
202            if parts.len() == 2 {
203                return Ok(TemplateSegment::MarkovRef {
204                    corpus: parts[0].to_string(),
205                    tag: parts[1].to_string(),
206                });
207            }
208            return Err(GrammarError::TemplateParse(format!(
209                "invalid markov ref '{}': expected markov:corpus:tag",
210                content
211            )));
212        }
213
214        // Check for entity field: entity.field
215        if let Some(field) = content.strip_prefix("entity.") {
216            if field.is_empty() {
217                return Err(GrammarError::TemplateParse(
218                    "empty entity field name".to_string(),
219                ));
220            }
221            return Ok(TemplateSegment::EntityField {
222                field: field.to_string(),
223            });
224        }
225
226        // Default: rule reference
227        Ok(TemplateSegment::RuleRef(content.to_string()))
228    }
229}
230
231/// A weighted text alternative within a grammar rule.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Alternative {
234    pub weight: u32,
235    pub template: Template,
236}
237
238/// A single grammar rule with tag preconditions and weighted alternatives.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct GrammarRule {
241    pub name: String,
242    pub requires: Vec<String>,
243    pub excludes: Vec<String>,
244    pub alternatives: Vec<Alternative>,
245}
246
247/// A set of named grammar rules.
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct GrammarSet {
250    pub rules: HashMap<String, GrammarRule>,
251}
252
253// RON deserialization helpers — the RON format uses a different shape
254// than the internal types, so we need intermediate structs.
255
256#[derive(Debug, Deserialize)]
257struct RonAlternative {
258    weight: u32,
259    text: String,
260}
261
262#[derive(Debug, Deserialize)]
263#[serde(rename = "Rule")]
264struct RonRule {
265    requires: Vec<String>,
266    #[serde(default)]
267    excludes: Vec<String>,
268    alternatives: Vec<RonAlternative>,
269}
270
271impl GrammarSet {
272    /// Load a grammar set from a RON file.
273    pub fn load_from_ron(path: &Path) -> Result<GrammarSet, GrammarError> {
274        let contents = std::fs::read_to_string(path)?;
275        Self::parse_ron(&contents)
276    }
277
278    /// Parse a grammar set from a RON string.
279    pub fn parse_ron(input: &str) -> Result<GrammarSet, GrammarError> {
280        let raw: HashMap<String, RonRule> = ron::from_str(input)?;
281        let mut rules = HashMap::new();
282
283        for (name, ron_rule) in raw {
284            let mut alternatives = Vec::new();
285            for alt in ron_rule.alternatives {
286                let template = Template::parse(&alt.text)?;
287                alternatives.push(Alternative {
288                    weight: alt.weight,
289                    template,
290                });
291            }
292            rules.insert(
293                name.clone(),
294                GrammarRule {
295                    name,
296                    requires: ron_rule.requires,
297                    excludes: ron_rule.excludes,
298                    alternatives,
299                },
300            );
301        }
302
303        Ok(GrammarSet { rules })
304    }
305
306    /// Merge another grammar set into this one. Rules from `other`
307    /// override rules in `self` with the same name.
308    pub fn merge(&mut self, other: GrammarSet) {
309        for (name, rule) in other.rules {
310            self.rules.insert(name, rule);
311        }
312    }
313
314    /// Find all rules whose `requires` tags are a subset of the context's
315    /// active tags, and whose `excludes` tags have no intersection.
316    pub fn find_matching_rules<'a, 'b>(
317        &'a self,
318        ctx: &SelectionContext<'b>,
319    ) -> Vec<&'a GrammarRule> {
320        self.rules
321            .values()
322            .filter(|rule| {
323                // All requires must be present in ctx tags
324                let requires_met = rule.requires.iter().all(|tag| ctx.tags.contains(tag));
325                // No excludes may be present in ctx tags
326                let excludes_clear = !rule.excludes.iter().any(|tag| ctx.tags.contains(tag));
327                requires_met && excludes_clear
328            })
329            .collect()
330    }
331
332    /// Expand a named rule into text using the given context and RNG.
333    pub fn expand(
334        &self,
335        rule_name: &str,
336        ctx: &mut SelectionContext<'_>,
337        rng: &mut StdRng,
338    ) -> Result<String, GrammarError> {
339        if ctx.depth >= MAX_EXPANSION_DEPTH {
340            return Err(GrammarError::MaxDepthExceeded(MAX_EXPANSION_DEPTH));
341        }
342
343        let rule = self
344            .rules
345            .get(rule_name)
346            .ok_or_else(|| GrammarError::RuleNotFound(rule_name.to_string()))?;
347
348        if rule.alternatives.is_empty() {
349            return Err(GrammarError::NoAlternatives(rule_name.to_string()));
350        }
351
352        // Propagate this rule's requires tags into context for child expansions
353        for tag in &rule.requires {
354            ctx.tags.insert(tag.clone());
355        }
356
357        // Select alternative by weighted random, with voice weight multipliers
358        let alt = select_alternative(&rule.alternatives, rule_name, ctx.voice_weights, rng)?;
359
360        // Expand template segments
361        ctx.depth += 1;
362        let mut output = String::new();
363
364        for segment in &alt.template.segments {
365            match segment {
366                TemplateSegment::Literal(text) => {
367                    output.push_str(text);
368                }
369                TemplateSegment::RuleRef(name) => {
370                    let expanded = self.expand(name, ctx, rng)?;
371                    output.push_str(&expanded);
372                }
373                TemplateSegment::MarkovRef { corpus, tag } => {
374                    if let Some(model) = ctx.markov_models.get(corpus.as_str()) {
375                        match model.generate(rng, Some(tag), 5, 15) {
376                            Ok(text) => output.push_str(&text),
377                            Err(e) => {
378                                // Fall back to untagged generation
379                                match model.generate(rng, None, 5, 15) {
380                                    Ok(text) => output.push_str(&text),
381                                    Err(_) => {
382                                        return Err(GrammarError::MarkovError(format!(
383                                            "markov generation failed for {}:{}: {}",
384                                            corpus, tag, e
385                                        )));
386                                    }
387                                }
388                            }
389                        }
390                    } else {
391                        // No model loaded — emit placeholder
392                        output.push_str(&format!("[markov:{}:{}]", corpus, tag));
393                    }
394                }
395                TemplateSegment::EntityField { field } => {
396                    output.push_str(&resolve_entity_field(ctx, field)?);
397                }
398                TemplateSegment::PronounRef { role } => {
399                    output.push_str(&resolve_pronoun(ctx, role)?);
400                }
401            }
402        }
403
404        ctx.depth -= 1;
405        Ok(output)
406    }
407}
408
409/// Select a weighted alternative, optionally applying voice weight multipliers.
410fn select_alternative<'a>(
411    alts: &'a [Alternative],
412    rule_name: &str,
413    voice_weights: Option<&HashMap<String, f32>>,
414    rng: &mut StdRng,
415) -> Result<&'a Alternative, GrammarError> {
416    let weights: Vec<f64> = alts
417        .iter()
418        .map(|alt| {
419            let base = alt.weight as f64;
420            let multiplier = voice_weights
421                .and_then(|vw| vw.get(rule_name))
422                .copied()
423                .unwrap_or(1.0) as f64;
424            (base * multiplier).max(0.0)
425        })
426        .collect();
427
428    let dist = WeightedIndex::new(&weights)
429        .map_err(|_| GrammarError::NoAlternatives(rule_name.to_string()))?;
430    Ok(&alts[dist.sample(rng)])
431}
432
433/// Look up an entity field from context bindings.
434fn resolve_entity_field(ctx: &SelectionContext<'_>, field: &str) -> Result<String, GrammarError> {
435    // Try to find the field in any bound entity's properties, or check name
436    // First check the "subject" binding, then any binding
437    let entity = ctx
438        .entity_bindings
439        .get("subject")
440        .or_else(|| ctx.entity_bindings.values().next())
441        .ok_or_else(|| GrammarError::EntityBindingNotFound("subject".to_string()))?;
442
443    if field == "name" {
444        return Ok(entity.name.clone());
445    }
446
447    match entity.properties.get(field) {
448        Some(Value::String(s)) => Ok(s.clone()),
449        Some(Value::Float(f)) => Ok(format!("{}", f)),
450        Some(Value::Int(i)) => Ok(format!("{}", i)),
451        Some(Value::Bool(b)) => Ok(format!("{}", b)),
452        None => Err(GrammarError::EntityFieldNotFound(field.to_string())),
453    }
454}
455
456/// Resolve a pronoun reference using the entity's pronoun set.
457///
458/// - `{subject}` → entity name (templates expect the name here)
459/// - `{object}` → entity name for the "object" role
460/// - `{possessive}` → possessive pronoun (her, his, their, its)
461fn resolve_pronoun(ctx: &SelectionContext<'_>, role: &str) -> Result<String, GrammarError> {
462    // Map pronoun role to entity binding
463    let binding_key = match role {
464        "subject" => "subject",
465        "object" => "object",
466        "possessive" => "subject",
467        other => other,
468    };
469
470    let entity = ctx
471        .entity_bindings
472        .get(binding_key)
473        // Fall back to subject for object/possessive if not separately bound
474        .or_else(|| ctx.entity_bindings.get("subject"))
475        .ok_or_else(|| GrammarError::EntityBindingNotFound(role.to_string()))?;
476
477    match role {
478        "possessive" => Ok(entity.pronouns.possessive().to_string()),
479        _ => Ok(entity.name.clone()),
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::schema::entity::{Entity, EntityId, VoiceId};
487    use rand::SeedableRng;
488
489    fn make_test_entity(name: &str) -> Entity {
490        Entity {
491            id: EntityId(1),
492            name: name.to_string(),
493            pronouns: crate::schema::entity::Pronouns::SheHer,
494            tags: FxHashSet::default(),
495            relationships: Vec::new(),
496            voice_id: Some(VoiceId(1)),
497            properties: HashMap::from([(
498                "held_item".to_string(),
499                Value::String("wine glass".to_string()),
500            )]),
501        }
502    }
503
504    fn load_test_grammar() -> GrammarSet {
505        GrammarSet::load_from_ron(std::path::Path::new("tests/fixtures/test_grammar.ron")).unwrap()
506    }
507
508    #[test]
509    fn parse_literal_only() {
510        let t = Template::parse("Hello, world.").unwrap();
511        assert_eq!(
512            t.segments,
513            vec![TemplateSegment::Literal("Hello, world.".to_string())]
514        );
515    }
516
517    #[test]
518    fn parse_rule_ref() {
519        let t = Template::parse("Start {action_detail} end").unwrap();
520        assert_eq!(t.segments.len(), 3);
521        assert_eq!(
522            t.segments[1],
523            TemplateSegment::RuleRef("action_detail".to_string())
524        );
525    }
526
527    #[test]
528    fn parse_markov_ref() {
529        let t = Template::parse("She said {markov:dialogue:accusatory} quietly.").unwrap();
530        assert_eq!(t.segments.len(), 3);
531        assert_eq!(
532            t.segments[1],
533            TemplateSegment::MarkovRef {
534                corpus: "dialogue".to_string(),
535                tag: "accusatory".to_string(),
536            }
537        );
538    }
539
540    #[test]
541    fn parse_entity_field() {
542        let t = Template::parse("Hello, {entity.name}.").unwrap();
543        assert_eq!(t.segments.len(), 3);
544        assert_eq!(
545            t.segments[1],
546            TemplateSegment::EntityField {
547                field: "name".to_string()
548            }
549        );
550    }
551
552    #[test]
553    fn parse_pronoun_refs() {
554        let t = Template::parse("{subject} looked at {object} with {possessive} eyes.").unwrap();
555        assert_eq!(
556            t.segments[0],
557            TemplateSegment::PronounRef {
558                role: "subject".to_string()
559            }
560        );
561        assert_eq!(
562            t.segments[2],
563            TemplateSegment::PronounRef {
564                role: "object".to_string()
565            }
566        );
567        assert_eq!(
568            t.segments[4],
569            TemplateSegment::PronounRef {
570                role: "possessive".to_string()
571            }
572        );
573    }
574
575    #[test]
576    fn parse_escaped_braces() {
577        let t = Template::parse("Use {{braces}} here.").unwrap();
578        assert_eq!(
579            t.segments,
580            vec![TemplateSegment::Literal("Use {braces} here.".to_string())]
581        );
582    }
583
584    #[test]
585    fn parse_empty_braces_error() {
586        assert!(Template::parse("Bad {} here").is_err());
587    }
588
589    #[test]
590    fn parse_nested_braces_error() {
591        assert!(Template::parse("Bad {outer{inner}} here").is_err());
592    }
593
594    #[test]
595    fn parse_unclosed_brace_error() {
596        assert!(Template::parse("Bad {unclosed here").is_err());
597    }
598
599    #[test]
600    fn parse_unmatched_close_error() {
601        assert!(Template::parse("Bad } here").is_err());
602    }
603
604    #[test]
605    fn parse_mixed_segments() {
606        let t = Template::parse(
607            "{subject} set down {possessive} {entity.held_item} and said {markov:dialogue:tense}.",
608        )
609        .unwrap();
610        assert_eq!(t.segments.len(), 8);
611        assert!(
612            matches!(&t.segments[0], TemplateSegment::PronounRef { role } if role == "subject")
613        );
614        assert!(
615            matches!(&t.segments[2], TemplateSegment::PronounRef { role } if role == "possessive")
616        );
617        assert!(
618            matches!(&t.segments[4], TemplateSegment::EntityField { field } if field == "held_item")
619        );
620        assert!(
621            matches!(&t.segments[6], TemplateSegment::MarkovRef { corpus, tag } if corpus == "dialogue" && tag == "tense")
622        );
623    }
624
625    #[test]
626    fn load_test_grammar_from_ron() {
627        let gs = load_test_grammar();
628        assert_eq!(gs.rules.len(), 7);
629        assert!(gs.rules.contains_key("greeting"));
630        assert!(gs.rules.contains_key("tense_observation"));
631        assert!(gs.rules.contains_key("action_detail"));
632        assert!(gs.rules.contains_key("confrontation_opening"));
633        assert!(gs.rules.contains_key("calm_greeting"));
634        assert!(gs.rules.contains_key("recursive_bomb"));
635        assert!(gs.rules.contains_key("markov_test"));
636
637        let greeting = &gs.rules["greeting"];
638        assert_eq!(greeting.alternatives.len(), 3);
639        assert!(greeting.requires.is_empty());
640    }
641
642    #[test]
643    fn ron_round_trip() {
644        let mut gs = GrammarSet::default();
645        gs.rules.insert(
646            "test_rule".to_string(),
647            GrammarRule {
648                name: "test_rule".to_string(),
649                requires: vec!["mood:tense".to_string()],
650                excludes: vec![],
651                alternatives: vec![Alternative {
652                    weight: 1,
653                    template: Template::parse("Hello {entity.name}.").unwrap(),
654                }],
655            },
656        );
657
658        let serialized = ron::to_string(&gs).unwrap();
659        let deserialized: GrammarSet = ron::from_str(&serialized).unwrap();
660        assert_eq!(deserialized.rules.len(), 1);
661        assert!(deserialized.rules.contains_key("test_rule"));
662    }
663
664    #[test]
665    fn merge_precedence() {
666        let mut base = GrammarSet::default();
667        base.rules.insert(
668            "shared".to_string(),
669            GrammarRule {
670                name: "shared".to_string(),
671                requires: vec![],
672                excludes: vec![],
673                alternatives: vec![Alternative {
674                    weight: 1,
675                    template: Template::parse("base version").unwrap(),
676                }],
677            },
678        );
679        base.rules.insert(
680            "base_only".to_string(),
681            GrammarRule {
682                name: "base_only".to_string(),
683                requires: vec![],
684                excludes: vec![],
685                alternatives: vec![Alternative {
686                    weight: 1,
687                    template: Template::parse("only in base").unwrap(),
688                }],
689            },
690        );
691
692        let mut override_set = GrammarSet::default();
693        override_set.rules.insert(
694            "shared".to_string(),
695            GrammarRule {
696                name: "shared".to_string(),
697                requires: vec!["mood:tense".to_string()],
698                excludes: vec![],
699                alternatives: vec![Alternative {
700                    weight: 2,
701                    template: Template::parse("override version").unwrap(),
702                }],
703            },
704        );
705
706        base.merge(override_set);
707
708        // Override took precedence
709        assert_eq!(base.rules["shared"].alternatives[0].weight, 2);
710        assert_eq!(
711            base.rules["shared"].requires,
712            vec!["mood:tense".to_string()]
713        );
714        // Base-only rule still present
715        assert!(base.rules.contains_key("base_only"));
716    }
717
718    #[test]
719    fn grammar_set_default() {
720        let gs = GrammarSet::default();
721        assert!(gs.rules.is_empty());
722    }
723
724    #[test]
725    fn template_requires_tags_loaded() {
726        let path = std::path::PathBuf::from("tests/fixtures/test_grammar.ron");
727        let gs = GrammarSet::load_from_ron(&path).unwrap();
728        let tense = &gs.rules["tense_observation"];
729        assert_eq!(tense.requires, vec!["mood:tense".to_string()]);
730    }
731
732    // --- Expansion tests ---
733
734    #[test]
735    fn expand_literal_rule() {
736        let gs = load_test_grammar();
737        let entity = make_test_entity("Margaret");
738        let mut ctx = SelectionContext::new()
739            .with_tags(["mood:tense".to_string()])
740            .with_entity("subject", &entity);
741        let mut rng = StdRng::seed_from_u64(42);
742
743        let result = gs.expand("tense_observation", &mut ctx, &mut rng).unwrap();
744        assert!(!result.is_empty());
745        // All alternatives are pure literals
746        let valid = [
747            "The air felt heavy with unspoken words.",
748            "A silence settled over the room.",
749            "No one dared to speak first.",
750        ];
751        assert!(
752            valid.contains(&result.as_str()),
753            "Unexpected output: {}",
754            result
755        );
756    }
757
758    #[test]
759    fn expand_three_levels_deep() {
760        let gs = load_test_grammar();
761        let entity = make_test_entity("Margaret");
762        let mut ctx = SelectionContext::new()
763            .with_tags(["mood:tense".to_string()])
764            .with_entity("subject", &entity);
765        let mut rng = StdRng::seed_from_u64(42);
766
767        // confrontation_opening references action_detail and tense_observation
768        let result = gs
769            .expand("confrontation_opening", &mut ctx, &mut rng)
770            .unwrap();
771        assert!(!result.is_empty());
772        // Should contain text from child rules
773        assert!(
774            result.len() > 20,
775            "Expected multi-rule expansion, got: {}",
776            result
777        );
778    }
779
780    #[test]
781    fn deterministic_with_same_seed() {
782        let gs = load_test_grammar();
783        let entity = make_test_entity("Margaret");
784
785        let mut ctx1 = SelectionContext::new()
786            .with_tags(["mood:tense".to_string()])
787            .with_entity("subject", &entity);
788        let mut rng1 = StdRng::seed_from_u64(99);
789        let result1 = gs
790            .expand("confrontation_opening", &mut ctx1, &mut rng1)
791            .unwrap();
792
793        let mut ctx2 = SelectionContext::new()
794            .with_tags(["mood:tense".to_string()])
795            .with_entity("subject", &entity);
796        let mut rng2 = StdRng::seed_from_u64(99);
797        let result2 = gs
798            .expand("confrontation_opening", &mut ctx2, &mut rng2)
799            .unwrap();
800
801        assert_eq!(result1, result2);
802    }
803
804    #[test]
805    fn different_seed_different_output() {
806        let gs = load_test_grammar();
807        let entity = make_test_entity("Margaret");
808
809        let mut ctx1 = SelectionContext::new()
810            .with_tags(["mood:tense".to_string()])
811            .with_entity("subject", &entity);
812        let mut rng1 = StdRng::seed_from_u64(1);
813        let result1 = gs
814            .expand("confrontation_opening", &mut ctx1, &mut rng1)
815            .unwrap();
816
817        let mut found_different = false;
818        for seed in 2..50 {
819            let mut ctx2 = SelectionContext::new()
820                .with_tags(["mood:tense".to_string()])
821                .with_entity("subject", &entity);
822            let mut rng2 = StdRng::seed_from_u64(seed);
823            let result2 = gs
824                .expand("confrontation_opening", &mut ctx2, &mut rng2)
825                .unwrap();
826            if result1 != result2 {
827                found_different = true;
828                break;
829            }
830        }
831        assert!(
832            found_different,
833            "Expected different output with different seeds"
834        );
835    }
836
837    #[test]
838    fn max_depth_error() {
839        let gs = load_test_grammar();
840        let mut ctx = SelectionContext::new();
841        let mut rng = StdRng::seed_from_u64(42);
842
843        let result = gs.expand("recursive_bomb", &mut ctx, &mut rng);
844        assert!(result.is_err());
845        assert!(
846            matches!(result, Err(GrammarError::MaxDepthExceeded(_))),
847            "Expected MaxDepthExceeded error"
848        );
849    }
850
851    #[test]
852    fn tag_propagation_affects_selection() {
853        let gs = load_test_grammar();
854
855        // Without mood:tense, calm_greeting should match but tense_observation should not
856        let ctx = SelectionContext::new();
857        let matching = gs.find_matching_rules(&ctx);
858        let names: Vec<&str> = matching.iter().map(|r| r.name.as_str()).collect();
859        assert!(
860            names.contains(&"calm_greeting"),
861            "calm_greeting should match without tense tag"
862        );
863        assert!(
864            !names.contains(&"tense_observation"),
865            "tense_observation should not match without tense tag"
866        );
867
868        // With mood:tense, tense_observation should match but calm_greeting should not
869        let ctx_tense = SelectionContext::new().with_tags(["mood:tense".to_string()]);
870        let matching_tense = gs.find_matching_rules(&ctx_tense);
871        let names_tense: Vec<&str> = matching_tense.iter().map(|r| r.name.as_str()).collect();
872        assert!(
873            names_tense.contains(&"tense_observation"),
874            "tense_observation should match with tense tag"
875        );
876        assert!(
877            !names_tense.contains(&"calm_greeting"),
878            "calm_greeting should be excluded by tense tag"
879        );
880    }
881
882    #[test]
883    fn entity_field_expansion() {
884        let gs = load_test_grammar();
885        let entity = make_test_entity("Margaret");
886
887        // greeting rule uses {entity.name}
888        // Run multiple seeds to hit a variant with entity.name
889        let mut found_name = false;
890        for seed in 0..20 {
891            let mut ctx = SelectionContext::new().with_entity("subject", &entity);
892            let mut rng = StdRng::seed_from_u64(seed);
893            let result = gs.expand("greeting", &mut ctx, &mut rng).unwrap();
894            if result.contains("Margaret") {
895                found_name = true;
896                break;
897            }
898        }
899        assert!(
900            found_name,
901            "Expected entity name expansion in at least one seed"
902        );
903    }
904
905    #[test]
906    fn markov_placeholder_expansion() {
907        let gs = load_test_grammar();
908        let mut ctx = SelectionContext::new();
909        let mut rng = StdRng::seed_from_u64(42);
910
911        let result = gs.expand("markov_test", &mut ctx, &mut rng).unwrap();
912        assert!(
913            result.contains("[markov:dialogue:accusatory]"),
914            "Expected markov placeholder, got: {}",
915            result
916        );
917    }
918
919    #[test]
920    fn rule_not_found_error() {
921        let gs = load_test_grammar();
922        let mut ctx = SelectionContext::new();
923        let mut rng = StdRng::seed_from_u64(42);
924
925        let result = gs.expand("nonexistent_rule", &mut ctx, &mut rng);
926        assert!(matches!(result, Err(GrammarError::RuleNotFound(_))));
927    }
928}