1use crate::snapshot::StateSnapshot;
36use serde::{Deserialize, Serialize};
37
38#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Verbosity {
42 Low,
44 #[default]
46 Medium,
47 High,
49}
50
51#[derive(Clone, Debug, Default, Serialize, Deserialize)]
56pub struct PromptContext {
57 pub guidelines: Vec<String>,
59
60 pub tone: String,
62
63 pub verbosity: Verbosity,
65
66 pub flags: Vec<String>,
68}
69
70pub trait Translator: Send + Sync {
72 fn to_prompt_context(&self, snapshot: &StateSnapshot) -> PromptContext;
74}
75
76#[derive(Clone, Debug)]
78pub struct Thresholds {
79 pub hi: f32,
81 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#[derive(Clone, Debug, Default)]
96pub struct RuleTranslator {
97 pub thresholds: Thresholds,
99}
100
101impl RuleTranslator {
102 pub fn new(thresholds: Thresholds) -> Self {
104 Self { thresholds }
105 }
106
107 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 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 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 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 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 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 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 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 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 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 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 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 let warmth = get("warmth");
208 let formality = get("formality");
209
210 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 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 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 mod property_tests {
360 use super::*;
361 use crate::axes::CANONICAL_AXES;
362 use proptest::prelude::*;
363
364 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 let context = translator.to_prompt_context(&snapshot);
393
394 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 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}