attuned_core/
translator.rs

1//! State-to-context translation.
2//!
3//! Translators convert a [`StateSnapshot`] into a [`PromptContext`] that can be
4//! used to condition LLM behavior.
5//!
6//! # Governance
7//!
8//! The [`RuleTranslator`] is both the crown jewel and the danger zone of Attuned.
9//! This is where state becomes behavior, and where "just one more heuristic" can
10//! introduce covert agency.
11//!
12//! ## What Translators Are Allowed to Do
13//!
14//! - **Map axis values to tone descriptors**: e.g., high warmth → "warm-casual" tone
15//! - **Generate behavioral guidelines**: e.g., low suggestion_tolerance → "only respond to explicit requests"
16//! - **Set verbosity levels**: e.g., low verbosity_preference → Verbosity::Low
17//! - **Add flags for edge conditions**: e.g., high cognitive_load → "high_cognitive_load" flag
18//!
19//! ## What Translators Are Forbidden to Do
20//!
21//! - **Infer hidden axes from other axes**: e.g., "if cognitive_load > 0.8 AND anxiety > 0.7, assume X"
22//! - **Optimize for engagement/conversion**: No rules that maximize time-on-site or purchase likelihood
23//! - **Override user self-report**: If user explicitly set an axis, don't "correct" it
24//! - **Add adaptive heuristics that learn**: Translators are pure functions; no state, no learning
25//! - **Insert persuasion during reflection time**: High reflection_bias means WAIT, not CONVINCE
26//!
27//! ## The Test
28//!
29//! Before adding any translation rule, ask:
30//!
31//! > "If a user knew exactly what this rule does, would they feel respected or manipulated?"
32//!
33//! If manipulated, reject the rule. See [MANIFESTO.md](../../MANIFESTO.md) for more.
34
35use crate::snapshot::StateSnapshot;
36use serde::{Deserialize, Serialize};
37
38/// Output verbosity level for LLM responses.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Verbosity {
42    /// Brief, concise responses.
43    Low,
44    /// Balanced detail level.
45    #[default]
46    Medium,
47    /// Comprehensive, detailed responses.
48    High,
49}
50
51/// Context produced by translating user state.
52///
53/// This is the output that should be injected into LLM system prompts
54/// to condition interaction style.
55#[derive(Clone, Debug, Default, Serialize, Deserialize)]
56pub struct PromptContext {
57    /// Behavioral guidelines for the LLM.
58    pub guidelines: Vec<String>,
59
60    /// Suggested tone (e.g., "calm-neutral", "warm-neutral").
61    pub tone: String,
62
63    /// Desired response verbosity.
64    pub verbosity: Verbosity,
65
66    /// Active flags for special conditions.
67    pub flags: Vec<String>,
68}
69
70/// Trait for translating state snapshots to prompt context.
71pub trait Translator: Send + Sync {
72    /// Translate a state snapshot into prompt context.
73    fn to_prompt_context(&self, snapshot: &StateSnapshot) -> PromptContext;
74}
75
76/// Threshold configuration for rule-based translation.
77#[derive(Clone, Debug)]
78pub struct Thresholds {
79    /// Values above this are considered "high".
80    pub hi: f32,
81    /// Values below this are considered "low".
82    pub lo: f32,
83}
84
85impl Default for Thresholds {
86    fn default() -> Self {
87        Self { hi: 0.7, lo: 0.3 }
88    }
89}
90
91/// Rule-based translator that converts state to context using threshold rules.
92///
93/// This is the reference implementation that provides full transparency into
94/// how state values affect generated guidelines.
95#[derive(Clone, Debug, Default)]
96pub struct RuleTranslator {
97    /// Thresholds for determining "high" and "low" axis values.
98    pub thresholds: Thresholds,
99}
100
101impl RuleTranslator {
102    /// Create a new RuleTranslator with the given thresholds.
103    pub fn new(thresholds: Thresholds) -> Self {
104        Self { thresholds }
105    }
106
107    /// Create a RuleTranslator with custom high/low thresholds.
108    pub fn with_thresholds(hi: f32, lo: f32) -> Self {
109        Self {
110            thresholds: Thresholds { hi, lo },
111        }
112    }
113}
114
115impl Translator for RuleTranslator {
116    #[tracing::instrument(skip(self, snapshot), fields(user_id = %snapshot.user_id))]
117    fn to_prompt_context(&self, snapshot: &StateSnapshot) -> PromptContext {
118        let get = |k: &str| snapshot.get_axis(k);
119        let hi = self.thresholds.hi;
120        let lo = self.thresholds.lo;
121
122        // Always-present base guidelines (from AGENTS.md non-goals)
123        let mut guidelines = vec![
124            "Offer suggestions, not actions".to_string(),
125            "Drafts require explicit user approval".to_string(),
126            "Silence is acceptable if no action is required".to_string(),
127        ];
128
129        let mut flags = Vec::new();
130
131        // Cognitive load rules
132        let cognitive_load = get("cognitive_load");
133        if cognitive_load > hi {
134            guidelines.push(
135                "Keep responses concise; avoid multi-step plans unless requested".to_string(),
136            );
137            flags.push("high_cognitive_load".to_string());
138        }
139
140        // Decision fatigue rules
141        let decision_fatigue = get("decision_fatigue");
142        if decision_fatigue > hi {
143            guidelines.push("Limit choices; present clear recommendations".to_string());
144            flags.push("high_decision_fatigue".to_string());
145        }
146
147        // Urgency sensitivity rules
148        let urgency = get("urgency_sensitivity");
149        if urgency > hi {
150            guidelines.push("Prioritize speed; get to the point quickly".to_string());
151            flags.push("high_urgency".to_string());
152        }
153
154        // Anxiety rules - MUST be specific and prominent to work
155        let anxiety = get("anxiety_level");
156        if anxiety > hi {
157            guidelines.push(
158                "IMPORTANT: The user may be feeling anxious. Begin responses by acknowledging their feelings \
159                (e.g., 'I understand this feels overwhelming' or 'It's completely normal to feel uncertain'). \
160                Use a calm, supportive tone throughout. Avoid adding pressure or urgency."
161                    .to_string(),
162            );
163            flags.push("high_anxiety".to_string());
164        }
165
166        // Boundary strength rules
167        let boundary_strength = get("boundary_strength");
168        if boundary_strength > hi {
169            guidelines.push("Maintain firm boundaries; do not over-accommodate".to_string());
170        }
171
172        // Ritual need rules
173        let ritual_need = get("ritual_need");
174        if ritual_need < lo {
175            guidelines.push("Avoid ceremonial gestures; keep interactions pragmatic".to_string());
176        }
177
178        // Suggestion tolerance rules
179        let suggestion_tolerance = get("suggestion_tolerance");
180        if suggestion_tolerance < lo {
181            guidelines
182                .push("Only respond to explicit requests; no proactive suggestions".to_string());
183        }
184
185        // Interruption tolerance rules
186        let interruption_tolerance = get("interruption_tolerance");
187        if interruption_tolerance < lo {
188            guidelines
189                .push("Do not interrupt; wait for user to complete their thought".to_string());
190        }
191
192        // Stakes awareness rules
193        let stakes = get("stakes_awareness");
194        if stakes > hi {
195            guidelines.push("High stakes context; be careful and thorough".to_string());
196            flags.push("high_stakes".to_string());
197        }
198
199        // Privacy sensitivity rules
200        let privacy = get("privacy_sensitivity");
201        if privacy > hi {
202            guidelines.push("Minimize data collection; respect privacy".to_string());
203            flags.push("high_privacy_sensitivity".to_string());
204        }
205
206        // Determine tone based on warmth and formality
207        let warmth = get("warmth");
208        let formality = get("formality");
209
210        // Add explicit warmth guidelines - tone label alone isn't enough
211        if warmth > hi {
212            guidelines.push(
213                "Use warm, friendly language. Include encouraging phrases like 'Great question!' \
214                or 'I'd be happy to help!'. Show enthusiasm and empathy."
215                    .to_string(),
216            );
217        } else if warmth < lo {
218            guidelines.push(
219                "Keep tone neutral and matter-of-fact. Avoid enthusiastic language, exclamations, \
220                or excessive friendliness. Be helpful but not effusive."
221                    .to_string(),
222            );
223        }
224
225        // Add explicit formality guidelines
226        if formality > hi {
227            guidelines.push(
228                "Use professional, formal language. Avoid contractions (use 'do not' instead of 'don't'). \
229                Use complete sentences and proper structure. Address topics with appropriate gravity."
230                    .to_string(),
231            );
232        } else if formality < lo {
233            guidelines.push(
234                "Use casual, conversational language. Contractions are fine. \
235                Keep it relaxed and approachable, like talking to a friend."
236                    .to_string(),
237            );
238        }
239
240        let tone = match (warmth > hi, formality > hi) {
241            (true, true) => "warm-formal".to_string(),
242            (true, false) => "warm-casual".to_string(),
243            (false, true) => "neutral-formal".to_string(),
244            (false, false) => "calm-neutral".to_string(),
245        };
246
247        // Determine verbosity with explicit guidelines
248        let verbosity_pref = get("verbosity_preference");
249        let verbosity = if verbosity_pref < lo {
250            guidelines.push(
251                "Keep responses brief and to the point. Use short paragraphs or bullet points. \
252                Aim for the minimum words needed to be helpful."
253                    .to_string(),
254            );
255            Verbosity::Low
256        } else if verbosity_pref > hi {
257            guidelines.push(
258                "Provide comprehensive, detailed responses. Include context, examples, and thorough explanations. \
259                Don't leave out relevant information for the sake of brevity."
260                    .to_string(),
261            );
262            Verbosity::High
263        } else {
264            Verbosity::Medium
265        };
266
267        PromptContext {
268            guidelines,
269            tone,
270            verbosity,
271            flags,
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::Source;
280
281    fn snapshot_with_axis(axis: &str, value: f32) -> StateSnapshot {
282        StateSnapshot::builder()
283            .user_id("test_user")
284            .source(Source::SelfReport)
285            .axis(axis, value)
286            .build()
287            .unwrap()
288    }
289
290    #[test]
291    fn test_base_guidelines_always_present() {
292        let translator = RuleTranslator::default();
293        let snapshot = StateSnapshot::builder().user_id("test").build().unwrap();
294
295        let context = translator.to_prompt_context(&snapshot);
296
297        assert!(context
298            .guidelines
299            .iter()
300            .any(|g| g.contains("suggestions, not actions")));
301        assert!(context
302            .guidelines
303            .iter()
304            .any(|g| g.contains("explicit user approval")));
305    }
306
307    #[test]
308    fn test_high_cognitive_load() {
309        let translator = RuleTranslator::default();
310        let snapshot = snapshot_with_axis("cognitive_load", 0.9);
311
312        let context = translator.to_prompt_context(&snapshot);
313
314        assert!(context.guidelines.iter().any(|g| g.contains("concise")));
315        assert!(context.flags.contains(&"high_cognitive_load".to_string()));
316    }
317
318    #[test]
319    fn test_low_ritual_need() {
320        let translator = RuleTranslator::default();
321        let snapshot = snapshot_with_axis("ritual_need", 0.1);
322
323        let context = translator.to_prompt_context(&snapshot);
324
325        assert!(context.guidelines.iter().any(|g| g.contains("ceremonial")));
326    }
327
328    #[test]
329    fn test_warm_tone() {
330        let translator = RuleTranslator::default();
331        let snapshot = snapshot_with_axis("warmth", 0.9);
332
333        let context = translator.to_prompt_context(&snapshot);
334
335        assert!(context.tone.starts_with("warm"));
336    }
337
338    #[test]
339    fn test_verbosity_levels() {
340        let translator = RuleTranslator::default();
341
342        let low = snapshot_with_axis("verbosity_preference", 0.1);
343        assert_eq!(translator.to_prompt_context(&low).verbosity, Verbosity::Low);
344
345        let high = snapshot_with_axis("verbosity_preference", 0.9);
346        assert_eq!(
347            translator.to_prompt_context(&high).verbosity,
348            Verbosity::High
349        );
350
351        let medium = snapshot_with_axis("verbosity_preference", 0.5);
352        assert_eq!(
353            translator.to_prompt_context(&medium).verbosity,
354            Verbosity::Medium
355        );
356    }
357
358    // Property-based tests
359    mod property_tests {
360        use super::*;
361        use crate::axes::CANONICAL_AXES;
362        use proptest::prelude::*;
363
364        // Strategy for generating valid axis values [0.0, 1.0]
365        fn valid_axis_value() -> impl Strategy<Value = f32> {
366            0.0f32..=1.0f32
367        }
368
369        proptest! {
370            #[test]
371            fn prop_translator_never_panics(
372                cognitive_load in valid_axis_value(),
373                warmth in valid_axis_value(),
374                formality in valid_axis_value(),
375                verbosity_pref in valid_axis_value(),
376                boundary_strength in valid_axis_value(),
377                ritual_need in valid_axis_value(),
378            ) {
379                let translator = RuleTranslator::default();
380                let snapshot = StateSnapshot::builder()
381                    .user_id("test_user")
382                    .axis("cognitive_load", cognitive_load)
383                    .axis("warmth", warmth)
384                    .axis("formality", formality)
385                    .axis("verbosity_preference", verbosity_pref)
386                    .axis("boundary_strength", boundary_strength)
387                    .axis("ritual_need", ritual_need)
388                    .build()
389                    .unwrap();
390
391                // Should never panic
392                let context = translator.to_prompt_context(&snapshot);
393
394                // Basic sanity checks
395                prop_assert!(!context.guidelines.is_empty(), "Guidelines should never be empty");
396                prop_assert!(!context.tone.is_empty(), "Tone should never be empty");
397            }
398
399            #[test]
400            fn prop_base_guidelines_always_present(
401                axes in prop::collection::btree_map(
402                    prop::sample::select(CANONICAL_AXES.iter().map(|a| a.name.to_string()).collect::<Vec<_>>()),
403                    valid_axis_value(),
404                    0..10
405                )
406            ) {
407                let translator = RuleTranslator::default();
408                let mut builder = StateSnapshot::builder().user_id("test_user");
409
410                for (name, value) in axes {
411                    builder = builder.axis(&name, value);
412                }
413
414                let snapshot = builder.build().unwrap();
415                let context = translator.to_prompt_context(&snapshot);
416
417                // Base guidelines should always be present
418                prop_assert!(
419                    context.guidelines.iter().any(|g| g.contains("suggestions")),
420                    "Base guideline about suggestions should always be present"
421                );
422                prop_assert!(
423                    context.guidelines.iter().any(|g| g.contains("approval")),
424                    "Base guideline about approval should always be present"
425                );
426            }
427
428            #[test]
429            fn prop_verbosity_is_deterministic(
430                verbosity_pref in valid_axis_value()
431            ) {
432                let translator = RuleTranslator::default();
433                let snapshot = StateSnapshot::builder()
434                    .user_id("test_user")
435                    .axis("verbosity_preference", verbosity_pref)
436                    .build()
437                    .unwrap();
438
439                let context1 = translator.to_prompt_context(&snapshot);
440                let context2 = translator.to_prompt_context(&snapshot);
441
442                prop_assert_eq!(context1.verbosity, context2.verbosity);
443                prop_assert_eq!(context1.tone, context2.tone);
444                prop_assert_eq!(context1.guidelines.len(), context2.guidelines.len());
445            }
446
447            #[test]
448            fn prop_high_cognitive_load_adds_flag(
449                cognitive_load in 0.71f32..=1.0f32
450            ) {
451                let translator = RuleTranslator::default();
452                let snapshot = StateSnapshot::builder()
453                    .user_id("test_user")
454                    .axis("cognitive_load", cognitive_load)
455                    .build()
456                    .unwrap();
457
458                let context = translator.to_prompt_context(&snapshot);
459
460                prop_assert!(
461                    context.flags.contains(&"high_cognitive_load".to_string()),
462                    "High cognitive load ({}) should add flag", cognitive_load
463                );
464            }
465
466            #[test]
467            fn prop_warm_tone_for_high_warmth(
468                warmth in 0.71f32..=1.0f32
469            ) {
470                let translator = RuleTranslator::default();
471                let snapshot = StateSnapshot::builder()
472                    .user_id("test_user")
473                    .axis("warmth", warmth)
474                    .build()
475                    .unwrap();
476
477                let context = translator.to_prompt_context(&snapshot);
478
479                prop_assert!(
480                    context.tone.contains("warm"),
481                    "High warmth ({}) should produce warm tone, got: {}", warmth, context.tone
482                );
483            }
484
485            #[test]
486            fn prop_custom_thresholds_respected(
487                hi in 0.5f32..=0.9f32,
488                lo in 0.1f32..=0.5f32,
489                value in valid_axis_value(),
490            ) {
491                prop_assume!(hi > lo);
492
493                let translator = RuleTranslator::new(Thresholds { hi, lo });
494                let snapshot = StateSnapshot::builder()
495                    .user_id("test_user")
496                    .axis("cognitive_load", value)
497                    .build()
498                    .unwrap();
499
500                let context = translator.to_prompt_context(&snapshot);
501
502                if value > hi {
503                    prop_assert!(
504                        context.flags.contains(&"high_cognitive_load".to_string()),
505                        "Value {} > threshold {} should trigger flag", value, hi
506                    );
507                }
508            }
509        }
510    }
511}