1use 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
38pub struct SelectionContext<'a> {
40 pub tags: FxHashSet<String>,
41 pub entity_bindings: HashMap<String, &'a Entity>,
42 pub depth: u32,
43 pub voice_weights: Option<&'a HashMap<String, f32>>,
45 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub enum TemplateSegment {
85 Literal(String),
87 RuleRef(String),
89 MarkovRef { corpus: String, tag: String },
91 EntityField { field: String },
93 PronounRef { role: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Template {
100 pub segments: Vec<TemplateSegment>,
101}
102
103impl Template {
104 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 if i + 1 < len && chars[i + 1] == '{' {
124 literal_buf.push('{');
125 i += 2;
126 continue;
127 }
128
129 if !literal_buf.is_empty() {
131 segments.push(TemplateSegment::Literal(literal_buf.clone()));
132 literal_buf.clear();
133 }
134
135 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 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 match content {
191 "subject" | "object" | "possessive" => {
192 return Ok(TemplateSegment::PronounRef {
193 role: content.to_string(),
194 });
195 }
196 _ => {}
197 }
198
199 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 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 Ok(TemplateSegment::RuleRef(content.to_string()))
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct Alternative {
234 pub weight: u32,
235 pub template: Template,
236}
237
238#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct GrammarSet {
250 pub rules: HashMap<String, GrammarRule>,
251}
252
253#[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 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 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 pub fn merge(&mut self, other: GrammarSet) {
309 for (name, rule) in other.rules {
310 self.rules.insert(name, rule);
311 }
312 }
313
314 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 let requires_met = rule.requires.iter().all(|tag| ctx.tags.contains(tag));
325 let excludes_clear = !rule.excludes.iter().any(|tag| ctx.tags.contains(tag));
327 requires_met && excludes_clear
328 })
329 .collect()
330 }
331
332 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 for tag in &rule.requires {
354 ctx.tags.insert(tag.clone());
355 }
356
357 let alt = select_alternative(&rule.alternatives, rule_name, ctx.voice_weights, rng)?;
359
360 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 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 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
409fn 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
433fn resolve_entity_field(ctx: &SelectionContext<'_>, field: &str) -> Result<String, GrammarError> {
435 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
456fn resolve_pronoun(ctx: &SelectionContext<'_>, role: &str) -> Result<String, GrammarError> {
462 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 .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 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 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 #[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 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 let result = gs
769 .expand("confrontation_opening", &mut ctx, &mut rng)
770 .unwrap();
771 assert!(!result.is_empty());
772 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 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 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 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}