use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Affinity {
pub id: Uuid,
pub session_id: Uuid,
pub user_id: Uuid,
pub instance_id: Uuid,
pub warmth: f64, pub trust: f64, pub intrigue: f64, pub intimacy: f64, pub patience: f64, pub tension: f64, pub warmth_grade: i16,
pub patience_grade: i16,
pub ghost_streak: i32,
pub last_ghost_at: Option<DateTime<Utc>>,
pub total_ghosts: i32,
pub relationship_label: Option<RelationshipLabel>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RelationshipLabel {
Stranger,
Romantic,
Friend,
Frenemy,
SlowBurn,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AffinityDeltas {
pub warmth: f64,
pub trust: f64,
pub intrigue: f64,
pub intimacy: f64,
pub patience: f64,
pub tension: f64,
}
impl Affinity {
pub fn apply_deltas(&mut self, d: &AffinityDeltas) {
self.warmth = clamp(self.warmth + d.warmth, 0.0, 1.0);
self.trust = clamp(self.trust + d.trust, 0.0, 1.0);
self.intrigue = clamp(self.intrigue + d.intrigue, 0.0, 1.0);
self.intimacy = clamp(self.intimacy + d.intimacy, 0.0, 1.0);
self.patience = clamp(self.patience + d.patience, 0.0, 1.0);
self.tension = clamp(self.tension + d.tension, 0.0, 1.0);
self.updated_at = Utc::now();
}
pub fn apply_time_decay(&mut self) {
let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
if days <= 0.0 {
return;
}
self.intrigue = clamp(self.intrigue - 0.01 * days, 0.0, 1.0);
self.tension = clamp(self.tension - 0.005 * days, 0.0, 1.0);
}
pub fn refresh_endpoints(&mut self, t: &AffinityTuning) {
let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
let decay = endpoint_time_decay(days, t.time_decay_rate, t.time_decay_floor);
self.warmth = endpoint_value(
self.warmth_grade,
self.chemistry_score(),
decay,
t.floor_ratio,
);
self.patience =
endpoint_value(self.patience_grade, self.bond_score(), decay, t.floor_ratio);
}
pub fn legacy_relationship_label(&self) -> RelationshipLabel {
let bond = self.bond_score();
let chem = self.chemistry_score();
if tier_index(bond) == 1 && tier_index(chem) == 1 {
return RelationshipLabel::Stranger;
}
if chem > bond {
if tier_index(chem) >= 3 {
RelationshipLabel::Romantic
} else {
RelationshipLabel::SlowBurn
}
} else {
RelationshipLabel::Friend
}
}
}
fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
const TIER1_HI: f64 = 0.15;
const TIER2_HI: f64 = 0.35;
const TIER3_HI: f64 = 0.62;
const TIER4_HI: f64 = 0.9;
const INTIMACY_RUNG3_LO: f64 = 0.76;
const _: () = assert!(
TIER3_HI < INTIMACY_RUNG3_LO && INTIMACY_RUNG3_LO < TIER4_HI,
"the top intimacy rung must open inside tier 4, not at the apex"
);
const PATIENCE_LO: f64 = 0.35;
const PATIENCE_HI: f64 = 0.65;
pub const ENDPOINT_BOOST_MAX: f64 = 1.5;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct EndpointLevelReads {
pub warmth: Option<i16>,
pub patience: Option<i16>,
}
fn endpoint_base(level: i16) -> f64 {
f64::from(level.clamp(1, 3) - 1) / 3.0
}
pub fn endpoint_boost(counterpart: f64) -> f64 {
let slope = (ENDPOINT_BOOST_MAX - 1.0) / (1.0 - TIER2_HI);
1.0 + slope * (counterpart - TIER2_HI)
}
pub fn endpoint_time_decay(days: f64, rate: f64, floor: f64) -> f64 {
(1.0 - rate * days.max(0.0)).max(floor)
}
pub fn endpoint_value(level: i16, counterpart: f64, decay: f64, floor_ratio: f64) -> f64 {
let boosted = endpoint_base(level) * endpoint_boost(counterpart);
(boosted.max(floor_ratio * counterpart) * decay).clamp(0.0, 1.0)
}
fn tier_index(score: f64) -> u8 {
if score < TIER1_HI {
1
} else if score < TIER2_HI {
2
} else if score < TIER3_HI {
3
} else if score < TIER4_HI {
4
} else {
5
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BondLabel {
Acquaintance,
Friend,
CloseFriend,
Confidant,
Soulmate,
}
impl BondLabel {
pub fn as_key(self) -> &'static str {
match self {
BondLabel::Acquaintance => "acquaintance",
BondLabel::Friend => "friend",
BondLabel::CloseFriend => "close_friend",
BondLabel::Confidant => "confidant",
BondLabel::Soulmate => "soulmate",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChemistryLabel {
Spark,
Flirtation,
Crush,
Lover,
Beloved,
}
impl ChemistryLabel {
pub fn as_key(self) -> &'static str {
match self {
ChemistryLabel::Spark => "spark",
ChemistryLabel::Flirtation => "flirtation",
ChemistryLabel::Crush => "crush",
ChemistryLabel::Lover => "lover",
ChemistryLabel::Beloved => "beloved",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatienceBand {
Low,
Mid,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LabelTransition {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnLabelChanges {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bond: Option<LabelTransition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chemistry: Option<LabelTransition>,
}
impl TurnLabelChanges {
pub fn is_empty(&self) -> bool {
self.bond.is_none() && self.chemistry.is_none()
}
}
pub fn diff_labels(before: &Affinity, after: &Affinity) -> Option<TurnLabelChanges> {
let bond = (before.bond_label() != after.bond_label()).then(|| LabelTransition {
from: before.bond_label().as_key().to_string(),
to: after.bond_label().as_key().to_string(),
});
let chemistry =
(before.chemistry_label() != after.chemistry_label()).then(|| LabelTransition {
from: before.chemistry_label().as_key().to_string(),
to: after.chemistry_label().as_key().to_string(),
});
let changes = TurnLabelChanges { bond, chemistry };
(!changes.is_empty()).then_some(changes)
}
impl Affinity {
pub fn bond_score(&self) -> f64 {
clamp((self.trust + self.intrigue) / 2.0, 0.0, 1.0)
}
pub fn chemistry_score(&self) -> f64 {
clamp((self.intimacy + self.tension) / 2.0, 0.0, 1.0)
}
pub fn bond_label(&self) -> BondLabel {
match tier_index(self.bond_score()) {
1 => BondLabel::Acquaintance,
2 => BondLabel::Friend,
3 => BondLabel::CloseFriend,
4 => BondLabel::Confidant,
_ => BondLabel::Soulmate,
}
}
pub fn chemistry_label(&self) -> ChemistryLabel {
match tier_index(self.chemistry_score()) {
1 => ChemistryLabel::Spark,
2 => ChemistryLabel::Flirtation,
3 => ChemistryLabel::Crush,
4 => ChemistryLabel::Lover,
_ => ChemistryLabel::Beloved,
}
}
pub fn intimacy_rung(&self) -> u8 {
let s = self.bond_score().max(self.chemistry_score());
if tier_index(s) == 1 {
1
} else if s < INTIMACY_RUNG3_LO {
2
} else {
3
}
}
pub fn patience_band(&self) -> PatienceBand {
if self.patience < PATIENCE_LO {
PatienceBand::Low
} else if self.patience < PATIENCE_HI {
PatienceBand::Mid
} else {
PatienceBand::High
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AffinityTuning {
pub grade_unit_bond: f64,
pub grade_unit_chem: f64,
pub neg_factor: f64,
pub tier_decay: [f64; 5],
pub cross_penalty_ratio: f64,
pub cross_penalty_start: f64,
pub delta_threshold: f64,
pub demo_boost: f64,
pub floor_ratio: f64,
pub time_decay_rate: f64,
pub time_decay_floor: f64,
}
impl Default for AffinityTuning {
fn default() -> Self {
Self {
grade_unit_bond: 0.0786,
grade_unit_chem: 0.0266,
neg_factor: 1.5,
tier_decay: [1.0, 0.70, 0.45, 0.25, 0.10],
cross_penalty_ratio: 5.0 / 6.0,
cross_penalty_start: 0.35,
delta_threshold: 0.0,
demo_boost: 1.4,
floor_ratio: 0.2,
time_decay_rate: 0.02,
time_decay_floor: 0.5,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AxisGrades {
pub trust: i8,
pub intrigue: i8,
pub intimacy: i8,
pub tension: i8,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct PendingDeltas {
#[serde(default)]
pub trust: f64,
#[serde(default)]
pub intrigue: f64,
#[serde(default)]
pub intimacy: f64,
#[serde(default)]
pub tension: f64,
}
impl PendingDeltas {
pub fn is_zero(&self) -> bool {
self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
}
}
#[derive(Debug, Clone, Default)]
pub struct GradeTurnOutcome {
pub raw: AffinityDeltas,
pub committed: AffinityDeltas,
pub pending: PendingDeltas,
pub cross_penalty_assessed: CrossPenaltyAssessed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct CrossPenaltyAssessed {
pub trust: f64,
pub intrigue: f64,
pub intimacy: f64,
pub tension: f64,
}
impl CrossPenaltyAssessed {
pub fn is_zero(&self) -> bool {
self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
}
}
pub fn grade_turn(
a: &Affinity,
grades: &AxisGrades,
rule: &AffinityDeltas,
pending: &PendingDeltas,
boost: f64,
t: &AffinityTuning,
) -> GradeTurnOutcome {
let bond = a.bond_score();
let chem = a.chemistry_score();
let bond_tier = tier_index(bond) as usize;
let chem_tier = tier_index(chem) as usize;
let decay = |tier: usize| t.tier_decay[tier - 1];
let penalty = |counterpart: f64, kappa: f64| {
let start = t.cross_penalty_start;
let ramp = ((counterpart - start).max(0.0) / (1.0 - start)).powi(2);
kappa * ramp
};
let raw = |g: i8, rule_d: f64, unit: f64| {
let g = f64::from(g.clamp(-4, 4));
let judge = if g >= 0.0 {
g * unit * boost
} else {
g * unit * t.neg_factor
};
judge + rule_d
};
let real = |r: f64, own_tier: usize, counterpart: f64, kappa: f64, g: i8| {
let p = penalty(counterpart, kappa) * f64::from(g.saturating_abs().min(4)) / 4.0;
(decay(own_tier) * r.max(0.0) + r.min(0.0) - p, p)
};
let gate = |rho: f64, pend: f64| {
let acc = pend + rho;
if acc.abs() >= t.delta_threshold {
(acc, 0.0)
} else {
(0.0, acc)
}
};
let axis = |g: i8, rule_d: f64, own_tier: usize, counterpart: f64, pend: f64, unit: f64| {
let r = raw(g, rule_d, unit);
let kappa = t.cross_penalty_ratio * unit;
let (rho, charged) = real(r, own_tier, counterpart, kappa, g);
let (committed, pend) = gate(rho, pend);
(r, committed, pend, charged)
};
let (t_raw, t_com, t_pend, t_pen) = axis(
grades.trust,
rule.trust,
bond_tier,
chem,
pending.trust,
t.grade_unit_bond,
);
let (ig_raw, ig_com, ig_pend, ig_pen) = axis(
grades.intrigue,
rule.intrigue,
bond_tier,
chem,
pending.intrigue,
t.grade_unit_bond,
);
let (im_raw, im_com, im_pend, im_pen) = axis(
grades.intimacy,
rule.intimacy,
chem_tier,
bond,
pending.intimacy,
t.grade_unit_chem,
);
let (tn_raw, tn_com, tn_pend, tn_pen) = axis(
grades.tension,
rule.tension,
chem_tier,
bond,
pending.tension,
t.grade_unit_chem,
);
GradeTurnOutcome {
raw: AffinityDeltas {
warmth: 0.0, trust: t_raw,
intrigue: ig_raw,
intimacy: im_raw,
tension: tn_raw,
patience: 0.0,
},
committed: AffinityDeltas {
warmth: 0.0,
trust: t_com,
intrigue: ig_com,
intimacy: im_com,
tension: tn_com,
patience: 0.0,
},
pending: PendingDeltas {
trust: t_pend,
intrigue: ig_pend,
intimacy: im_pend,
tension: tn_pend,
},
cross_penalty_assessed: CrossPenaltyAssessed {
trust: t_pen,
intrigue: ig_pen,
intimacy: im_pen,
tension: tn_pen,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> 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.3,
trust: 0.2,
intrigue: 0.5,
intimacy: 0.0,
patience: 0.5,
tension: 0.1,
warmth_grade: 2,
patience_grade: 2,
ghost_streak: 0,
last_ghost_at: None,
total_ghosts: 0,
relationship_label: None,
created_at: now,
updated_at: now,
}
}
#[test]
fn apply_deltas_clamps_to_valid_ranges() {
let mut a = fresh();
a.apply_deltas(&AffinityDeltas {
warmth: 5.0, trust: -2.0, intrigue: 0.1,
intimacy: 0.0,
patience: 0.0,
tension: 0.0,
});
assert_eq!(a.warmth, 1.0, "warmth clamps to 1.0 (max)");
assert_eq!(a.trust, 0.0, "trust clamps to 0.0 (min)");
assert!((a.intrigue - 0.6).abs() < 1e-9);
}
#[test]
fn warmth_floors_at_zero_like_every_axis() {
let mut a = fresh();
a.apply_deltas(&AffinityDeltas {
warmth: -2.0,
trust: 0.0,
intrigue: 0.0,
intimacy: 0.0,
patience: 0.0,
tension: 0.0,
});
assert_eq!(a.warmth, 0.0);
}
#[test]
fn apply_deltas_is_direct_no_smoothing() {
let mut a = fresh(); a.apply_deltas(&AffinityDeltas {
warmth: 0.15,
..Default::default()
});
assert!((a.warmth - 0.45).abs() < 1e-9);
}
#[test]
fn time_decay_reduces_intrigue_softens_tension_leaves_the_rest() {
let mut a = fresh();
a.intrigue = 0.5;
a.patience = 0.5;
a.tension = 0.5;
a.warmth = 0.7;
a.trust = 0.6;
a.intimacy = 0.4;
a.updated_at = Utc::now() - chrono::Duration::days(10);
a.apply_time_decay();
assert!((a.intrigue - 0.4).abs() < 1e-9);
assert!((a.tension - 0.45).abs() < 1e-9);
assert_eq!(a.patience, 0.5);
assert_eq!(a.warmth, 0.7);
assert_eq!(a.trust, 0.6);
assert_eq!(a.intimacy, 0.4);
}
#[test]
fn time_decay_clamps_at_floors() {
let mut a = fresh();
a.intrigue = 0.05;
a.tension = 0.02;
a.updated_at = Utc::now() - chrono::Duration::days(100);
a.apply_time_decay();
assert_eq!(a.intrigue, 0.0);
assert_eq!(a.tension, 0.0);
}
#[test]
fn tier_index_boundaries() {
assert_eq!(tier_index(0.0), 1);
assert_eq!(tier_index(0.149), 1);
assert_eq!(tier_index(0.15), 2);
assert_eq!(tier_index(0.349), 2);
assert_eq!(tier_index(0.35), 3);
assert_eq!(tier_index(0.619), 3);
assert_eq!(tier_index(0.62), 4);
assert_eq!(tier_index(0.899), 4);
assert_eq!(tier_index(0.9), 5);
assert_eq!(tier_index(1.0), 5);
}
#[test]
fn intimacy_rung_cuts_against_the_tier_ladder() {
let mut a = fresh();
a.warmth = 0.1;
a.trust = 0.0;
a.intrigue = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
assert_eq!(a.bond_label(), BondLabel::Acquaintance);
assert_eq!(a.chemistry_label(), ChemistryLabel::Spark);
assert_eq!(a.intimacy_rung(), 1);
a.warmth = 0.7;
a.trust = 0.7;
a.intrigue = 0.7;
assert_eq!(a.intimacy_rung(), 2);
a.warmth = 0.8;
a.trust = 0.8;
a.intrigue = 0.8;
assert_eq!(a.bond_label(), BondLabel::Confidant);
assert_eq!(a.intimacy_rung(), 3);
}
#[test]
fn intimacy_rung_takes_the_further_line() {
let mut a = fresh();
a.warmth = 0.95;
a.trust = 0.0;
a.intrigue = 0.3;
a.intimacy = 0.95;
a.tension = 0.95;
assert!(a.bond_score() < a.chemistry_score());
assert_eq!(a.intimacy_rung(), 3);
a.trust = 0.95;
a.intrigue = 0.95;
a.intimacy = 0.0;
a.tension = 0.0;
assert!(a.chemistry_score() < a.bond_score());
assert_eq!(a.intimacy_rung(), 3);
}
#[test]
fn intimacy_rung_of_a_seeded_session_is_one() {
let mut a = fresh();
a.warmth = 0.1;
a.trust = 0.0;
a.intrigue = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
assert!(a.bond_score().max(a.chemistry_score()) < TIER1_HI);
assert_eq!(a.intimacy_rung(), 1);
}
#[test]
fn patience_band_boundaries() {
let mut a = fresh();
let mut at = |p: f64| {
a.patience = p;
a.patience_band()
};
assert_eq!(at(0.0), PatienceBand::Low);
assert_eq!(at(0.349), PatienceBand::Low);
assert_eq!(at(0.35), PatienceBand::Mid); assert_eq!(at(0.649), PatienceBand::Mid);
assert_eq!(at(0.65), PatienceBand::High); assert_eq!(at(1.0), PatienceBand::High);
}
#[test]
fn patience_band_is_independent_of_the_composites() {
let mut a = fresh();
a.patience = 0.2;
a.warmth = 1.0;
a.trust = 1.0;
a.intrigue = 1.0;
a.intimacy = 1.0;
a.tension = 1.0;
assert_eq!(a.intimacy_rung(), 3);
assert_eq!(a.patience_band(), PatienceBand::Low);
}
#[test]
fn labels_map_from_scores() {
let mut a = fresh();
a.warmth = 0.0;
a.trust = 0.0;
a.intrigue = 0.0;
assert_eq!(a.bond_label(), BondLabel::Acquaintance); a.trust = 0.6;
a.intrigue = 0.6; assert_eq!(a.bond_label(), BondLabel::CloseFriend);
a.warmth = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
assert_eq!(a.chemistry_label(), ChemistryLabel::Spark); a.intimacy = 0.8;
a.tension = 0.6; assert_eq!(a.chemistry_label(), ChemistryLabel::Lover);
a.warmth = 1.0;
a.trust = 1.0;
a.intrigue = 1.0; assert_eq!(a.bond_label(), BondLabel::Soulmate);
a.intimacy = 1.0;
a.tension = 1.0; assert_eq!(a.chemistry_label(), ChemistryLabel::Beloved);
assert_eq!(BondLabel::Soulmate.as_key(), "soulmate");
assert_eq!(ChemistryLabel::Beloved.as_key(), "beloved");
}
#[test]
fn legacy_label_stranger_when_both_tier1() {
let mut a = fresh();
a.warmth = 0.0;
a.trust = 0.0;
a.intrigue = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Stranger);
}
#[test]
fn legacy_label_friend_when_bond_leads() {
let mut a = fresh();
a.warmth = 0.3;
a.trust = 0.6;
a.intrigue = 0.6;
a.intimacy = 0.0;
a.tension = 0.0;
assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Friend);
}
#[test]
fn legacy_label_romantic_when_chemistry_high() {
let mut a = fresh();
a.warmth = 0.3;
a.intimacy = 0.9;
a.tension = 0.9;
a.trust = 0.0;
a.intrigue = 0.0;
assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Romantic);
}
#[test]
fn legacy_label_slow_burn_when_chemistry_leads_but_mid() {
let mut a = fresh();
a.warmth = 0.3;
a.intimacy = 0.3;
a.tension = 0.2;
a.trust = 0.0;
a.intrigue = 0.0;
assert_eq!(a.legacy_relationship_label(), RelationshipLabel::SlowBurn);
}
#[test]
fn diff_labels_none_when_no_tier_change() {
let a = fresh();
let b = a.clone();
assert!(diff_labels(&a, &b).is_none());
}
#[test]
fn diff_labels_reports_single_line_change() {
let mut before = fresh();
before.warmth = 0.0;
before.trust = 0.0;
before.intrigue = 0.0;
before.intimacy = 0.0;
before.tension = 0.0; let mut after = before.clone();
after.trust = 0.6;
after.intrigue = 0.6; let d = diff_labels(&before, &after).unwrap();
let bond = d.bond.unwrap();
assert_eq!(bond.from, "acquaintance");
assert_eq!(bond.to, "close_friend");
assert!(d.chemistry.is_none());
}
fn zeroed() -> Affinity {
let mut a = fresh();
a.warmth = 0.0;
a.trust = 0.0;
a.intrigue = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
a
}
fn turn(
a: &Affinity,
grades: AxisGrades,
rule: AffinityDeltas,
pending: PendingDeltas,
boost: f64,
t: &AffinityTuning,
) -> GradeTurnOutcome {
grade_turn(a, &grades, &rule, &pending, boost, t)
}
#[test]
fn positive_decays_by_own_tier_and_pays_counterpart_ramp() {
let t = AffinityTuning::default();
let mut a = zeroed();
a.trust = 0.75;
a.intrigue = 0.75; let o = turn(
&a,
AxisGrades {
trust: 2,
intimacy: 2,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&t,
);
assert!((o.committed.trust - 2.0 * t.grade_unit_bond * 0.25).abs() < 1e-9);
let p = t.cross_penalty_ratio * t.grade_unit_chem * (0.40f64 / 0.65).powi(2) * 2.0 / 4.0;
assert!((o.committed.intimacy - (2.0 * t.grade_unit_chem - p)).abs() < 1e-9);
assert!((o.cross_penalty_assessed.intimacy - p).abs() < 1e-9);
assert_eq!(o.cross_penalty_assessed.trust, 0.0, "counterpart below y₀");
}
#[test]
fn penalty_scales_with_the_grade_so_the_outcome_cannot_flip_between_grades() {
let mut a = zeroed();
a.intimacy = 1.0;
a.tension = 1.0; let t = AffinityTuning::default();
let at = |g: i8| {
turn(
&a,
AxisGrades {
trust: g,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&t,
)
};
let unit_net = t.grade_unit_bond * (1.0 - t.cross_penalty_ratio / 4.0);
assert!((at(1).committed.trust - unit_net).abs() < 1e-9);
assert!((at(2).committed.trust - 2.0 * unit_net).abs() < 1e-9);
assert!((at(4).committed.trust - 4.0 * unit_net).abs() < 1e-9);
for g in 1..=4 {
assert!(
at(g).committed.trust > 0.0,
"a positive verdict must not lower the score here (g{g})"
);
}
}
#[test]
fn tier_five_against_a_high_counterpart_still_loses_at_every_grade() {
let t = AffinityTuning::default();
let mut a = zeroed();
a.trust = 1.0;
a.intrigue = 1.0; a.intimacy = 1.0;
a.tension = 1.0; let unit_net = t.grade_unit_chem * (0.10 - t.cross_penalty_ratio / 4.0);
assert!(unit_net < 0.0);
for g in 1..=4 {
let o = turn(
&a,
AxisGrades {
intimacy: g,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&t,
);
assert!(
(o.committed.intimacy - f64::from(g) * unit_net).abs() < 1e-9,
"g{g}"
);
assert!(o.committed.intimacy < 0.0, "g{g}");
}
}
#[test]
fn negative_skips_decay_and_pays_extra() {
let t = AffinityTuning::default();
let mut a = zeroed();
a.intimacy = 1.0;
a.tension = 1.0; a.trust = 0.9;
a.intrigue = 0.9; let o = turn(
&a,
AxisGrades {
trust: -2,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&t,
);
let p = t.cross_penalty_ratio * t.grade_unit_bond * 2.0 / 4.0;
let expect = -2.0 * t.grade_unit_bond * t.neg_factor - p;
assert!((o.committed.trust - expect).abs() < 1e-9);
assert!((o.cross_penalty_assessed.trust - p).abs() < 1e-9);
}
#[test]
fn zero_verdict_charges_nothing() {
let mut a = zeroed();
a.warmth = 1.0;
a.trust = 1.0;
a.intrigue = 1.0;
a.intimacy = 1.0;
a.tension = 1.0;
let pending = PendingDeltas {
trust: 0.02,
..Default::default()
};
let t = AffinityTuning {
delta_threshold: 0.5,
..Default::default()
};
let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas::default(),
pending,
1.0,
&t,
);
assert_eq!(o.committed.trust, 0.0);
assert_eq!(o.committed.warmth, 0.0);
assert!((o.pending.trust - 0.02).abs() < 1e-9);
}
#[test]
fn rule_only_delta_decays_but_pays_no_penalty() {
let mut a = zeroed();
a.trust = 0.2;
a.intrigue = 0.3; a.intimacy = 1.0;
a.tension = 1.0; let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas {
intrigue: 0.02,
..Default::default()
},
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((o.committed.intrigue - 0.7 * 0.02).abs() < 1e-9);
}
#[test]
fn threshold_accumulates_until_it_clears() {
let a = zeroed(); let t = AffinityTuning {
delta_threshold: 0.5,
..Default::default()
};
let mut pending = PendingDeltas::default();
let mut committed = Vec::new();
for r in [0.1, 0.2, 0.3] {
let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas {
trust: r,
..Default::default()
},
pending,
1.0,
&t,
);
committed.push(o.committed.trust);
pending = o.pending;
}
assert_eq!(committed[0], 0.0);
assert_eq!(committed[1], 0.0);
assert!((committed[2] - 0.6).abs() < 1e-9);
assert!(pending.is_zero());
}
#[test]
fn threshold_gate_cancels_opposite_signs() {
let a = zeroed();
let t = AffinityTuning {
delta_threshold: 0.5,
..Default::default()
};
let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas {
trust: -0.1,
..Default::default()
},
PendingDeltas {
trust: 0.1,
..Default::default()
},
1.0,
&t,
);
assert_eq!(o.committed.trust, 0.0);
assert_eq!(o.pending.trust, 0.0, "+0.1 pending − 0.1 real cancels out");
}
#[test]
fn demo_boost_is_positive_only() {
let t = AffinityTuning::default();
let a = zeroed();
let o = turn(
&a,
AxisGrades {
trust: 1,
intimacy: -1,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.4,
&t,
);
assert!((o.committed.trust - 1.4 * t.grade_unit_bond).abs() < 1e-9);
assert!((o.committed.intimacy - (-t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
}
#[test]
fn endpoint_fields_are_inert_in_the_pipeline() {
let a = zeroed();
let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas {
warmth: 0.5,
patience: -0.02,
..Default::default()
},
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert_eq!(o.raw.warmth, 0.0);
assert_eq!(o.committed.warmth, 0.0);
assert_eq!(o.raw.patience, 0.0);
assert_eq!(o.committed.patience, 0.0);
}
#[test]
fn grades_clamp_to_plus_minus_four() {
let t = AffinityTuning::default();
let a = zeroed();
let o = turn(
&a,
AxisGrades {
trust: 9,
intimacy: -9,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&t,
);
assert!((o.committed.trust - 4.0 * t.grade_unit_bond).abs() < 1e-9);
assert!((o.committed.intimacy - (-4.0 * t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
}
#[test]
fn diff_labels_reports_both_lines() {
let mut before = fresh();
before.warmth = 0.0;
before.trust = 0.0;
before.intrigue = 0.0;
before.intimacy = 0.0;
before.tension = 0.0;
let mut after = before.clone();
after.trust = 0.6;
after.intrigue = 0.6; after.intimacy = 0.5;
after.tension = 0.5; let d = diff_labels(&before, &after).unwrap();
assert_eq!(d.bond.unwrap().to, "close_friend");
assert_eq!(d.chemistry.unwrap().to, "crush");
}
#[test]
fn endpoint_boost_anchors() {
assert!((endpoint_boost(0.35) - 1.0).abs() < 1e-12);
assert!((endpoint_boost(1.0) - 1.5).abs() < 1e-12);
assert!((endpoint_boost(0.0) - (1.0 - 0.35 * 10.0 / 13.0)).abs() < 1e-12);
}
#[test]
fn endpoint_value_exact_ceiling_and_ranges() {
assert!((endpoint_value(3, 1.0, 1.0, 0.2) - 1.0).abs() < 1e-9);
assert!(
(endpoint_value(2, 0.0, 1.0, 0.2) - (1.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
< 1e-9
);
assert!((endpoint_value(2, 1.0, 1.0, 0.2) - 0.5).abs() < 1e-9);
assert!(
(endpoint_value(3, 0.0, 1.0, 0.2) - (2.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
< 1e-9
);
}
#[test]
fn endpoint_floor_only_acts_on_level_one() {
assert!((endpoint_value(1, 0.9, 1.0, 0.2) - 0.18).abs() < 1e-9);
assert!((endpoint_value(1, 0.0, 1.0, 0.2) - 0.0).abs() < 1e-9);
for x in [0.0, 0.35, 0.7, 1.0] {
let with_floor = endpoint_value(2, x, 1.0, 0.2);
let without = endpoint_value(2, x, 1.0, 0.0);
assert!(
(with_floor - without).abs() < 1e-12,
"floor must not touch level 2 at x={x}"
);
}
}
#[test]
fn endpoint_level_out_of_range_clamps() {
assert_eq!(
endpoint_value(0, 0.5, 1.0, 0.2),
endpoint_value(1, 0.5, 1.0, 0.2)
);
assert_eq!(
endpoint_value(7, 0.5, 1.0, 0.2),
endpoint_value(3, 0.5, 1.0, 0.2)
);
}
#[test]
fn composites_are_two_axis_means() {
let mut a = fresh();
a.warmth = 1.0; a.trust = 0.4;
a.intrigue = 0.6;
a.intimacy = 0.3;
a.tension = 0.2;
assert!((a.bond_score() - 0.5).abs() < 1e-9);
assert!((a.chemistry_score() - 0.25).abs() < 1e-9);
}
#[test]
fn refresh_endpoints_tsundere_quadrant() {
let t = AffinityTuning::default();
let mut a = fresh();
a.trust = 0.1;
a.intrigue = 0.1; a.intimacy = 0.9;
a.tension = 0.9; a.warmth_grade = 3;
a.patience_grade = 3;
a.updated_at = Utc::now(); a.refresh_endpoints(&t);
assert!((a.warmth - (2.0 / 3.0) * (1.0 + (10.0 / 13.0) * 0.55)).abs() < 1e-6);
assert!((a.patience - (2.0 / 3.0) * (1.0 - (10.0 / 13.0) * 0.25)).abs() < 1e-6);
assert!(a.warmth > a.patience, "tsundere: warm but impatient");
}
#[test]
fn time_decay_no_longer_drifts_patience_up() {
let mut a = fresh();
a.patience = 0.4;
a.updated_at = Utc::now() - chrono::Duration::days(10);
a.apply_time_decay();
assert!(
(a.patience - 0.4).abs() < 1e-12,
"patience drift retired; endpoint decay owns absence now"
);
}
#[test]
fn per_line_units_and_ratio_kappa() {
let t = AffinityTuning::default();
let mut a = fresh();
a.warmth = 0.0;
a.trust = 0.0;
a.intrigue = 0.0;
a.intimacy = 0.0;
a.tension = 0.0;
let g = AxisGrades {
trust: 2,
intrigue: 0,
intimacy: 2,
tension: 0,
};
let out = grade_turn(
&a,
&g,
&AffinityDeltas::default(),
&PendingDeltas::default(),
1.0,
&t,
);
assert!((out.committed.trust - 2.0 * t.grade_unit_bond).abs() < 1e-9);
assert!((out.committed.intimacy - 2.0 * t.grade_unit_chem).abs() < 1e-9);
assert_eq!(out.committed.warmth, 0.0);
assert_eq!(out.committed.patience, 0.0);
}
#[test]
fn break_even_position_is_unit_invariant() {
let mut t1 = AffinityTuning::default();
let mut t2 = AffinityTuning::default();
t1.grade_unit_chem = 0.0266;
t2.grade_unit_chem = 0.10;
let y_star = |t: &AffinityTuning| {
let mut a = fresh();
a.warmth = 0.0;
a.intimacy = 1.0;
a.tension = 0.9; (0..=1000).map(|i| f64::from(i) / 1000.0).find(|&y| {
let mut b = a.clone();
b.trust = y;
b.intrigue = y; let g = AxisGrades {
trust: 0,
intrigue: 0,
intimacy: 1,
tension: 0,
};
let out = grade_turn(
&b,
&g,
&AffinityDeltas::default(),
&PendingDeltas::default(),
1.0,
t,
);
out.committed.intimacy < 0.0
})
};
let y1 = y_star(&t1);
assert!(y1.is_some(), "a break-even must exist at tier 5");
assert_eq!(
y1,
y_star(&t2),
"κ tied to unit ⇒ wall does not move with the unit"
);
}
#[test]
fn endpoint_time_decay_linear_with_floor() {
assert!((endpoint_time_decay(0.0, 0.02, 0.5) - 1.0).abs() < 1e-12);
assert!((endpoint_time_decay(7.0, 0.02, 0.5) - 0.86).abs() < 1e-12);
assert!((endpoint_time_decay(25.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
assert!((endpoint_time_decay(60.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
assert!((endpoint_time_decay(-3.0, 0.02, 0.5) - 1.0).abs() < 1e-12); }
}