Skip to content

Commit 266557f

Browse files
milkyskiesclaude
andauthored
[#520] Expand Big Five personality to NEO-PI-R 30 facets (#742)
Each Big Five trait now decomposes into 6 facets (Costa & McCrae). Trait-level scores are derived as the mean of their facets, never stored separately, so existing readers (`personality.traits.openness()` etc.) keep their behavior while new code can read facets directly for finer-grained reactions. At spawn, facets are sampled around the trait baseline (genome-derived) using a deterministic seed from the genome's locus bits. Sibling facets share the trait baseline as a common factor, producing the within-trait correlation observed in the NEO-PI-R. Co-authored-by: milkyskies <milkyskies> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5eac54d commit 266557f

23 files changed

Lines changed: 407 additions & 129 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ strum = { version = "0.28.0", features = ["derive"] }
4949
tracing-tracy = { version = "0.11", optional = true }
5050
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"], optional = true }
5151
ron = "0.12.1"
52+
rand_distr = "0.5"
5253

5354
# Enable a small amount of optimization in the dev profile.
5455
[profile.dev]

src/agent/body/genetics/phenotype.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
//! mind::perception (vision range), nervous_system (Personality)
88
99
use bevy::prelude::*;
10+
use rand_chacha::ChaCha8Rng;
11+
use rand_chacha::rand_core::SeedableRng;
1012

1113
use crate::agent::body::genetics::genome::{
1214
AEROBIC_CAPACITY_START, AGREEABLENESS_START, ANAEROBIC_CAPACITY_START, BMR_START,
@@ -253,6 +255,18 @@ fn develop_personality(locus_sum: f32) -> f32 {
253255
(1.0 - H2_PERSONALITY) * 0.5 + H2_PERSONALITY * genetic_value
254256
}
255257

258+
/// Deterministic 64-bit seed derived from the genome's locus bits. Used to
259+
/// drive facet-sampling RNG so identical genomes produce identical facet
260+
/// profiles run-to-run.
261+
fn genome_seed(genome: &Genome) -> u64 {
262+
use std::hash::{Hash, Hasher};
263+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
264+
for v in genome.maternal.iter().chain(genome.paternal.iter()) {
265+
v.to_bits().hash(&mut hasher);
266+
}
267+
hasher.finish()
268+
}
269+
256270
/// Runs once per entity when a [`Genome`] is added (at spawn).
257271
///
258272
/// Inserts (or overwrites):
@@ -286,14 +300,16 @@ pub fn develop_phenotype_system(
286300

287301
let vision_range = species.vision_range * phenotype.vision;
288302

303+
let mut facet_rng = ChaCha8Rng::seed_from_u64(genome_seed(genome));
289304
let personality = Personality {
290-
traits: PersonalityTraits {
291-
openness: phenotype.openness,
292-
conscientiousness: phenotype.conscientiousness,
293-
extraversion: phenotype.extraversion,
294-
agreeableness: phenotype.agreeableness,
295-
neuroticism: phenotype.neuroticism,
296-
},
305+
traits: PersonalityTraits::sample(
306+
phenotype.openness,
307+
phenotype.conscientiousness,
308+
phenotype.extraversion,
309+
phenotype.agreeableness,
310+
phenotype.neuroticism,
311+
&mut facet_rng,
312+
),
297313
};
298314

299315
let mut drives = PsychologicalDrives::from_personality(&personality.traits);

src/agent/body/needs.rs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -348,16 +348,16 @@ impl PsychologicalDrives {
348348
Self {
349349
// Extraverts start unsatisfied (low companionship) so they
350350
// reach toward socializing sooner.
351-
companionship: Need::new(1.0 - traits.extraversion),
351+
companionship: Need::new(1.0 - traits.extraversion()),
352352
// Open personalities start understimulated, driving exploration.
353-
stimulation: Need::new(1.0 - traits.openness),
353+
stimulation: Need::new(1.0 - traits.openness()),
354354
// Neurotic agents feel less safe at baseline.
355-
safety: Need::new(1.0 - traits.neuroticism),
355+
safety: Need::new(1.0 - traits.neuroticism()),
356356
// Conscientious agents start with lower esteem (more to prove).
357-
esteem: Need::new(1.0 - traits.conscientiousness),
357+
esteem: Need::new(1.0 - traits.conscientiousness()),
358358
// Disagreeable agents start with low autonomy satisfaction
359359
// (feel constrained more easily).
360-
autonomy: Need::new(traits.agreeableness),
360+
autonomy: Need::new(traits.agreeableness()),
361361
enjoyment: Need::new(0.5),
362362
// Starts uncontested; territoriality system lowers this when
363363
// intruders appear.
@@ -369,12 +369,15 @@ impl PsychologicalDrives {
369369
#[cfg(test)]
370370
mod tests {
371371
use super::*;
372-
use crate::agent::psyche::personality::PersonalityTraits;
372+
use crate::agent::psyche::personality::{
373+
AgreeablenessFacets, ExtraversionFacets, NeuroticismFacets, OpennessFacets,
374+
PersonalityTraits,
375+
};
373376

374377
#[test]
375378
fn high_extraversion_lowers_baseline_companionship() {
376379
let traits = PersonalityTraits {
377-
extraversion: 0.9,
380+
extraversion: ExtraversionFacets::uniform(0.9),
378381
..Default::default()
379382
};
380383
let drives = PsychologicalDrives::from_personality(&traits);
@@ -388,7 +391,7 @@ mod tests {
388391
#[test]
389392
fn low_extraversion_raises_baseline_companionship() {
390393
let traits = PersonalityTraits {
391-
extraversion: 0.1,
394+
extraversion: ExtraversionFacets::uniform(0.1),
392395
..Default::default()
393396
};
394397
let drives = PsychologicalDrives::from_personality(&traits);
@@ -402,7 +405,7 @@ mod tests {
402405
#[test]
403406
fn high_openness_lowers_baseline_stimulation() {
404407
let traits = PersonalityTraits {
405-
openness: 0.9,
408+
openness: OpennessFacets::uniform(0.9),
406409
..Default::default()
407410
};
408411
let drives = PsychologicalDrives::from_personality(&traits);
@@ -416,7 +419,7 @@ mod tests {
416419
#[test]
417420
fn high_neuroticism_lowers_baseline_safety() {
418421
let traits = PersonalityTraits {
419-
neuroticism: 0.9,
422+
neuroticism: NeuroticismFacets::uniform(0.9),
420423
..Default::default()
421424
};
422425
let drives = PsychologicalDrives::from_personality(&traits);
@@ -430,7 +433,7 @@ mod tests {
430433
#[test]
431434
fn high_agreeableness_raises_baseline_autonomy() {
432435
let traits = PersonalityTraits {
433-
agreeableness: 0.9,
436+
agreeableness: AgreeablenessFacets::uniform(0.9),
434437
..Default::default()
435438
};
436439
let drives = PsychologicalDrives::from_personality(&traits);

src/agent/brains/arbitration.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pub fn calculate_brain_powers(
5959
// even without acute emotions.
6060
let instinct_base = 25.0;
6161
let emotional_intensity: f32 = emotions.active_emotions.iter().map(|e| e.intensity).sum();
62-
let neuroticism_multiplier = 0.5 + personality.traits.neuroticism * 0.5;
62+
let neuroticism_multiplier = 0.5 + personality.traits.neuroticism() * 0.5;
6363
let stress_factor = (emotions.stress_level / 100.0).clamp(0.0, 1.0);
6464
let stress_multiplier = 1.0 + stress_factor * 0.5;
6565

@@ -68,7 +68,7 @@ pub fn calculate_brain_powers(
6868

6969
// === RATIONAL POWER ===
7070
// Baseline from conscientiousness, reduced by stress and survival urgency.
71-
let base_rational = 30.0 + personality.traits.conscientiousness * 40.0;
71+
let base_rational = 30.0 + personality.traits.conscientiousness() * 40.0;
7272
let stress_penalty = stress_factor * 0.5;
7373

7474
// High survival urgency makes it hard to think straight.
@@ -302,6 +302,7 @@ mod tests {
302302
use crate::agent::brains::thinking::ActionTemplate;
303303
use crate::agent::nervous_system::urgency::{Urgency, UrgencySource};
304304
use crate::agent::psyche::emotions::{Emotion, EmotionType};
305+
use crate::agent::psyche::personality::NeuroticismFacets;
305306

306307
fn make_proposal(
307308
brain: BrainType,
@@ -613,7 +614,7 @@ mod tests {
613614
emotions.add_emotion(Emotion::new(EmotionType::Fear, 0.8));
614615

615616
let mut personality = Personality::default();
616-
personality.traits.neuroticism = 1.0;
617+
personality.traits.neuroticism = NeuroticismFacets::uniform(1.0);
617618

618619
let powers = calculate_brain_powers(&cns, &consciousness, &emotions, &personality);
619620

src/agent/brains/brain_system.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub fn tick_cognitive_drain(
3737
>,
3838
) {
3939
for (mut consciousness, personality) in query.iter_mut() {
40-
let tick_relief = personality.traits.conscientiousness
40+
let tick_relief = personality.traits.conscientiousness()
4141
* crate::constants::brains::cognition::CONSCIENTIOUSNESS_TICK_RELIEF;
4242
let tick_drain = crate::constants::brains::rational::COGNITIVE_TICK_ALERTNESS_DRAIN
4343
* (1.0 - tick_relief)

src/agent/brains/planner.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ impl PlanCostContext {
137137
Self {
138138
stamina_aerobic: physical.stamina.aerobic_fraction().clamp(0.0, 1.0),
139139
alertness: consciousness.alertness.clamp(0.0, 1.0),
140-
neuroticism: personality.traits.neuroticism.clamp(0.0, 1.0),
140+
neuroticism: personality.traits.neuroticism().clamp(0.0, 1.0),
141141
current_tick,
142142
body_mass: species
143143
.map(|s| s.mass_kg)

src/agent/brains/rational.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -473,8 +473,8 @@ pub fn update_rational_planning(
473473
urgency,
474474
alone,
475475
announcement_made,
476-
neuroticism: personality.traits.neuroticism,
477-
conscientiousness: personality.traits.conscientiousness,
476+
neuroticism: personality.traits.neuroticism(),
477+
conscientiousness: personality.traits.conscientiousness(),
478478
});
479479
plan.commitment = (plan.commitment + delta).max(0.0);
480480
plan.last_touched = current_tick;
@@ -507,7 +507,7 @@ pub fn update_rational_planning(
507507
}
508508
let threshold = compute_commit_threshold(
509509
plan.subjective_cost,
510-
personality.traits.conscientiousness,
510+
personality.traits.conscientiousness(),
511511
);
512512
match plan.state {
513513
PlanState::Background
@@ -657,7 +657,7 @@ pub fn update_rational_planning(
657657
// GOAP search drains alertness. Curious (high-openness)
658658
// agents pay less. The cooldown gate above ensures this
659659
// drain fires at most once per interval per urgency.
660-
let openness_relief = personality.traits.openness
660+
let openness_relief = personality.traits.openness()
661661
* crate::constants::brains::cognition::OPENNESS_PLANNING_RELIEF;
662662
let plan_drain = crate::constants::brains::rational::PLAN_GENERATION_ALERTNESS_DRAIN
663663
* (1.0 - openness_relief);
@@ -701,7 +701,7 @@ pub fn update_rational_planning(
701701
);
702702
let id = plan_memory.mint_plan_id();
703703
let threshold =
704-
compute_commit_threshold(cost, personality.traits.conscientiousness);
704+
compute_commit_threshold(cost, personality.traits.conscientiousness());
705705
// Seed commitment with urgency-weighted boost so urgent
706706
// plans cross the threshold immediately.
707707
let initial_commitment = threshold * (0.5 + value.clamp(0.0, 1.0));
@@ -757,9 +757,9 @@ pub fn update_rational_planning(
757757
// 6. Cognitive load cap: evict the weakest background plans if
758758
// we're over capacity. Personality modulates the cap.
759759
let max = max_plans_for(
760-
personality.traits.openness,
761-
personality.traits.conscientiousness,
762-
personality.traits.neuroticism,
760+
personality.traits.openness(),
761+
personality.traits.conscientiousness(),
762+
personality.traits.neuroticism(),
763763
);
764764
plan_memory.evict_excess(max);
765765
}

src/agent/brains/threat_appraisal.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ pub fn boldness_score(personality: Option<&PersonalityTraits>) -> f32 {
151151
let Some(p) = personality else {
152152
return 0.5;
153153
};
154-
let timid = p.neuroticism * 0.6 + p.agreeableness * 0.4;
154+
let timid = p.neuroticism() * 0.6 + p.agreeableness() * 0.4;
155155
(1.0 - timid).clamp(0.0, 1.0)
156156
}
157157

@@ -240,14 +240,15 @@ mod tests {
240240
#[test]
241241
fn bold_personality_fights_at_lower_anger_than_timid() {
242242
let physical = PhysicalNeeds::default();
243+
use crate::agent::psyche::personality::{AgreeablenessFacets, NeuroticismFacets};
243244
let bold = PersonalityTraits {
244-
neuroticism: 0.05,
245-
agreeableness: 0.05,
245+
agreeableness: AgreeablenessFacets::uniform(0.05),
246+
neuroticism: NeuroticismFacets::uniform(0.05),
246247
..Default::default()
247248
};
248249
let timid = PersonalityTraits {
249-
neuroticism: 0.95,
250-
agreeableness: 0.95,
250+
agreeableness: AgreeablenessFacets::uniform(0.95),
251+
neuroticism: NeuroticismFacets::uniform(0.95),
251252
..Default::default()
252253
};
253254

@@ -285,14 +286,15 @@ mod tests {
285286

286287
#[test]
287288
fn boldness_score_scales_inversely_with_neuroticism() {
289+
use crate::agent::psyche::personality::{AgreeablenessFacets, NeuroticismFacets};
288290
let timid = PersonalityTraits {
289-
neuroticism: 0.9,
290-
agreeableness: 0.9,
291+
agreeableness: AgreeablenessFacets::uniform(0.9),
292+
neuroticism: NeuroticismFacets::uniform(0.9),
291293
..Default::default()
292294
};
293295
let bold = PersonalityTraits {
294-
neuroticism: 0.1,
295-
agreeableness: 0.1,
296+
agreeableness: AgreeablenessFacets::uniform(0.1),
297+
neuroticism: NeuroticismFacets::uniform(0.1),
296298
..Default::default()
297299
};
298300
assert!(boldness_score(Some(&bold)) > boldness_score(Some(&timid)));

src/agent/engagement/converse.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ pub fn select_turn_intent(
770770
if let Ok(mut c) = consciousnesses.get_mut(speaker) {
771771
let extraversion = personalities
772772
.get(speaker)
773-
.map(|p| p.traits.extraversion)
773+
.map(|p| p.traits.extraversion())
774774
.unwrap_or(0.5);
775775
let drain = speaker_drain_base * (1.0 - extraversion * extraversion_relief);
776776
c.alertness = (c.alertness - drain).max(0.0);
@@ -779,7 +779,7 @@ pub fn select_turn_intent(
779779
if let Ok(mut c) = consciousnesses.get_mut(listener) {
780780
let extraversion = personalities
781781
.get(listener)
782-
.map(|p| p.traits.extraversion)
782+
.map(|p| p.traits.extraversion())
783783
.unwrap_or(0.5);
784784
let drain = listener_drain_base * (1.0 - extraversion * extraversion_relief);
785785
c.alertness = (c.alertness - drain).max(0.0);
@@ -834,8 +834,8 @@ pub fn select_turn_intent(
834834
}
835835

836836
pub(crate) fn speak_desire(personality: Option<&Personality>, wants_to_speak: bool) -> f32 {
837-
let extraversion = personality.map(|p| p.traits.extraversion).unwrap_or(0.5);
838-
let agreeableness = personality.map(|p| p.traits.agreeableness).unwrap_or(0.5);
837+
let extraversion = personality.map(|p| p.traits.extraversion()).unwrap_or(0.5);
838+
let agreeableness = personality.map(|p| p.traits.agreeableness()).unwrap_or(0.5);
839839
let base = 1.0 + extraversion * 2.0 - agreeableness * 0.6;
840840
if wants_to_speak { base + 2.5 } else { base }
841841
}
@@ -889,8 +889,8 @@ pub(crate) fn select_intent(
889889
has_deliberate: bool,
890890
has_casual: bool,
891891
) -> Intent {
892-
let neuroticism = personality.map(|p| p.traits.neuroticism).unwrap_or(0.5);
893-
let extraversion = personality.map(|p| p.traits.extraversion).unwrap_or(0.5);
892+
let neuroticism = personality.map(|p| p.traits.neuroticism()).unwrap_or(0.5);
893+
let extraversion = personality.map(|p| p.traits.extraversion()).unwrap_or(0.5);
894894

895895
if conv.state == ConversationState::Greeting && conv.turns.is_empty() {
896896
return Intent::Greet;
@@ -922,7 +922,7 @@ pub(crate) fn select_intent(
922922
return Intent::Share;
923923
}
924924

925-
let agreeableness = personality.map(|p| p.traits.agreeableness).unwrap_or(0.5);
925+
let agreeableness = personality.map(|p| p.traits.agreeableness()).unwrap_or(0.5);
926926
let other_last = conv
927927
.turns
928928
.last()
@@ -1151,7 +1151,7 @@ fn compute_interaction_valence(
11511151

11521152
let (speaker_mood, speaker_agreeableness) = agents
11531153
.get(speaker)
1154-
.map(|(_, e, p)| (e.current_mood, p.traits.agreeableness))
1154+
.map(|(_, e, p)| (e.current_mood, p.traits.agreeableness()))
11551155
.unwrap_or((0.0, 0.5));
11561156
let listener_mood = agents
11571157
.get(listener)

0 commit comments

Comments
 (0)