Skip to main content

eros_engine_core/
affinity.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct Affinity {
8    pub id: Uuid,
9    pub session_id: Uuid,
10    pub user_id: Uuid,
11    pub instance_id: Uuid,
12    pub warmth: f64,   //  0.0 ..= 1.0 (derived cache, 4.0 — see refresh_endpoints)
13    pub trust: f64,    //  0.0 ..= 1.0
14    pub intrigue: f64, //  0.0 ..= 1.0
15    pub intimacy: f64, //  0.0 ..= 1.0
16    pub patience: f64, //  0.0 ..= 1.0 (derived cache, 4.0)
17    pub tension: f64,  //  0.0 ..= 1.0
18    /// Judge's last absolute warmth level (1..=3). Authoritative; `warmth` is
19    /// a materialized cache of `endpoint_value` over it.
20    pub warmth_grade: i16,
21    /// Judge's last absolute patience level (1..=3).
22    pub patience_grade: i16,
23    pub ghost_streak: i32,
24    pub last_ghost_at: Option<DateTime<Utc>>,
25    pub total_ghosts: i32,
26    pub relationship_label: Option<RelationshipLabel>,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum RelationshipLabel {
34    Stranger,
35    Romantic,
36    Friend,
37    Frenemy,
38    SlowBurn,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct AffinityDeltas {
43    pub warmth: f64,
44    pub trust: f64,
45    pub intrigue: f64,
46    pub intimacy: f64,
47    pub patience: f64,
48    pub tension: f64,
49}
50
51impl Affinity {
52    /// Apply committed deltas directly, clamping each axis to its range.
53    /// Damping lives in `grade_turn`'s tier decay, so a committed delta means
54    /// exactly what it says.
55    pub fn apply_deltas(&mut self, d: &AffinityDeltas) {
56        self.warmth = clamp(self.warmth + d.warmth, 0.0, 1.0);
57        self.trust = clamp(self.trust + d.trust, 0.0, 1.0);
58        self.intrigue = clamp(self.intrigue + d.intrigue, 0.0, 1.0);
59        self.intimacy = clamp(self.intimacy + d.intimacy, 0.0, 1.0);
60        self.patience = clamp(self.patience + d.patience, 0.0, 1.0);
61        self.tension = clamp(self.tension + d.tension, 0.0, 1.0);
62        self.updated_at = Utc::now();
63    }
64
65    /// Per-day drift on the line axes only (intrigue cools, tension softens).
66    /// The old patience up-drift is retired: absence handling for the two
67    /// endpoints lives in `refresh_endpoints`' multiplicative decay, which
68    /// cools rather than heals — an old friend's resilience comes from
69    /// B(bond), not from a drift patch.
70    pub fn apply_time_decay(&mut self) {
71        let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
72        if days <= 0.0 {
73            return;
74        }
75        self.intrigue = clamp(self.intrigue - 0.01 * days, 0.0, 1.0);
76        self.tension = clamp(self.tension - 0.005 * days, 0.0, 1.0);
77    }
78
79    /// Recompute the two derived endpoints from the authoritative facts
80    /// (judge levels + line scores + time since last update). Runs wherever
81    /// `apply_time_decay` runs: the row-locked persist and the in-memory
82    /// read paths. Line scores no longer contain warmth, so there is no
83    /// circularity.
84    pub fn refresh_endpoints(&mut self, t: &AffinityTuning) {
85        let days = (Utc::now() - self.updated_at).num_minutes() as f64 / (60.0 * 24.0);
86        let decay = endpoint_time_decay(days, t.time_decay_rate, t.time_decay_floor);
87        self.warmth = endpoint_value(
88            self.warmth_grade,
89            self.chemistry_score(),
90            decay,
91            t.floor_ratio,
92        );
93        self.patience =
94            endpoint_value(self.patience_grade, self.bond_score(), decay, t.floor_ratio);
95    }
96
97    /// Legacy 5-name relationship label (back-compat), derived purely from the
98    /// two line scores — replaces the old multi-axis `infer_label` heuristic.
99    /// New consumers should read `bond_label`/`chemistry_label`. `frenemy` is
100    /// retired from emission (kept in the enum for parse compat).
101    pub fn legacy_relationship_label(&self) -> RelationshipLabel {
102        let bond = self.bond_score();
103        let chem = self.chemistry_score();
104        if tier_index(bond) == 1 && tier_index(chem) == 1 {
105            return RelationshipLabel::Stranger;
106        }
107        if chem > bond {
108            if tier_index(chem) >= 3 {
109                RelationshipLabel::Romantic
110            } else {
111                RelationshipLabel::SlowBurn
112            }
113        } else {
114            RelationshipLabel::Friend
115        }
116    }
117}
118
119fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
120    if v < lo {
121        lo
122    } else if v > hi {
123        hi
124    } else {
125        v
126    }
127}
128
129// ─── Bond / Chemistry lines (read-layer folds of the 4 line axes) ───
130//
131// As of 4.0 the two lines share nothing: bond is friendship (trust + continued
132// interest), chemistry is romance (closeness + charge). warmth and patience are
133// no longer inputs to either line — they are OUTPUTS, derived from the judge's
134// absolute levels amplified by the counterpart line (see endpoint derivation
135// below).
136//
137// Mirrored by the `bond`/`chemistry` GENERATED columns in store migration 0048.
138// Keep the formula in sync.
139
140/// Tier upper bounds on a line's 0..1 score. Widening by design: easy early, a
141/// grind near the top. Tier 1 = [0, T1), 2 = [T1, T2), 3 = [T2, T3),
142/// 4 = [T3, T4), 5 = [T4, 1]. Tunable.
143const TIER1_HI: f64 = 0.15;
144const TIER2_HI: f64 = 0.35;
145const TIER3_HI: f64 = 0.62;
146const TIER4_HI: f64 = 0.9;
147
148/// Floor of the top intimacy rung, on `max(bond_score, chemistry_score)`. Sits
149/// *inside* tier 4 rather than on the tier-5 edge, deliberately loose: the rung
150/// ladder exists to stop a stranger talking their way into a nude, not to make
151/// intimacy expensive, and gating the top rung at the apex (`TIER4_HI`) is a
152/// wall rather than a gate. The bottom rung still folds `TIER1_HI`, so only this
153/// cut is independent — keep it in `(TIER3_HI, TIER4_HI)` so the rungs stay
154/// coarser than the tier ladder they sit on. Tunable.
155const INTIMACY_RUNG3_LO: f64 = 0.76;
156
157const _: () = assert!(
158    TIER3_HI < INTIMACY_RUNG3_LO && INTIMACY_RUNG3_LO < TIER4_HI,
159    "the top intimacy rung must open inside tier 4, not at the apex"
160);
161
162/// Patience band cut-points: low = [0, LO), mid = [LO, HI), high = [HI, 1].
163/// Separate from the tier ladder above on purpose — `patience` is rule-owned
164/// and never folded into either composite, so it carries its own cuts. These
165/// mirror the three bands the PDE judge prompt already prescribes; the engine
166/// owns them so the judge classifies against a stated band instead of
167/// comparing floats itself. Tunable.
168const PATIENCE_LO: f64 = 0.35;
169const PATIENCE_HI: f64 = 0.65;
170
171// ─── Endpoint derivation (affinity 4.0) ─────────────────────────────
172//
173// warmth and patience are no longer accumulated state: the judge reports a
174// coarse absolute level (1 cold / 2 baseline / 3 warm) and the engine folds it
175// into a continuous value using the counterpart LINE score — chemistry warms
176// warmth, bond funds patience. Amplification, not correlation.
177// Design spec: docs/superpowers/specs/2026-08-16-affinity-40-design.md
178
179/// Boost at a counterpart score of 1.0. With base(3) = 2/3, a full judge level
180/// times a full counterpart line lands exactly at 1.0 — a structural
181/// commitment, so a code constant rather than a knob (the pivot below is
182/// `TIER2_HI` for the same reason: the boost turns positive the moment the
183/// counterpart line enters tier 3).
184pub const ENDPOINT_BOOST_MAX: f64 = 1.5;
185
186/// The judge's absolute endpoint levels for one turn. `None` = the judge
187/// omitted the field or the eval was skipped → hold the stored level.
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
189pub struct EndpointLevelReads {
190    pub warmth: Option<i16>,
191    pub patience: Option<i16>,
192}
193
194/// base(g) = (g−1)/3 ∈ {0, 1/3, 2/3}; out-of-range stored levels clamp.
195fn endpoint_base(level: i16) -> f64 {
196    f64::from(level.clamp(1, 3) - 1) / 3.0
197}
198
199/// B(x) = 1 + λ·(x − TIER2_HI), λ = (B_MAX − 1)/(1 − TIER2_HI) = 10/13.
200/// Below the pivot the endpoint is damped under its base; above it, boosted.
201pub fn endpoint_boost(counterpart: f64) -> f64 {
202    let slope = (ENDPOINT_BOOST_MAX - 1.0) / (1.0 - TIER2_HI);
203    1.0 + slope * (counterpart - TIER2_HI)
204}
205
206/// Multiplicative absence decay: 1 − rate·days, floored. Linear like the
207/// line-axis drift; the floor keeps long absence from zeroing a relationship.
208pub fn endpoint_time_decay(days: f64, rate: f64, floor: f64) -> f64 {
209    (1.0 - rate * days.max(0.0)).max(floor)
210}
211
212/// One endpoint's real value. The φ·x floor only ever acts on level 1
213/// (φ·x ≤ φ < 1/3·B(0) for φ ≤ 0.2): a cold verdict decays to a
214/// relationship-scaled ember instead of an absolute zero, and it can never
215/// overwrite a non-cold verdict. clamp01 is float insurance, not mechanism.
216pub fn endpoint_value(level: i16, counterpart: f64, decay: f64, floor_ratio: f64) -> f64 {
217    let boosted = endpoint_base(level) * endpoint_boost(counterpart);
218    (boosted.max(floor_ratio * counterpart) * decay).clamp(0.0, 1.0)
219}
220
221/// 1..=5 tier index for a 0..1 line score.
222fn tier_index(score: f64) -> u8 {
223    if score < TIER1_HI {
224        1
225    } else if score < TIER2_HI {
226        2
227    } else if score < TIER3_HI {
228        3
229    } else if score < TIER4_HI {
230        4
231    } else {
232        5
233    }
234}
235
236/// Friendship-line tier (pure function of `bond_score`). Serialised snake_case
237/// key is the frontend's lookup; Chinese display lives in the frontend.
238#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "snake_case")]
240pub enum BondLabel {
241    Acquaintance,
242    Friend,
243    CloseFriend,
244    Confidant,
245    Soulmate,
246}
247
248impl BondLabel {
249    pub fn as_key(self) -> &'static str {
250        match self {
251            BondLabel::Acquaintance => "acquaintance",
252            BondLabel::Friend => "friend",
253            BondLabel::CloseFriend => "close_friend",
254            BondLabel::Confidant => "confidant",
255            BondLabel::Soulmate => "soulmate",
256        }
257    }
258}
259
260/// Romance-line tier (pure function of `chemistry_score`).
261#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
262#[serde(rename_all = "snake_case")]
263pub enum ChemistryLabel {
264    Spark,
265    Flirtation,
266    Crush,
267    Lover,
268    Beloved,
269}
270
271impl ChemistryLabel {
272    pub fn as_key(self) -> &'static str {
273        match self {
274            ChemistryLabel::Spark => "spark",
275            ChemistryLabel::Flirtation => "flirtation",
276            ChemistryLabel::Crush => "crush",
277            ChemistryLabel::Lover => "lover",
278            ChemistryLabel::Beloved => "beloved",
279        }
280    }
281}
282
283/// Patience band for the PDE judge. Three bands, not five: the judge prompt
284/// prescribes one interaction register per band (how curt the tone runs,
285/// whether irritation shows), and the engine states which band applies.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum PatienceBand {
288    Low,
289    Mid,
290    High,
291}
292
293/// One line's tier transition this turn, as serialised keys.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct LabelTransition {
296    pub from: String,
297    pub to: String,
298}
299
300/// Per-turn tier transition across the two lines. Serde skips `None` fields, so
301/// a JSON object only carries the line(s) that actually moved.
302#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
303pub struct TurnLabelChanges {
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub bond: Option<LabelTransition>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub chemistry: Option<LabelTransition>,
308}
309
310impl TurnLabelChanges {
311    pub fn is_empty(&self) -> bool {
312        self.bond.is_none() && self.chemistry.is_none()
313    }
314}
315
316/// Tier transition over a delta-only span (before = post-decay/pre-delta,
317/// after = post-delta). `None` when neither line crossed a tier.
318pub fn diff_labels(before: &Affinity, after: &Affinity) -> Option<TurnLabelChanges> {
319    let bond = (before.bond_label() != after.bond_label()).then(|| LabelTransition {
320        from: before.bond_label().as_key().to_string(),
321        to: after.bond_label().as_key().to_string(),
322    });
323    let chemistry =
324        (before.chemistry_label() != after.chemistry_label()).then(|| LabelTransition {
325            from: before.chemistry_label().as_key().to_string(),
326            to: after.chemistry_label().as_key().to_string(),
327        });
328    let changes = TurnLabelChanges { bond, chemistry };
329    (!changes.is_empty()).then_some(changes)
330}
331
332impl Affinity {
333    /// 0..1 friendship composite. Mirrors the `bond` generated column in
334    /// migration 0048.
335    pub fn bond_score(&self) -> f64 {
336        clamp((self.trust + self.intrigue) / 2.0, 0.0, 1.0)
337    }
338
339    /// 0..1 romance composite. Mirrors the `chemistry` generated column in
340    /// migration 0048.
341    pub fn chemistry_score(&self) -> f64 {
342        clamp((self.intimacy + self.tension) / 2.0, 0.0, 1.0)
343    }
344
345    /// Friendship-line tier label.
346    pub fn bond_label(&self) -> BondLabel {
347        match tier_index(self.bond_score()) {
348            1 => BondLabel::Acquaintance,
349            2 => BondLabel::Friend,
350            3 => BondLabel::CloseFriend,
351            4 => BondLabel::Confidant,
352            _ => BondLabel::Soulmate,
353        }
354    }
355
356    /// Romance-line tier label.
357    pub fn chemistry_label(&self) -> ChemistryLabel {
358        match tier_index(self.chemistry_score()) {
359            1 => ChemistryLabel::Spark,
360            2 => ChemistryLabel::Flirtation,
361            3 => ChemistryLabel::Crush,
362            4 => ChemistryLabel::Lover,
363            _ => ChemistryLabel::Beloved,
364        }
365    }
366
367    /// Coarse 1..=3 intimacy rung for the PDE image gate, taken over whichever
368    /// line is further along. Rung 1 = both lines still tier 1; rung 3 = at or
369    /// above `INTIMACY_RUNG3_LO`; rung 2 = everything between. `max` rather than
370    /// a sum so a purely romantic track and a purely companionable one can each
371    /// unlock on their own.
372    ///
373    /// The bottom cut folds `TIER1_HI` and so cannot drift away from the
374    /// `Acquaintance` / `Spark` labels the rest of the system shows. The top cut
375    /// is deliberately its own constant, set below the tier-5 apex — see
376    /// `INTIMACY_RUNG3_LO`.
377    pub fn intimacy_rung(&self) -> u8 {
378        let s = self.bond_score().max(self.chemistry_score());
379        if tier_index(s) == 1 {
380            1
381        } else if s < INTIMACY_RUNG3_LO {
382            2
383        } else {
384            3
385        }
386    }
387
388    /// Patience band. Reads the raw axis, not a composite — `patience` is
389    /// rule-owned and stays outside both folds.
390    pub fn patience_band(&self) -> PatienceBand {
391        if self.patience < PATIENCE_LO {
392            PatienceBand::Low
393        } else if self.patience < PATIENCE_HI {
394            PatienceBand::Mid
395        } else {
396            PatienceBand::High
397        }
398    }
399}
400
401// ─── Affinity write-side pipeline (grades → raw → decay → penalty → gate) ───
402//
403// The judge reports per-axis *grades* (0..=4 magnitude + direction, folded to a
404// signed integer at parse time) for the FOUR line axes; the engine owns every
405// number. A grade converts to a raw delta at its line's unit, positive raw is
406// damped by the line's tier, every axis pays a cross-line penalty while the
407// counterpart line is high, and the resulting real delta passes a threshold
408// accumulator before it commits. The two endpoints (warmth/patience) never
409// enter this pipeline — they are derived, see `refresh_endpoints`. 3.1's
410// scope steering is retired: `AffinityScope` is read-side only again.
411// Design specs: docs/superpowers/specs/2026-08-13-affinity-30-grade-pipeline-design.md
412//               docs/superpowers/specs/2026-08-14-cross-penalty-by-grade-design.md
413//               docs/superpowers/specs/2026-08-16-affinity-40-design.md
414
415/// Tuning knobs for the 4.0 pipeline, env-driven server-side.
416#[derive(Debug, Clone, PartialEq)]
417pub struct AffinityTuning {
418    /// Raw score per grade step on the bond axes (`AFFINITY_GRADE_UNIT_BOND`).
419    /// The 2.96× spread between the two units is the judge's measured grading
420    /// asymmetry (tension reaches grade ≥2 on ~half of turns, trust is graded
421    /// 0 on ~80%), written down where it can be argued with.
422    pub grade_unit_bond: f64,
423    /// Raw score per grade step on the chemistry axes (`AFFINITY_GRADE_UNIT_CHEM`).
424    pub grade_unit_chem: f64,
425    /// Extra multiplier on negative raw scores (`AFFINITY_NEG_FACTOR`) —
426    /// keeps 2.0's "slow up, fast down" asymmetry.
427    pub neg_factor: f64,
428    /// Positive-delta damping per tier 1..=5 (`AFFINITY_TIER_DECAY`).
429    pub tier_decay: [f64; 5],
430    /// Cross-line penalty ceiling as a multiple of the line's unit
431    /// (`AFFINITY_CROSS_PENALTY_RATIO`): κ_line = ratio · u_line. Tying κ to
432    /// the unit makes the double-high break-even independent of the unit —
433    /// per-line units would otherwise silently move the wall.
434    pub cross_penalty_ratio: f64,
435    /// Counterpart line score where the penalty starts (`AFFINITY_CROSS_PENALTY_START`).
436    pub cross_penalty_start: f64,
437    /// Commit threshold θ (`AFFINITY_DELTA_THRESHOLD`); 0 commits every turn.
438    pub delta_threshold: f64,
439    /// Multiplier on the judge's positive raw component for demo sessions
440    /// (`AFFINITY_DEMO_BOOST`); rule nudges are unaffected.
441    pub demo_boost: f64,
442    /// Endpoint floor ratio φ (`AFFINITY_FLOOR_RATIO`): a level-1 verdict
443    /// reads φ·counterpart instead of 0. Must stay ≤ 0.24 so the floor can
444    /// never touch a level-2 verdict (1/3·B(0) ≈ 0.2436).
445    pub floor_ratio: f64,
446    /// Endpoint absence decay per day (`AFFINITY_TIME_DECAY_RATE`).
447    pub time_decay_rate: f64,
448    /// Endpoint absence decay floor (`AFFINITY_TIME_DECAY_FLOOR`).
449    pub time_decay_floor: f64,
450}
451
452impl Default for AffinityTuning {
453    fn default() -> Self {
454        Self {
455            // Derived to reproduce the shipped 3.1 pace (tier 5 in ~99/98
456            // turns) after the shared warmth term and the chemistry ladder are
457            // both gone; re-derive on a full week of 4.0 data.
458            grade_unit_bond: 0.0786,
459            grade_unit_chem: 0.0266,
460            neg_factor: 1.5,
461            tier_decay: [1.0, 0.70, 0.45, 0.25, 0.10],
462            // 5/6 = the 3.x κ/u ratio (0.05/0.06) made definitional.
463            cross_penalty_ratio: 5.0 / 6.0,
464            cross_penalty_start: 0.35,
465            delta_threshold: 0.0,
466            demo_boost: 1.4,
467            floor_ratio: 0.2,
468            time_decay_rate: 0.02,
469            time_decay_floor: 0.5,
470        }
471    }
472}
473
474/// Signed judge grades, one per line axis, −4..=4 (out-of-range input is
475/// clamped). 0 = nothing happened, the overwhelmingly common verdict.
476/// `warmth` and `patience` are absent by construction: they are absolute
477/// levels on the derived channel (`EndpointLevelReads`), not graded deltas.
478#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
479pub struct AxisGrades {
480    pub trust: i8,
481    pub intrigue: i8,
482    pub intimacy: i8,
483    pub tension: i8,
484}
485
486/// Per-axis balance the threshold gate is still holding back. Persisted as
487/// JSONB on the affinity row; absent column reads as all-zero. A stale
488/// `"warmth"` key from pre-4.0 rows is ignored by serde and drains naturally.
489#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
490pub struct PendingDeltas {
491    #[serde(default)]
492    pub trust: f64,
493    #[serde(default)]
494    pub intrigue: f64,
495    #[serde(default)]
496    pub intimacy: f64,
497    #[serde(default)]
498    pub tension: f64,
499}
500
501impl PendingDeltas {
502    pub fn is_zero(&self) -> bool {
503        self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
504    }
505}
506
507/// One turn through the 4.0 pipeline.
508/// `raw` = grade conversion + rule deltas, pre-decay — what the event row
509/// records as `deltas`. `committed` = what actually applies to the axes this
510/// turn (zero while the gate holds). `pending` = the gate's new balance.
511/// The `warmth`/`patience` fields of `raw` and `committed` are always 0.0:
512/// the endpoints left the graded pipeline (see `refresh_endpoints`).
513#[derive(Debug, Clone, Default)]
514pub struct GradeTurnOutcome {
515    pub raw: AffinityDeltas,
516    pub committed: AffinityDeltas,
517    pub pending: PendingDeltas,
518    /// Cross-line penalty *assessed* this turn, per axis.
519    ///
520    /// Assessed, not applied. It is subtracted inside `ρ` before the threshold
521    /// gate, so on a turn the gate buffers, nothing has reached the axis yet —
522    /// the amount rides along in `pending` rather than being lost (the gate
523    /// re-times commits, it never rescales them), and the caller's axis clamp
524    /// can swallow part of a commit besides. The penalty scales with the
525    /// applied grade, so it is not derivable from the grades alone and is
526    /// recorded rather than reconstructed.
527    pub cross_penalty_assessed: CrossPenaltyAssessed,
528}
529
530/// Per-axis cross-line penalty assessed in one turn (always ≥ 0).
531#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
532pub struct CrossPenaltyAssessed {
533    pub trust: f64,
534    pub intrigue: f64,
535    pub intimacy: f64,
536    pub tension: f64,
537}
538
539impl CrossPenaltyAssessed {
540    pub fn is_zero(&self) -> bool {
541        self.trust == 0.0 && self.intrigue == 0.0 && self.intimacy == 0.0 && self.tension == 0.0
542    }
543}
544
545/// Run one judge verdict through conversion, tier decay, cross-line penalty
546/// and the threshold gate. Pure: reads the *pre-turn* affinity snapshot (all
547/// axes use the same tier lookup, so ordering inside a turn cannot matter)
548/// and returns what to apply; the caller owns clamping and persistence.
549pub fn grade_turn(
550    a: &Affinity,
551    grades: &AxisGrades,
552    rule: &AffinityDeltas,
553    pending: &PendingDeltas,
554    boost: f64,
555    t: &AffinityTuning,
556) -> GradeTurnOutcome {
557    // Pre-turn snapshot: every axis reads the same tiers and counterparts.
558    let bond = a.bond_score();
559    let chem = a.chemistry_score();
560    let bond_tier = tier_index(bond) as usize;
561    let chem_tier = tier_index(chem) as usize;
562
563    let decay = |tier: usize| t.tier_decay[tier - 1];
564    // κ_line = ratio · u_line, so the break-even below is unit-independent.
565    let penalty = |counterpart: f64, kappa: f64| {
566        let start = t.cross_penalty_start;
567        let ramp = ((counterpart - start).max(0.0) / (1.0 - start)).powi(2);
568        kappa * ramp
569    };
570
571    // grade → raw at the line's unit: positive grades earn unit × boost,
572    // negative grades cost unit × neg_factor. Rule nudges join pre-decay.
573    let raw = |g: i8, rule_d: f64, unit: f64| {
574        let g = f64::from(g.clamp(-4, 4));
575        let judge = if g >= 0.0 {
576            g * unit * boost
577        } else {
578            g * unit * t.neg_factor
579        };
580        judge + rule_d
581    };
582
583    // ρ = D·max(r,0) + min(r,0) − P: positive part damped, negative part full
584    // price, and the cross penalty assessed in PROPORTION to the grade actually
585    // applied — P = κ·φ(y)·(|g|/4), κ = ratio·u.
586    //
587    // Ignoring rule nudges, ρ factorises:
588    //     g > 0:  ρ = g·u · (D_k − ratio·φ(y)/4)
589    //     g < 0:  ρ = g·u · (λ⁻ + ratio·φ(y)/4)      (no decay on the negative part)
590    // Neither bracket contains g OR u, so the outcome cannot change sign
591    // between grades at a fixed position, and the break-even position
592    // φ(y*) = 4·D_k/ratio is the SAME for both lines regardless of their
593    // units — see `break_even_position_is_unit_invariant`. The negative
594    // bracket is always positive, so a negative verdict always lowers the
595    // axis.
596    //
597    // It does NOT make every positive verdict a gain: past y* every grade nets
598    // negative, uniformly — see
599    // `tier_five_against_a_high_counterpart_still_loses_at_every_grade`.
600    //
601    // Rule nudges sit outside this: they join `r` before decay but are not
602    // part of `g`, so a large enough opposing nudge could in principle invert
603    // the sign. None can today — the only rule deltas reaching a graded axis
604    // are intrigue +0.02 and tension +0.03, both positive.
605    //
606    // Magnitude, not sign: a negative grade already moves the axis away from
607    // the double-high position the penalty exists to discourage, so it pays in
608    // proportion too rather than at a flat rate.
609    let real = |r: f64, own_tier: usize, counterpart: f64, kappa: f64, g: i8| {
610        let p = penalty(counterpart, kappa) * f64::from(g.saturating_abs().min(4)) / 4.0;
611        (decay(own_tier) * r.max(0.0) + r.min(0.0) - p, p)
612    };
613
614    // Threshold gate: signed accumulation, everything commits once |acc| ≥ θ.
615    let gate = |rho: f64, pend: f64| {
616        let acc = pend + rho;
617        if acc.abs() >= t.delta_threshold {
618            (acc, 0.0)
619        } else {
620            (0.0, acc)
621        }
622    };
623
624    let axis = |g: i8, rule_d: f64, own_tier: usize, counterpart: f64, pend: f64, unit: f64| {
625        let r = raw(g, rule_d, unit);
626        let kappa = t.cross_penalty_ratio * unit;
627        let (rho, charged) = real(r, own_tier, counterpart, kappa, g);
628        let (committed, pend) = gate(rho, pend);
629        (r, committed, pend, charged)
630    };
631
632    let (t_raw, t_com, t_pend, t_pen) = axis(
633        grades.trust,
634        rule.trust,
635        bond_tier,
636        chem,
637        pending.trust,
638        t.grade_unit_bond,
639    );
640    let (ig_raw, ig_com, ig_pend, ig_pen) = axis(
641        grades.intrigue,
642        rule.intrigue,
643        bond_tier,
644        chem,
645        pending.intrigue,
646        t.grade_unit_bond,
647    );
648    let (im_raw, im_com, im_pend, im_pen) = axis(
649        grades.intimacy,
650        rule.intimacy,
651        chem_tier,
652        bond,
653        pending.intimacy,
654        t.grade_unit_chem,
655    );
656    let (tn_raw, tn_com, tn_pend, tn_pen) = axis(
657        grades.tension,
658        rule.tension,
659        chem_tier,
660        bond,
661        pending.tension,
662        t.grade_unit_chem,
663    );
664
665    GradeTurnOutcome {
666        raw: AffinityDeltas {
667            warmth: 0.0, // endpoints are derived, never graded — see refresh_endpoints
668            trust: t_raw,
669            intrigue: ig_raw,
670            intimacy: im_raw,
671            tension: tn_raw,
672            patience: 0.0,
673        },
674        committed: AffinityDeltas {
675            warmth: 0.0,
676            trust: t_com,
677            intrigue: ig_com,
678            intimacy: im_com,
679            tension: tn_com,
680            patience: 0.0,
681        },
682        pending: PendingDeltas {
683            trust: t_pend,
684            intrigue: ig_pend,
685            intimacy: im_pend,
686            tension: tn_pend,
687        },
688        cross_penalty_assessed: CrossPenaltyAssessed {
689            trust: t_pen,
690            intrigue: ig_pen,
691            intimacy: im_pen,
692            tension: tn_pen,
693        },
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    fn fresh() -> Affinity {
702        let now = Utc::now();
703        Affinity {
704            id: Uuid::new_v4(),
705            session_id: Uuid::new_v4(),
706            user_id: Uuid::new_v4(),
707            instance_id: Uuid::new_v4(),
708            warmth: 0.3,
709            trust: 0.2,
710            intrigue: 0.5,
711            intimacy: 0.0,
712            patience: 0.5,
713            tension: 0.1,
714            warmth_grade: 2,
715            patience_grade: 2,
716            ghost_streak: 0,
717            last_ghost_at: None,
718            total_ghosts: 0,
719            relationship_label: None,
720            created_at: now,
721            updated_at: now,
722        }
723    }
724
725    #[test]
726    fn apply_deltas_clamps_to_valid_ranges() {
727        let mut a = fresh();
728        a.apply_deltas(&AffinityDeltas {
729            warmth: 5.0, // would push past 1.0
730            trust: -2.0, // would push below 0.0
731            intrigue: 0.1,
732            intimacy: 0.0,
733            patience: 0.0,
734            tension: 0.0,
735        });
736        assert_eq!(a.warmth, 1.0, "warmth clamps to 1.0 (max)");
737        assert_eq!(a.trust, 0.0, "trust clamps to 0.0 (min)");
738        assert!((a.intrigue - 0.6).abs() < 1e-9);
739    }
740
741    #[test]
742    fn warmth_floors_at_zero_like_every_axis() {
743        // 4.0: warmth is 0..1 like everything else — no negative band.
744        let mut a = fresh();
745        a.apply_deltas(&AffinityDeltas {
746            warmth: -2.0,
747            trust: 0.0,
748            intrigue: 0.0,
749            intimacy: 0.0,
750            patience: 0.0,
751            tension: 0.0,
752        });
753        assert_eq!(a.warmth, 0.0);
754    }
755
756    #[test]
757    fn apply_deltas_is_direct_no_smoothing() {
758        // A committed delta from grade_turn applies verbatim: 0.3 + 0.15 = 0.45.
759        let mut a = fresh(); // warmth 0.3
760        a.apply_deltas(&AffinityDeltas {
761            warmth: 0.15,
762            ..Default::default()
763        });
764        assert!((a.warmth - 0.45).abs() < 1e-9);
765    }
766
767    #[test]
768    fn time_decay_reduces_intrigue_softens_tension_leaves_the_rest() {
769        let mut a = fresh();
770        a.intrigue = 0.5;
771        a.patience = 0.5;
772        a.tension = 0.5;
773        a.warmth = 0.7;
774        a.trust = 0.6;
775        a.intimacy = 0.4;
776        a.updated_at = Utc::now() - chrono::Duration::days(10);
777
778        a.apply_time_decay();
779
780        // 10 days * -0.01/day = -0.1
781        assert!((a.intrigue - 0.4).abs() < 1e-9);
782        // 10 days * -0.005/day = -0.05
783        assert!((a.tension - 0.45).abs() < 1e-9);
784        // unchanged — the endpoints' absence handling lives in refresh_endpoints
785        assert_eq!(a.patience, 0.5);
786        assert_eq!(a.warmth, 0.7);
787        assert_eq!(a.trust, 0.6);
788        assert_eq!(a.intimacy, 0.4);
789    }
790
791    #[test]
792    fn time_decay_clamps_at_floors() {
793        let mut a = fresh();
794        a.intrigue = 0.05;
795        a.tension = 0.02;
796        a.updated_at = Utc::now() - chrono::Duration::days(100);
797
798        a.apply_time_decay();
799
800        assert_eq!(a.intrigue, 0.0);
801        assert_eq!(a.tension, 0.0);
802    }
803
804    #[test]
805    fn tier_index_boundaries() {
806        assert_eq!(tier_index(0.0), 1);
807        assert_eq!(tier_index(0.149), 1);
808        assert_eq!(tier_index(0.15), 2);
809        assert_eq!(tier_index(0.349), 2);
810        assert_eq!(tier_index(0.35), 3);
811        assert_eq!(tier_index(0.619), 3);
812        assert_eq!(tier_index(0.62), 4);
813        assert_eq!(tier_index(0.899), 4);
814        assert_eq!(tier_index(0.9), 5);
815        assert_eq!(tier_index(1.0), 5);
816    }
817
818    /// Rung 1 stays welded to the bottom tier labels, so it cannot drift away
819    /// from what the rest of the system displays. The top rung has a floor of
820    /// its own, deliberately inside tier 4 — a tier-4 relationship already
821    /// clears it, well short of the apex. (That the two ladders cannot cross is
822    /// a compile-time assertion beside the constant. Exact cut values are not
823    /// reachable through the `/3` composites in f64, hence values either side
824    /// rather than on them.)
825    #[test]
826    fn intimacy_rung_cuts_against_the_tier_ladder() {
827        let mut a = fresh();
828
829        // Both lines at the bottom label → rung 1.
830        a.warmth = 0.1;
831        a.trust = 0.0;
832        a.intrigue = 0.0;
833        a.intimacy = 0.0;
834        a.tension = 0.0;
835        assert_eq!(a.bond_label(), BondLabel::Acquaintance);
836        assert_eq!(a.chemistry_label(), ChemistryLabel::Spark);
837        assert_eq!(a.intimacy_rung(), 1);
838
839        // Clear of tier 1, short of the top floor → rung 2.
840        a.warmth = 0.7;
841        a.trust = 0.7;
842        a.intrigue = 0.7;
843        assert_eq!(a.intimacy_rung(), 2);
844
845        // Past the floor while still tier 4 → rung 3 without reaching the apex.
846        a.warmth = 0.8;
847        a.trust = 0.8;
848        a.intrigue = 0.8;
849        assert_eq!(a.bond_label(), BondLabel::Confidant);
850        assert_eq!(a.intimacy_rung(), 3);
851    }
852
853    /// `max` over the two lines: either track alone can unlock.
854    #[test]
855    fn intimacy_rung_takes_the_further_line() {
856        let mut a = fresh();
857        // chemistry = (warmth + intimacy + tension)/3 = 0.95, bond = 0.1
858        a.warmth = 0.95;
859        a.trust = 0.0;
860        a.intrigue = 0.3;
861        a.intimacy = 0.95;
862        a.tension = 0.95;
863        assert!(a.bond_score() < a.chemistry_score());
864        assert_eq!(a.intimacy_rung(), 3);
865        // Mirror image: bond ahead, chemistry flat.
866        a.trust = 0.95;
867        a.intrigue = 0.95;
868        a.intimacy = 0.0;
869        a.tension = 0.0;
870        assert!(a.chemistry_score() < a.bond_score());
871        assert_eq!(a.intimacy_rung(), 3);
872    }
873
874    /// A brand-new session (migration-0029 seed) is rung 1, not an absent value.
875    #[test]
876    fn intimacy_rung_of_a_seeded_session_is_one() {
877        let mut a = fresh();
878        a.warmth = 0.1;
879        a.trust = 0.0;
880        a.intrigue = 0.0;
881        a.intimacy = 0.0;
882        a.tension = 0.0;
883        assert!(a.bond_score().max(a.chemistry_score()) < TIER1_HI);
884        assert_eq!(a.intimacy_rung(), 1);
885    }
886
887    #[test]
888    fn patience_band_boundaries() {
889        let mut a = fresh();
890        let mut at = |p: f64| {
891            a.patience = p;
892            a.patience_band()
893        };
894        assert_eq!(at(0.0), PatienceBand::Low);
895        assert_eq!(at(0.349), PatienceBand::Low);
896        assert_eq!(at(0.35), PatienceBand::Mid); // low → mid, inclusive
897        assert_eq!(at(0.649), PatienceBand::Mid);
898        assert_eq!(at(0.65), PatienceBand::High); // mid → high, inclusive
899        assert_eq!(at(1.0), PatienceBand::High);
900    }
901
902    /// The band reads the raw axis: moving the composites must not move it.
903    #[test]
904    fn patience_band_is_independent_of_the_composites() {
905        let mut a = fresh();
906        a.patience = 0.2;
907        a.warmth = 1.0;
908        a.trust = 1.0;
909        a.intrigue = 1.0;
910        a.intimacy = 1.0;
911        a.tension = 1.0;
912        assert_eq!(a.intimacy_rung(), 3);
913        assert_eq!(a.patience_band(), PatienceBand::Low);
914    }
915
916    #[test]
917    fn labels_map_from_scores() {
918        let mut a = fresh();
919        a.warmth = 0.0;
920        a.trust = 0.0;
921        a.intrigue = 0.0;
922        assert_eq!(a.bond_label(), BondLabel::Acquaintance); // bond 0
923        a.trust = 0.6;
924        a.intrigue = 0.6; // bond = 0.6 → tier 3
925        assert_eq!(a.bond_label(), BondLabel::CloseFriend);
926        a.warmth = 0.0;
927        a.intimacy = 0.0;
928        a.tension = 0.0;
929        assert_eq!(a.chemistry_label(), ChemistryLabel::Spark); // chem 0
930        a.intimacy = 0.8;
931        a.tension = 0.6; // chem = 0.7 → tier 4
932        assert_eq!(a.chemistry_label(), ChemistryLabel::Lover);
933        // tier 5 apex
934        a.warmth = 1.0;
935        a.trust = 1.0;
936        a.intrigue = 1.0; // bond = 1.0 → tier 5
937        assert_eq!(a.bond_label(), BondLabel::Soulmate);
938        a.intimacy = 1.0;
939        a.tension = 1.0; // chem = 1.0 → tier 5
940        assert_eq!(a.chemistry_label(), ChemistryLabel::Beloved);
941        assert_eq!(BondLabel::Soulmate.as_key(), "soulmate");
942        assert_eq!(ChemistryLabel::Beloved.as_key(), "beloved");
943    }
944
945    #[test]
946    fn legacy_label_stranger_when_both_tier1() {
947        let mut a = fresh();
948        a.warmth = 0.0;
949        a.trust = 0.0;
950        a.intrigue = 0.0;
951        a.intimacy = 0.0;
952        a.tension = 0.0;
953        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Stranger);
954    }
955
956    #[test]
957    fn legacy_label_friend_when_bond_leads() {
958        let mut a = fresh();
959        // bond = (0.3+0.6+0.6)/3 = 0.5 ; chem = (0.3+0+0)/3 = 0.1
960        a.warmth = 0.3;
961        a.trust = 0.6;
962        a.intrigue = 0.6;
963        a.intimacy = 0.0;
964        a.tension = 0.0;
965        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Friend);
966    }
967
968    #[test]
969    fn legacy_label_romantic_when_chemistry_high() {
970        let mut a = fresh();
971        // chem = (0.3+0.9+0.9)/3 = 0.7 (tier4) ; bond = 0.1
972        a.warmth = 0.3;
973        a.intimacy = 0.9;
974        a.tension = 0.9;
975        a.trust = 0.0;
976        a.intrigue = 0.0;
977        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::Romantic);
978    }
979
980    #[test]
981    fn legacy_label_slow_burn_when_chemistry_leads_but_mid() {
982        let mut a = fresh();
983        // chem = (0.3+0.3+0.2)/3 ≈ 0.267 (tier2) ; bond = 0.1 (tier1)
984        a.warmth = 0.3;
985        a.intimacy = 0.3;
986        a.tension = 0.2;
987        a.trust = 0.0;
988        a.intrigue = 0.0;
989        assert_eq!(a.legacy_relationship_label(), RelationshipLabel::SlowBurn);
990    }
991
992    #[test]
993    fn diff_labels_none_when_no_tier_change() {
994        let a = fresh();
995        let b = a.clone();
996        assert!(diff_labels(&a, &b).is_none());
997    }
998
999    #[test]
1000    fn diff_labels_reports_single_line_change() {
1001        let mut before = fresh();
1002        before.warmth = 0.0;
1003        before.trust = 0.0;
1004        before.intrigue = 0.0;
1005        before.intimacy = 0.0;
1006        before.tension = 0.0; // bond + chem both tier 1
1007        let mut after = before.clone();
1008        after.trust = 0.6;
1009        after.intrigue = 0.6; // bond = 0.6 → tier 3 (close_friend)
1010        let d = diff_labels(&before, &after).unwrap();
1011        let bond = d.bond.unwrap();
1012        assert_eq!(bond.from, "acquaintance");
1013        assert_eq!(bond.to, "close_friend");
1014        assert!(d.chemistry.is_none());
1015    }
1016
1017    // ─── grade pipeline (4.0) ───
1018
1019    fn zeroed() -> Affinity {
1020        let mut a = fresh();
1021        a.warmth = 0.0;
1022        a.trust = 0.0;
1023        a.intrigue = 0.0;
1024        a.intimacy = 0.0;
1025        a.tension = 0.0;
1026        a
1027    }
1028
1029    fn turn(
1030        a: &Affinity,
1031        grades: AxisGrades,
1032        rule: AffinityDeltas,
1033        pending: PendingDeltas,
1034        boost: f64,
1035        t: &AffinityTuning,
1036    ) -> GradeTurnOutcome {
1037        grade_turn(a, &grades, &rule, &pending, boost, t)
1038    }
1039
1040    /// Positive raw is damped by the OWN line's tier; a counterpart line inside
1041    /// the grace zone charges no penalty, past it the quadratic ramp starts.
1042    #[test]
1043    fn positive_decays_by_own_tier_and_pays_counterpart_ramp() {
1044        let t = AffinityTuning::default();
1045        let mut a = zeroed();
1046        a.trust = 0.75;
1047        a.intrigue = 0.75; // bond = 0.75 → tier 4; chemistry = 0 → tier 1
1048        let o = turn(
1049            &a,
1050            AxisGrades {
1051                trust: 2,
1052                intimacy: 2,
1053                ..Default::default()
1054            },
1055            AffinityDeltas::default(),
1056            PendingDeltas::default(),
1057            1.0,
1058            &t,
1059        );
1060        // trust: own tier 4 → ×0.25 at the bond unit, counterpart chem 0 → no penalty
1061        assert!((o.committed.trust - 2.0 * t.grade_unit_bond * 0.25).abs() < 1e-9);
1062        // intimacy: own tier 1 → ×1.0 at the chem unit, counterpart bond 0.75 →
1063        // κ_chem·((0.40/0.65)²), charged at 2/4 because the judge graded this
1064        // axis a 2 (proportional to the applied grade, not a flat toll).
1065        let p = t.cross_penalty_ratio * t.grade_unit_chem * (0.40f64 / 0.65).powi(2) * 2.0 / 4.0;
1066        assert!((o.committed.intimacy - (2.0 * t.grade_unit_chem - p)).abs() < 1e-9);
1067        assert!((o.cross_penalty_assessed.intimacy - p).abs() < 1e-9);
1068        assert_eq!(o.cross_penalty_assessed.trust, 0.0, "counterpart below y₀");
1069    }
1070
1071    /// **Changed with proportional charging.** Under the old flat toll this
1072    /// case asserted a g1 push netting −0.015 against a maxed counterpart while
1073    /// a g2 netted +0.02 — the sign flipped between grades, which is what made
1074    /// "the judge said up and the meter went down" possible.
1075    ///
1076    /// Charging in proportion makes `ρ = g · (D_k·u − κ·φ(y)/4)`: the bracket no
1077    /// longer depends on the grade, so **the outcome's sign always matches the
1078    /// verdict's** at this position. The grade sets the size, not the direction.
1079    #[test]
1080    fn penalty_scales_with_the_grade_so_the_outcome_cannot_flip_between_grades() {
1081        let mut a = zeroed();
1082        a.intimacy = 1.0;
1083        a.tension = 1.0; // chemistry = 1.0 — the counterpart; bond = 0 → own tier 1
1084        let t = AffinityTuning::default();
1085        let at = |g: i8| {
1086            turn(
1087                &a,
1088                AxisGrades {
1089                    trust: g,
1090                    ..Default::default()
1091                },
1092                AffinityDeltas::default(),
1093                PendingDeltas::default(),
1094                1.0,
1095                &t,
1096            )
1097        };
1098        // ρ = g·u_bond·(D₁ − ratio·φ(1)/4) = g·u_bond·(1 − 5/24): the bracket
1099        // has no g in it, so honest effort lands and g2 is exactly double g1.
1100        let unit_net = t.grade_unit_bond * (1.0 - t.cross_penalty_ratio / 4.0);
1101        assert!((at(1).committed.trust - unit_net).abs() < 1e-9);
1102        assert!((at(2).committed.trust - 2.0 * unit_net).abs() < 1e-9);
1103        assert!((at(4).committed.trust - 4.0 * unit_net).abs() < 1e-9);
1104        for g in 1..=4 {
1105            assert!(
1106                at(g).committed.trust > 0.0,
1107                "a positive verdict must not lower the score here (g{g})"
1108            );
1109        }
1110    }
1111
1112    /// The double-high lock survives where it is meant to. At own tier 5 the
1113    /// bracket `D₅·u − κ·φ(y)/4` is genuinely negative once the counterpart
1114    /// passes ≈0.761, so every grade nets negative — uniformly, not just the
1115    /// cheap ones. "You cannot be both" still holds at the apex; it just stopped
1116    /// firing on ordinary mid-relationship turns.
1117    #[test]
1118    fn tier_five_against_a_high_counterpart_still_loses_at_every_grade() {
1119        let t = AffinityTuning::default();
1120        let mut a = zeroed();
1121        a.trust = 1.0;
1122        a.intrigue = 1.0; // bond = 1.0 — the counterpart
1123        a.intimacy = 1.0;
1124        a.tension = 1.0; // chemistry = 1.0 → own tier 5
1125                         // ρ = g·u_chem·(D₅ − ratio·φ(1)/4) = g·u_chem·(0.10 − 5/24) < 0.
1126        let unit_net = t.grade_unit_chem * (0.10 - t.cross_penalty_ratio / 4.0);
1127        assert!(unit_net < 0.0);
1128        for g in 1..=4 {
1129            let o = turn(
1130                &a,
1131                AxisGrades {
1132                    intimacy: g,
1133                    ..Default::default()
1134                },
1135                AffinityDeltas::default(),
1136                PendingDeltas::default(),
1137                1.0,
1138                &t,
1139            );
1140            assert!(
1141                (o.committed.intimacy - f64::from(g) * unit_net).abs() < 1e-9,
1142                "g{g}"
1143            );
1144            assert!(o.committed.intimacy < 0.0, "g{g}");
1145        }
1146    }
1147
1148    /// Negative raw is never damped by tier, and the penalty still stacks on
1149    /// top — now at the grade's share rather than the full toll.
1150    #[test]
1151    fn negative_skips_decay_and_pays_extra() {
1152        let t = AffinityTuning::default();
1153        let mut a = zeroed();
1154        a.intimacy = 1.0;
1155        a.tension = 1.0; // chemistry = 1.0
1156        a.trust = 0.9;
1157        a.intrigue = 0.9; // bond = 0.9 → tier 5 (own tier must not soften the loss)
1158        let o = turn(
1159            &a,
1160            AxisGrades {
1161                trust: -2,
1162                ..Default::default()
1163            },
1164            AffinityDeltas::default(),
1165            PendingDeltas::default(),
1166            1.0,
1167            &t,
1168        );
1169        // −2·u_bond·1.5, minus κ_bond·φ(1.0)·2/4 on top — the penalty reads the
1170        // grade's MAGNITUDE, so a loss is taxed by how big it is.
1171        let p = t.cross_penalty_ratio * t.grade_unit_bond * 2.0 / 4.0;
1172        let expect = -2.0 * t.grade_unit_bond * t.neg_factor - p;
1173        assert!((o.committed.trust - expect).abs() < 1e-9);
1174        assert!((o.cross_penalty_assessed.trust - p).abs() < 1e-9);
1175    }
1176
1177    /// P6: the pipeline charges events, not rent — an all-zero verdict moves
1178    /// nothing no matter how high the lines sit, and pending survives intact.
1179    #[test]
1180    fn zero_verdict_charges_nothing() {
1181        let mut a = zeroed();
1182        a.warmth = 1.0;
1183        a.trust = 1.0;
1184        a.intrigue = 1.0;
1185        a.intimacy = 1.0;
1186        a.tension = 1.0;
1187        let pending = PendingDeltas {
1188            trust: 0.02,
1189            ..Default::default()
1190        };
1191        let t = AffinityTuning {
1192            delta_threshold: 0.5,
1193            ..Default::default()
1194        };
1195        let o = turn(
1196            &a,
1197            AxisGrades::default(),
1198            AffinityDeltas::default(),
1199            pending,
1200            1.0,
1201            &t,
1202        );
1203        assert_eq!(o.committed.trust, 0.0);
1204        assert_eq!(o.committed.warmth, 0.0);
1205        assert!((o.pending.trust - 0.02).abs() < 1e-9);
1206    }
1207
1208    /// Rule nudges ride the same decay but never trigger the penalty (only a
1209    /// judge-touched axis pays it).
1210    #[test]
1211    fn rule_only_delta_decays_but_pays_no_penalty() {
1212        let mut a = zeroed();
1213        a.trust = 0.2;
1214        a.intrigue = 0.3; // bond = 0.25 → own tier 2 (decay 0.70)
1215        a.intimacy = 1.0;
1216        a.tension = 1.0; // chem 1.0 — the counterpart, maximally expensive
1217        let o = turn(
1218            &a,
1219            AxisGrades::default(),
1220            AffinityDeltas {
1221                intrigue: 0.02,
1222                ..Default::default()
1223            },
1224            PendingDeltas::default(),
1225            1.0,
1226            &AffinityTuning::default(),
1227        );
1228        assert!((o.committed.intrigue - 0.7 * 0.02).abs() < 1e-9);
1229    }
1230
1231    /// The decision-doc threshold example, verbatim: θ=0.5, real scores
1232    /// 0.1 / 0.2 / 0.3 per turn → committed deltas 0 / 0 / 0.6.
1233    #[test]
1234    fn threshold_accumulates_until_it_clears() {
1235        let a = zeroed(); // trust stays 0 (nothing commits), so tier stays 1 and D=1
1236        let t = AffinityTuning {
1237            delta_threshold: 0.5,
1238            ..Default::default()
1239        };
1240        let mut pending = PendingDeltas::default();
1241        let mut committed = Vec::new();
1242        for r in [0.1, 0.2, 0.3] {
1243            let o = turn(
1244                &a,
1245                AxisGrades::default(),
1246                AffinityDeltas {
1247                    trust: r,
1248                    ..Default::default()
1249                },
1250                pending,
1251                1.0,
1252                &t,
1253            );
1254            committed.push(o.committed.trust);
1255            pending = o.pending;
1256        }
1257        assert_eq!(committed[0], 0.0);
1258        assert_eq!(committed[1], 0.0);
1259        assert!((committed[2] - 0.6).abs() < 1e-9);
1260        assert!(pending.is_zero());
1261    }
1262
1263    /// Signed accumulation: opposite-sign real scores cancel inside the gate,
1264    /// and a cancelled balance neither commits nor lingers.
1265    #[test]
1266    fn threshold_gate_cancels_opposite_signs() {
1267        let a = zeroed();
1268        let t = AffinityTuning {
1269            delta_threshold: 0.5,
1270            ..Default::default()
1271        };
1272        let o = turn(
1273            &a,
1274            AxisGrades::default(),
1275            AffinityDeltas {
1276                trust: -0.1,
1277                ..Default::default()
1278            },
1279            PendingDeltas {
1280                trust: 0.1,
1281                ..Default::default()
1282            },
1283            1.0,
1284            &t,
1285        );
1286        assert_eq!(o.committed.trust, 0.0);
1287        assert_eq!(o.pending.trust, 0.0, "+0.1 pending − 0.1 real cancels out");
1288    }
1289
1290    /// Demo boost multiplies positive raw only; losses stay full price.
1291    #[test]
1292    fn demo_boost_is_positive_only() {
1293        let t = AffinityTuning::default();
1294        let a = zeroed();
1295        let o = turn(
1296            &a,
1297            AxisGrades {
1298                trust: 1,
1299                intimacy: -1,
1300                ..Default::default()
1301            },
1302            AffinityDeltas::default(),
1303            PendingDeltas::default(),
1304            1.4,
1305            &t,
1306        );
1307        assert!((o.committed.trust - 1.4 * t.grade_unit_bond).abs() < 1e-9);
1308        assert!((o.committed.intimacy - (-t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
1309    }
1310
1311    /// The endpoints left the pipeline: a rule patience delta (there are none
1312    /// in production any more) is discarded, and raw/committed report 0.0 on
1313    /// both endpoint fields — deriving them is `refresh_endpoints`' job.
1314    #[test]
1315    fn endpoint_fields_are_inert_in_the_pipeline() {
1316        let a = zeroed();
1317        let o = turn(
1318            &a,
1319            AxisGrades::default(),
1320            AffinityDeltas {
1321                warmth: 0.5,
1322                patience: -0.02,
1323                ..Default::default()
1324            },
1325            PendingDeltas::default(),
1326            1.0,
1327            &AffinityTuning::default(),
1328        );
1329        assert_eq!(o.raw.warmth, 0.0);
1330        assert_eq!(o.committed.warmth, 0.0);
1331        assert_eq!(o.raw.patience, 0.0);
1332        assert_eq!(o.committed.patience, 0.0);
1333    }
1334
1335    /// Out-of-range grades clamp instead of scaling: the judge cannot mint
1336    /// more than a ±4 verdict no matter what it emits.
1337    #[test]
1338    fn grades_clamp_to_plus_minus_four() {
1339        let t = AffinityTuning::default();
1340        let a = zeroed();
1341        let o = turn(
1342            &a,
1343            AxisGrades {
1344                trust: 9,
1345                intimacy: -9,
1346                ..Default::default()
1347            },
1348            AffinityDeltas::default(),
1349            PendingDeltas::default(),
1350            1.0,
1351            &t,
1352        );
1353        assert!((o.committed.trust - 4.0 * t.grade_unit_bond).abs() < 1e-9);
1354        assert!((o.committed.intimacy - (-4.0 * t.grade_unit_chem * t.neg_factor)).abs() < 1e-9);
1355    }
1356
1357    #[test]
1358    fn diff_labels_reports_both_lines() {
1359        let mut before = fresh();
1360        before.warmth = 0.0;
1361        before.trust = 0.0;
1362        before.intrigue = 0.0;
1363        before.intimacy = 0.0;
1364        before.tension = 0.0;
1365        let mut after = before.clone();
1366        after.trust = 0.6;
1367        after.intrigue = 0.6; // bond = 0.6 → tier 3 (close_friend)
1368        after.intimacy = 0.5;
1369        after.tension = 0.5; // chem = 0.5 → tier 3 (crush)
1370        let d = diff_labels(&before, &after).unwrap();
1371        assert_eq!(d.bond.unwrap().to, "close_friend");
1372        assert_eq!(d.chemistry.unwrap().to, "crush");
1373    }
1374
1375    // ─── Endpoint derivation (4.0) ──────────────────────────────────
1376
1377    #[test]
1378    fn endpoint_boost_anchors() {
1379        // B(PIVOT)=1 exactly; B(1)=B_MAX; B(0)=1−0.35·10/13.
1380        assert!((endpoint_boost(0.35) - 1.0).abs() < 1e-12);
1381        assert!((endpoint_boost(1.0) - 1.5).abs() < 1e-12);
1382        assert!((endpoint_boost(0.0) - (1.0 - 0.35 * 10.0 / 13.0)).abs() < 1e-12);
1383    }
1384
1385    #[test]
1386    fn endpoint_value_exact_ceiling_and_ranges() {
1387        // Level 3 × counterpart 1 × decay 1 = exactly 1.0 (no clamp doing work).
1388        assert!((endpoint_value(3, 1.0, 1.0, 0.2) - 1.0).abs() < 1e-9);
1389        // Level ranges at decay=1: L2 ∈ [0.2436, 0.5], L3 ∈ [0.4872, 1.0].
1390        assert!(
1391            (endpoint_value(2, 0.0, 1.0, 0.2) - (1.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
1392                < 1e-9
1393        );
1394        assert!((endpoint_value(2, 1.0, 1.0, 0.2) - 0.5).abs() < 1e-9);
1395        assert!(
1396            (endpoint_value(3, 0.0, 1.0, 0.2) - (2.0 / 3.0) * (1.0 - 0.35 * 10.0 / 13.0)).abs()
1397                < 1e-9
1398        );
1399    }
1400
1401    #[test]
1402    fn endpoint_floor_only_acts_on_level_one() {
1403        // Level 1: value = φ·x (base 0, floor carries it).
1404        assert!((endpoint_value(1, 0.9, 1.0, 0.2) - 0.18).abs() < 1e-9);
1405        assert!((endpoint_value(1, 0.0, 1.0, 0.2) - 0.0).abs() < 1e-9);
1406        // Level 2 at ANY counterpart beats the floor: φ·x ≤ 0.2 < 0.2436 ≤ base·B.
1407        for x in [0.0, 0.35, 0.7, 1.0] {
1408            let with_floor = endpoint_value(2, x, 1.0, 0.2);
1409            let without = endpoint_value(2, x, 1.0, 0.0);
1410            assert!(
1411                (with_floor - without).abs() < 1e-12,
1412                "floor must not touch level 2 at x={x}"
1413            );
1414        }
1415    }
1416
1417    #[test]
1418    fn endpoint_level_out_of_range_clamps() {
1419        // Defensive: a stored 0 or 7 behaves as the nearest valid level.
1420        assert_eq!(
1421            endpoint_value(0, 0.5, 1.0, 0.2),
1422            endpoint_value(1, 0.5, 1.0, 0.2)
1423        );
1424        assert_eq!(
1425            endpoint_value(7, 0.5, 1.0, 0.2),
1426            endpoint_value(3, 0.5, 1.0, 0.2)
1427        );
1428    }
1429
1430    #[test]
1431    fn composites_are_two_axis_means() {
1432        let mut a = fresh();
1433        a.warmth = 1.0; // must NOT leak into either line any more
1434        a.trust = 0.4;
1435        a.intrigue = 0.6;
1436        a.intimacy = 0.3;
1437        a.tension = 0.2;
1438        assert!((a.bond_score() - 0.5).abs() < 1e-9);
1439        assert!((a.chemistry_score() - 0.25).abs() < 1e-9);
1440    }
1441
1442    #[test]
1443    fn refresh_endpoints_tsundere_quadrant() {
1444        // Low bond × high chem ⇒ low patience × high warmth, from one verdict pair.
1445        let t = AffinityTuning::default();
1446        let mut a = fresh();
1447        a.trust = 0.1;
1448        a.intrigue = 0.1; // bond = 0.1
1449        a.intimacy = 0.9;
1450        a.tension = 0.9; // chem = 0.9
1451        a.warmth_grade = 3;
1452        a.patience_grade = 3;
1453        a.updated_at = Utc::now(); // decay ≈ 1
1454        a.refresh_endpoints(&t);
1455        // warmth = 2/3 · B(0.9) = 2/3 · 1.4231 ≈ 0.949
1456        assert!((a.warmth - (2.0 / 3.0) * (1.0 + (10.0 / 13.0) * 0.55)).abs() < 1e-6);
1457        // patience = 2/3 · B(0.1) = 2/3 · 0.8077 ≈ 0.538
1458        assert!((a.patience - (2.0 / 3.0) * (1.0 - (10.0 / 13.0) * 0.25)).abs() < 1e-6);
1459        assert!(a.warmth > a.patience, "tsundere: warm but impatient");
1460    }
1461
1462    #[test]
1463    fn time_decay_no_longer_drifts_patience_up() {
1464        let mut a = fresh();
1465        a.patience = 0.4;
1466        a.updated_at = Utc::now() - chrono::Duration::days(10);
1467        a.apply_time_decay();
1468        assert!(
1469            (a.patience - 0.4).abs() < 1e-12,
1470            "patience drift retired; endpoint decay owns absence now"
1471        );
1472    }
1473
1474    #[test]
1475    fn per_line_units_and_ratio_kappa() {
1476        // A +2 trust grade at tier 1, no counterpart pressure, no gate:
1477        // committed = 2 · u_bond · decay(tier1)=1.0. Same grade on intimacy → 2·u_chem.
1478        let t = AffinityTuning::default();
1479        let mut a = fresh();
1480        a.warmth = 0.0;
1481        a.trust = 0.0;
1482        a.intrigue = 0.0;
1483        a.intimacy = 0.0;
1484        a.tension = 0.0;
1485        let g = AxisGrades {
1486            trust: 2,
1487            intrigue: 0,
1488            intimacy: 2,
1489            tension: 0,
1490        };
1491        let out = grade_turn(
1492            &a,
1493            &g,
1494            &AffinityDeltas::default(),
1495            &PendingDeltas::default(),
1496            1.0,
1497            &t,
1498        );
1499        assert!((out.committed.trust - 2.0 * t.grade_unit_bond).abs() < 1e-9);
1500        assert!((out.committed.intimacy - 2.0 * t.grade_unit_chem).abs() < 1e-9);
1501        assert_eq!(out.committed.warmth, 0.0);
1502        assert_eq!(out.committed.patience, 0.0);
1503    }
1504
1505    #[test]
1506    fn break_even_position_is_unit_invariant() {
1507        // κ = ratio·u ⇒ φ(y*) = 4·D_k/ratio — the double-high wall cannot move
1508        // with the unit. Verify by scanning for the sign flip of a +1 intimacy
1509        // grade at chem tier 5, under two very different chem units.
1510        let mut t1 = AffinityTuning::default();
1511        let mut t2 = AffinityTuning::default();
1512        t1.grade_unit_chem = 0.0266;
1513        t2.grade_unit_chem = 0.10;
1514        let y_star = |t: &AffinityTuning| {
1515            let mut a = fresh();
1516            a.warmth = 0.0;
1517            a.intimacy = 1.0;
1518            a.tension = 0.9; // own line (chem) tier 5
1519            (0..=1000).map(|i| f64::from(i) / 1000.0).find(|&y| {
1520                let mut b = a.clone();
1521                b.trust = y;
1522                b.intrigue = y; // counterpart bond = y
1523                let g = AxisGrades {
1524                    trust: 0,
1525                    intrigue: 0,
1526                    intimacy: 1,
1527                    tension: 0,
1528                };
1529                let out = grade_turn(
1530                    &b,
1531                    &g,
1532                    &AffinityDeltas::default(),
1533                    &PendingDeltas::default(),
1534                    1.0,
1535                    t,
1536                );
1537                out.committed.intimacy < 0.0
1538            })
1539        };
1540        let y1 = y_star(&t1);
1541        assert!(y1.is_some(), "a break-even must exist at tier 5");
1542        assert_eq!(
1543            y1,
1544            y_star(&t2),
1545            "κ tied to unit ⇒ wall does not move with the unit"
1546        );
1547    }
1548
1549    #[test]
1550    fn endpoint_time_decay_linear_with_floor() {
1551        assert!((endpoint_time_decay(0.0, 0.02, 0.5) - 1.0).abs() < 1e-12);
1552        assert!((endpoint_time_decay(7.0, 0.02, 0.5) - 0.86).abs() < 1e-12);
1553        assert!((endpoint_time_decay(25.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
1554        assert!((endpoint_time_decay(60.0, 0.02, 0.5) - 0.5).abs() < 1e-12);
1555        assert!((endpoint_time_decay(-3.0, 0.02, 0.5) - 1.0).abs() < 1e-12); // clock skew → no decay
1556    }
1557}