use crate::affinity::AffinityDeltas;
use crate::ghost::{self, GhostDecision, GhostSignals};
use crate::types::{ActionPlan, ActionType, DecisionInput, Event, ImageRef, ReplyStyle};
const LONG_MSG_CHARS: usize = 30;
const SHORT_MSG_CHARS: usize = 3;
const STALE_HOURS: f64 = 24.0;
const INTRIGUE_LONG_BUMP: f64 = 0.02;
const PATIENCE_LONG_BUMP: f64 = 0.02;
const PATIENCE_SHORT_PENALTY: f64 = -0.02;
const PATIENCE_STALE_PENALTY: f64 = -0.05;
const TENSION_STALE_BUMP: f64 = 0.03;
const ENERGY_COST_REPLY: f64 = 0.05;
const ENERGY_COST_PROACTIVE: f64 = 0.10;
const ENERGY_COST_GHOST: f64 = 0.0;
const ENERGY_COST_APP_OPEN: f64 = 0.0;
const GHOST_DELTA_PATIENCE: f64 = -0.05;
const GHOST_DELTA_TENSION: f64 = 0.05;
pub fn decide(input: &DecisionInput) -> ActionPlan {
if let Event::UserMessage {
tips_amount_usd: Some(_),
..
} = &input.event
{
let reply_style = match input.persona.genome.tip_personality.as_deref() {
Some(_) => ReplyStyle::Neutral,
None => ReplyStyle::Tsundere,
};
return ActionPlan {
action_type: ActionType::ReplyText,
reply_style,
affinity_deltas: predict_reply_deltas(input),
energy_cost: ENERGY_COST_REPLY,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
};
}
let ghost_signals = GhostSignals {
message_count: input.signals.message_count,
hours_since_last_ghost: input.signals.hours_since_last_ghost,
};
if ghost::decide(&input.affinity, ghost_signals) == GhostDecision::Ghost {
return ActionPlan {
action_type: ActionType::Ghost,
reply_style: ReplyStyle::Cold,
affinity_deltas: ghost_affinity_deltas(),
energy_cost: ENERGY_COST_GHOST,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
};
}
if matches!(input.event, Event::ProactiveTrigger) {
return ActionPlan {
action_type: ActionType::Proactive,
reply_style: ReplyStyle::Neutral,
affinity_deltas: AffinityDeltas::default(),
energy_cost: ENERGY_COST_PROACTIVE,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
};
}
if matches!(input.event, Event::AppOpen) {
return ActionPlan {
action_type: ActionType::Proactive,
reply_style: ReplyStyle::Neutral,
affinity_deltas: AffinityDeltas::default(),
energy_cost: ENERGY_COST_APP_OPEN,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
};
}
ActionPlan {
action_type: ActionType::ReplyText,
reply_style: ReplyStyle::Neutral,
affinity_deltas: predict_reply_deltas(input),
energy_cost: ENERGY_COST_REPLY,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
}
}
pub fn plan_for(
input: &DecisionInput,
action: ActionType,
hints: Vec<String>,
reply_tone: Option<String>,
image_ref: ImageRef,
aspect_ratio: Option<String>,
) -> ActionPlan {
match action {
ActionType::ReplyText | ActionType::ReplyImage | ActionType::ReplyTextImage => ActionPlan {
action_type: action,
reply_style: ReplyStyle::Neutral,
affinity_deltas: predict_reply_deltas(input),
energy_cost: ENERGY_COST_REPLY,
context_hints: hints,
reply_tone: if matches!(action, ActionType::ReplyImage) {
None
} else {
reply_tone
},
image_caption: None,
image_ref,
aspect_ratio,
},
ActionType::Ghost => ActionPlan {
action_type: ActionType::Ghost,
reply_style: ReplyStyle::Cold,
affinity_deltas: ghost_affinity_deltas(),
energy_cost: ENERGY_COST_GHOST,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
},
ActionType::ProductQa => ActionPlan {
action_type: ActionType::ProductQa,
reply_style: ReplyStyle::Neutral,
affinity_deltas: AffinityDeltas::default(),
energy_cost: 0.0,
context_hints: vec![],
reply_tone: None,
image_caption: None,
image_ref: ImageRef::Face,
aspect_ratio: None,
},
ActionType::Proactive => {
unreachable!("plan_for is never called with Proactive; it comes only from pde::decide")
}
}
}
fn predict_reply_deltas(input: &DecisionInput) -> AffinityDeltas {
let mut d = AffinityDeltas::default();
if let Event::UserMessage { content, .. } = &input.event {
let chars = content.chars().count();
if chars >= LONG_MSG_CHARS {
d.intrigue += INTRIGUE_LONG_BUMP;
d.patience += PATIENCE_LONG_BUMP;
}
if chars <= SHORT_MSG_CHARS {
d.patience += PATIENCE_SHORT_PENALTY;
}
}
if input.signals.hours_since_last_message > STALE_HOURS {
d.patience += PATIENCE_STALE_PENALTY;
d.tension += TENSION_STALE_BUMP;
}
d
}
fn ghost_affinity_deltas() -> AffinityDeltas {
AffinityDeltas {
patience: GHOST_DELTA_PATIENCE,
tension: GHOST_DELTA_TENSION,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::affinity::Affinity;
use crate::persona::{CompanionPersona, PersonaGenome, PersonaInstance};
use crate::types::ConversationSignals;
use chrono::Utc;
use uuid::Uuid;
fn base_persona() -> CompanionPersona {
let iid = Uuid::new_v4();
let gid = Uuid::new_v4();
let oid = Uuid::new_v4();
CompanionPersona {
instance_id: iid,
genome: PersonaGenome {
id: gid,
name: "Mia".into(),
system_prompt: "You are Mia.".into(),
tip_personality: Some("normal".into()),
art_metadata: serde_json::json!({}),
},
instance: PersonaInstance {
id: iid,
genome_id: gid,
owner_uid: oid,
status: "active".into(),
},
}
}
fn base_affinity() -> Affinity {
let now = Utc::now();
Affinity {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
user_id: Uuid::new_v4(),
instance_id: Uuid::new_v4(),
warmth: 0.4,
trust: 0.3,
intrigue: 0.5,
intimacy: 0.2,
patience: 0.5,
tension: 0.1,
ghost_streak: 0,
last_ghost_at: None,
total_ghosts: 0,
relationship_label: None,
created_at: now,
updated_at: now,
}
}
fn base_signals() -> ConversationSignals {
ConversationSignals {
message_count: 20,
hours_since_last_message: 1.0,
ghost_streak: 0,
hours_since_last_ghost: Some(10.0),
}
}
fn user_msg(content: &str) -> Event {
Event::UserMessage {
content: content.into(),
message_id: Uuid::new_v4(),
prompt_traits: Vec::new(),
audit: None,
tier: None,
memory_scope: Default::default(),
affinity_scope: Default::default(),
tips_amount_usd: None,
history_anchor: Default::default(),
}
}
fn tip_msg(amount: f64) -> Event {
Event::UserMessage {
content: String::new(),
message_id: Uuid::new_v4(),
prompt_traits: Vec::new(),
audit: None,
tier: None,
memory_scope: Default::default(),
affinity_scope: Default::default(),
tips_amount_usd: Some(amount),
history_anchor: Default::default(),
}
}
fn persona_with_tip(tip: Option<&str>) -> CompanionPersona {
let mut p = base_persona();
p.genome.tip_personality = tip.map(String::from);
p
}
#[test]
fn test_tip_forces_reply_even_when_ghost_signals_present() {
let mut affinity = base_affinity();
affinity.intrigue = 0.05;
affinity.patience = 0.05;
affinity.tension = 0.5;
let input = DecisionInput {
event: tip_msg(20.0),
affinity,
persona: persona_with_tip(None),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(
plan.action_type,
ActionType::ReplyText,
"a tip must never be ghosted"
);
}
#[test]
fn test_tip_reply_style_neutral_when_personality_present() {
let input = DecisionInput {
event: tip_msg(20.0),
affinity: base_affinity(),
persona: persona_with_tip(Some("傲娇")),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::ReplyText);
assert_eq!(plan.reply_style, ReplyStyle::Neutral);
}
#[test]
fn test_tip_reply_style_tsundere_when_personality_absent() {
let input = DecisionInput {
event: tip_msg(20.0),
affinity: base_affinity(),
persona: persona_with_tip(None),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::ReplyText);
assert_eq!(plan.reply_style, ReplyStyle::Tsundere);
}
#[test]
fn test_ghost_threshold_triggers_ghost_action() {
let mut affinity = base_affinity();
affinity.intrigue = 0.05;
affinity.patience = 0.05;
affinity.tension = 0.5;
let input = DecisionInput {
event: user_msg("."),
affinity,
persona: base_persona(),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::Ghost);
}
#[test]
fn test_new_relationship_never_ghosts() {
let mut affinity = base_affinity();
affinity.intrigue = 0.05;
affinity.patience = 0.05;
affinity.tension = 0.5;
let mut signals = base_signals();
signals.message_count = 3;
let input = DecisionInput {
event: user_msg("hello"),
affinity,
persona: base_persona(),
signals,
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::ReplyText);
}
#[test]
fn test_long_message_predicts_positive_intrigue() {
let input = DecisionInput {
event: user_msg(&"deep content".repeat(10)),
affinity: base_affinity(),
persona: base_persona(),
signals: base_signals(),
};
let plan = decide(&input);
assert!(plan.affinity_deltas.intrigue > 0.0);
}
#[test]
fn test_long_absence_penalises_patience() {
let mut signals = base_signals();
signals.hours_since_last_message = 48.0;
let input = DecisionInput {
event: user_msg("hey"),
affinity: base_affinity(),
persona: base_persona(),
signals,
};
let plan = decide(&input);
assert!(plan.affinity_deltas.patience < 0.0);
}
#[test]
fn test_app_open_does_not_reply_and_has_zero_cost() {
let input = DecisionInput {
event: Event::AppOpen,
affinity: base_affinity(),
persona: base_persona(),
signals: base_signals(),
};
let plan = decide(&input);
assert_eq!(plan.action_type, ActionType::Proactive);
assert_eq!(plan.energy_cost, 0.0);
}
#[test]
fn test_short_msg_and_stale_both_apply_to_patience() {
let mut signals = base_signals();
signals.hours_since_last_message = 48.0;
let input = DecisionInput {
event: user_msg("k"),
affinity: base_affinity(),
persona: base_persona(),
signals,
};
let plan = decide(&input);
assert!((plan.affinity_deltas.patience - (-0.07)).abs() < 1e-9);
}
fn test_decision_input() -> DecisionInput {
DecisionInput {
event: user_msg("hello"),
affinity: base_affinity(),
persona: base_persona(),
signals: base_signals(),
}
}
#[test]
fn plan_for_reply_text_is_neutral_with_hints() {
let input = test_decision_input();
let plan = plan_for(
&input,
ActionType::ReplyText,
vec!["有点开心".into()],
None,
ImageRef::Face,
None,
);
assert_eq!(plan.action_type, ActionType::ReplyText);
assert_eq!(plan.reply_style, ReplyStyle::Neutral);
assert_eq!(plan.context_hints, vec!["有点开心".to_string()]);
assert_eq!(plan.energy_cost, ENERGY_COST_REPLY);
}
#[test]
fn plan_for_ghost_is_cold_and_drops_hints() {
let input = test_decision_input();
let plan = plan_for(
&input,
ActionType::Ghost,
vec!["想躲".into()],
None,
ImageRef::Face,
None,
);
assert_eq!(plan.action_type, ActionType::Ghost);
assert_eq!(plan.reply_style, ReplyStyle::Cold);
assert!(plan.context_hints.is_empty());
assert_eq!(plan.energy_cost, ENERGY_COST_GHOST);
}
#[test]
fn plan_for_threads_image_prompt() {
let input = test_decision_input();
let plan = plan_for(
&input,
ActionType::ReplyTextImage,
vec![],
None,
ImageRef::Face,
None,
);
assert_eq!(plan.action_type, ActionType::ReplyTextImage);
assert_eq!(
plan.image_caption, None,
"no caption exists at decision time — the composer has not run"
);
let ghost = plan_for(
&input,
ActionType::Ghost,
vec![],
None,
ImageRef::Face,
None,
);
assert_eq!(ghost.image_caption, None, "ghost carries no image prompt");
}
#[test]
fn plan_for_threads_image_ref_and_aspect() {
let input = test_decision_input(); let plan = plan_for(
&input,
ActionType::ReplyImage,
vec![],
None,
ImageRef::Previous,
Some("9:16".into()),
);
assert_eq!(plan.image_ref, ImageRef::Previous);
assert_eq!(plan.aspect_ratio.as_deref(), Some("9:16"));
let ghost = plan_for(
&input,
ActionType::Ghost,
vec![],
None,
ImageRef::Previous,
Some("9:16".into()),
);
assert_eq!(ghost.image_ref, ImageRef::Face);
assert_eq!(ghost.aspect_ratio, None);
}
#[test]
fn plan_for_keeps_tone_for_text_bearing_drops_for_image_and_ghost() {
let input = test_decision_input(); let tone = Some("语气敷衍一点".to_string());
let text = plan_for(
&input,
ActionType::ReplyText,
vec![],
tone.clone(),
ImageRef::Face,
None,
);
assert_eq!(text.reply_tone.as_deref(), Some("语气敷衍一点"));
let text_image = plan_for(
&input,
ActionType::ReplyTextImage,
vec![],
tone.clone(),
ImageRef::Face,
None,
);
assert_eq!(text_image.reply_tone.as_deref(), Some("语气敷衍一点"));
let image_only = plan_for(
&input,
ActionType::ReplyImage,
vec![],
tone.clone(),
ImageRef::Face,
None,
);
assert_eq!(
image_only.reply_tone, None,
"reply_image has no text to tone"
);
let ghost = plan_for(
&input,
ActionType::Ghost,
vec![],
tone,
ImageRef::Face,
None,
);
assert_eq!(
ghost.reply_tone, None,
"ghost discards tone like it discards hints"
);
assert_eq!(decide(&input).reply_tone, None);
}
#[test]
fn plan_for_product_qa_is_inert_out_of_character_plan() {
let input = test_decision_input(); let plan = plan_for(
&input,
ActionType::ProductQa,
vec!["ignored".into()],
Some("ignored".into()),
ImageRef::Face,
None,
);
assert_eq!(plan.action_type, ActionType::ProductQa);
assert_eq!(plan.reply_style, ReplyStyle::Neutral);
assert_eq!(plan.affinity_deltas.warmth, 0.0);
assert_eq!(plan.affinity_deltas.trust, 0.0);
assert_eq!(plan.affinity_deltas.intrigue, 0.0);
assert_eq!(plan.affinity_deltas.intimacy, 0.0);
assert_eq!(plan.affinity_deltas.patience, 0.0);
assert_eq!(plan.affinity_deltas.tension, 0.0);
assert_eq!(plan.energy_cost, 0.0);
assert!(plan.context_hints.is_empty()); assert!(plan.reply_tone.is_none());
assert!(!ActionType::ProductQa.is_text_reply());
}
}