1use std::collections::HashMap;
19use std::fs;
20use std::io::Write;
21use std::path::Path;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24const ACTIVATION_RECORD_BYTES: usize = 32; const ACTIVATION_GROWTH_LIMIT: usize = 4_000_000;
32const COACTIVATION_RECORD_BYTES: usize = 20; const ENERGY_HALF_LIFE_MS: f64 = 86_400_000.0; const DRIFT_RATE: f32 = 0.01; const DRIFT_MAX: f32 = 0.1; #[repr(C)]
41#[derive(Clone, Copy, Debug)]
42pub struct ActivationRecord {
43 pub activation_count: u32,
44 pub last_activated_ms: u64,
45 pub drift_x: f32,
46 pub drift_y: f32,
47 pub drift_z: f32,
48 pub energy: f32,
49 pub _pad: u32,
50}
51
52impl Default for ActivationRecord {
53 fn default() -> Self {
54 Self {
55 activation_count: 0,
56 last_activated_ms: 0,
57 drift_x: 0.0,
58 drift_y: 0.0,
59 drift_z: 0.0,
60 energy: 0.0,
61 _pad: 0,
62 }
63 }
64}
65
66#[repr(C)]
68#[derive(Clone, Copy, Debug)]
69pub struct CoactivationPair {
70 pub block_a: u32,
71 pub block_b: u32,
72 pub count: u32,
73 pub last_ts_ms: u64,
74}
75
76#[derive(Clone, Debug)]
79pub struct ActivationFingerprint {
80 pub timestamp_ms: u64,
81 pub query_hash: u64,
82 pub activations: Vec<(u32, f32)>, }
84
85#[derive(Clone)]
89pub struct HebbianState {
90 pub activations: Vec<ActivationRecord>,
91 pub coactivations: HashMap<(u32, u32), CoactivationPair>,
92 pub fingerprints: Vec<ActivationFingerprint>,
93}
94
95impl HebbianState {
96 pub fn load_or_init(output_dir: &Path, block_count: usize) -> Self {
98 let activations = load_activations(output_dir, block_count);
99 let coactivations = load_coactivations(output_dir);
100 let fingerprints = load_fingerprints(output_dir);
101
102 Self {
103 activations,
104 coactivations,
105 fingerprints,
106 }
107 }
108
109 pub fn record_activation(&mut self, results: &[(u32, f32)], query_hash: u64) {
112 let now_ms = now_epoch_ms();
113
114 for &(block_idx, _score) in results {
116 let idx = block_idx as usize;
117 if idx < self.activations.len() {
118 let rec = &mut self.activations[idx];
119 rec.activation_count = rec.activation_count.saturating_add(1);
120 rec.last_activated_ms = now_ms;
121 rec.energy = 1.0; } else if idx < ACTIVATION_GROWTH_LIMIT {
123 self.activations
133 .resize(idx + 1, ActivationRecord::default());
134 let rec = &mut self.activations[idx];
135 rec.activation_count = 1;
136 rec.last_activated_ms = now_ms;
137 rec.energy = 1.0;
138 }
139 }
140
141 for i in 0..results.len() {
143 for j in (i + 1)..results.len() {
144 let a = results[i].0.min(results[j].0);
145 let b = results[i].0.max(results[j].0);
146 let pair = self
147 .coactivations
148 .entry((a, b))
149 .or_insert(CoactivationPair {
150 block_a: a,
151 block_b: b,
152 count: 0,
153 last_ts_ms: 0,
154 });
155 pair.count = pair.count.saturating_add(1);
156 pair.last_ts_ms = now_ms;
157 }
158 }
159
160 self.fingerprints.push(ActivationFingerprint {
162 timestamp_ms: now_ms,
163 query_hash,
164 activations: results.to_vec(),
165 });
166
167 if self.fingerprints.len() > 1000 {
169 self.fingerprints.drain(0..self.fingerprints.len() - 1000);
170 }
171 }
172
173 pub fn apply_drift(&mut self, headers: &[(f32, f32, f32)]) {
176 let now_ms = now_epoch_ms();
177
178 for rec in &mut self.activations {
183 if rec.energy > 0.0 && rec.last_activated_ms > 0 {
184 let elapsed_ms = (now_ms - rec.last_activated_ms) as f64;
185 if elapsed_ms < 0.0 {
186 continue;
188 }
189 let decay =
190 (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2).exp() as f32;
191 rec.energy *= decay;
192 }
193 }
194
195 for pair in self.coactivations.values() {
197 let a = pair.block_a as usize;
198 let b = pair.block_b as usize;
199
200 if a >= headers.len() || b >= headers.len() {
201 continue;
202 }
203
204 let strength = (pair.count as f32).ln().min(5.0) * DRIFT_RATE;
206 if strength < 0.001 {
207 continue;
208 }
209
210 let (ax, ay, az) = headers[a];
211 let (bx, by, bz) = headers[b];
212
213 let dx = bx + self.activations[b].drift_x - (ax + self.activations[a].drift_x);
215 let dy = by + self.activations[b].drift_y - (ay + self.activations[a].drift_y);
216 let dz = bz + self.activations[b].drift_z - (az + self.activations[a].drift_z);
217
218 let dist = (dx * dx + dy * dy + dz * dz).sqrt();
219 if dist < 0.001 {
220 continue;
221 }
222
223 let nx = dx / dist * strength;
225 let ny = dy / dist * strength;
226 let nz = dz / dist * strength;
227
228 self.activations[a].drift_x = clamp_drift(self.activations[a].drift_x + nx);
229 self.activations[a].drift_y = clamp_drift(self.activations[a].drift_y + ny);
230 self.activations[a].drift_z = clamp_drift(self.activations[a].drift_z + nz);
231
232 self.activations[b].drift_x = clamp_drift(self.activations[b].drift_x - nx);
233 self.activations[b].drift_y = clamp_drift(self.activations[b].drift_y - ny);
234 self.activations[b].drift_z = clamp_drift(self.activations[b].drift_z - nz);
235 }
236 }
237
238 pub fn effective_coords(&self, block_idx: usize, original: (f32, f32, f32)) -> (f32, f32, f32) {
240 if block_idx < self.activations.len() {
241 let rec = &self.activations[block_idx];
242 (
243 original.0 + rec.drift_x,
244 original.1 + rec.drift_y,
245 original.2 + rec.drift_z,
246 )
247 } else {
248 original
249 }
250 }
251
252 pub fn energy(&self, block_idx: usize) -> f32 {
254 if block_idx < self.activations.len() {
255 let rec = &self.activations[block_idx];
256 if rec.energy > 0.0 && rec.last_activated_ms > 0 {
257 let elapsed_ms = (now_epoch_ms() - rec.last_activated_ms) as f64;
258 let decay = (-(elapsed_ms / ENERGY_HALF_LIFE_MS) * std::f64::consts::LN_2).exp();
259 decay as f32
260 } else {
261 0.0
262 }
263 } else {
264 0.0
265 }
266 }
267
268 pub fn hot_indices(&self, threshold: f32) -> Vec<usize> {
277 let mut out = Vec::new();
278 for (i, rec) in self.activations.iter().enumerate() {
279 if rec.energy <= 0.0 || rec.last_activated_ms == 0 {
282 continue;
283 }
284 if self.energy(i) >= threshold {
285 out.push(i);
286 }
287 }
288 out
289 }
290
291 pub fn save(&self, output_dir: &Path) -> Result<(), String> {
294 save_activations(output_dir, &self.activations)?;
295 save_coactivations(output_dir, &self.coactivations)?;
296 save_fingerprints(output_dir, &self.fingerprints)?;
297 Ok(())
298 }
299
300 pub fn save_dirty(&self, output_dir: &Path, dirty: &[u32]) -> Result<(), String> {
309 let base_missing = !output_dir.join("activations.bin").exists();
310 if base_missing || delta_journal_records(output_dir) >= JOURNAL_MAX_RECORDS {
311 save_activations(output_dir, &self.activations)?;
312 } else {
313 append_activation_deltas(output_dir, &self.activations, dirty)?;
314 }
315 save_coactivations(output_dir, &self.coactivations)?;
316 save_fingerprints(output_dir, &self.fingerprints)?;
317 Ok(())
318 }
319
320 pub fn stats(&self) -> HebbianStats {
322 let active_blocks = self
323 .activations
324 .iter()
325 .filter(|r| r.activation_count > 0)
326 .count();
327 let total_activations: u64 = self
328 .activations
329 .iter()
330 .map(|r| r.activation_count as u64)
331 .sum();
332 let hot_blocks = self
333 .activations
334 .iter()
335 .enumerate()
336 .filter(|(i, _)| self.energy(*i) > 0.1)
337 .count();
338 let drifted_blocks = self
339 .activations
340 .iter()
341 .filter(|r| {
342 r.drift_x.abs() > 0.001 || r.drift_y.abs() > 0.001 || r.drift_z.abs() > 0.001
343 })
344 .count();
345
346 HebbianStats {
347 block_count: self.activations.len(),
348 active_blocks,
349 total_activations,
350 hot_blocks,
351 coactivation_pairs: self.coactivations.len(),
352 fingerprint_count: self.fingerprints.len(),
353 drifted_blocks,
354 }
355 }
356
357 pub fn latest_fingerprint(&self) -> Option<&ActivationFingerprint> {
359 self.fingerprints.last()
360 }
361
362 pub fn hottest_blocks(&self, n: usize) -> Vec<(usize, f32)> {
364 let mut blocks: Vec<(usize, f32)> = (0..self.activations.len())
365 .map(|i| (i, self.energy(i)))
366 .filter(|(_, e)| *e > 0.01)
367 .collect();
368 blocks.sort_by(|a, b| b.1.total_cmp(&a.1));
369 blocks.truncate(n);
370 blocks
371 }
372
373 pub fn strongest_pairs(&self, n: usize) -> Vec<&CoactivationPair> {
375 let mut pairs: Vec<&CoactivationPair> = self.coactivations.values().collect();
376 pairs.sort_by_key(|b| std::cmp::Reverse(b.count));
377 pairs.truncate(n);
378 pairs
379 }
380
381 pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
386 let old_len = self.activations.len();
387 let new_len = old_to_new
390 .iter()
391 .copied()
392 .filter(|&m| m != u32::MAX)
393 .max()
394 .map_or(0, |m| m as usize + 1);
395
396 let mut new_activations = vec![ActivationRecord::default(); new_len];
398 for (old_idx, &new_idx) in old_to_new
399 .iter()
400 .enumerate()
401 .take(old_len.min(old_to_new.len()))
402 {
403 if new_idx != u32::MAX && (new_idx as usize) < new_len {
404 new_activations[new_idx as usize] = self.activations[old_idx];
405 }
406 }
407 self.activations = new_activations;
408
409 let mut new_coactivations = HashMap::new();
411 for ((a, b), mut pair) in self.coactivations.drain() {
412 let na = if (a as usize) < old_to_new.len() {
413 old_to_new[a as usize]
414 } else {
415 u32::MAX
416 };
417 let nb = if (b as usize) < old_to_new.len() {
418 old_to_new[b as usize]
419 } else {
420 u32::MAX
421 };
422 if na != u32::MAX && nb != u32::MAX && na != nb {
423 let key = (na.min(nb), na.max(nb));
424 pair.block_a = key.0;
425 pair.block_b = key.1;
426 new_coactivations
427 .entry(key)
428 .and_modify(|e: &mut CoactivationPair| {
429 e.count = e.count.saturating_add(pair.count);
430 })
431 .or_insert(pair);
432 }
433 }
434 self.coactivations = new_coactivations;
435
436 for fp in &mut self.fingerprints {
438 for (idx, _) in &mut fp.activations {
439 if (*idx as usize) < old_to_new.len() {
440 *idx = old_to_new[*idx as usize];
441 } else {
442 *idx = u32::MAX;
443 }
444 }
445 fp.activations.retain(|(idx, _)| *idx != u32::MAX);
446 }
447 }
448}
449
450pub struct HebbianStats {
451 pub block_count: usize,
452 pub active_blocks: usize,
453 pub total_activations: u64,
454 pub hot_blocks: usize,
455 pub coactivation_pairs: usize,
456 pub fingerprint_count: usize,
457 pub drifted_blocks: usize,
458}
459
460fn read_u32(b: &[u8], off: usize) -> u32 {
463 u32::from_le_bytes(b[off..off + 4].try_into().unwrap())
464}
465fn read_u64(b: &[u8], off: usize) -> u64 {
466 u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
467}
468fn read_f32(b: &[u8], off: usize) -> f32 {
469 f32::from_le_bytes(b[off..off + 4].try_into().unwrap())
470}
471
472fn crc32(bytes: &[u8]) -> u32 {
475 let mut crc: u32 = 0xFFFF_FFFF;
476 for &b in bytes {
477 crc ^= b as u32;
478 for _ in 0..8 {
479 let mask = (crc & 1).wrapping_neg();
480 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
481 }
482 }
483 !crc
484}
485
486const DELTA_RECORD_BYTES: usize = 4 + ACTIVATION_RECORD_BYTES + 4;
488const DELTA_MAGIC: &[u8; 4] = b"AEM2";
489const DELTA_FILE: &str = "activations_delta.bin";
490const JOURNAL_MAX_RECORDS: usize = 4096;
493
494fn decode_activation_record(data: &[u8], off: usize) -> ActivationRecord {
495 ActivationRecord {
496 activation_count: read_u32(data, off),
497 last_activated_ms: read_u64(data, off + 4),
498 drift_x: read_f32(data, off + 12),
499 drift_y: read_f32(data, off + 16),
500 drift_z: read_f32(data, off + 20),
501 energy: read_f32(data, off + 24),
502 _pad: read_u32(data, off + 28),
503 }
504}
505
506fn encode_activation_record(buf: &mut Vec<u8>, rec: &ActivationRecord) {
507 buf.extend_from_slice(&rec.activation_count.to_le_bytes());
508 buf.extend_from_slice(&rec.last_activated_ms.to_le_bytes());
509 buf.extend_from_slice(&rec.drift_x.to_le_bytes());
510 buf.extend_from_slice(&rec.drift_y.to_le_bytes());
511 buf.extend_from_slice(&rec.drift_z.to_le_bytes());
512 buf.extend_from_slice(&rec.energy.to_le_bytes());
513 buf.extend_from_slice(&rec._pad.to_le_bytes());
514}
515
516fn is_default_record(rec: &ActivationRecord) -> bool {
520 rec.activation_count == 0
521 && rec.last_activated_ms == 0
522 && rec.drift_x == 0.0
523 && rec.drift_y == 0.0
524 && rec.drift_z == 0.0
525 && rec.energy == 0.0
526 && rec._pad == 0
527}
528
529fn apply_activation_deltas(
536 output_dir: &Path,
537 records: &mut Vec<ActivationRecord>,
538 block_count: usize,
539) {
540 let path = output_dir.join(DELTA_FILE);
541 let data = match fs::read(&path) {
542 Ok(d) => d,
543 Err(_) => return,
544 };
545 if data.len() < 8 || &data[0..4] != DELTA_MAGIC {
546 return;
549 }
550 let mut pos = 8usize;
551 while pos + DELTA_RECORD_BYTES <= data.len() {
552 let body = &data[pos..pos + 4 + ACTIVATION_RECORD_BYTES];
553 if crc32(body) != read_u32(&data, pos + 4 + ACTIVATION_RECORD_BYTES) {
554 break;
555 }
556 let idx = read_u32(&data, pos) as usize;
557 if idx >= block_count {
558 break;
562 }
563 if idx >= records.len() {
564 records.resize(idx + 1, ActivationRecord::default());
570 }
571 records[idx] = decode_activation_record(&data, pos + 4);
572 pos += DELTA_RECORD_BYTES;
573 }
574 if pos != data.len() {
575 if let Ok(f) = fs::OpenOptions::new().write(true).open(&path) {
578 let _ = f.set_len(pos as u64);
579 }
580 }
581}
582
583fn load_activations(output_dir: &Path, block_count: usize) -> Vec<ActivationRecord> {
584 let path = output_dir.join("activations.bin");
585 let mut records: Vec<ActivationRecord> = Vec::new();
595 if let Ok(data) = fs::read(&path) {
596 if data.len() >= 12 && &data[0..4] == b"HEB2" {
597 let _stored = read_u32(&data, 4) as usize;
599 let count = read_u32(&data, 8) as usize;
600 let stride = 4 + ACTIVATION_RECORD_BYTES;
601 let mut needed = 0usize;
610 for i in 0..count {
611 let off = 12 + i * stride;
612 if off + stride > data.len() {
613 break;
614 }
615 let idx = read_u32(&data, off) as usize;
616 if idx < block_count {
617 needed = needed.max(idx + 1);
618 }
619 }
620 records.resize(needed, ActivationRecord::default());
621 for i in 0..count {
622 let off = 12 + i * stride;
623 if off + stride > data.len() {
624 break;
625 }
626 let idx = read_u32(&data, off) as usize;
627 if idx < records.len() {
628 records[idx] = decode_activation_record(&data, off + 4);
629 }
630 }
631 } else if data.len() >= 8 && &data[0..4] == b"HEB1" {
632 let stored_count = read_u32(&data, 4) as usize;
635 let expected_size = 8 + stored_count * ACTIVATION_RECORD_BYTES;
636 if data.len() >= expected_size {
637 records.clear();
638 records.reserve(block_count.max(stored_count));
639 for i in 0..stored_count {
640 records.push(decode_activation_record(
641 &data,
642 8 + i * ACTIVATION_RECORD_BYTES,
643 ));
644 }
645 records.resize(block_count.max(stored_count), ActivationRecord::default());
646 }
647 }
648 }
649 apply_activation_deltas(output_dir, &mut records, block_count);
650 records
651}
652
653fn save_activations(output_dir: &Path, records: &[ActivationRecord]) -> Result<(), String> {
656 let path = output_dir.join("activations.bin");
657 let idxs: Vec<usize> = records
658 .iter()
659 .enumerate()
660 .filter(|(_, rec)| !is_default_record(rec))
661 .map(|(i, _)| i)
662 .collect();
663 let mut buf = Vec::with_capacity(12 + idxs.len() * (4 + ACTIVATION_RECORD_BYTES));
664 buf.extend_from_slice(b"HEB2");
665 buf.extend_from_slice(&(records.len() as u32).to_le_bytes());
666 buf.extend_from_slice(&(idxs.len() as u32).to_le_bytes());
667 for &i in &idxs {
668 buf.extend_from_slice(&(i as u32).to_le_bytes());
669 encode_activation_record(&mut buf, &records[i]);
670 }
671 let tmp_path = output_dir.join("activations.bin.tmp");
672 fs::write(&tmp_path, &buf).map_err(|e| format!("write activations.bin: {}", e))?;
673 fs::rename(&tmp_path, &path).map_err(|e| format!("rename activations.bin: {}", e))?;
674 let _ = fs::remove_file(output_dir.join(DELTA_FILE));
676 Ok(())
677}
678
679fn delta_journal_records(output_dir: &Path) -> usize {
681 match fs::metadata(output_dir.join(DELTA_FILE)) {
682 Ok(m) if m.len() > 8 => ((m.len() - 8) / DELTA_RECORD_BYTES as u64) as usize,
683 _ => 0,
684 }
685}
686
687fn append_activation_deltas(
693 output_dir: &Path,
694 records: &[ActivationRecord],
695 dirty: &[u32],
696) -> Result<(), String> {
697 let path = output_dir.join(DELTA_FILE);
698 let existing = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
699 let mut buf: Vec<u8> = Vec::with_capacity(8 + dirty.len() * DELTA_RECORD_BYTES);
700 if existing == 0 {
701 buf.extend_from_slice(DELTA_MAGIC);
702 buf.extend_from_slice(&0u32.to_le_bytes()); }
704 let mut written = 0usize;
705 for &i in dirty {
706 let idx = i as usize;
707 if idx >= records.len() {
708 continue;
709 }
710 let rec = &records[idx];
711 if is_default_record(rec) {
712 continue;
713 }
714 let start = buf.len();
715 buf.extend_from_slice(&i.to_le_bytes());
716 encode_activation_record(&mut buf, rec);
717 let crc = crc32(&buf[start..]);
718 buf.extend_from_slice(&crc.to_le_bytes());
719 written += 1;
720 }
721 if written == 0 && existing > 0 {
722 return Ok(());
723 }
724 let mut f = fs::OpenOptions::new()
725 .create(true)
726 .append(true)
727 .open(&path)
728 .map_err(|e| format!("open activations journal: {}", e))?;
729 f.write_all(&buf)
730 .map_err(|e| format!("write activations journal: {}", e))?;
731 f.flush()
732 .map_err(|e| format!("flush activations journal: {}", e))?;
733 Ok(())
734}
735
736fn load_coactivations(output_dir: &Path) -> HashMap<(u32, u32), CoactivationPair> {
737 let path = output_dir.join("coactivations.bin");
738 let mut map = HashMap::new();
739 if let Ok(data) = fs::read(&path) {
740 if data.len() >= 8 && &data[0..4] == b"COA1" {
741 let pair_count = read_u32(&data, 4) as usize;
742 for i in 0..pair_count {
743 let off = 8 + i * COACTIVATION_RECORD_BYTES;
744 if off + COACTIVATION_RECORD_BYTES > data.len() {
745 break;
746 }
747 let pair = CoactivationPair {
748 block_a: read_u32(&data, off),
749 block_b: read_u32(&data, off + 4),
750 count: read_u32(&data, off + 8),
751 last_ts_ms: read_u64(&data, off + 12),
752 };
753 map.insert((pair.block_a, pair.block_b), pair);
754 }
755 }
756 }
757 map
758}
759
760fn save_coactivations(
761 output_dir: &Path,
762 pairs: &HashMap<(u32, u32), CoactivationPair>,
763) -> Result<(), String> {
764 let path = output_dir.join("coactivations.bin");
765 let mut buf = Vec::with_capacity(8 + pairs.len() * COACTIVATION_RECORD_BYTES);
766 buf.extend_from_slice(b"COA1");
767 buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
768 for pair in pairs.values() {
769 buf.extend_from_slice(&pair.block_a.to_le_bytes());
770 buf.extend_from_slice(&pair.block_b.to_le_bytes());
771 buf.extend_from_slice(&pair.count.to_le_bytes());
772 buf.extend_from_slice(&pair.last_ts_ms.to_le_bytes());
773 }
774 let tmp_path = output_dir.join("coactivations.bin.tmp");
775 fs::write(&tmp_path, &buf).map_err(|e| format!("write coactivations.bin: {}", e))?;
776 fs::rename(&tmp_path, &path).map_err(|e| format!("rename coactivations.bin: {}", e))
777}
778
779fn load_fingerprints(output_dir: &Path) -> Vec<ActivationFingerprint> {
780 let path = output_dir.join("fingerprints.bin");
781 let mut fingerprints = Vec::new();
782 if let Ok(data) = fs::read(&path) {
783 if data.len() >= 8 && &data[0..4] == b"FPR1" {
784 let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
785 let mut pos = 8;
786 for _ in 0..count {
787 if pos + 18 > data.len() {
788 break;
789 }
790 let timestamp_ms = u64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
791 let query_hash = u64::from_le_bytes(data[pos + 8..pos + 16].try_into().unwrap());
792 let activated_count =
793 u16::from_le_bytes(data[pos + 16..pos + 18].try_into().unwrap()) as usize;
794 pos += 18;
795
796 let mut activations = Vec::with_capacity(activated_count);
797 for _ in 0..activated_count {
798 if pos + 8 > data.len() {
799 break;
800 }
801 let block_idx = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
802 let score = f32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
803 activations.push((block_idx, score));
804 pos += 8;
805 }
806
807 fingerprints.push(ActivationFingerprint {
808 timestamp_ms,
809 query_hash,
810 activations,
811 });
812 }
813 }
814 }
815 fingerprints
816}
817
818fn save_fingerprints(
819 output_dir: &Path,
820 fingerprints: &[ActivationFingerprint],
821) -> Result<(), String> {
822 let path = output_dir.join("fingerprints.bin");
823 let mut buf: Vec<u8> = Vec::with_capacity(8 + fingerprints.len() * 32);
824 buf.extend_from_slice(b"FPR1");
825 buf.extend_from_slice(&(fingerprints.len() as u32).to_le_bytes());
826 for fp in fingerprints {
827 buf.extend_from_slice(&fp.timestamp_ms.to_le_bytes());
828 buf.extend_from_slice(&fp.query_hash.to_le_bytes());
829 buf.extend_from_slice(&(fp.activations.len() as u16).to_le_bytes());
830 for &(block_idx, score) in &fp.activations {
831 buf.extend_from_slice(&block_idx.to_le_bytes());
832 buf.extend_from_slice(&score.to_le_bytes());
833 }
834 }
835 let tmp_path = output_dir.join("fingerprints.bin.tmp");
839 fs::write(&tmp_path, &buf).map_err(|e| format!("write fingerprints.bin: {}", e))?;
840 fs::rename(&tmp_path, &path).map_err(|e| format!("rename fingerprints.bin: {}", e))
841}
842
843fn now_epoch_ms() -> u64 {
846 SystemTime::now()
847 .duration_since(UNIX_EPOCH)
848 .unwrap_or_default()
849 .as_millis() as u64
850}
851
852pub fn now_epoch_ms_pub() -> u64 {
854 now_epoch_ms()
855}
856
857fn clamp_drift(v: f32) -> f32 {
858 v.clamp(-DRIFT_MAX, DRIFT_MAX)
859}
860
861pub fn query_hash(query: &str) -> u64 {
863 let mut h: u64 = 0xcbf29ce484222325;
864 for &b in query.as_bytes() {
865 h = h.wrapping_mul(0x100000001b3) ^ b as u64;
866 }
867 h
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use std::path::PathBuf;
874
875 fn tmp_dir(name: &str) -> PathBuf {
876 let d = std::env::temp_dir().join(name);
877 let _ = fs::remove_dir_all(&d);
878 fs::create_dir_all(&d).unwrap();
879 d
880 }
881
882 #[test]
899 fn untouched_activation_is_absent_and_reads_as_zero() {
900 let dir = tmp_dir("hebb_absent_vs_zero");
901 let mut hebb = HebbianState::load_or_init(&dir, 4);
902
903 assert!(
905 hebb.activations.is_empty(),
906 "a fresh index must not allocate a record per block, got {}",
907 hebb.activations.len()
908 );
909
910 let rec = hebb.activations.get(3);
912 assert!(
913 rec.is_none(),
914 "an unstored index must read as None under the sparse prefix"
915 );
916 assert_eq!(rec.map(|a| a.energy).unwrap_or(0.0), 0.0);
917 assert_eq!(rec.map(|r| r.energy).unwrap_or(0.0), 0.0);
918
919 hebb.activations.resize(4, ActivationRecord::default());
921 hebb.activations[3].energy = 0.4;
922 assert_eq!(hebb.activations.get(3).map(|a| a.energy), Some(0.4));
923 }
924
925 #[test]
930 fn first_activation_of_a_high_index_grows_the_vector() {
931 let dir = tmp_dir("hebb_first_activation");
932 let mut hebb = HebbianState::load_or_init(&dir, 10_000);
933 assert!(hebb.activations.is_empty(), "a fresh index starts empty");
934
935 hebb.record_activation(&[(9_000, 0.9)], 0xdead_beef);
936
937 assert!(
938 hebb.activations.len() > 9_000,
939 "the record must be created, len is {}",
940 hebb.activations.len()
941 );
942 assert_eq!(hebb.activations[9_000].activation_count, 1);
943 assert_eq!(hebb.activations[9_000].energy, 1.0);
944 }
945
946 #[test]
950 fn high_index_record_round_trips() {
951 let dir = tmp_dir("hebb_high_index");
952 let mut hebb = HebbianState::load_or_init(&dir, 10_000);
953 for _ in 0..3 {
955 hebb.record_activation(&[(9_000, 0.77)], 1);
956 }
957 hebb.activations[9_000].energy = 0.77;
958 hebb.save(&dir).unwrap();
959
960 let back = HebbianState::load_or_init(&dir, 10_000);
961 assert_eq!(back.activations[9_000].energy, 0.77);
962 assert_eq!(back.activations[9_000].activation_count, 3);
963 assert_eq!(back.activations[8_999].energy, 0.0);
965 }
966
967 #[test]
968 fn sparse_base_stores_only_learned_records() {
969 let dir = tmp_dir("mscope_hebb_sparse");
970 let n = 1000;
971 let mut recs = vec![ActivationRecord::default(); n];
972 recs[7].activation_count = 3;
973 recs[7].energy = 1.0;
974 recs[900].activation_count = 1;
975 recs[900].drift_x = 0.05;
976 save_activations(&dir, &recs).unwrap();
977
978 let file = fs::read(dir.join("activations.bin")).unwrap();
979 assert_eq!(&file[0..4], b"HEB2");
980 assert!(
981 file.len() < 200,
982 "sparse base should be a few records, got {} bytes",
983 file.len()
984 );
985 assert!(file.len() < 8 + n * ACTIVATION_RECORD_BYTES);
986
987 let back = load_activations(&dir, n);
988 assert_eq!(back.len(), 901, "sized to the highest stored index, not n");
993 assert_eq!(back[7].activation_count, 3);
994 assert_eq!(back[7].energy, 1.0);
995 assert_eq!(back[900].drift_x, 0.05);
996 assert!(is_default_record(&back[11]));
997 let _ = fs::remove_dir_all(&dir);
1000 }
1001
1002 #[test]
1003 fn legacy_dense_base_still_loads() {
1004 let dir = tmp_dir("mscope_hebb_legacy");
1005 let n = 64;
1006 let mut buf = Vec::new();
1007 buf.extend_from_slice(b"HEB1");
1008 buf.extend_from_slice(&(n as u32).to_le_bytes());
1009 for i in 0..n {
1010 let mut rec = ActivationRecord::default();
1011 if i == 5 {
1012 rec.activation_count = 9;
1013 rec.energy = 0.75;
1014 }
1015 encode_activation_record(&mut buf, &rec);
1016 }
1017 fs::write(dir.join("activations.bin"), &buf).unwrap();
1018
1019 let back = load_activations(&dir, n);
1020 assert_eq!(back.len(), n);
1021 assert_eq!(back[5].activation_count, 9);
1022 assert_eq!(back[5].energy, 0.75);
1023 assert!(is_default_record(&back[6]));
1024 let _ = fs::remove_dir_all(&dir);
1025 }
1026
1027 #[test]
1028 fn journaled_update_is_visible_to_the_next_process() {
1029 let dir = tmp_dir("mscope_hebb_journal");
1033 let n = 500;
1034 let mut first = HebbianState::load_or_init(&dir, n);
1035 first.record_activation(&[(3, 0.9)], 1);
1036 first.save_dirty(&dir, &[3]).unwrap(); let mut second = HebbianState::load_or_init(&dir, n);
1039 assert_eq!(second.activations[3].activation_count, 1);
1040 second.record_activation(&[(3, 0.8), (4, 0.4)], 2);
1041 second.save_dirty(&dir, &[3, 4]).unwrap(); assert!(
1043 dir.join(DELTA_FILE).exists(),
1044 "an incremental save must not rewrite the whole base"
1045 );
1046
1047 let third = HebbianState::load_or_init(&dir, n);
1048 assert_eq!(third.activations[3].activation_count, 2);
1049 assert_eq!(third.activations[4].activation_count, 1);
1050 let _ = fs::remove_dir_all(&dir);
1051 }
1052
1053 #[test]
1054 fn torn_journal_tail_is_dropped_and_the_file_is_truncated() {
1055 let dir = tmp_dir("mscope_hebb_torn");
1056 let n = 200;
1057 let mut first = HebbianState::load_or_init(&dir, n);
1058 first.record_activation(&[(1, 1.0)], 1);
1059 first.save_dirty(&dir, &[1]).unwrap();
1060
1061 let mut second = HebbianState::load_or_init(&dir, n);
1062 second.record_activation(&[(2, 1.0)], 2);
1063 second.save_dirty(&dir, &[2]).unwrap();
1064
1065 let jp = dir.join(DELTA_FILE);
1067 let mut data = fs::read(&jp).unwrap();
1068 let good_len = data.len();
1069 data.extend_from_slice(&[0x01, 0x02, 0x03]);
1070 fs::write(&jp, &data).unwrap();
1071
1072 let loaded = HebbianState::load_or_init(&dir, n);
1073 assert_eq!(loaded.activations[1].activation_count, 1);
1074 assert_eq!(
1075 loaded.activations[2].activation_count, 1,
1076 "the record before the tear must be applied"
1077 );
1078 assert_eq!(
1079 fs::metadata(&jp).unwrap().len(),
1080 good_len as u64,
1081 "the unusable tail is truncated so the next append is readable"
1082 );
1083
1084 let mut third = loaded.clone();
1087 third.record_activation(&[(3, 1.0)], 3);
1088 third.save_dirty(&dir, &[3]).unwrap();
1089 let after = HebbianState::load_or_init(&dir, n);
1090 assert_eq!(after.activations[3].activation_count, 1);
1091 assert_eq!(after.activations[2].activation_count, 1);
1092 let _ = fs::remove_dir_all(&dir);
1093 }
1094
1095 #[test]
1096 fn journal_is_folded_into_the_base_at_the_threshold() {
1097 let dir = tmp_dir("mscope_hebb_compact");
1098 let n = 64;
1099 let mut buf = Vec::new();
1101 buf.extend_from_slice(DELTA_MAGIC);
1102 buf.extend_from_slice(&0u32.to_le_bytes());
1103 for i in 0..JOURNAL_MAX_RECORDS {
1104 let rec = ActivationRecord {
1105 activation_count: 1,
1106 energy: 1.0,
1107 ..Default::default()
1108 };
1109 let start = buf.len();
1110 buf.extend_from_slice(&((i % n) as u32).to_le_bytes());
1111 encode_activation_record(&mut buf, &rec);
1112 let crc = crc32(&buf[start..]);
1113 buf.extend_from_slice(&crc.to_le_bytes());
1114 }
1115 fs::write(dir.join(DELTA_FILE), &buf).unwrap();
1116 assert!(delta_journal_records(&dir) >= JOURNAL_MAX_RECORDS);
1117
1118 let state = HebbianState::load_or_init(&dir, n);
1120 state.save_dirty(&dir, &[1]).unwrap();
1121 assert_eq!(
1122 delta_journal_records(&dir),
1123 0,
1124 "the journal is retired when the base is rewritten"
1125 );
1126 let data = fs::read(dir.join("activations.bin")).unwrap();
1127 assert_eq!(&data[0..4], b"HEB2");
1128 let back = load_activations(&dir, n);
1129 let learned = back.iter().filter(|r| r.activation_count > 0).count();
1133 assert_eq!(learned, n, "every journalled index must survive compaction");
1134 assert!(back.iter().all(|r| r.activation_count == 1));
1135 let _ = fs::remove_dir_all(&dir);
1136 }
1137
1138 #[test]
1139 fn remap_keeps_activations_across_a_rebuild() {
1140 let dir = tmp_dir("mscope_hebb_remap");
1141 let n = 10;
1142 let mut state = HebbianState::load_or_init(&dir, n);
1143 state.record_activation(&[(2, 1.0), (7, 1.0)], 42);
1144 state.save(&dir).unwrap(); let map: Vec<u32> = (0..n).map(|i| (n - 1 - i) as u32).collect();
1148 let mut state = HebbianState::load_or_init(&dir, n);
1149 state.remap_indexes(&map);
1150 state.save(&dir).unwrap();
1151
1152 let back = HebbianState::load_or_init(&dir, n);
1153 assert_eq!(back.activations[n - 1 - 2].activation_count, 1);
1154 assert_eq!(back.activations[n - 1 - 7].activation_count, 1);
1155 let _ = fs::remove_dir_all(&dir);
1156 }
1157
1158 #[test]
1159 fn test_serialization_sizes() {
1160 assert_eq!(ACTIVATION_RECORD_BYTES, 32); assert_eq!(COACTIVATION_RECORD_BYTES, 20); }
1164
1165 #[test]
1166 fn test_record_activation() {
1167 let mut state = HebbianState {
1168 activations: vec![ActivationRecord::default(); 10],
1169 coactivations: HashMap::new(),
1170 fingerprints: Vec::new(),
1171 };
1172
1173 state.record_activation(&[(0, 0.5), (3, 0.3), (7, 0.1)], 12345);
1174
1175 assert_eq!(state.activations[0].activation_count, 1);
1176 assert_eq!(state.activations[3].activation_count, 1);
1177 assert_eq!(state.activations[7].activation_count, 1);
1178 assert_eq!(state.activations[1].activation_count, 0);
1179
1180 assert_eq!(state.coactivations.len(), 3);
1182 assert!(state.coactivations.contains_key(&(0, 3)));
1183 assert!(state.coactivations.contains_key(&(0, 7)));
1184 assert!(state.coactivations.contains_key(&(3, 7)));
1185
1186 assert_eq!(state.fingerprints.len(), 1);
1188 assert_eq!(state.fingerprints[0].query_hash, 12345);
1189 assert_eq!(state.fingerprints[0].activations.len(), 3);
1190 }
1191
1192 #[test]
1193 fn test_repeated_coactivation() {
1194 let mut state = HebbianState {
1195 activations: vec![ActivationRecord::default(); 5],
1196 coactivations: HashMap::new(),
1197 fingerprints: Vec::new(),
1198 };
1199
1200 state.record_activation(&[(1, 0.5), (2, 0.3)], 100);
1201 state.record_activation(&[(1, 0.4), (2, 0.6)], 200);
1202 state.record_activation(&[(1, 0.3), (2, 0.2)], 300);
1203
1204 assert_eq!(state.activations[1].activation_count, 3);
1205 assert_eq!(state.coactivations[&(1, 2)].count, 3);
1206 }
1207
1208 #[test]
1209 fn test_drift_application() {
1210 let mut state = HebbianState {
1211 activations: vec![ActivationRecord::default(); 3],
1212 coactivations: HashMap::new(),
1213 fingerprints: Vec::new(),
1214 };
1215
1216 for _ in 0..20 {
1218 state.record_activation(&[(0, 0.5), (2, 0.5)], 42);
1219 }
1220
1221 let headers = vec![(0.0, 0.0, 0.0), (0.5, 0.5, 0.5), (1.0, 1.0, 1.0)];
1222 state.apply_drift(&headers);
1223
1224 assert!(state.activations[0].drift_x > 0.0);
1226 assert!(state.activations[0].drift_y > 0.0);
1227 assert!(state.activations[0].drift_z > 0.0);
1228 assert!(state.activations[2].drift_x < 0.0);
1229 assert!(state.activations[2].drift_y < 0.0);
1230 assert!(state.activations[2].drift_z < 0.0);
1231 }
1232
1233 #[test]
1234 fn test_effective_coords() {
1235 let mut state = HebbianState {
1236 activations: vec![ActivationRecord::default(); 2],
1237 coactivations: HashMap::new(),
1238 fingerprints: Vec::new(),
1239 };
1240
1241 state.activations[0].drift_x = 0.05;
1242 state.activations[0].drift_y = -0.03;
1243 state.activations[0].drift_z = 0.01;
1244
1245 let (x, y, z) = state.effective_coords(0, (0.2, 0.3, 0.4));
1246 assert!((x - 0.25).abs() < 0.001);
1247 assert!((y - 0.27).abs() < 0.001);
1248 assert!((z - 0.41).abs() < 0.001);
1249 }
1250
1251 #[test]
1252 fn test_save_load_roundtrip() {
1253 let tmp = tempfile::tempdir().expect("create temp dir");
1254 let dir = tmp.path();
1255
1256 let mut state = HebbianState {
1257 activations: vec![ActivationRecord::default(); 5],
1258 coactivations: HashMap::new(),
1259 fingerprints: Vec::new(),
1260 };
1261
1262 state.record_activation(&[(0, 0.5), (2, 0.3), (4, 0.1)], 999);
1263 state.record_activation(&[(1, 0.8), (3, 0.2)], 888);
1264
1265 state.save(dir).expect("save");
1266
1267 let loaded = HebbianState::load_or_init(dir, 5);
1268 assert_eq!(loaded.activations[0].activation_count, 1);
1269 assert_eq!(loaded.activations[1].activation_count, 1);
1270 assert_eq!(loaded.coactivations.len(), 4); assert_eq!(loaded.fingerprints.len(), 2);
1272 assert_eq!(loaded.fingerprints[0].query_hash, 999);
1273 assert_eq!(loaded.fingerprints[1].query_hash, 888);
1274 }
1275
1276 #[test]
1277 fn test_clamp_drift() {
1278 assert_eq!(clamp_drift(0.05), 0.05);
1279 assert_eq!(clamp_drift(0.2), DRIFT_MAX);
1280 assert_eq!(clamp_drift(-0.2), -DRIFT_MAX);
1281 }
1282
1283 #[test]
1284 fn test_query_hash_deterministic() {
1285 assert_eq!(query_hash("hello"), query_hash("hello"));
1286 assert_ne!(query_hash("hello"), query_hash("world"));
1287 }
1288
1289 #[test]
1290 fn test_hottest_blocks() {
1291 let mut state = HebbianState {
1292 activations: vec![ActivationRecord::default(); 5],
1293 coactivations: HashMap::new(),
1294 fingerprints: Vec::new(),
1295 };
1296
1297 state.record_activation(&[(0, 1.0), (2, 0.5)], 1);
1298
1299 let hot = state.hottest_blocks(10);
1300 assert!(!hot.is_empty());
1301 assert!(hot.iter().any(|(idx, _)| *idx == 0));
1303 assert!(hot.iter().any(|(idx, _)| *idx == 2));
1304 }
1305
1306 #[test]
1307 fn test_stats() {
1308 let mut state = HebbianState {
1309 activations: vec![ActivationRecord::default(); 10],
1310 coactivations: HashMap::new(),
1311 fingerprints: Vec::new(),
1312 };
1313
1314 state.record_activation(&[(0, 1.0), (5, 0.5)], 42);
1315
1316 let stats = state.stats();
1317 assert_eq!(stats.block_count, 10);
1318 assert_eq!(stats.active_blocks, 2);
1319 assert_eq!(stats.total_activations, 2);
1320 assert_eq!(stats.coactivation_pairs, 1);
1321 assert_eq!(stats.fingerprint_count, 1);
1322 }
1323}
1324#[test]
1327fn mm001_remap_preserves_activation_energy() {
1328 let mut hebb = HebbianState {
1329 activations: vec![ActivationRecord::default(); 5],
1330 coactivations: HashMap::new(),
1331 fingerprints: Vec::new(),
1332 };
1333 hebb.activations[2].energy = 0.95;
1335 hebb.activations[2].activation_count = 42;
1336
1337 let old_to_new = vec![3u32, 0, 4, 1, 2];
1339 hebb.remap_indexes(&old_to_new);
1340
1341 assert_eq!(hebb.activations[4].energy, 0.95);
1343 assert_eq!(hebb.activations[4].activation_count, 42);
1344 assert_eq!(hebb.activations[2].energy, 0.0);
1346}
1347
1348#[test]
1349fn mm001_remap_coactivations_merge_duplicates() {
1350 let mut hebb = HebbianState {
1351 activations: vec![ActivationRecord::default(); 4],
1352 coactivations: HashMap::new(),
1353 fingerprints: Vec::new(),
1354 };
1355 hebb.coactivations.insert(
1357 (0, 1),
1358 CoactivationPair {
1359 block_a: 0,
1360 block_b: 1,
1361 count: 3,
1362 last_ts_ms: 100,
1363 },
1364 );
1365 hebb.coactivations.insert(
1366 (2, 3),
1367 CoactivationPair {
1368 block_a: 2,
1369 block_b: 3,
1370 count: 5,
1371 last_ts_ms: 200,
1372 },
1373 );
1374
1375 let old_to_new = vec![0u32, 2, 0, 2];
1378 hebb.remap_indexes(&old_to_new);
1379
1380 assert_eq!(hebb.coactivations.len(), 1, "pairs should merge");
1381 let merged = hebb.coactivations.values().next().unwrap();
1382 assert_eq!(merged.count, 8, "counts should be summed");
1383}
1384
1385#[test]
1386fn mm001_remap_drops_out_of_range() {
1387 let mut hebb = HebbianState {
1388 activations: vec![ActivationRecord::default(); 3],
1389 coactivations: HashMap::new(),
1390 fingerprints: Vec::new(),
1391 };
1392 hebb.activations[1].energy = 0.5;
1393
1394 let old_to_new = vec![0u32, 1];
1396 hebb.remap_indexes(&old_to_new);
1397
1398 assert_eq!(hebb.activations.len(), 2);
1399 assert_eq!(hebb.activations[1].energy, 0.5);
1400}
1401
1402#[test]
1403fn mm001_remap_ignores_drop_markers_when_sizing() {
1404 let mut hebb = HebbianState {
1405 activations: vec![ActivationRecord::default(); 3],
1406 coactivations: HashMap::new(),
1407 fingerprints: Vec::new(),
1408 };
1409 hebb.activations[1].energy = 0.5;
1410
1411 let old_to_new = vec![0u32, 1, u32::MAX];
1415 hebb.remap_indexes(&old_to_new);
1416
1417 assert_eq!(hebb.activations.len(), 2);
1418 assert_eq!(hebb.activations[1].energy, 0.5);
1419}