1use ainl_memory::{
7 AinlMemoryNode, AinlNodeType, EpisodicNode, GraphStore, SemanticNode, SqliteGraphStore,
8};
9use ainl_persona::{signals::episodic_should_process, MemoryNodeType, PersonaAxis, RawSignal};
10use ainl_semantic_tagger::{
11 extract_correction_behavior, infer_brevity_preference, infer_formality, tag_tool_names,
12 SemanticTag, TagNamespace,
13};
14use serde_json::Value;
15use std::collections::{HashMap, HashSet};
16use uuid::Uuid;
17
18#[derive(Debug, Default, Clone)]
20pub struct PersonaSignalExtractorState {
21 pub pass_seq: u64,
23 pub global_turn_index: u32,
25 implicit_brevity_streak: u8,
26 last_brevity_emit_turn: Option<u32>,
28 formality_run: Option<(FormalityDir, u8)>,
30 domain_cluster_last_emit_pass: HashMap<String, u64>,
32}
33
34impl PersonaSignalExtractorState {
35 pub fn new() -> Self {
36 Self::default()
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum FormalityDir {
42 Informal,
43 Formal,
44}
45
46const BREVITY_DEBOUNCE_TURNS: u32 = 3;
47const DOMAIN_COOLDOWN_PASSES: u64 = 2;
48const DOMAIN_MIN_RECURRENCE_NODE: u32 = 3;
49const DOMAIN_EMIT_AT_LEAST_NODES: usize = 2;
50const DOMAIN_SINGLE_NODE_RECURRENCE: u32 = 6;
51
52fn trace_obj(ep: &EpisodicNode) -> Option<&serde_json::Map<String, Value>> {
53 ep.trace_event.as_ref()?.as_object()
54}
55
56fn user_text(ep: &EpisodicNode) -> String {
57 if let Some(s) = &ep.user_message {
58 return s.clone();
59 }
60 trace_obj(ep)
61 .and_then(|m| m.get("user_message"))
62 .and_then(|v| v.as_str())
63 .unwrap_or("")
64 .to_string()
65}
66
67fn assistant_tokens(ep: &EpisodicNode) -> u32 {
68 if ep.assistant_response_tokens > 0 {
69 return ep.assistant_response_tokens;
70 }
71 trace_obj(ep)
72 .and_then(|m| m.get("assistant_response_tokens"))
73 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
74 .map(|u| u as u32)
75 .unwrap_or(0)
76}
77
78fn user_tokens(ep: &EpisodicNode) -> u32 {
79 if ep.user_message_tokens > 0 {
80 return ep.user_message_tokens;
81 }
82 let t = user_text(ep);
83 if t.is_empty() {
84 0
85 } else {
86 t.split_whitespace().count() as u32
87 }
88}
89
90fn implicit_brevity_shape(ep: &EpisodicNode) -> bool {
91 let ut = user_tokens(ep);
92 let atok = assistant_tokens(ep);
93 ut < 12 && atok > 300
94}
95
96fn formality_direction_from_tag(user: &str) -> Option<FormalityDir> {
97 infer_formality(user).and_then(|tag| match tag.value.as_str() {
98 "informal" => Some(FormalityDir::Informal),
99 "formal" => Some(FormalityDir::Formal),
100 _ => None,
101 })
102}
103
104fn brevity_debounce_allows(state: &PersonaSignalExtractorState, turn: u32) -> bool {
105 match state.last_brevity_emit_turn {
106 None => true,
107 Some(prev) if turn.saturating_sub(prev) >= BREVITY_DEBOUNCE_TURNS => true,
108 _ => false,
109 }
110}
111
112fn append_episode_tags(
113 store: &SqliteGraphStore,
114 node_id: Uuid,
115 tags: &[String],
116) -> Result<(), String> {
117 if tags.is_empty() {
118 return Ok(());
119 }
120 let Some(mut node) = store.read_node(node_id)? else {
121 return Ok(());
122 };
123 let AinlNodeType::Episode { ref mut episodic } = node.node_type else {
124 return Ok(());
125 };
126 let existing: HashSet<&str> = episodic
127 .persona_signals_emitted
128 .iter()
129 .map(|s| s.as_str())
130 .collect();
131 let mut seen_new: HashSet<String> = HashSet::new();
132 let mut new_tags: Vec<String> = Vec::new();
133 for t in tags.iter().filter(|t| !existing.contains(t.as_str())) {
134 if seen_new.insert(t.clone()) {
135 new_tags.push(t.clone());
136 }
137 }
138 if new_tags.is_empty() {
139 return Ok(());
140 }
141 episodic.persona_signals_emitted.extend(new_tags);
142 store.write_node(&node)
143}
144
145fn tool_affinity_signals(episode_id: Uuid, ep: &EpisodicNode) -> Vec<RawSignal> {
146 let tools: Vec<String> = ep.effective_tools().to_vec();
147 let tagged = tag_tool_names(&tools);
148 let mut out = Vec::new();
149 for _ in tagged {
150 out.push(RawSignal {
151 axis: PersonaAxis::Instrumentality,
152 reward: 0.68,
153 weight: 0.5,
154 source_node_id: episode_id,
155 source_node_type: MemoryNodeType::Episodic,
156 });
157 }
158 out
159}
160
161fn cluster_key(topic: Option<&String>) -> Option<String> {
162 let t = topic?.trim();
163 if t.is_empty() {
164 return None;
165 }
166 Some(t.to_ascii_lowercase())
167}
168
169fn domain_emergence_signals(
170 store: &SqliteGraphStore,
171 agent_id: &str,
172 state: &mut PersonaSignalExtractorState,
173) -> Result<Vec<RawSignal>, String> {
174 let mut by_cluster: HashMap<String, Vec<SemanticNode>> = HashMap::new();
175 for node in store.find_by_type("semantic")? {
176 if node.agent_id != agent_id {
177 continue;
178 }
179 let AinlNodeType::Semantic { semantic } = node.node_type else {
180 continue;
181 };
182 let Some(key) = cluster_key(semantic.topic_cluster.as_ref()) else {
183 continue;
184 };
185 by_cluster.entry(key).or_default().push(semantic);
186 }
187
188 let mut out = Vec::new();
189 for (cluster, nodes) in by_cluster {
190 let strong_nodes = nodes
191 .iter()
192 .filter(|n| n.recurrence_count >= DOMAIN_MIN_RECURRENCE_NODE)
193 .count();
194 let max_rec = nodes.iter().map(|n| n.recurrence_count).max().unwrap_or(0);
195 let crosses =
196 strong_nodes >= DOMAIN_EMIT_AT_LEAST_NODES || max_rec >= DOMAIN_SINGLE_NODE_RECURRENCE;
197 if !crosses {
198 continue;
199 }
200 if let Some(last_pass) = state.domain_cluster_last_emit_pass.get(&cluster).copied() {
201 if state.pass_seq.saturating_sub(last_pass) < DOMAIN_COOLDOWN_PASSES {
202 continue;
203 }
204 }
205 let Some(anchor) = nodes.first() else {
206 continue;
207 };
208 state
209 .domain_cluster_last_emit_pass
210 .insert(cluster.clone(), state.pass_seq);
211 out.push(RawSignal {
212 axis: PersonaAxis::Persistence,
213 reward: 0.72,
214 weight: 0.6,
215 source_node_id: anchor.source_turn_id,
216 source_node_type: MemoryNodeType::Semantic,
217 });
218 }
219 Ok(out)
220}
221
222fn correction_emit_tag(tag: &SemanticTag) -> String {
223 match tag.namespace {
224 TagNamespace::Behavior => format!("det:behavior:{}", tag.value),
225 TagNamespace::Correction => format!("det:correction:{}", tag.value),
226 _ => format!("det:{}", tag.to_canonical_string().replace(':', "_")),
227 }
228}
229
230#[derive(Debug, Default)]
232pub struct ExtractPassCollected {
233 pub signals: Vec<RawSignal>,
234 pub pending_tags: Vec<(Uuid, Vec<String>)>,
235}
236
237pub fn extract_pass(
239 store: &SqliteGraphStore,
240 agent_id: &str,
241 state: &mut PersonaSignalExtractorState,
242) -> Result<Vec<RawSignal>, String> {
243 let collected = extract_pass_collect(store, agent_id, state)?;
244 flush_episode_pattern_tags(store, &collected.pending_tags)?;
245 Ok(collected.signals)
246}
247
248pub fn extract_pass_collect(
250 store: &SqliteGraphStore,
251 agent_id: &str,
252 state: &mut PersonaSignalExtractorState,
253) -> Result<ExtractPassCollected, String> {
254 state.pass_seq = state.pass_seq.saturating_add(1);
255
256 let mut episodes: Vec<AinlMemoryNode> = store
257 .find_by_type("episode")?
258 .into_iter()
259 .filter(|n| n.agent_id == agent_id)
260 .collect();
261 episodes.sort_by_key(|n| match &n.node_type {
262 AinlNodeType::Episode { episodic } => episodic.timestamp,
263 _ => 0,
264 });
265
266 let mut out = Vec::new();
267 let mut pending_tags: Vec<(Uuid, Vec<String>)> = Vec::new();
268
269 for ep_node in &episodes {
270 let episode_id = ep_node.id;
271 let AinlNodeType::Episode { episodic } = &ep_node.node_type else {
272 continue;
273 };
274 let turn = state.global_turn_index;
275 state.global_turn_index = state.global_turn_index.saturating_add(1);
276
277 let mut tags: Vec<String> = Vec::new();
278
279 if !episodic_should_process(episodic) {
282 out.extend(tool_affinity_signals(episode_id, episodic));
283 }
284
285 let user = user_text(episodic);
286
287 if let Some(tag) = extract_correction_behavior(&user) {
288 out.push(RawSignal {
289 axis: PersonaAxis::Systematicity,
290 reward: 0.84,
291 weight: 0.85,
292 source_node_id: episode_id,
293 source_node_type: MemoryNodeType::Episodic,
294 });
295 tags.push(correction_emit_tag(&tag));
296 }
297
298 if !user.is_empty()
299 && infer_brevity_preference(&user).is_some()
300 && brevity_debounce_allows(state, turn)
301 {
302 out.push(RawSignal {
303 axis: PersonaAxis::Verbosity,
304 reward: 0.22,
305 weight: 0.75,
306 source_node_id: episode_id,
307 source_node_type: MemoryNodeType::Episodic,
308 });
309 tags.push("det:brevity:explicit".into());
310 state.last_brevity_emit_turn = Some(turn);
311 state.implicit_brevity_streak = 0;
312 } else if implicit_brevity_shape(episodic) {
313 state.implicit_brevity_streak = state.implicit_brevity_streak.saturating_add(1);
314 if state.implicit_brevity_streak >= 2 && brevity_debounce_allows(state, turn) {
315 out.push(RawSignal {
316 axis: PersonaAxis::Verbosity,
317 reward: 0.24,
318 weight: 0.7,
319 source_node_id: episode_id,
320 source_node_type: MemoryNodeType::Episodic,
321 });
322 tags.push("det:brevity:implicit_shape".into());
323 state.last_brevity_emit_turn = Some(turn);
324 state.implicit_brevity_streak = 0;
325 }
326 } else {
327 state.implicit_brevity_streak = 0;
328 }
329
330 if !user.is_empty() {
331 match formality_direction_from_tag(&user) {
332 Some(dir) => {
333 let bump = match &mut state.formality_run {
334 Some((cur, n)) if *cur == dir => {
335 *n = n.saturating_add(1);
336 *n
337 }
338 _ => {
339 state.formality_run = Some((dir, 1));
340 1
341 }
342 };
343 if bump >= 3 {
344 let (reward, tag) = match dir {
345 FormalityDir::Formal => (0.78_f32, "det:formality:formal_run"),
346 FormalityDir::Informal => (0.28_f32, "det:formality:informal_run"),
347 };
348 out.push(RawSignal {
349 axis: PersonaAxis::Systematicity,
350 reward,
351 weight: 0.65,
352 source_node_id: episode_id,
353 source_node_type: MemoryNodeType::Episodic,
354 });
355 tags.push(tag.into());
356 state.formality_run = None;
357 }
358 }
359 None => {
360 state.formality_run = None;
361 }
362 }
363 }
364
365 if !tags.is_empty() {
366 pending_tags.push((episode_id, tags));
367 }
368 }
369
370 out.extend(domain_emergence_signals(store, agent_id, state)?);
371 Ok(ExtractPassCollected {
372 signals: out,
373 pending_tags,
374 })
375}
376
377pub fn flush_episode_pattern_tags(
379 store: &SqliteGraphStore,
380 pending: &[(Uuid, Vec<String>)],
381) -> Result<(), String> {
382 for (episode_id, tags) in pending {
383 append_episode_tags(store, *episode_id, tags)?;
384 }
385 Ok(())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use ainl_memory::{AinlMemoryNode, AinlNodeType, SqliteGraphStore};
392 use ainl_semantic_tagger::{
393 extract_correction_behavior, infer_brevity_preference, infer_formality, TagNamespace,
394 };
395 use uuid::Uuid;
396
397 fn ep_with_tokens(user_t: u32, asst_t: u32) -> EpisodicNode {
398 let tid = Uuid::new_v4();
399 EpisodicNode {
400 turn_id: tid,
401 timestamp: 0,
402 tool_calls: vec![],
403 delegation_to: None,
404 trace_event: None,
405 turn_index: 0,
406 user_message_tokens: user_t,
407 assistant_response_tokens: asst_t,
408 tools_invoked: vec![],
409 persona_signals_emitted: vec![],
410 sentiment: None,
411 flagged: false,
412 conversation_id: String::new(),
413 follows_episode_id: None,
414 user_message: None,
415 assistant_response: None,
416 tags: vec![],
417 vitals_gate: None,
418 vitals_phase: None,
419 vitals_trust: None,
420 }
421 }
422
423 #[test]
424 fn brevity_explicit_keyword_emits() {
425 let mut st = PersonaSignalExtractorState::default();
426 let tid = Uuid::new_v4();
427 let mut ep = ep_with_tokens(0, 0);
428 ep.user_message = Some("Please be more concise here.".into());
429 let mut out: Vec<RawSignal> = Vec::new();
430 let mut tags: Vec<String> = Vec::new();
431 let turn = 0;
432 let user = user_text(&ep);
433 if !user.is_empty()
434 && infer_brevity_preference(&user).is_some()
435 && brevity_debounce_allows(&st, turn)
436 {
437 out.push(RawSignal {
438 axis: PersonaAxis::Verbosity,
439 reward: 0.22,
440 weight: 0.75,
441 source_node_id: tid,
442 source_node_type: MemoryNodeType::Episodic,
443 });
444 tags.push("det:brevity:explicit".into());
445 st.last_brevity_emit_turn = Some(turn);
446 }
447 assert_eq!(out.len(), 1);
448 assert_eq!(tags.len(), 1);
449 }
450
451 #[test]
452 fn brevity_implicit_single_no_emit_double_emits() {
453 let mut st = PersonaSignalExtractorState::default();
454 let ep = ep_with_tokens(5, 400);
455 assert!(implicit_brevity_shape(&ep));
456 st.implicit_brevity_streak = st.implicit_brevity_streak.saturating_add(1);
457 assert_eq!(st.implicit_brevity_streak, 1);
458 assert!(st.implicit_brevity_streak < 2);
459 }
460
461 #[test]
462 fn brevity_implicit_two_consecutive_emits_via_pass() {
463 let dir = tempfile::tempdir().expect("d");
464 let store = SqliteGraphStore::open(&dir.path().join("br.db")).expect("open");
465 let agent = "agent-br";
466 let mut st = PersonaSignalExtractorState::default();
467 for (ts, ut, at) in [(1_i64, 5_u32, 400_u32), (2_i64, 4_u32, 350_u32)] {
468 let tid = Uuid::new_v4();
469 let mut n = AinlMemoryNode::new_episode(tid, ts, vec![], None, None);
470 n.agent_id = agent.into();
471 if let AinlNodeType::Episode { episodic } = &mut n.node_type {
472 episodic.user_message_tokens = ut;
473 episodic.assistant_response_tokens = at;
474 }
475 store.write_node(&n).expect("w");
476 }
477 let sigs = extract_pass(&store, agent, &mut st).expect("extract");
478 let brevity = sigs
479 .iter()
480 .filter(|s| s.axis == PersonaAxis::Verbosity)
481 .count();
482 assert!(
483 brevity >= 1,
484 "expected implicit brevity after two qualifying turns"
485 );
486 }
487
488 #[test]
489 fn brevity_debounce_blocks() {
490 let st = PersonaSignalExtractorState {
491 last_brevity_emit_turn: Some(0),
492 ..Default::default()
493 };
494 assert!(!brevity_debounce_allows(&st, 1));
495 assert!(!brevity_debounce_allows(&st, 2));
496 assert!(brevity_debounce_allows(&st, 3));
497 }
498
499 #[test]
500 fn tool_invocations_emit_one_each() {
501 let tid = Uuid::new_v4();
502 let mut ep = ep_with_tokens(0, 0);
503 ep.tools_invoked = vec!["file_read".into(), "shell_exec".into()];
504 let sigs = tool_affinity_signals(tid, &ep);
505 assert_eq!(sigs.len(), 2);
506 assert!(sigs.iter().all(|s| s.axis == PersonaAxis::Instrumentality));
507 }
508
509 #[test]
510 fn append_episode_tags_dedupes_existing_and_within_batch() {
511 let dir = tempfile::tempdir().expect("d");
512 let store = SqliteGraphStore::open(&dir.path().join("ep_tags.db")).expect("open");
513 let tid = Uuid::new_v4();
514 let mut n = AinlMemoryNode::new_episode(tid, 1, vec![], None, None);
515 n.agent_id = "a".into();
516 store.write_node(&n).expect("w");
517 append_episode_tags(
518 &store,
519 n.id,
520 &["det:brevity:explicit".into(), "det:brevity:explicit".into()],
521 )
522 .expect("append");
523 let r = store.read_node(n.id).expect("r").expect("node");
524 let AinlNodeType::Episode { episodic } = r.node_type else {
525 panic!();
526 };
527 assert_eq!(
528 episodic.persona_signals_emitted,
529 vec!["det:brevity:explicit".to_string()]
530 );
531 append_episode_tags(&store, n.id, &["det:brevity:explicit".into()]).expect("append2");
532 let r2 = store.read_node(n.id).expect("r2").expect("node");
533 let AinlNodeType::Episode { episodic: e2 } = r2.node_type else {
534 panic!();
535 };
536 assert_eq!(e2.persona_signals_emitted.len(), 1);
537 }
538
539 #[test]
540 fn formality_single_informal_no_emit_until_three() {
541 let t = infer_formality("yo gonna grab some food lol yeah").expect("tag");
542 assert_eq!(t.value, "informal");
543 }
544
545 #[test]
546 fn formality_three_informal_emits_logic() {
547 let mut run: Option<(FormalityDir, u8)> = None;
548 let informal_line = "yeah gonna wanna grab some cool stuff lol";
549 let mut emitted = false;
550 for _ in 0..3 {
551 let dir = formality_direction_from_tag(informal_line).expect("dir");
552 assert_eq!(dir, FormalityDir::Informal);
553 let bump = match &mut run {
554 Some((FormalityDir::Informal, n)) => {
555 *n += 1;
556 *n
557 }
558 _ => {
559 run = Some((FormalityDir::Informal, 1));
560 1
561 }
562 };
563 if bump >= 3 {
564 emitted = true;
565 }
566 }
567 assert!(emitted);
568 }
569
570 #[test]
571 fn formality_mixed_resets() {
572 let mut run: Option<(FormalityDir, u8)> = None;
573 let msgs = [
574 "gonna grab food",
575 "Therefore, the coefficient matrix exhibits stability.",
576 "ok lol",
577 ];
578 let mut max_run = 0u8;
579 for m in msgs {
580 match formality_direction_from_tag(m) {
581 Some(dir) => {
582 let bump = match &mut run {
583 Some((cur, n)) if *cur == dir => {
584 *n += 1;
585 *n
586 }
587 _ => {
588 run = Some((dir, 1));
589 1
590 }
591 };
592 max_run = max_run.max(bump);
593 }
594 None => run = None,
595 }
596 }
597 assert!(max_run < 3);
598 }
599
600 #[test]
601 fn domain_recurrence_not_reference() {
602 let (_d, store) = {
603 let dir = tempfile::tempdir().expect("d");
604 let p = dir.path().join("t.db");
605 let s = SqliteGraphStore::open(&p).expect("open");
606 (dir, s)
607 };
608 let tid = Uuid::new_v4();
609 let mut s1 = AinlMemoryNode::new_fact("a".into(), 0.8, tid);
610 s1.agent_id = "ag".into();
611 if let AinlNodeType::Semantic { semantic } = &mut s1.node_type {
612 semantic.topic_cluster = Some("rust".into());
613 semantic.recurrence_count = 1;
614 semantic.reference_count = 99;
615 }
616 store.write_node(&s1).expect("w");
617 let mut s2 = AinlMemoryNode::new_fact("b".into(), 0.8, tid);
618 s2.agent_id = "ag".into();
619 if let AinlNodeType::Semantic { semantic } = &mut s2.node_type {
620 semantic.topic_cluster = Some("rust".into());
621 semantic.recurrence_count = 1;
622 semantic.reference_count = 99;
623 }
624 store.write_node(&s2).expect("w");
625 let mut st = PersonaSignalExtractorState {
626 pass_seq: 1,
627 ..Default::default()
628 };
629 let sigs = domain_emergence_signals(&store, "ag", &mut st).expect("d");
630 assert!(sigs.is_empty(), "high reference_count must not gate domain");
631 }
632
633 #[test]
634 fn domain_threshold_crosses() {
635 let dir = tempfile::tempdir().expect("d");
636 let store = SqliteGraphStore::open(&dir.path().join("d.db")).expect("open");
637 let tid = Uuid::new_v4();
638 for fact in ["a", "b"] {
639 let mut s = AinlMemoryNode::new_fact(fact.into(), 0.8, tid);
640 s.agent_id = "ag".into();
641 if let AinlNodeType::Semantic { semantic } = &mut s.node_type {
642 semantic.topic_cluster = Some("rust".into());
643 semantic.recurrence_count = 3;
644 }
645 store.write_node(&s).expect("w");
646 }
647 let mut st = PersonaSignalExtractorState {
648 pass_seq: 1,
649 ..Default::default()
650 };
651 let sigs = domain_emergence_signals(&store, "ag", &mut st).expect("d");
652 assert_eq!(sigs.len(), 1);
653 }
654
655 #[test]
656 fn domain_cooldown_second_pass_suppressed() {
657 let dir = tempfile::tempdir().expect("d");
658 let store = SqliteGraphStore::open(&dir.path().join("d2.db")).expect("open");
659 let tid = Uuid::new_v4();
660 for fact in ["a", "b"] {
661 let mut s = AinlMemoryNode::new_fact(fact.into(), 0.8, tid);
662 s.agent_id = "ag".into();
663 if let AinlNodeType::Semantic { semantic } = &mut s.node_type {
664 semantic.topic_cluster = Some("go".into());
665 semantic.recurrence_count = 3;
666 }
667 store.write_node(&s).expect("w");
668 }
669 let mut st = PersonaSignalExtractorState {
670 pass_seq: 1,
671 ..Default::default()
672 };
673 let n1 = domain_emergence_signals(&store, "ag", &mut st)
674 .expect("d")
675 .len();
676 st.pass_seq = 2;
677 let n2 = domain_emergence_signals(&store, "ag", &mut st)
678 .expect("d")
679 .len();
680 assert_eq!(n1, 1);
681 assert_eq!(n2, 0);
682 }
683
684 #[test]
685 fn correction_dont_use_bullets() {
686 let t = extract_correction_behavior("don't use bullet points").expect("tag");
687 assert_eq!(t.namespace, TagNamespace::Correction);
688 assert_eq!(t.value, "avoid_bullets");
689 }
690
691 #[test]
692 fn correction_you_keep_caveats() {
693 let t = extract_correction_behavior("you keep adding caveats").expect("tag");
694 assert_eq!(t.namespace, TagNamespace::Behavior);
695 assert_eq!(t.value, "adding_caveats");
696 }
697
698 #[test]
699 fn correction_told_emojis() {
700 let t = extract_correction_behavior("I told you not to use emojis").expect("tag");
701 assert_eq!(t.namespace, TagNamespace::Correction);
702 assert_eq!(t.value, "avoid_emojis");
703 }
704
705 #[test]
706 fn correction_stop_alone() {
707 assert!(extract_correction_behavior("stop").is_none());
708 }
709
710 #[test]
711 fn correction_i_said_so() {
712 assert!(extract_correction_behavior("I said so").is_none());
713 }
714
715 #[test]
716 fn correction_dont_do_that_no_behavior() {
717 assert!(extract_correction_behavior("don't do that").is_none());
718 }
719}