Skip to main content

brainwires_knowledge/knowledge/
config.rs

1//! Memory bank configuration: mission, directives, and disposition traits.
2//!
3//! [`MemoryBankConfig`] shapes how a [`crate::knowledge::BrainClient`] annotates
4//! stored thoughts and filters/scores search results.  The default configuration
5//! is a no-op — everything works exactly as before unless you opt in.
6
7use serde::{Deserialize, Serialize};
8
9/// Behavioral reasoning traits that bias search result scoring.
10///
11/// Each active trait applies a small score delta (positive or negative) to
12/// each result based on content characteristics.  Deltas are summed and
13/// clamped to `[-0.1, 0.1]` before being applied.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum DispositionTrait {
17    /// Boost structured, data-driven, or reasoned content.
18    Analytical,
19    /// Penalise very long content; prefer concise results.
20    Concise,
21    /// Boost content that expresses caution or uncertainty.
22    Cautious,
23    /// Boost content that presents novel ideas or possibilities.
24    Creative,
25    /// Boost content that follows sequential or procedural structure.
26    Systematic,
27}
28
29/// Configuration that shapes how a [`crate::knowledge::BrainClient`] memory
30/// bank behaves.
31///
32/// ## Mission
33/// A short statement of the agent's purpose.  When set, every captured thought
34/// is tagged with a normalised mission slug (e.g. `"mission:security_assistant"`)
35/// so thoughts can be scoped by client identity.
36///
37/// ## Directives
38/// Compliance or safety rules.  Directives that start with `"Never "` or
39/// `"Do not "` are parsed as content-blocking rules: any search result whose
40/// content contains words from the directive's object phrase is removed from
41/// the response.  Example: `"Never store PII"` will remove results that
42/// contain the words "store" **and** "Pii" (case-insensitive).
43///
44/// ## Disposition
45/// A set of reasoning traits that apply small score adjustments (±0.1) based
46/// on the content characteristics of each result.  The net delta is clamped
47/// so no single result can gain or lose more than 0.1.
48///
49/// # Default
50/// All fields are empty/`None` — the config is a no-op.
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct MemoryBankConfig {
53    /// Agent mission statement (used for auto-tagging).
54    pub mission: Option<String>,
55    /// Content blocking / compliance directives.
56    pub directives: Vec<String>,
57    /// Reasoning trait biases applied to search scores.
58    pub disposition: Vec<DispositionTrait>,
59}
60
61impl MemoryBankConfig {
62    /// Create an empty (no-op) config.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Set the mission (builder-style).
68    pub fn with_mission(mut self, mission: impl Into<String>) -> Self {
69        self.mission = Some(mission.into());
70        self
71    }
72
73    /// Append a directive (builder-style).
74    pub fn with_directive(mut self, directive: impl Into<String>) -> Self {
75        self.directives.push(directive.into());
76        self
77    }
78
79    /// Append a disposition trait (builder-style).
80    pub fn with_disposition(mut self, trait_: DispositionTrait) -> Self {
81        if !self.disposition.contains(&trait_) {
82            self.disposition.push(trait_);
83        }
84        self
85    }
86
87    /// Returns `true` when the config has no effect (all fields empty/None).
88    pub fn is_noop(&self) -> bool {
89        self.mission.is_none() && self.directives.is_empty() && self.disposition.is_empty()
90    }
91
92    /// Returns a mission slug suitable for use as a tag.
93    ///
94    /// Lowercases the mission and replaces whitespace with `_`.
95    pub fn mission_tag(&self) -> Option<String> {
96        self.mission.as_ref().map(|m| {
97            let slug = m
98                .to_lowercase()
99                .split_whitespace()
100                .collect::<Vec<_>>()
101                .join("_");
102            format!("mission:{slug}")
103        })
104    }
105
106    /// Returns `true` if the given content should be excluded by a blocking
107    /// directive.
108    ///
109    /// A directive is treated as blocking when it starts with `"Never "` or
110    /// `"Do not "`.  The object phrase (the part after the prefix) is split
111    /// into words; the content is blocked if it contains **all** of those
112    /// words (case-insensitive).
113    pub fn blocks_content(&self, content: &str) -> bool {
114        let lower_content = content.to_lowercase();
115        for directive in &self.directives {
116            let object = if let Some(rest) = directive.strip_prefix("Never ") {
117                rest
118            } else if let Some(rest) = directive.strip_prefix("Do not ") {
119                rest
120            } else {
121                continue; // not a blocking directive
122            };
123
124            let words: Vec<&str> = object.split_whitespace().collect();
125            if !words.is_empty()
126                && words
127                    .iter()
128                    .all(|w| lower_content.contains(&w.to_lowercase()))
129            {
130                return true;
131            }
132        }
133        false
134    }
135
136    /// Compute a score delta in `[-0.1, 0.1]` based on disposition traits and
137    /// content characteristics.
138    pub fn disposition_score_delta(&self, content: &str) -> f32 {
139        if self.disposition.is_empty() {
140            return 0.0;
141        }
142
143        let lower = content.to_lowercase();
144        let mut delta: f32 = 0.0;
145
146        for trait_ in &self.disposition {
147            delta += match trait_ {
148                DispositionTrait::Analytical => {
149                    // Boost content with structured markers: numbers, code blocks, bullet points
150                    let has_numbers = lower.chars().any(|c| c.is_ascii_digit());
151                    let has_code = lower.contains("```") || lower.contains("    ");
152                    let has_bullets = lower.contains("- ") || lower.contains("* ");
153                    if has_numbers || has_code || has_bullets {
154                        0.05
155                    } else {
156                        0.0
157                    }
158                }
159                DispositionTrait::Concise => {
160                    // Penalise very long content
161                    if content.len() > 500 { -0.05 } else { 0.0 }
162                }
163                DispositionTrait::Cautious => {
164                    // Boost hedging language
165                    let hedges = ["might", "could", "consider", "perhaps", "possibly", "maybe"];
166                    if hedges.iter().any(|h| lower.contains(h)) {
167                        0.05
168                    } else {
169                        0.0
170                    }
171                }
172                DispositionTrait::Creative => {
173                    // Boost generative / ideation phrasing
174                    let creative = [
175                        "idea",
176                        "what if",
177                        "novel",
178                        "alternative",
179                        "propose",
180                        "imagine",
181                    ];
182                    if creative.iter().any(|c| lower.contains(c)) {
183                        0.05
184                    } else {
185                        0.0
186                    }
187                }
188                DispositionTrait::Systematic => {
189                    // Boost sequential / procedural structure
190                    let sequential = ["first", "then", "finally", "step ", "1.", "2.", "3."];
191                    if sequential.iter().any(|s| lower.contains(s)) {
192                        0.05
193                    } else {
194                        0.0
195                    }
196                }
197            };
198        }
199
200        delta.clamp(-0.1, 0.1)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_default_is_noop() {
210        assert!(MemoryBankConfig::default().is_noop());
211        assert!(MemoryBankConfig::new().is_noop());
212    }
213
214    #[test]
215    fn test_builder_chain() {
216        let cfg = MemoryBankConfig::new()
217            .with_mission("Security assistant")
218            .with_directive("Never store PII")
219            .with_disposition(DispositionTrait::Analytical);
220        assert!(!cfg.is_noop());
221        assert_eq!(cfg.mission.as_deref(), Some("Security assistant"));
222        assert_eq!(cfg.directives.len(), 1);
223        assert_eq!(cfg.disposition.len(), 1);
224    }
225
226    #[test]
227    fn test_mission_tag() {
228        let cfg = MemoryBankConfig::new().with_mission("Security Assistant");
229        assert_eq!(cfg.mission_tag(), Some("mission:security_assistant".into()));
230        assert!(MemoryBankConfig::new().mission_tag().is_none());
231    }
232
233    #[test]
234    fn test_blocks_content_never() {
235        let cfg = MemoryBankConfig::new().with_directive("Never store PII");
236        assert!(cfg.blocks_content("we should store user PII here"));
237        assert!(!cfg.blocks_content("authentication token handling"));
238    }
239
240    #[test]
241    fn test_blocks_content_do_not() {
242        let cfg = MemoryBankConfig::new().with_directive("Do not log passwords");
243        assert!(cfg.blocks_content("log passwords to the debug output"));
244        assert!(!cfg.blocks_content("log request headers"));
245    }
246
247    #[test]
248    fn test_blocks_content_non_blocking_directive() {
249        let cfg = MemoryBankConfig::new().with_directive("Prefer Rust over Python");
250        // Not a "Never" or "Do not" directive — should never block
251        assert!(!cfg.blocks_content("Prefer Rust over Python everywhere"));
252    }
253
254    #[test]
255    fn test_disposition_concise_penalty() {
256        let cfg = MemoryBankConfig::new().with_disposition(DispositionTrait::Concise);
257        let long_content = "x".repeat(501);
258        let short_content = "short";
259        assert!(cfg.disposition_score_delta(&long_content) < 0.0);
260        assert_eq!(cfg.disposition_score_delta(short_content), 0.0);
261    }
262
263    #[test]
264    fn test_disposition_analytical_boost() {
265        let cfg = MemoryBankConfig::new().with_disposition(DispositionTrait::Analytical);
266        assert!(cfg.disposition_score_delta("Step 1. Use 42 requests") > 0.0);
267        assert_eq!(cfg.disposition_score_delta("casual chat"), 0.0);
268    }
269
270    #[test]
271    fn test_disposition_delta_clamp() {
272        // All traits at once on matching content — delta stays in [-0.1, 0.1]
273        let cfg = MemoryBankConfig::new()
274            .with_disposition(DispositionTrait::Analytical)
275            .with_disposition(DispositionTrait::Cautious)
276            .with_disposition(DispositionTrait::Creative)
277            .with_disposition(DispositionTrait::Systematic)
278            .with_disposition(DispositionTrait::Concise);
279        let content = "first idea: might use 42 steps - consider alternatives";
280        let delta = cfg.disposition_score_delta(content);
281        assert!((-0.1..=0.1).contains(&delta));
282    }
283}