Skip to main content

ainl_graph_extractor/
persona_signals.rs

1//! Heuristic persona-relevant signals for [`ainl_persona::EvolutionEngine`].
2//!
3//! Runs inside `GraphExtractorTask::run_pass` after graph-backed `EvolutionEngine::extract_signals`,
4//! using episode text/tokens, tool lists, and semantic `topic_cluster` + `recurrence_count` only.
5
6use 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/// Rolling state for debounce / streak detectors (in-memory, per [`GraphExtractorTask`](crate::GraphExtractorTask)).
19#[derive(Debug, Default, Clone)]
20pub struct PersonaSignalExtractorState {
21    /// Monotonic counter incremented each `extract_pass` invocation.
22    pub pass_seq: u64,
23    /// Chronological turn index (within agent episode stream) advanced each processed episode.
24    pub global_turn_index: u32,
25    implicit_brevity_streak: u8,
26    /// Last `global_turn_index` at which a brevity-family signal was emitted (explicit or implicit).
27    last_brevity_emit_turn: Option<u32>,
28    /// Current run of same formality direction (`Informal` / `Formal`) on user text.
29    formality_run: Option<(FormalityDir, u8)>,
30    /// `topic_cluster` key → `pass_seq` when domain emergence last fired.
31    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/// Collected signals plus episode tag writes deferred to [`flush_episode_pattern_tags`].
231#[derive(Debug, Default)]
232pub struct ExtractPassCollected {
233    pub signals: Vec<RawSignal>,
234    pub pending_tags: Vec<(Uuid, Vec<String>)>,
235}
236
237/// Episode-ordered heuristics plus semantic domain pass; updates `state` and may patch episode rows.
238pub 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
248/// Build signals and pending episode tag patches without writing episodes yet.
249pub 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        // `GraphExtractor` / `extract_episodic_signals` already emits Instrumentality from
280        // `effective_tools()` when `episodic_should_process` — skip redundant tool affinity here.
281        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
377/// Apply episode `persona_signals_emitted` tag patches from [`extract_pass_collect`].
378pub 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}