Skip to main content

microscope_memory/
emotional.rs

1//! Emotional bias warp for Microscope Memory.
2//!
3//! Bends search space coordinates based on the emotional layer's active clusters.
4//! Does NOT override the search — warps the query point toward emotional attractors.
5//! The weight is configurable in config.toml (`search.emotional_bias_weight`).
6
7use crate::hebbian::HebbianState;
8use crate::reader::MicroscopeReader;
9use crate::LAYER_NAMES;
10
11/// The emotional layer ID (index 4 in LAYER_NAMES: "emotional").
12const EMOTIONAL_LAYER_ID: u8 = 4;
13
14/// Compute the emotional bias warp for a query point.
15/// Returns warped (x, y, z) coordinates.
16/// The warp pulls the query point toward the centroid of hot emotional blocks,
17/// weighted by their Hebbian energy. With weight=0, the original coords are returned.
18pub fn apply_emotional_bias(
19    qx: f32,
20    qy: f32,
21    qz: f32,
22    weight: f32,
23    reader: &MicroscopeReader,
24    hebb: &HebbianState,
25) -> (f32, f32, f32) {
26    if weight <= 0.0 {
27        return (qx, qy, qz);
28    }
29
30    let weight = weight.clamp(0.0, 1.0);
31
32    // Find active emotional blocks and their weighted centroid
33    let mut sum_x = 0.0f32;
34    let mut sum_y = 0.0f32;
35    let mut sum_z = 0.0f32;
36    let mut total_energy = 0.0f32;
37
38    // Candidates first, headers second. The previous form read all 699,110
39    // block headers to find the handful of hot emotional blocks (measured:
40    // 11.7 ms per recall, and this function runs on every recall when
41    // emotional_bias_weight > 0). hot_indices() yields the same set in the
42    // same order, so the sums below are unchanged bit for bit.
43    for i in hebb.hot_indices(0.01) {
44        let h = reader.header(i);
45        if h.layer_id != EMOTIONAL_LAYER_ID {
46            continue;
47        }
48        let energy = hebb.energy(i);
49
50        // Copy packed struct fields
51        let hx = h.x;
52        let hy = h.y;
53        let hz = h.z;
54
55        sum_x += hx * energy;
56        sum_y += hy * energy;
57        sum_z += hz * energy;
58        total_energy += energy;
59    }
60
61    if total_energy < 0.01 {
62        return (qx, qy, qz); // No active emotional blocks
63    }
64
65    // Emotional centroid
66    let cx = sum_x / total_energy;
67    let cy = sum_y / total_energy;
68    let cz = sum_z / total_energy;
69
70    // Warp: blend query point toward emotional centroid
71    let warped_x = qx + (cx - qx) * weight;
72    let warped_y = qy + (cy - qy) * weight;
73    let warped_z = qz + (cz - qz) * weight;
74
75    (warped_x, warped_y, warped_z)
76}
77
78/// Get the current emotional field summary:
79/// centroid of active emotional blocks, total energy, and count.
80pub fn emotional_field(reader: &MicroscopeReader, hebb: &HebbianState) -> Option<EmotionalField> {
81    let mut sum_x = 0.0f32;
82    let mut sum_y = 0.0f32;
83    let mut sum_z = 0.0f32;
84    let mut total_energy = 0.0f32;
85    let mut active_count = 0usize;
86    let mut hottest_idx: Option<(usize, f32)> = None;
87
88    // Same candidate-first construction as apply_emotional_bias: build the hot
89    // set from the in-memory energy, then read only those headers. The
90    // integration test asserts the result is bit-identical to the full scan,
91    // including the accumulation order.
92    for i in hebb.hot_indices(0.01) {
93        let h = reader.header(i);
94        if h.layer_id != EMOTIONAL_LAYER_ID {
95            continue;
96        }
97        let energy = hebb.energy(i);
98
99        let hx = h.x;
100        let hy = h.y;
101        let hz = h.z;
102
103        sum_x += hx * energy;
104        sum_y += hy * energy;
105        sum_z += hz * energy;
106        total_energy += energy;
107        active_count += 1;
108
109        if hottest_idx.is_none() || energy > hottest_idx.unwrap().1 {
110            hottest_idx = Some((i, energy));
111        }
112    }
113
114    if active_count == 0 {
115        return None;
116    }
117
118    Some(EmotionalField {
119        centroid: (
120            sum_x / total_energy,
121            sum_y / total_energy,
122            sum_z / total_energy,
123        ),
124        total_energy,
125        active_blocks: active_count,
126        hottest_block: hottest_idx,
127    })
128}
129
130pub struct EmotionalField {
131    pub centroid: (f32, f32, f32),
132    pub total_energy: f32,
133    pub active_blocks: usize,
134    pub hottest_block: Option<(usize, f32)>,
135}
136
137/// Pure math version of `apply_emotional_bias` — bends the query point toward
138/// a precomputed emotional centroid. Public so recall paths can reuse the
139/// single `emotional_field` scan instead of rescanning all blocks.
140pub fn apply_emotional_bias_from_centroid(
141    qx: f32,
142    qy: f32,
143    qz: f32,
144    weight: f32,
145    centroid: Option<(f32, f32, f32)>,
146) -> (f32, f32, f32) {
147    if weight <= 0.0 {
148        return (qx, qy, qz);
149    }
150    match centroid {
151        None => (qx, qy, qz),
152        Some((cx, cy, cz)) => {
153            let w = weight.clamp(0.0, 1.0);
154            (qx + (cx - qx) * w, qy + (cy - qy) * w, qz + (cz - qz) * w)
155        }
156    }
157}
158
159/// Verify the emotional layer ID matches our constant.
160pub fn emotional_layer_name() -> &'static str {
161    LAYER_NAMES
162        .get(EMOTIONAL_LAYER_ID as usize)
163        .unwrap_or(&"emotional")
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    #[test]
170    fn test_no_warp_at_zero_weight() {
171        let (x, _y, _z) = apply_emotional_bias_from_centroid(0.5, 0.5, 0.5, 0.0, None);
172        assert!((x - 0.5).abs() < 0.001);
173    }
174
175    #[test]
176    fn test_warp_with_centroid() {
177        // Test the pure math: warp toward centroid
178        let centroid = Some((0.2, 0.3, 0.4));
179        let (x, y, z) = apply_emotional_bias_from_centroid(0.5, 0.5, 0.5, 0.5, centroid);
180        // Should move halfway toward centroid
181        assert!((x - 0.35).abs() < 0.001);
182        assert!((y - 0.4).abs() < 0.001);
183        assert!((z - 0.45).abs() < 0.001);
184    }
185
186    #[test]
187    fn test_warp_full_weight() {
188        let centroid = Some((0.2, 0.3, 0.4));
189        let (x, y, z) = apply_emotional_bias_from_centroid(0.5, 0.5, 0.5, 1.0, centroid);
190        // Should move fully to centroid
191        assert!((x - 0.2).abs() < 0.001);
192        assert!((y - 0.3).abs() < 0.001);
193        assert!((z - 0.4).abs() < 0.001);
194    }
195
196    #[test]
197    fn test_no_centroid() {
198        let (x, _y, _z) = apply_emotional_bias_from_centroid(0.5, 0.5, 0.5, 1.0, None);
199        // No centroid → no warp
200        assert!((x - 0.5).abs() < 0.001);
201    }
202
203    #[test]
204    fn test_emotional_layer_name() {
205        assert_eq!(emotional_layer_name(), "emotional");
206    }
207}