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 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, -1.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.patience = clamp(self.patience + 0.005 * days, 0.0, 1.0);
self.tension = clamp(self.tension - 0.005 * days, 0.0, 1.0);
}
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;
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 {
let warm_pos = self.warmth.max(0.0);
clamp((warm_pos + self.trust + self.intrigue) / 3.0, 0.0, 1.0)
}
pub fn chemistry_score(&self) -> f64 {
let warm_pos = self.warmth.max(0.0);
clamp((warm_pos + self.intimacy + self.tension) / 3.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: f64,
pub neg_factor: f64,
pub tier_decay: [f64; 5],
pub cross_penalty: f64,
pub cross_penalty_start: f64,
pub delta_threshold: f64,
pub demo_boost: f64,
}
impl Default for AffinityTuning {
fn default() -> Self {
Self {
grade_unit: 0.05,
neg_factor: 1.5,
tier_decay: [1.0, 0.70, 0.45, 0.25, 0.10],
cross_penalty: 0.05,
cross_penalty_start: 0.35,
delta_threshold: 0.0,
demo_boost: 1.4,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AxisGrades {
pub warmth: i8,
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 warmth: f64,
#[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.warmth == 0.0
&& 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 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| {
let start = t.cross_penalty_start;
let ramp = ((counterpart - start).max(0.0) / (1.0 - start)).powi(2);
t.cross_penalty * ramp
};
let raw = |g: i8, rule_d: f64| {
let g = f64::from(g.clamp(-4, 4));
let judge = if g >= 0.0 {
g * t.grade_unit * boost
} else {
g * t.grade_unit * t.neg_factor
};
judge + rule_d
};
let real = |r: f64, own_tier: usize, counterpart: Option<f64>, judged: bool| {
let p = match counterpart {
Some(y) if judged => penalty(y),
_ => 0.0,
};
decay(own_tier) * r.max(0.0) + r.min(0.0) - 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: Option<f64>, pend: f64| {
let r = raw(g, rule_d);
let rho = real(r, own_tier, counterpart, g != 0);
let (committed, pend) = gate(rho, pend);
(r, committed, pend)
};
let max_tier = bond_tier.max(chem_tier);
let (w_raw, w_com, w_pend) = axis(grades.warmth, rule.warmth, max_tier, None, pending.warmth);
let (t_raw, t_com, t_pend) = axis(
grades.trust,
rule.trust,
bond_tier,
Some(chem),
pending.trust,
);
let (ig_raw, ig_com, ig_pend) = axis(
grades.intrigue,
rule.intrigue,
bond_tier,
Some(chem),
pending.intrigue,
);
let (im_raw, im_com, im_pend) = axis(
grades.intimacy,
rule.intimacy,
chem_tier,
Some(bond),
pending.intimacy,
);
let (tn_raw, tn_com, tn_pend) = axis(
grades.tension,
rule.tension,
chem_tier,
Some(bond),
pending.tension,
);
GradeTurnOutcome {
raw: AffinityDeltas {
warmth: w_raw,
trust: t_raw,
intrigue: ig_raw,
intimacy: im_raw,
tension: tn_raw,
patience: rule.patience,
},
committed: AffinityDeltas {
warmth: w_com,
trust: t_com,
intrigue: ig_com,
intimacy: im_com,
tension: tn_com,
patience: rule.patience,
},
pending: PendingDeltas {
warmth: w_pend,
trust: t_pend,
intrigue: ig_pend,
intimacy: im_pend,
tension: tn_pend,
},
}
}
#[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,
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_can_go_negative_others_cannot() {
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, -1.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_recovers_patience_softens_tension() {
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.patience - 0.55).abs() < 1e-9);
assert!((a.tension - 0.45).abs() < 1e-9);
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_and_ceilings() {
let mut a = fresh();
a.intrigue = 0.05;
a.patience = 0.95;
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.patience, 1.0);
assert_eq!(a.tension, 0.0);
}
#[test]
fn bond_chemistry_scores_fold_axes_with_warmth_floored() {
let mut a = fresh();
a.warmth = 0.2;
a.trust = 0.4;
a.intrigue = 0.6;
a.intimacy = 0.1;
a.tension = 0.3;
assert!((a.bond_score() - 0.4).abs() < 1e-9);
assert!((a.chemistry_score() - 0.2).abs() < 1e-9);
a.warmth = -1.0;
a.trust = 0.0;
a.intrigue = 0.0;
assert!((a.bond_score()).abs() < 1e-9);
}
#[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 = 1.0;
a.tension = 1.0; 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.9;
after.intrigue = 0.9; 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 grade_envelope_matches_2_0_effective_caps() {
let a = zeroed();
let o = turn(
&a,
AxisGrades {
warmth: 4,
trust: -4,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((o.raw.warmth - 0.2).abs() < 1e-9);
assert!((o.committed.warmth - 0.2).abs() < 1e-9);
assert!((o.committed.trust - (-0.3)).abs() < 1e-9);
assert_eq!(o.raw.patience, 0.0);
assert_eq!(o.committed.patience, 0.0);
}
#[test]
fn positive_decays_by_own_tier_and_pays_counterpart_ramp() {
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,
&AffinityTuning::default(),
);
assert!((o.committed.trust - 0.045).abs() < 1e-9);
let p = 0.05 * (0.15f64 / 0.65).powi(2);
assert!((o.committed.intimacy - (0.10 - p)).abs() < 1e-9);
}
#[test]
fn warmth_decays_by_max_tier_and_is_penalty_exempt() {
let mut a = zeroed();
a.warmth = 0.85;
a.intimacy = 1.0;
a.tension = 1.0; let o = turn(
&a,
AxisGrades {
warmth: 2,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((o.committed.warmth - 0.1 * 0.10).abs() < 1e-9);
}
#[test]
fn penalty_flips_small_positive_pushes_negative() {
let mut a = zeroed();
a.warmth = 1.0;
a.intimacy = 1.0;
a.tension = 1.0; let g1 = turn(
&a,
AxisGrades {
trust: 1,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((g1.committed.trust - (-0.015)).abs() < 1e-9);
let g2 = turn(
&a,
AxisGrades {
trust: 2,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((g2.committed.trust - 0.02).abs() < 1e-9);
}
#[test]
fn negative_skips_decay_and_pays_extra() {
let mut a = zeroed();
a.warmth = 1.0;
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,
&AffinityTuning::default(),
);
assert!((o.committed.trust - (-0.20)).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.warmth = 1.0;
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 a = zeroed();
let o = turn(
&a,
AxisGrades {
warmth: 1,
trust: -1,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.4,
&AffinityTuning::default(),
);
assert!((o.committed.warmth - 0.07).abs() < 1e-9);
assert!((o.committed.trust - (-0.075)).abs() < 1e-9);
}
#[test]
fn rule_patience_passes_through_ungated() {
let a = zeroed();
let t = AffinityTuning {
delta_threshold: 0.5, ..Default::default()
};
let o = turn(
&a,
AxisGrades::default(),
AffinityDeltas {
patience: -0.02,
..Default::default()
},
PendingDeltas::default(),
1.0,
&t,
);
assert!((o.committed.patience - (-0.02)).abs() < 1e-9);
assert!((o.raw.patience - (-0.02)).abs() < 1e-9);
}
#[test]
fn grades_clamp_to_plus_minus_four() {
let a = zeroed();
let o = turn(
&a,
AxisGrades {
warmth: 9,
trust: -9,
..Default::default()
},
AffinityDeltas::default(),
PendingDeltas::default(),
1.0,
&AffinityTuning::default(),
);
assert!((o.committed.warmth - 0.2).abs() < 1e-9);
assert!((o.committed.trust - (-0.3)).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.9;
after.intrigue = 0.9; after.intimacy = 0.9;
after.tension = 0.9; let d = diff_labels(&before, &after).unwrap();
assert_eq!(d.bond.unwrap().to, "close_friend");
assert_eq!(d.chemistry.unwrap().to, "crush");
}
}