use crate::snapshot::StateSnapshot;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PromptContext {
pub guidelines: Vec<String>,
pub tone: String,
pub verbosity: Verbosity,
pub flags: Vec<String>,
}
pub trait Translator: Send + Sync {
fn to_prompt_context(&self, snapshot: &StateSnapshot) -> PromptContext;
}
#[derive(Clone, Debug)]
pub struct Thresholds {
pub hi: f32,
pub lo: f32,
}
impl Default for Thresholds {
fn default() -> Self {
Self { hi: 0.7, lo: 0.3 }
}
}
#[derive(Clone, Debug, Default)]
pub struct RuleTranslator {
pub thresholds: Thresholds,
}
impl RuleTranslator {
pub fn new(thresholds: Thresholds) -> Self {
Self { thresholds }
}
pub fn with_thresholds(hi: f32, lo: f32) -> Self {
Self {
thresholds: Thresholds { hi, lo },
}
}
}
impl Translator for RuleTranslator {
#[tracing::instrument(skip(self, snapshot), fields(user_id = %snapshot.user_id))]
fn to_prompt_context(&self, snapshot: &StateSnapshot) -> PromptContext {
let get = |k: &str| snapshot.get_axis(k);
let hi = self.thresholds.hi;
let lo = self.thresholds.lo;
let mut guidelines = vec![
"Offer suggestions, not actions".to_string(),
"Drafts require explicit user approval".to_string(),
"Silence is acceptable if no action is required".to_string(),
];
let mut flags = Vec::new();
let cognitive_load = get("cognitive_load");
if cognitive_load > hi {
guidelines.push(
"Keep responses concise; avoid multi-step plans unless requested".to_string(),
);
flags.push("high_cognitive_load".to_string());
}
let decision_fatigue = get("decision_fatigue");
if decision_fatigue > hi {
guidelines.push("Limit choices; present clear recommendations".to_string());
flags.push("high_decision_fatigue".to_string());
}
let urgency = get("urgency_sensitivity");
if urgency > hi {
guidelines.push("Prioritize speed; get to the point quickly".to_string());
flags.push("high_urgency".to_string());
}
let anxiety = get("anxiety_level");
if anxiety > hi {
guidelines.push(
"IMPORTANT: The user may be feeling anxious. Begin responses by acknowledging their feelings \
(e.g., 'I understand this feels overwhelming' or 'It's completely normal to feel uncertain'). \
Use a calm, supportive tone throughout. Avoid adding pressure or urgency."
.to_string(),
);
flags.push("high_anxiety".to_string());
}
let boundary_strength = get("boundary_strength");
if boundary_strength > hi {
guidelines.push("Maintain firm boundaries; do not over-accommodate".to_string());
}
let ritual_need = get("ritual_need");
if ritual_need < lo {
guidelines.push("Avoid ceremonial gestures; keep interactions pragmatic".to_string());
}
let suggestion_tolerance = get("suggestion_tolerance");
if suggestion_tolerance < lo {
guidelines
.push("Only respond to explicit requests; no proactive suggestions".to_string());
}
let interruption_tolerance = get("interruption_tolerance");
if interruption_tolerance < lo {
guidelines
.push("Do not interrupt; wait for user to complete their thought".to_string());
}
let stakes = get("stakes_awareness");
if stakes > hi {
guidelines.push("High stakes context; be careful and thorough".to_string());
flags.push("high_stakes".to_string());
}
let privacy = get("privacy_sensitivity");
if privacy > hi {
guidelines.push("Minimize data collection; respect privacy".to_string());
flags.push("high_privacy_sensitivity".to_string());
}
let warmth = get("warmth");
let formality = get("formality");
if warmth > hi {
guidelines.push(
"Use warm, friendly language. Include encouraging phrases like 'Great question!' \
or 'I'd be happy to help!'. Show enthusiasm and empathy."
.to_string(),
);
} else if warmth < lo {
guidelines.push(
"Keep tone neutral and matter-of-fact. Avoid enthusiastic language, exclamations, \
or excessive friendliness. Be helpful but not effusive."
.to_string(),
);
}
if formality > hi {
guidelines.push(
"Use professional, formal language. Avoid contractions (use 'do not' instead of 'don't'). \
Use complete sentences and proper structure. Address topics with appropriate gravity."
.to_string(),
);
} else if formality < lo {
guidelines.push(
"Use casual, conversational language. Contractions are fine. \
Keep it relaxed and approachable, like talking to a friend."
.to_string(),
);
}
let tone = match (warmth > hi, formality > hi) {
(true, true) => "warm-formal".to_string(),
(true, false) => "warm-casual".to_string(),
(false, true) => "neutral-formal".to_string(),
(false, false) => "calm-neutral".to_string(),
};
let verbosity_pref = get("verbosity_preference");
let verbosity = if verbosity_pref < lo {
guidelines.push(
"Keep responses brief and to the point. Use short paragraphs or bullet points. \
Aim for the minimum words needed to be helpful."
.to_string(),
);
Verbosity::Low
} else if verbosity_pref > hi {
guidelines.push(
"Provide comprehensive, detailed responses. Include context, examples, and thorough explanations. \
Don't leave out relevant information for the sake of brevity."
.to_string(),
);
Verbosity::High
} else {
Verbosity::Medium
};
PromptContext {
guidelines,
tone,
verbosity,
flags,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Source;
fn snapshot_with_axis(axis: &str, value: f32) -> StateSnapshot {
StateSnapshot::builder()
.user_id("test_user")
.source(Source::SelfReport)
.axis(axis, value)
.build()
.unwrap()
}
#[test]
fn test_base_guidelines_always_present() {
let translator = RuleTranslator::default();
let snapshot = StateSnapshot::builder().user_id("test").build().unwrap();
let context = translator.to_prompt_context(&snapshot);
assert!(context
.guidelines
.iter()
.any(|g| g.contains("suggestions, not actions")));
assert!(context
.guidelines
.iter()
.any(|g| g.contains("explicit user approval")));
}
#[test]
fn test_high_cognitive_load() {
let translator = RuleTranslator::default();
let snapshot = snapshot_with_axis("cognitive_load", 0.9);
let context = translator.to_prompt_context(&snapshot);
assert!(context.guidelines.iter().any(|g| g.contains("concise")));
assert!(context.flags.contains(&"high_cognitive_load".to_string()));
}
#[test]
fn test_low_ritual_need() {
let translator = RuleTranslator::default();
let snapshot = snapshot_with_axis("ritual_need", 0.1);
let context = translator.to_prompt_context(&snapshot);
assert!(context.guidelines.iter().any(|g| g.contains("ceremonial")));
}
#[test]
fn test_warm_tone() {
let translator = RuleTranslator::default();
let snapshot = snapshot_with_axis("warmth", 0.9);
let context = translator.to_prompt_context(&snapshot);
assert!(context.tone.starts_with("warm"));
}
#[test]
fn test_verbosity_levels() {
let translator = RuleTranslator::default();
let low = snapshot_with_axis("verbosity_preference", 0.1);
assert_eq!(translator.to_prompt_context(&low).verbosity, Verbosity::Low);
let high = snapshot_with_axis("verbosity_preference", 0.9);
assert_eq!(
translator.to_prompt_context(&high).verbosity,
Verbosity::High
);
let medium = snapshot_with_axis("verbosity_preference", 0.5);
assert_eq!(
translator.to_prompt_context(&medium).verbosity,
Verbosity::Medium
);
}
mod property_tests {
use super::*;
use crate::axes::CANONICAL_AXES;
use proptest::prelude::*;
fn valid_axis_value() -> impl Strategy<Value = f32> {
0.0f32..=1.0f32
}
proptest! {
#[test]
fn prop_translator_never_panics(
cognitive_load in valid_axis_value(),
warmth in valid_axis_value(),
formality in valid_axis_value(),
verbosity_pref in valid_axis_value(),
boundary_strength in valid_axis_value(),
ritual_need in valid_axis_value(),
) {
let translator = RuleTranslator::default();
let snapshot = StateSnapshot::builder()
.user_id("test_user")
.axis("cognitive_load", cognitive_load)
.axis("warmth", warmth)
.axis("formality", formality)
.axis("verbosity_preference", verbosity_pref)
.axis("boundary_strength", boundary_strength)
.axis("ritual_need", ritual_need)
.build()
.unwrap();
let context = translator.to_prompt_context(&snapshot);
prop_assert!(!context.guidelines.is_empty(), "Guidelines should never be empty");
prop_assert!(!context.tone.is_empty(), "Tone should never be empty");
}
#[test]
fn prop_base_guidelines_always_present(
axes in prop::collection::btree_map(
prop::sample::select(CANONICAL_AXES.iter().map(|a| a.name.to_string()).collect::<Vec<_>>()),
valid_axis_value(),
0..10
)
) {
let translator = RuleTranslator::default();
let mut builder = StateSnapshot::builder().user_id("test_user");
for (name, value) in axes {
builder = builder.axis(&name, value);
}
let snapshot = builder.build().unwrap();
let context = translator.to_prompt_context(&snapshot);
prop_assert!(
context.guidelines.iter().any(|g| g.contains("suggestions")),
"Base guideline about suggestions should always be present"
);
prop_assert!(
context.guidelines.iter().any(|g| g.contains("approval")),
"Base guideline about approval should always be present"
);
}
#[test]
fn prop_verbosity_is_deterministic(
verbosity_pref in valid_axis_value()
) {
let translator = RuleTranslator::default();
let snapshot = StateSnapshot::builder()
.user_id("test_user")
.axis("verbosity_preference", verbosity_pref)
.build()
.unwrap();
let context1 = translator.to_prompt_context(&snapshot);
let context2 = translator.to_prompt_context(&snapshot);
prop_assert_eq!(context1.verbosity, context2.verbosity);
prop_assert_eq!(context1.tone, context2.tone);
prop_assert_eq!(context1.guidelines.len(), context2.guidelines.len());
}
#[test]
fn prop_high_cognitive_load_adds_flag(
cognitive_load in 0.71f32..=1.0f32
) {
let translator = RuleTranslator::default();
let snapshot = StateSnapshot::builder()
.user_id("test_user")
.axis("cognitive_load", cognitive_load)
.build()
.unwrap();
let context = translator.to_prompt_context(&snapshot);
prop_assert!(
context.flags.contains(&"high_cognitive_load".to_string()),
"High cognitive load ({}) should add flag", cognitive_load
);
}
#[test]
fn prop_warm_tone_for_high_warmth(
warmth in 0.71f32..=1.0f32
) {
let translator = RuleTranslator::default();
let snapshot = StateSnapshot::builder()
.user_id("test_user")
.axis("warmth", warmth)
.build()
.unwrap();
let context = translator.to_prompt_context(&snapshot);
prop_assert!(
context.tone.contains("warm"),
"High warmth ({}) should produce warm tone, got: {}", warmth, context.tone
);
}
#[test]
fn prop_custom_thresholds_respected(
hi in 0.5f32..=0.9f32,
lo in 0.1f32..=0.5f32,
value in valid_axis_value(),
) {
prop_assume!(hi > lo);
let translator = RuleTranslator::new(Thresholds { hi, lo });
let snapshot = StateSnapshot::builder()
.user_id("test_user")
.axis("cognitive_load", value)
.build()
.unwrap();
let context = translator.to_prompt_context(&snapshot);
if value > hi {
prop_assert!(
context.flags.contains(&"high_cognitive_load".to_string()),
"Value {} > threshold {} should trigger flag", value, hi
);
}
}
}
}
}