use crate::enums::{HexacoPath, Species};
use crate::types::Duration;
use serde::{Deserialize, Serialize};
pub const MAX_SINGLE_EVENT_SHIFT: f32 = 0.30;
pub const SEVERE_SHIFT_THRESHOLD: f32 = 0.20;
pub const SEVERE_SHIFT_RETENTION: f32 = 0.70;
pub const SETTLING_DAYS: u32 = 180;
pub const SATURATION_CONSTANT: f32 = 0.50;
pub const CUMULATIVE_CAP: f32 = 1.0;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BaseShiftRecord {
timestamp: Duration,
trait_path: HexacoPath,
immediate: f32,
settled: f32,
settling_days: u32,
}
impl BaseShiftRecord {
#[must_use]
pub fn new(timestamp: Duration, trait_path: HexacoPath, shift_amount: f32) -> Self {
let abs_shift = shift_amount.abs();
let is_severe = abs_shift > SEVERE_SHIFT_THRESHOLD;
let (settled, settling_days) = if is_severe {
(shift_amount * SEVERE_SHIFT_RETENTION, SETTLING_DAYS)
} else {
(shift_amount, 0)
};
BaseShiftRecord {
timestamp,
trait_path,
immediate: shift_amount,
settled,
settling_days,
}
}
#[must_use]
pub fn timestamp(&self) -> Duration {
self.timestamp
}
#[must_use]
pub fn trait_path(&self) -> HexacoPath {
self.trait_path
}
#[must_use]
pub fn immediate(&self) -> f32 {
self.immediate
}
#[must_use]
pub fn settled(&self) -> f32 {
self.settled
}
#[must_use]
pub fn settling_days(&self) -> u32 {
self.settling_days
}
#[must_use]
pub fn is_severe(&self) -> bool {
self.settling_days > 0
}
#[must_use]
pub fn contribution_at(&self, query_timestamp: Duration) -> f32 {
if query_timestamp < self.timestamp {
return 0.0;
}
if self.settling_days == 0 {
return self.immediate;
}
let days_since = (query_timestamp - self.timestamp).as_days();
let settling_days_u64 = u64::from(self.settling_days);
if days_since >= settling_days_u64 {
return self.settled;
}
let progress = days_since as f32 / self.settling_days as f32;
let change = self.immediate - self.settled;
self.immediate - (change * progress)
}
}
#[must_use]
pub fn stability_coefficient(trait_path: HexacoPath) -> f32 {
match trait_path {
HexacoPath::Extraversion => 0.85,
HexacoPath::Openness => 0.80,
HexacoPath::HonestyHumility => 0.75,
HexacoPath::Conscientiousness => 0.70,
HexacoPath::Agreeableness => 0.65,
HexacoPath::Neuroticism => 0.60,
}
}
#[must_use]
pub fn trait_modifier(trait_path: HexacoPath) -> f32 {
1.0 - stability_coefficient(trait_path)
}
#[must_use]
pub fn age_plasticity(age_years: u16) -> f32 {
match age_years {
0..=17 => 1.3,
18..=29 => 1.0,
30..=49 => 0.8,
50..=69 => 0.7,
_ => 0.6, }
}
#[must_use]
pub fn sensitive_period_modifier(trait_path: HexacoPath, age_years: u16) -> f32 {
let (start, end, multiplier) = sensitive_period_range(trait_path);
if age_years >= start && age_years <= end {
multiplier
} else {
1.0
}
}
#[must_use]
fn sensitive_period_range(trait_path: HexacoPath) -> (u16, u16, f32) {
match trait_path {
HexacoPath::Neuroticism => (12, 25, 1.4),
HexacoPath::Conscientiousness => (18, 35, 1.2),
HexacoPath::Agreeableness => (25, 40, 1.2),
HexacoPath::Extraversion => (13, 22, 1.2),
HexacoPath::Openness => (15, 30, 1.2),
HexacoPath::HonestyHumility => (18, 30, 1.2),
}
}
#[must_use]
pub fn combined_plasticity(trait_path: HexacoPath, age_years: u16) -> f32 {
let age_mod = age_plasticity(age_years);
let sensitive_mod = sensitive_period_modifier(trait_path, age_years);
age_mod.max(sensitive_mod)
}
#[must_use]
pub fn saturation_factor(existing_cumulative: f32) -> f32 {
1.0 / (1.0 + existing_cumulative / SATURATION_CONSTANT)
}
#[must_use]
pub fn apply_formative_modifiers(
shift_request: f32,
trait_path: HexacoPath,
age_years: u16,
existing_cumulative: f32,
species: &Species,
) -> f32 {
let species_plasticity = species_plasticity_modifier(species);
let plasticity = combined_plasticity(trait_path, age_years);
let trait_mod = trait_modifier(trait_path);
let saturation = saturation_factor(existing_cumulative);
let modified = shift_request * species_plasticity * plasticity * trait_mod * saturation;
let capped = modified.clamp(-MAX_SINGLE_EVENT_SHIFT, MAX_SINGLE_EVENT_SHIFT);
enforce_cumulative_cap(capped, existing_cumulative)
}
#[must_use]
pub fn species_plasticity_modifier(species: &Species) -> f32 {
match species {
Species::Human => 1.0,
Species::Custom {
social_complexity, ..
} => {
0.8 + (*social_complexity * 0.4)
}
_ => 1.2,
}
}
#[must_use]
fn enforce_cumulative_cap(proposed_shift: f32, existing_cumulative: f32) -> f32 {
let new_cumulative = existing_cumulative + proposed_shift.abs();
if new_cumulative > CUMULATIVE_CAP {
let remaining = CUMULATIVE_CAP - existing_cumulative;
proposed_shift.signum() * remaining.max(0.0)
} else {
proposed_shift
}
}
#[must_use]
pub fn effective_base_at(
anchor_value: f32,
shifts: &[BaseShiftRecord],
query_timestamp: Duration,
) -> f32 {
let total_shift: f32 = shifts
.iter()
.map(|shift| shift.contribution_at(query_timestamp))
.sum();
(anchor_value + total_shift).clamp(-1.0, 1.0)
}
#[must_use]
pub fn cumulative_in_direction(shifts: &[BaseShiftRecord], positive: bool) -> f32 {
shifts
.iter()
.filter(|s| (s.settled > 0.0) == positive)
.map(|s| s.settled.abs())
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_shift_record_new_non_severe() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, 0.15);
assert!((shift.immediate() - 0.15).abs() < f32::EPSILON);
assert!((shift.settled() - 0.15).abs() < f32::EPSILON);
assert_eq!(shift.settling_days(), 0);
assert!(!shift.is_severe());
}
#[test]
fn base_shift_record_new_severe() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Neuroticism, 0.25);
assert!((shift.immediate() - 0.25).abs() < f32::EPSILON);
assert!((shift.settled() - 0.175).abs() < f32::EPSILON); assert_eq!(shift.settling_days(), SETTLING_DAYS);
assert!(shift.is_severe());
}
#[test]
fn base_shift_record_new_severe_negative() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, -0.25);
assert!((shift.immediate() - (-0.25)).abs() < f32::EPSILON);
assert!((shift.settled() - (-0.175)).abs() < f32::EPSILON);
assert!(shift.is_severe());
}
#[test]
fn base_shift_record_at_threshold_not_severe() {
let shift = BaseShiftRecord::new(
Duration::seconds(0),
HexacoPath::Openness,
SEVERE_SHIFT_THRESHOLD,
);
assert!(!shift.is_severe());
}
#[test]
fn base_shift_record_accessors() {
let ts = Duration::seconds(1000);
let shift = BaseShiftRecord::new(ts, HexacoPath::Extraversion, 0.10);
assert_eq!(shift.timestamp(), ts);
assert_eq!(shift.trait_path(), HexacoPath::Extraversion);
}
#[test]
fn contribution_before_shift_is_zero() {
let shift = BaseShiftRecord::new(Duration::seconds(1000), HexacoPath::Agreeableness, 0.15);
let contribution = shift.contribution_at(Duration::seconds(500));
assert!(contribution.abs() < f32::EPSILON);
}
#[test]
fn contribution_at_shift_time_is_immediate() {
let shift = BaseShiftRecord::new(Duration::seconds(1000), HexacoPath::Agreeableness, 0.15);
let contribution = shift.contribution_at(Duration::seconds(1000));
assert!((contribution - 0.15).abs() < f32::EPSILON);
}
#[test]
fn contribution_non_severe_always_immediate() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, 0.15);
let day_1 = Duration::seconds(86400);
let day_100 = Duration::seconds(86400 * 100);
assert!((shift.contribution_at(day_1) - 0.15).abs() < f32::EPSILON);
assert!((shift.contribution_at(day_100) - 0.15).abs() < f32::EPSILON);
}
#[test]
fn contribution_severe_settles_over_time() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Neuroticism, 0.25);
let at_start = shift.contribution_at(Duration::seconds(0));
assert!((at_start - 0.25).abs() < f32::EPSILON);
let day_90 = Duration::seconds(86400 * 90);
let at_mid = shift.contribution_at(day_90);
let expected_mid = 0.25 - (0.25 - 0.175) * 0.5;
assert!((at_mid - expected_mid).abs() < 0.01);
let day_200 = Duration::seconds(86400 * 200);
let at_end = shift.contribution_at(day_200);
assert!((at_end - 0.175).abs() < f32::EPSILON);
}
#[test]
fn stability_coefficients_correct() {
assert!((stability_coefficient(HexacoPath::Extraversion) - 0.85).abs() < f32::EPSILON);
assert!((stability_coefficient(HexacoPath::Openness) - 0.80).abs() < f32::EPSILON);
assert!((stability_coefficient(HexacoPath::HonestyHumility) - 0.75).abs() < f32::EPSILON);
assert!((stability_coefficient(HexacoPath::Conscientiousness) - 0.70).abs() < f32::EPSILON);
assert!((stability_coefficient(HexacoPath::Agreeableness) - 0.65).abs() < f32::EPSILON);
assert!((stability_coefficient(HexacoPath::Neuroticism) - 0.60).abs() < f32::EPSILON);
}
#[test]
fn trait_modifiers_are_inverse() {
for path in HexacoPath::all() {
let stability = stability_coefficient(path);
let modifier = trait_modifier(path);
assert!((stability + modifier - 1.0).abs() < f32::EPSILON);
}
}
#[test]
fn age_plasticity_under_18() {
assert!((age_plasticity(0) - 1.3).abs() < f32::EPSILON);
assert!((age_plasticity(10) - 1.3).abs() < f32::EPSILON);
assert!((age_plasticity(17) - 1.3).abs() < f32::EPSILON);
}
#[test]
fn age_plasticity_18_to_29() {
assert!((age_plasticity(18) - 1.0).abs() < f32::EPSILON);
assert!((age_plasticity(25) - 1.0).abs() < f32::EPSILON);
assert!((age_plasticity(29) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn age_plasticity_30_to_49() {
assert!((age_plasticity(30) - 0.8).abs() < f32::EPSILON);
assert!((age_plasticity(40) - 0.8).abs() < f32::EPSILON);
assert!((age_plasticity(49) - 0.8).abs() < f32::EPSILON);
}
#[test]
fn age_plasticity_50_to_69() {
assert!((age_plasticity(50) - 0.7).abs() < f32::EPSILON);
assert!((age_plasticity(60) - 0.7).abs() < f32::EPSILON);
assert!((age_plasticity(69) - 0.7).abs() < f32::EPSILON);
}
#[test]
fn age_plasticity_70_plus() {
assert!((age_plasticity(70) - 0.6).abs() < f32::EPSILON);
assert!((age_plasticity(80) - 0.6).abs() < f32::EPSILON);
assert!((age_plasticity(100) - 0.6).abs() < f32::EPSILON);
}
#[test]
fn sensitive_period_neuroticism() {
assert!(
(sensitive_period_modifier(HexacoPath::Neuroticism, 11) - 1.0).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Neuroticism, 12) - 1.4).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Neuroticism, 20) - 1.4).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Neuroticism, 25) - 1.4).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Neuroticism, 26) - 1.0).abs() < f32::EPSILON
);
}
#[test]
fn sensitive_period_conscientiousness() {
assert!(
(sensitive_period_modifier(HexacoPath::Conscientiousness, 17) - 1.0).abs()
< f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Conscientiousness, 18) - 1.2).abs()
< f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Conscientiousness, 35) - 1.2).abs()
< f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Conscientiousness, 36) - 1.0).abs()
< f32::EPSILON
);
}
#[test]
fn sensitive_period_agreeableness() {
assert!(
(sensitive_period_modifier(HexacoPath::Agreeableness, 24) - 1.0).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Agreeableness, 25) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Agreeableness, 40) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Agreeableness, 41) - 1.0).abs() < f32::EPSILON
);
}
#[test]
fn sensitive_period_extraversion() {
assert!(
(sensitive_period_modifier(HexacoPath::Extraversion, 12) - 1.0).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Extraversion, 13) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Extraversion, 22) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::Extraversion, 23) - 1.0).abs() < f32::EPSILON
);
}
#[test]
fn sensitive_period_openness() {
assert!((sensitive_period_modifier(HexacoPath::Openness, 14) - 1.0).abs() < f32::EPSILON);
assert!((sensitive_period_modifier(HexacoPath::Openness, 15) - 1.2).abs() < f32::EPSILON);
assert!((sensitive_period_modifier(HexacoPath::Openness, 30) - 1.2).abs() < f32::EPSILON);
assert!((sensitive_period_modifier(HexacoPath::Openness, 31) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn sensitive_period_honesty_humility() {
assert!(
(sensitive_period_modifier(HexacoPath::HonestyHumility, 17) - 1.0).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::HonestyHumility, 18) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::HonestyHumility, 30) - 1.2).abs() < f32::EPSILON
);
assert!(
(sensitive_period_modifier(HexacoPath::HonestyHumility, 31) - 1.0).abs() < f32::EPSILON
);
}
#[test]
fn combined_plasticity_uses_max() {
let result = combined_plasticity(HexacoPath::Neuroticism, 15);
assert!((result - 1.4).abs() < f32::EPSILON);
let result2 = combined_plasticity(HexacoPath::Agreeableness, 25);
assert!((result2 - 1.2).abs() < f32::EPSILON);
let result3 = combined_plasticity(HexacoPath::Openness, 50);
assert!((result3 - 1.0).abs() < f32::EPSILON);
}
#[test]
fn saturation_factor_zero_existing() {
let factor = saturation_factor(0.0);
assert!((factor - 1.0).abs() < f32::EPSILON);
}
#[test]
fn saturation_factor_decreases_with_existing() {
let factor_0 = saturation_factor(0.0);
let factor_25 = saturation_factor(0.25);
let factor_50 = saturation_factor(0.50);
assert!(factor_25 < factor_0);
assert!(factor_50 < factor_25);
}
#[test]
fn saturation_factor_at_constant() {
let factor = saturation_factor(SATURATION_CONSTANT);
assert!((factor - 0.5).abs() < f32::EPSILON);
}
#[test]
fn species_plasticity_human() {
assert!((species_plasticity_modifier(&Species::Human) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn species_plasticity_animals() {
assert!((species_plasticity_modifier(&Species::Dog) - 1.2).abs() < f32::EPSILON);
assert!((species_plasticity_modifier(&Species::Cat) - 1.2).abs() < f32::EPSILON);
assert!((species_plasticity_modifier(&Species::Elephant) - 1.2).abs() < f32::EPSILON);
}
#[test]
fn species_plasticity_custom() {
let custom = Species::custom("Test", 50, 5, 0.5);
assert!((species_plasticity_modifier(&custom) - 1.0).abs() < f32::EPSILON);
let custom_high = Species::custom("HighSocial", 50, 5, 1.0);
assert!((species_plasticity_modifier(&custom_high) - 1.2).abs() < f32::EPSILON);
}
#[test]
fn enforce_cap_within_limit() {
let result = enforce_cumulative_cap(0.2, 0.5);
assert!((result - 0.2).abs() < f32::EPSILON);
}
#[test]
fn enforce_cap_at_limit() {
let result = enforce_cumulative_cap(0.3, 0.8);
assert!((result - 0.2).abs() < f32::EPSILON);
}
#[test]
fn enforce_cap_negative_at_limit() {
let result = enforce_cumulative_cap(-0.3, 0.8);
assert!((result - (-0.2)).abs() < f32::EPSILON);
}
#[test]
fn enforce_cap_already_at_cap() {
let result = enforce_cumulative_cap(0.1, CUMULATIVE_CAP);
assert!(result.abs() < f32::EPSILON);
}
#[test]
fn apply_modifiers_human_reference() {
let result =
apply_formative_modifiers(0.1, HexacoPath::Agreeableness, 25, 0.0, &Species::Human);
assert!((result - 0.042).abs() < 0.001);
}
#[test]
fn apply_modifiers_respects_single_event_cap() {
let result = apply_formative_modifiers(
1.0, HexacoPath::Neuroticism,
15, 0.0,
&Species::Human,
);
assert!(result.abs() <= MAX_SINGLE_EVENT_SHIFT);
}
#[test]
fn apply_modifiers_respects_cumulative_cap() {
let result = apply_formative_modifiers(
1.0,
HexacoPath::Neuroticism,
15,
0.95, &Species::Human,
);
assert!(result.abs() <= CUMULATIVE_CAP - 0.95 + f32::EPSILON);
}
#[test]
fn effective_base_no_shifts() {
let result = effective_base_at(0.5, &[], Duration::seconds(1000));
assert!((result - 0.5).abs() < f32::EPSILON);
}
#[test]
fn effective_base_single_shift() {
let shifts = vec![BaseShiftRecord::new(
Duration::seconds(0),
HexacoPath::Agreeableness,
0.1,
)];
let result = effective_base_at(0.5, &shifts, Duration::seconds(1000));
assert!((result - 0.6).abs() < f32::EPSILON);
}
#[test]
fn effective_base_multiple_shifts() {
let shifts = vec![
BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, 0.1),
BaseShiftRecord::new(Duration::seconds(100), HexacoPath::Agreeableness, -0.05),
];
let result = effective_base_at(0.5, &shifts, Duration::seconds(1000));
assert!((result - 0.55).abs() < f32::EPSILON);
}
#[test]
fn effective_base_before_shift() {
let shifts = vec![BaseShiftRecord::new(
Duration::seconds(1000),
HexacoPath::Agreeableness,
0.1,
)];
let result = effective_base_at(0.5, &shifts, Duration::seconds(500));
assert!((result - 0.5).abs() < f32::EPSILON);
}
#[test]
fn effective_base_clamped_high() {
let shifts = vec![BaseShiftRecord::new(
Duration::seconds(0),
HexacoPath::Agreeableness,
0.8,
)];
let result = effective_base_at(0.9, &shifts, Duration::seconds(1000));
assert!((result - 1.0).abs() < f32::EPSILON);
}
#[test]
fn effective_base_clamped_low() {
let shifts = vec![BaseShiftRecord::new(
Duration::seconds(0),
HexacoPath::Agreeableness,
-0.8,
)];
let result = effective_base_at(-0.9, &shifts, Duration::seconds(1000));
assert!((result - (-1.0)).abs() < f32::EPSILON);
}
#[test]
fn cumulative_in_direction_positive() {
let shifts = vec![
BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, 0.1),
BaseShiftRecord::new(Duration::seconds(100), HexacoPath::Agreeableness, 0.15),
BaseShiftRecord::new(Duration::seconds(200), HexacoPath::Agreeableness, -0.05),
];
let positive_sum = cumulative_in_direction(&shifts, true);
assert!((positive_sum - 0.25).abs() < f32::EPSILON);
}
#[test]
fn cumulative_in_direction_negative() {
let shifts = vec![
BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Agreeableness, 0.1),
BaseShiftRecord::new(Duration::seconds(100), HexacoPath::Agreeableness, -0.15),
BaseShiftRecord::new(Duration::seconds(200), HexacoPath::Agreeableness, -0.05),
];
let negative_sum = cumulative_in_direction(&shifts, false);
assert!((negative_sum - 0.20).abs() < f32::EPSILON);
}
#[test]
fn cumulative_in_direction_empty() {
let positive_sum = cumulative_in_direction(&[], true);
assert!(positive_sum.abs() < f32::EPSILON);
}
#[test]
fn base_shift_record_clone() {
let shift = BaseShiftRecord::new(Duration::seconds(1000), HexacoPath::Openness, 0.15);
let cloned = shift.clone();
assert_eq!(shift, cloned);
}
#[test]
fn base_shift_record_debug() {
let shift = BaseShiftRecord::new(Duration::seconds(0), HexacoPath::Extraversion, 0.1);
let debug = format!("{:?}", shift);
assert!(debug.contains("BaseShiftRecord"));
}
}