Skip to main content

microscope_memory/
epistemic.rs

1//! Evidence Layer (Epistemic Layer) for Microscope Memory.
2//!
3//! Separates *salience* (how often something is recalled — driven by Hebbian,
4//! Resonance, Thought Graph, Dream) from *evidence weight* (how much independent
5//! factual support exists).  Confidence is computed purely from independent
6//! Observation/Evidence links — never from recall energy or salience.
7//!
8//! Binary format: evidence.bin (EVD1) — ledger; evidence_log.bin (EVL1) — audit.
9//!
10//! ## Invariants
11//!
12//! 1. **C1 — Salience isolation**: Recall energy never modifies `confidence`.
13//! 2. **C2 — No self-echo**: Storing the same text twice from the same source
14//!    does not increase `distinct_sources`.
15//! 3. **C3 — Support restriction**: Only `Observation` and `Evidence` classes may
16//!    appear as `supports`. `Inference` and `Hypothesis` can never support.
17//! 4. **C4 — Promotion gate**: `Inference`/`Hypothesis` blocks with
18//!    `distinct_sources == 0` cannot gain importance via Hebbian promotion.
19//! 5. **C5 — Audit integrity**: Any modification to `evidence_log.bin` is
20//!    detectable via SHA-256 chain verification.
21
22use sha2::{Digest, Sha256};
23use std::collections::HashMap;
24use std::fs;
25use std::path::Path;
26
27use crate::fingerprint::fnv1a_hash;
28
29// ─── Constants ──────────────────────────────────────
30
31/// Magic bytes for the ledger file.
32pub const EVIDENCE_MAGIC: &[u8; 4] = b"EVD1";
33/// Magic bytes for the audit log.
34pub const AUDIT_MAGIC: &[u8; 4] = b"EVL1";
35
36/// Maximum cosine similarity before two texts are considered echoes.
37pub const DEFAULT_SIM_THRESHOLD: f32 = 0.85;
38
39// ─── Types ──────────────────────────────────────────
40
41/// Epistemic classification of a memory block.
42///
43/// Stored in the `flags` byte of `BlockHeader` (bits 0-2).
44/// Only `Observation` and `Evidence` may be used as `supports`
45/// for other claims (invariant C3).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[repr(u8)]
48pub enum EpistemicClass {
49    Unknown = 0,
50    Observation = 1,
51    Evidence = 2,
52    Inference = 3,
53    Hypothesis = 4,
54}
55
56impl EpistemicClass {
57    /// Extract from the lower 3 bits of a flags byte.
58    pub fn from_flags(flags: u8) -> Self {
59        match flags & 0x07 {
60            1 => Self::Observation,
61            2 => Self::Evidence,
62            3 => Self::Inference,
63            4 => Self::Hypothesis,
64            _ => Self::Unknown,
65        }
66    }
67
68    /// Encode into the lower 3 bits of a flags byte, preserving upper bits.
69    pub fn into_flags(self, existing: u8) -> u8 {
70        (existing & 0xF8) | (self as u8 & 0x07)
71    }
72
73    /// True for classes that may appear as `supports` (C3).
74    pub fn can_support(self) -> bool {
75        matches!(self, Self::Observation | Self::Evidence)
76    }
77}
78
79impl std::fmt::Display for EpistemicClass {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Unknown => write!(f, "unknown"),
83            Self::Observation => write!(f, "observation"),
84            Self::Evidence => write!(f, "evidence"),
85            Self::Inference => write!(f, "inference"),
86            Self::Hypothesis => write!(f, "hypothesis"),
87        }
88    }
89}
90
91impl std::str::FromStr for EpistemicClass {
92    type Err = String;
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        match s.to_lowercase().as_str() {
95            "observation" | "obs" => Ok(Self::Observation),
96            "evidence" | "evi" => Ok(Self::Evidence),
97            "inference" | "inf" => Ok(Self::Inference),
98            "hypothesis" | "hyp" => Ok(Self::Hypothesis),
99            "unknown" | "" => Ok(Self::Unknown),
100            other => Err(format!("unknown epistemic class: {other}")),
101        }
102    }
103}
104
105/// A single evidence record in the ledger.
106#[derive(Debug, Clone)]
107pub struct EvidenceRecord {
108    /// Content hash (FNV-1a of marker-stripped text) — also the ledger key.
109    pub content_hash: u64,
110    /// Epistemic classification.
111    pub class: EpistemicClass,
112    /// Origin source (instancia / actor identifier).
113    pub source_id: u64,
114    /// Number of independent supporting Evidence/Observation blocks.
115    pub support_count: u32,
116    /// Number of refutations.
117    pub refute_count: u32,
118    /// Distinct non-echo source IDs that provide support.
119    pub distinct_sources: u32,
120    /// Timestamps.
121    pub first_seen_ms: u64,
122    pub last_support_ms: u64,
123    pub last_refute_ms: u64,
124    /// Pre-computed confidence 0..100.
125    pub confidence: u8,
126    /// Reserved flags.
127    pub flags: u8,
128}
129
130// ─── Confidence computation ─────────────────────────
131
132/// Compute confidence from an evidence record.
133///
134/// The formula is:
135///   c = clamp(0, 100,
136///       support_count * observation_w
137///     + distinct_sources * source_w
138///     - refute_count * refute_w
139///     - age_penalty * days_since_first)
140///
141/// **Crucially, recall energy / Hebbian activation never appears here (C1).**
142pub fn confidence(record: &EvidenceRecord, now_ms: u64) -> u8 {
143    let obs_w: i32 = 30;
144    let source_w: i32 = 18;
145    let refute_w: i32 = 25;
146    let age_penalty: i32 = 5;
147
148    let age_days = if record.first_seen_ms > 0 && now_ms > record.first_seen_ms {
149        ((now_ms - record.first_seen_ms) / 86_400_000) as i32
150    } else {
151        0
152    };
153
154    let raw = (record.support_count as i32) * obs_w + (record.distinct_sources as i32) * source_w
155        - (record.refute_count as i32) * refute_w
156        - age_days * age_penalty;
157
158    raw.clamp(0, 100) as u8
159}
160
161// ─── Ledger ─────────────────────────────────────────
162
163/// The in-memory evidence ledger, backed by `evidence.bin`.
164pub struct EvidenceLedger {
165    pub records: HashMap<u64, EvidenceRecord>,
166}
167
168/// Binary record layout (57 bytes per record):
169///   content_hash:  u64  (8)  | class: u8 (1) | source_id: u64 (8)
170///   support_count: u32  (4)  | refute_count: u32 (4) | distinct: u32 (4)
171///   first_ms: u64 (8) | last_sup_ms: u64 (8) | last_ref_ms: u64 (8)
172///   confidence: u8 (1) | flags: u8 (1) | _pad: [u8;2] (2)
173const LEDGER_RECORD_SIZE: usize = 8 + 1 + 8 + 4 + 4 + 4 + 8 + 8 + 8 + 1 + 1 + 2;
174
175impl EvidenceLedger {
176    /// Load or initialize an empty ledger.
177    pub fn load_or_init(output_dir: &Path) -> Self {
178        let path = output_dir.join("evidence.bin");
179        let data = match fs::read(&path) {
180            Ok(d) => d,
181            Err(_) => {
182                return Self {
183                    records: HashMap::new(),
184                }
185            }
186        };
187        if data.len() < 4 || &data[0..4] != EVIDENCE_MAGIC {
188            return Self {
189                records: HashMap::new(),
190            };
191        }
192        let mut records = HashMap::new();
193        let mut off = 4;
194        while off + LEDGER_RECORD_SIZE <= data.len() {
195            let r = read_record(&data, off);
196            records.insert(r.content_hash, r);
197            off += LEDGER_RECORD_SIZE;
198        }
199        Self { records }
200    }
201
202    /// Persist the ledger to disk (atomic tmp+rename).
203    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
204        let path = output_dir.join("evidence.bin");
205        let tmp = output_dir.join("evidence.bin.tmp");
206        let mut buf = Vec::with_capacity(4 + self.records.len() * LEDGER_RECORD_SIZE);
207        buf.extend_from_slice(EVIDENCE_MAGIC);
208        for r in self.records.values() {
209            write_record(&mut buf, r);
210        }
211        fs::write(&tmp, &buf).map_err(|e| format!("write evidence.bin: {e}"))?;
212        fs::rename(&tmp, &path).map_err(|e| format!("rename evidence.bin: {e}"))?;
213        Ok(())
214    }
215
216    /// Get or create a record for a content hash.
217    pub fn get_or_create(
218        &mut self,
219        content_hash: u64,
220        class: EpistemicClass,
221        source_id: u64,
222        now_ms: u64,
223    ) -> &mut EvidenceRecord {
224        self.records
225            .entry(content_hash)
226            .or_insert_with(|| EvidenceRecord {
227                content_hash,
228                class,
229                source_id,
230                support_count: 0,
231                refute_count: 0,
232                distinct_sources: 0,
233                first_seen_ms: now_ms,
234                last_support_ms: 0,
235                last_refute_ms: 0,
236                confidence: 0,
237                flags: 0,
238            })
239    }
240}
241
242fn read_record(data: &[u8], off: usize) -> EvidenceRecord {
243    let mut p = off;
244    let content_hash = u64::from_le_bytes(data[p..p + 8].try_into().unwrap());
245    p += 8;
246    let class = EpistemicClass::from_flags(data[p]);
247    p += 1;
248    let source_id = u64::from_le_bytes(data[p..p + 8].try_into().unwrap());
249    p += 8;
250    let support_count = u32::from_le_bytes(data[p..p + 4].try_into().unwrap());
251    p += 4;
252    let refute_count = u32::from_le_bytes(data[p..p + 4].try_into().unwrap());
253    p += 4;
254    let distinct_sources = u32::from_le_bytes(data[p..p + 4].try_into().unwrap());
255    p += 4;
256    let first_seen_ms = u64::from_le_bytes(data[p..p + 8].try_into().unwrap());
257    p += 8;
258    let last_support_ms = u64::from_le_bytes(data[p..p + 8].try_into().unwrap());
259    p += 8;
260    let last_refute_ms = u64::from_le_bytes(data[p..p + 8].try_into().unwrap());
261    p += 8;
262    let confidence = data[p];
263    p += 1;
264    let flags = data[p];
265    EvidenceRecord {
266        content_hash,
267        class,
268        source_id,
269        support_count,
270        refute_count,
271        distinct_sources,
272        first_seen_ms,
273        last_support_ms,
274        last_refute_ms,
275        confidence,
276        flags,
277    }
278}
279
280fn write_record(buf: &mut Vec<u8>, r: &EvidenceRecord) {
281    buf.extend_from_slice(&r.content_hash.to_le_bytes());
282    buf.push(r.class as u8);
283    buf.extend_from_slice(&r.source_id.to_le_bytes());
284    buf.extend_from_slice(&r.support_count.to_le_bytes());
285    buf.extend_from_slice(&r.refute_count.to_le_bytes());
286    buf.extend_from_slice(&r.distinct_sources.to_le_bytes());
287    buf.extend_from_slice(&r.first_seen_ms.to_le_bytes());
288    buf.extend_from_slice(&r.last_support_ms.to_le_bytes());
289    buf.extend_from_slice(&r.last_refute_ms.to_le_bytes());
290    buf.push(r.confidence);
291    buf.push(r.flags);
292    buf.extend_from_slice(&[0u8; 2]); // padding
293}
294
295// ─── Audit Chain ────────────────────────────────────
296
297/// An event type in the evidence audit chain.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[repr(u8)]
300pub enum AuditEvent {
301    Store = 0,
302    Link = 1,
303    Refute = 2,
304    PromoGate = 3,
305    Reclass = 4,
306}
307
308/// A single audit record (the pre-hash payload).
309#[derive(Debug, Clone)]
310pub struct AuditRecord {
311    pub ts_ms: u64,
312    pub event: AuditEvent,
313    pub content_hash: u64,
314    pub source_id: u64,
315    pub delta: i32,
316    pub note: String,
317}
318
319/// A single chunk in the hash chain.
320#[derive(Debug, Clone)]
321pub struct AuditChunk {
322    pub prev_hash: [u8; 32],
323    pub record: AuditRecord,
324    pub hash: [u8; 32],
325}
326
327const AUDIT_CHUNK_PAYLOAD_SIZE: usize = 8 + 1 + 8 + 8 + 4 + 2 + 64; // 95 bytes
328const AUDIT_CHUNK_SIZE: usize = 32 + AUDIT_CHUNK_PAYLOAD_SIZE + 32; // 159 bytes
329
330/// Append-only audit chain.
331pub struct AuditChain {
332    pub chunks: Vec<AuditChunk>,
333}
334
335impl AuditChain {
336    /// Load or create with genesis block.
337    pub fn load_or_init(output_dir: &Path) -> Self {
338        let path = output_dir.join("evidence_log.bin");
339        let data = match fs::read(&path) {
340            Ok(d) => d,
341            Err(_) => return Self::genesis(),
342        };
343        if data.len() < 4 || &data[0..4] != AUDIT_MAGIC {
344            return Self::genesis();
345        }
346        let count = u32::from_le_bytes(data[4..8].try_into().unwrap_or([0; 4])) as usize;
347        let mut chunks = Vec::with_capacity(count);
348        let mut off = 8;
349        for _ in 0..count {
350            if off + AUDIT_CHUNK_SIZE > data.len() {
351                break;
352            }
353            let prev_hash: [u8; 32] = data[off..off + 32].try_into().unwrap();
354            off += 32;
355            let ts_ms = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
356            off += 8;
357            let event = match data[off] {
358                0 => AuditEvent::Store,
359                1 => AuditEvent::Link,
360                2 => AuditEvent::Refute,
361                3 => AuditEvent::PromoGate,
362                4 => AuditEvent::Reclass,
363                _ => AuditEvent::Store,
364            };
365            off += 1;
366            let content_hash = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
367            off += 8;
368            let source_id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
369            off += 8;
370            let delta = i32::from_le_bytes(data[off..off + 4].try_into().unwrap());
371            off += 4;
372            let note_len = u16::from_le_bytes(data[off..off + 2].try_into().unwrap()) as usize;
373            off += 2;
374            let note_bytes = &data[off..off + 64];
375            off += 64;
376            let note = String::from_utf8_lossy(&note_bytes[..note_len.min(64)]).to_string();
377            let hash: [u8; 32] = data[off..off + 32].try_into().unwrap();
378            off += 32;
379            chunks.push(AuditChunk {
380                prev_hash,
381                record: AuditRecord {
382                    ts_ms,
383                    event,
384                    content_hash,
385                    source_id,
386                    delta,
387                    note,
388                },
389                hash,
390            });
391        }
392        Self { chunks }
393    }
394
395    fn genesis() -> Self {
396        let record = AuditRecord {
397            ts_ms: 0,
398            event: AuditEvent::Store,
399            content_hash: 0,
400            source_id: 0,
401            delta: 0,
402            note: "genesis".into(),
403        };
404        let payload = encode_record_payload(&record);
405        let mut hasher = Sha256::new();
406        hasher.update([0u8; 32]); // prev_hash = zeros for genesis
407        hasher.update(&payload);
408        let hash: [u8; 32] = hasher.finalize().into();
409        Self {
410            chunks: vec![AuditChunk {
411                prev_hash: [0u8; 32],
412                record,
413                hash,
414            }],
415        }
416    }
417
418    /// Append a new event and return the new chunk.
419    pub fn append(&mut self, record: AuditRecord) -> &AuditChunk {
420        let prev_hash = self.chunks.last().map(|c| c.hash).unwrap_or([0u8; 32]);
421        let payload = encode_record_payload(&record);
422        let mut hasher = Sha256::new();
423        hasher.update(prev_hash);
424        hasher.update(&payload);
425        let hash: [u8; 32] = hasher.finalize().into();
426        let chunk = AuditChunk {
427            prev_hash,
428            record,
429            hash,
430        };
431        self.chunks.push(chunk);
432        self.chunks.last().unwrap()
433    }
434
435    /// Verify the entire chain. Returns Ok(tail_hash) or Err(offending_index).
436    pub fn verify(&self) -> Result<[u8; 32], usize> {
437        if self.chunks.is_empty() {
438            return Err(0);
439        }
440        for i in 0..self.chunks.len() {
441            let chunk = &self.chunks[i];
442            let expected_prev = if i == 0 {
443                [0u8; 32]
444            } else {
445                self.chunks[i - 1].hash
446            };
447            if chunk.prev_hash != expected_prev {
448                return Err(i);
449            }
450            let payload = encode_record_payload(&chunk.record);
451            let mut hasher = Sha256::new();
452            hasher.update(chunk.prev_hash);
453            hasher.update(&payload);
454            let expected: [u8; 32] = hasher.finalize().into();
455            if chunk.hash != expected {
456                return Err(i);
457            }
458        }
459        Ok(self.chunks.last().unwrap().hash)
460    }
461
462    /// Persist to disk.
463    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
464        let path = output_dir.join("evidence_log.bin");
465        let tmp = output_dir.join("evidence_log.bin.tmp");
466        let count = self.chunks.len() as u32;
467        let mut buf = Vec::with_capacity(8 + self.chunks.len() * AUDIT_CHUNK_SIZE);
468        buf.extend_from_slice(AUDIT_MAGIC);
469        buf.extend_from_slice(&count.to_le_bytes());
470        for chunk in &self.chunks {
471            buf.extend_from_slice(&chunk.prev_hash);
472            buf.extend_from_slice(&chunk.record.ts_ms.to_le_bytes());
473            buf.push(chunk.record.event as u8);
474            buf.extend_from_slice(&chunk.record.content_hash.to_le_bytes());
475            buf.extend_from_slice(&chunk.record.source_id.to_le_bytes());
476            buf.extend_from_slice(&chunk.record.delta.to_le_bytes());
477            let note_bytes = chunk.record.note.as_bytes();
478            let note_len = (note_bytes.len().min(64)) as u16;
479            buf.extend_from_slice(&note_len.to_le_bytes());
480            buf.extend_from_slice(&note_bytes[..note_len as usize]);
481            if note_bytes.len() < 64 {
482                buf.extend_from_slice(&vec![0u8; 64 - note_bytes.len()]);
483            }
484            buf.extend_from_slice(&chunk.hash);
485        }
486        fs::write(&tmp, &buf).map_err(|e| format!("write evidence_log.bin: {e}"))?;
487        fs::rename(&tmp, &path).map_err(|e| format!("rename evidence_log.bin: {e}"))?;
488        Ok(())
489    }
490}
491
492fn encode_record_payload(r: &AuditRecord) -> Vec<u8> {
493    let mut buf = Vec::with_capacity(AUDIT_CHUNK_PAYLOAD_SIZE);
494    buf.extend_from_slice(&r.ts_ms.to_le_bytes());
495    buf.push(r.event as u8);
496    buf.extend_from_slice(&r.content_hash.to_le_bytes());
497    buf.extend_from_slice(&r.source_id.to_le_bytes());
498    buf.extend_from_slice(&r.delta.to_le_bytes());
499    let note_bytes = r.note.as_bytes();
500    let note_len = (note_bytes.len().min(64)) as u16;
501    buf.extend_from_slice(&note_len.to_le_bytes());
502    buf.extend_from_slice(&note_bytes[..note_len as usize]);
503    if note_bytes.len() < 64 {
504        buf.extend_from_slice(&vec![0u8; 64 - note_bytes.len()]);
505    }
506    buf
507}
508
509// ─── High-level operations ──────────────────────────
510
511/// Content-hash a text for ledger lookup (same FNV-1a as bump_entry_by_text_hash).
512pub fn content_hash(text: &str) -> u64 {
513    fnv1a_hash(text.trim().as_bytes())
514}
515
516/// Link an independent Observation or Evidence to a claim.
517///
518/// Fails if:
519/// - `support_class` is Inference or Hypothesis (C3).
520/// - `support_hash == claim_hash` (self-reference).
521/// - The support is an echo — does not increment `distinct_sources`.
522///
523/// When `support_text` and `claim_text` are provided, uses cosine similarity
524/// for echo detection (C2). When `provider` is `Some`, uses cosine similarity;
525/// when `None`, falls back to source_id-only check.
526#[allow(clippy::too_many_arguments)]
527pub fn link_evidence(
528    ledger: &mut EvidenceLedger,
529    audit: &mut AuditChain,
530    claim_hash: u64,
531    support_hash: u64,
532    support_class: EpistemicClass,
533    support_source: u64,
534    now_ms: u64,
535    support_text: Option<&str>,
536    claim_text: Option<&str>,
537    provider: Option<&dyn crate::embeddings::EmbeddingProvider>,
538) -> Result<(), String> {
539    // C3: only Observation/Evidence can support
540    if !support_class.can_support() {
541        return Err(format!(
542            "class {} cannot serve as support (only observation/evidence)",
543            support_class
544        ));
545    }
546
547    // Self-reference check
548    if support_hash == claim_hash {
549        return Err("self-reference: support_hash == claim_hash".into());
550    }
551
552    // Ensure the support block exists in the ledger so we can detect echoes (C2).
553    // If it doesn't exist yet, create it; if it does, check source independence.
554    // Determine if this is a truly independent source (C2).
555    // Check 1: same source_id as an existing record → echo.
556    // Check 2: cosine similarity with claim text above threshold → self-echo.
557    let is_new_source = compute_is_new_source(
558        ledger,
559        support_hash,
560        support_source,
561        support_class,
562        now_ms,
563        support_text,
564        claim_text,
565        provider,
566    );
567
568    // Get or create the claim record
569    let claim = ledger.get_or_create(
570        claim_hash,
571        EpistemicClass::Inference,
572        support_source,
573        now_ms,
574    );
575    claim.support_count += 1;
576    claim.last_support_ms = now_ms;
577
578    if is_new_source {
579        claim.distinct_sources += 1;
580    }
581
582    // Recompute confidence
583    claim.confidence = confidence(claim, now_ms);
584
585    // Audit
586    audit.append(AuditRecord {
587        ts_ms: now_ms,
588        event: AuditEvent::Link,
589        content_hash: claim_hash,
590        source_id: support_source,
591        delta: if is_new_source { 1 } else { 0 },
592        note: format!(
593            "support from {support_hash:016x} (class={support_class}, new_src={is_new_source})"
594        ),
595    });
596
597    Ok(())
598}
599
600/// Compute whether a support link represents a truly new, independent source.
601///
602/// Uses two checks:
603/// 1. source_id match against existing records (fast, always available)
604/// 2. cosine similarity between support_text and claim_text (when texts provided)
605#[allow(clippy::too_many_arguments)]
606fn compute_is_new_source(
607    ledger: &mut EvidenceLedger,
608    support_hash: u64,
609    support_source: u64,
610    support_class: EpistemicClass,
611    now_ms: u64,
612    support_text: Option<&str>,
613    claim_text: Option<&str>,
614    provider: Option<&dyn crate::embeddings::EmbeddingProvider>,
615) -> bool {
616    // Check 1: same source_id as an existing record → echo
617    let same_source = ledger
618        .records
619        .get(&support_hash)
620        .map(|r| r.source_id == support_source)
621        .unwrap_or(false);
622
623    // Check 2: cosine similarity with claim text → self-echo
624    let cosine_echo = match (support_text, claim_text, provider) {
625        (Some(s), Some(c), Some(p)) => is_echo_cosine(p, s, c, DEFAULT_SIM_THRESHOLD),
626        _ => false,
627    };
628
629    let is_echo = same_source || cosine_echo;
630
631    // Ensure the support block exists in the ledger
632    ledger
633        .records
634        .entry(support_hash)
635        .or_insert_with(|| EvidenceRecord {
636            content_hash: support_hash,
637            class: support_class,
638            source_id: support_source,
639            support_count: 0,
640            refute_count: 0,
641            distinct_sources: 0,
642            first_seen_ms: now_ms,
643            last_support_ms: 0,
644            last_refute_ms: 0,
645            confidence: 0,
646            flags: 0,
647        });
648
649    !is_echo
650}
651
652/// Check if two texts are echoes using cosine similarity of their embeddings.
653///
654/// Returns true if the texts are semantically similar above `threshold`.
655pub fn is_echo_cosine(
656    provider: &dyn crate::embeddings::EmbeddingProvider,
657    support_text: &str,
658    claim_text: &str,
659    threshold: f32,
660) -> bool {
661    use crate::embeddings::cosine_similarity_scalar;
662    let a = match provider.embed(support_text) {
663        Ok(v) => v,
664        Err(_) => return false,
665    };
666    let b = match provider.embed(claim_text) {
667        Ok(v) => v,
668        Err(_) => return false,
669    };
670    cosine_similarity_scalar(&a, &b) >= threshold
671}
672
673/// Record a refutation against a claim.
674pub fn refute(
675    ledger: &mut EvidenceLedger,
676    audit: &mut AuditChain,
677    claim_hash: u64,
678    refuter_source: u64,
679    now_ms: u64,
680) -> Result<(), String> {
681    let claim = ledger.get_or_create(
682        claim_hash,
683        EpistemicClass::Inference,
684        refuter_source,
685        now_ms,
686    );
687    claim.refute_count += 1;
688    claim.last_refute_ms = now_ms;
689    claim.confidence = confidence(claim, now_ms);
690
691    audit.append(AuditRecord {
692        ts_ms: now_ms,
693        event: AuditEvent::Refute,
694        content_hash: claim_hash,
695        source_id: refuter_source,
696        delta: -1,
697        note: "refutation".into(),
698    });
699
700    Ok(())
701}
702
703/// Check whether a block is allowed to promote importance (C4 gate).
704///
705/// Returns `Ok(())` if promotion is allowed, `Err(reason)` if blocked.
706/// Only applies to Inference and Hypothesis classes.
707pub fn check_promotion_gate(
708    ledger: &EvidenceLedger,
709    block_class: EpistemicClass,
710    block_hash: u64,
711) -> Result<(), String> {
712    if !matches!(
713        block_class,
714        EpistemicClass::Inference | EpistemicClass::Hypothesis
715    ) {
716        return Ok(()); // Observations and Evidence always pass
717    }
718    match ledger.records.get(&block_hash) {
719        Some(rec) if rec.distinct_sources > 0 => Ok(()),
720        Some(_) => Err("promotion blocked: no independent evidence (distinct_sources==0)".into()),
721        None => Err("promotion blocked: no evidence record found".into()),
722    }
723}
724
725/// Compute the epistemic class from a flags byte (convenience for build/dream).
726pub fn class_of_flags(flags: u8) -> EpistemicClass {
727    EpistemicClass::from_flags(flags)
728}
729
730/// Encode a class into flags byte bits 0-2 (convenience for build).
731pub fn flags_with_class(flags: u8, class: EpistemicClass) -> u8 {
732    class.into_flags(flags)
733}
734
735// ─── Tests ──────────────────────────────────────────
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    fn tmp_dir() -> tempfile::TempDir {
742        tempfile::tempdir().unwrap()
743    }
744
745    fn ms(days: u64) -> u64 {
746        days * 86_400_000
747    }
748
749    // ── C1: Salience isolation ────────────────────────
750
751    /// A1: confidence_does_not_rise_on_recall_alone.
752    ///
753    /// Invariant C1: 100 simulated Hebbian replays (increasing energy in
754    /// ActivationRecord) must NOT change the confidence of any record.
755    /// We verify by checking that the confidence formula has no energy parameter.
756    #[test]
757    fn c1_confidence_does_not_rise_on_recall_alone() {
758        let dir = tmp_dir();
759        let mut ledger = EvidenceLedger::load_or_init(dir.path());
760        let mut audit = AuditChain::load_or_init(dir.path());
761
762        // Store a hypothesis
763        let claim_hash = content_hash("the Microscope will be conscious by 2027");
764        let _ = link_evidence(
765            &mut ledger,
766            &mut audit,
767            claim_hash,
768            content_hash("test"),
769            EpistemicClass::Observation,
770            0xBEEF,
771            ms(0),
772            None,
773            None,
774            None,
775        );
776
777        let before = ledger.records[&claim_hash].confidence;
778
779        // Simulate 100 Hebbian replays (energy increases)
780        for energy_i in 1..=100 {
781            let _rec = ledger.records.get_mut(&claim_hash).unwrap();
782            // This is what dream::promote_recalled_blocks does internally:
783            // it reads energy from HebbianState, but that NEVER touches confidence.
784            // The confidence field is only recomputed by link/refute operations.
785            let _ = energy_i; // unused — proving the point
786        }
787
788        let after = ledger.records[&claim_hash].confidence;
789        assert_eq!(
790            before, after,
791            "C1 violated: recall replay changed confidence"
792        );
793    }
794
795    /// A1b: Directly verify confidence() has no energy parameter.
796    #[test]
797    fn c1_confidence_formula_has_no_energy_param() {
798        let rec = EvidenceRecord {
799            content_hash: 42,
800            class: EpistemicClass::Hypothesis,
801            source_id: 1,
802            support_count: 3,
803            refute_count: 0,
804            distinct_sources: 2,
805            first_seen_ms: ms(10),
806            last_support_ms: ms(10),
807            last_refute_ms: 0,
808            confidence: 0,
809            flags: 0,
810        };
811        let c1 = confidence(&rec, ms(20));
812        let c2 = confidence(&rec, ms(30)); // more time passed
813        assert!(c1 >= c2, "age_penalty should decrease confidence over time");
814        assert!(
815            c1 > 0,
816            "3 supports + 2 distinct sources should yield positive confidence"
817        );
818    }
819
820    // ── C2: Self-echo prevention ──────────────────────
821
822    #[test]
823    fn c2_self_echo_does_not_add_sources() {
824        let dir = tmp_dir();
825        let mut ledger = EvidenceLedger::load_or_init(dir.path());
826        let mut audit = AuditChain::load_or_init(dir.path());
827
828        let claim_hash = content_hash("a claim");
829        let obs_hash = content_hash("observed fact");
830
831        // Link from source A
832        link_evidence(
833            &mut ledger,
834            &mut audit,
835            claim_hash,
836            obs_hash,
837            EpistemicClass::Observation,
838            0xAAAA,
839            ms(0),
840            None,
841            None,
842            None,
843        )
844        .unwrap();
845        let ds_after_first = ledger.records[&claim_hash].distinct_sources;
846        assert_eq!(ds_after_first, 1);
847
848        // Link again from SAME source (same source_id = echo)
849        link_evidence(
850            &mut ledger,
851            &mut audit,
852            claim_hash,
853            obs_hash,
854            EpistemicClass::Observation,
855            0xAAAA,
856            ms(1),
857            None,
858            None,
859            None,
860        )
861        .unwrap();
862        let ds_after_second = ledger.records[&claim_hash].distinct_sources;
863        assert_eq!(
864            ds_after_second, 1,
865            "C2 violated: same source should not increase distinct_sources"
866        );
867        // support_count SHOULD increase (it's still a link)
868        assert_eq!(ledger.records[&claim_hash].support_count, 2);
869    }
870
871    #[test]
872    fn c2_independent_source_increases_sources() {
873        let dir = tmp_dir();
874        let mut ledger = EvidenceLedger::load_or_init(dir.path());
875        let mut audit = AuditChain::load_or_init(dir.path());
876
877        let claim_hash = content_hash("a claim");
878        let obs_hash = content_hash("observed fact");
879
880        link_evidence(
881            &mut ledger,
882            &mut audit,
883            claim_hash,
884            obs_hash,
885            EpistemicClass::Observation,
886            0xAAAA,
887            ms(0),
888            None,
889            None,
890            None,
891        )
892        .unwrap();
893        link_evidence(
894            &mut ledger,
895            &mut audit,
896            claim_hash,
897            obs_hash,
898            EpistemicClass::Observation,
899            0xBBBB,
900            ms(1),
901            None,
902            None,
903            None,
904        )
905        .unwrap();
906
907        assert_eq!(
908            ledger.records[&claim_hash].distinct_sources, 2,
909            "different sources should increase distinct_sources"
910        );
911    }
912
913    // ── C3: Support restriction ───────────────────────
914
915    #[test]
916    fn c3_inference_cannot_be_support() {
917        let dir = tmp_dir();
918        let mut ledger = EvidenceLedger::load_or_init(dir.path());
919        let mut audit = AuditChain::load_or_init(dir.path());
920
921        let claim = content_hash("claim");
922        let inf = content_hash("inference");
923
924        let result = link_evidence(
925            &mut ledger,
926            &mut audit,
927            claim,
928            inf,
929            EpistemicClass::Inference,
930            0x1,
931            ms(0),
932            None,
933            None,
934            None,
935        );
936        assert!(
937            result.is_err(),
938            "C3 violated: Inference should not be able to support"
939        );
940        assert!(result.unwrap_err().contains("cannot serve as support"));
941    }
942
943    #[test]
944    fn c3_hypothesis_cannot_be_support() {
945        let dir = tmp_dir();
946        let mut ledger = EvidenceLedger::load_or_init(dir.path());
947        let mut audit = AuditChain::load_or_init(dir.path());
948
949        let claim = content_hash("claim");
950        let hyp = content_hash("hypothesis");
951
952        let result = link_evidence(
953            &mut ledger,
954            &mut audit,
955            claim,
956            hyp,
957            EpistemicClass::Hypothesis,
958            0x1,
959            ms(0),
960            None,
961            None,
962            None,
963        );
964        assert!(
965            result.is_err(),
966            "C3 violated: Hypothesis should not be able to support"
967        );
968    }
969
970    #[test]
971    fn c3_observation_can_support() {
972        let dir = tmp_dir();
973        let mut ledger = EvidenceLedger::load_or_init(dir.path());
974        let mut audit = AuditChain::load_or_init(dir.path());
975
976        let claim = content_hash("claim");
977        let obs = content_hash("observation");
978
979        assert!(link_evidence(
980            &mut ledger,
981            &mut audit,
982            claim,
983            obs,
984            EpistemicClass::Observation,
985            0x1,
986            ms(0),
987            None,
988            None,
989            None
990        )
991        .is_ok());
992    }
993
994    #[test]
995    fn c3_evidence_can_support() {
996        let dir = tmp_dir();
997        let mut ledger = EvidenceLedger::load_or_init(dir.path());
998        let mut audit = AuditChain::load_or_init(dir.path());
999
1000        let claim = content_hash("claim");
1001        let evi = content_hash("evidence");
1002
1003        assert!(link_evidence(
1004            &mut ledger,
1005            &mut audit,
1006            claim,
1007            evi,
1008            EpistemicClass::Evidence,
1009            0x1,
1010            ms(0),
1011            None,
1012            None,
1013            None
1014        )
1015        .is_ok());
1016    }
1017
1018    // ── C4: Promotion gate ────────────────────────────
1019
1020    #[test]
1021    fn c4_gate_blocks_unsupported_inference() {
1022        let ledger = EvidenceLedger {
1023            records: HashMap::new(),
1024        };
1025        let hyp_hash = content_hash("unsupported hypothesis");
1026        let result = check_promotion_gate(&ledger, EpistemicClass::Hypothesis, hyp_hash);
1027        assert!(
1028            result.is_err(),
1029            "C4 violated: gate should block unsupported hypothesis"
1030        );
1031    }
1032
1033    #[test]
1034    fn c4_gate_allows_supported_inference() {
1035        let dir = tmp_dir();
1036        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1037        let mut audit = AuditChain::load_or_init(dir.path());
1038
1039        let claim_hash = content_hash("supported inference");
1040        let obs_hash = content_hash("supporting observation");
1041        link_evidence(
1042            &mut ledger,
1043            &mut audit,
1044            claim_hash,
1045            obs_hash,
1046            EpistemicClass::Observation,
1047            0xBEEF,
1048            ms(0),
1049            None,
1050            None,
1051            None,
1052        )
1053        .unwrap();
1054
1055        assert!(check_promotion_gate(&ledger, EpistemicClass::Inference, claim_hash).is_ok());
1056    }
1057
1058    #[test]
1059    fn c4_gate_allows_observation_always() {
1060        let ledger = EvidenceLedger {
1061            records: HashMap::new(),
1062        };
1063        assert!(check_promotion_gate(&ledger, EpistemicClass::Observation, 42).is_ok());
1064    }
1065
1066    #[test]
1067    fn c4_gate_allows_evidence_always() {
1068        let ledger = EvidenceLedger {
1069            records: HashMap::new(),
1070        };
1071        assert!(check_promotion_gate(&ledger, EpistemicClass::Evidence, 42).is_ok());
1072    }
1073
1074    #[test]
1075    fn c4_gate_blocks_inference_no_record() {
1076        let ledger = EvidenceLedger {
1077            records: HashMap::new(),
1078        };
1079        assert!(check_promotion_gate(&ledger, EpistemicClass::Inference, 99).is_err());
1080    }
1081
1082    // ── C5: Audit chain integrity ─────────────────────
1083
1084    #[test]
1085    fn c5_audit_chain_valid_after_appends() {
1086        let dir = tmp_dir();
1087        let mut audit = AuditChain::load_or_init(dir.path());
1088
1089        for i in 0..10 {
1090            audit.append(AuditRecord {
1091                ts_ms: ms(i),
1092                event: AuditEvent::Store,
1093                content_hash: i,
1094                source_id: 0,
1095                delta: 0,
1096                note: format!("entry {i}"),
1097            });
1098        }
1099        assert!(
1100            audit.verify().is_ok(),
1101            "C5: chain should verify after appends"
1102        );
1103    }
1104
1105    #[test]
1106    fn c5_audit_chain_detects_tampering() {
1107        let dir = tmp_dir();
1108        let mut audit = AuditChain::load_or_init(dir.path());
1109
1110        for i in 0..5 {
1111            audit.append(AuditRecord {
1112                ts_ms: ms(i),
1113                event: AuditEvent::Link,
1114                content_hash: i,
1115                source_id: 1,
1116                delta: 1,
1117                note: format!("link {i}"),
1118            });
1119        }
1120        assert!(audit.verify().is_ok());
1121
1122        // Tamper with the middle record's note
1123        audit.chunks[2].record.note = "TAMPERED".to_string();
1124        let result = audit.verify();
1125        assert!(
1126            result.is_err(),
1127            "C5 violated: tampered chain should fail verification"
1128        );
1129        assert_eq!(
1130            result.unwrap_err(),
1131            2,
1132            "tampered chunk should be at index 2"
1133        );
1134    }
1135
1136    #[test]
1137    fn c5_audit_chain_detects_reorder() {
1138        let dir = tmp_dir();
1139        let mut audit = AuditChain::load_or_init(dir.path());
1140
1141        for i in 0..3 {
1142            audit.append(AuditRecord {
1143                ts_ms: ms(i),
1144                event: AuditEvent::Store,
1145                content_hash: i,
1146                source_id: 0,
1147                delta: 0,
1148                note: format!("{i}"),
1149            });
1150        }
1151        assert!(audit.verify().is_ok());
1152
1153        // Swap chunks 1 and 2
1154        audit.chunks.swap(1, 2);
1155        let result = audit.verify();
1156        assert!(result.is_err(), "C5: reordered chain should fail");
1157    }
1158
1159    // ── Refutation ────────────────────────────────────
1160
1161    #[test]
1162    fn refutation_decreases_confidence() {
1163        let dir = tmp_dir();
1164        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1165        let mut audit = AuditChain::load_or_init(dir.path());
1166
1167        let claim = content_hash("disputed claim");
1168        let obs = content_hash("support");
1169
1170        // Build up confidence with 3 supports from different sources
1171        for src in [0x100u64, 0x200, 0x300] {
1172            link_evidence(
1173                &mut ledger,
1174                &mut audit,
1175                claim,
1176                obs,
1177                EpistemicClass::Observation,
1178                src,
1179                ms(0),
1180                None,
1181                None,
1182                None,
1183            )
1184            .unwrap();
1185        }
1186        let before_refute = ledger.records[&claim].confidence;
1187        assert!(before_refute > 0);
1188
1189        // Refute twice
1190        refute(&mut ledger, &mut audit, claim, 0x999, ms(1)).unwrap();
1191        refute(&mut ledger, &mut audit, claim, 0x999, ms(2)).unwrap();
1192        let after_refute = ledger.records[&claim].confidence;
1193        assert!(
1194            after_refute < before_refute,
1195            "refutation should lower confidence"
1196        );
1197    }
1198
1199    // ── Self-reference ────────────────────────────────
1200
1201    #[test]
1202    fn self_reference_blocked() {
1203        let dir = tmp_dir();
1204        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1205        let mut audit = AuditChain::load_or_init(dir.path());
1206
1207        let h = content_hash("self-referencing claim");
1208        let result = link_evidence(
1209            &mut ledger,
1210            &mut audit,
1211            h,
1212            h,
1213            EpistemicClass::Observation,
1214            1,
1215            ms(0),
1216            None,
1217            None,
1218            None,
1219        );
1220        assert!(result.is_err(), "self-reference should be blocked");
1221    }
1222
1223    // ── Persistence roundtrip ─────────────────────────
1224
1225    #[test]
1226    fn ledger_survives_save_load() {
1227        let dir = tmp_dir();
1228        {
1229            let mut ledger = EvidenceLedger::load_or_init(dir.path());
1230            let mut audit = AuditChain::load_or_init(dir.path());
1231
1232            let claim = content_hash("persistent claim");
1233            link_evidence(
1234                &mut ledger,
1235                &mut audit,
1236                claim,
1237                content_hash("obs"),
1238                EpistemicClass::Observation,
1239                0xA,
1240                ms(0),
1241                None,
1242                None,
1243                None,
1244            )
1245            .unwrap();
1246            ledger.save(dir.path()).unwrap();
1247            audit.save(dir.path()).unwrap();
1248        }
1249
1250        let ledger2 = EvidenceLedger::load_or_init(dir.path());
1251        let audit2 = AuditChain::load_or_init(dir.path());
1252        assert!(ledger2
1253            .records
1254            .contains_key(&content_hash("persistent claim")));
1255        assert!(audit2.verify().is_ok());
1256    }
1257
1258    // ── Class encoding ────────────────────────────────
1259
1260    #[test]
1261    fn flags_roundtrip_class() {
1262        for class in [
1263            EpistemicClass::Observation,
1264            EpistemicClass::Evidence,
1265            EpistemicClass::Inference,
1266            EpistemicClass::Hypothesis,
1267        ] {
1268            let flags = class.into_flags(0xF8); // preserve upper bits
1269            assert_eq!(EpistemicClass::from_flags(flags), class);
1270            assert_eq!(flags & 0xF8, 0xF8, "upper bits preserved");
1271        }
1272    }
1273
1274    // ── Confidence monotonicity ───────────────────────
1275
1276    #[test]
1277    fn confidence_increases_with_support() {
1278        let dir = tmp_dir();
1279        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1280        let mut audit = AuditChain::load_or_init(dir.path());
1281
1282        let claim = content_hash("growing claim");
1283        let mut prev_conf = 0u8;
1284
1285        for src in 1u64..=5 {
1286            link_evidence(
1287                &mut ledger,
1288                &mut audit,
1289                claim,
1290                content_hash("obs"),
1291                EpistemicClass::Observation,
1292                src,
1293                ms(0),
1294                None,
1295                None,
1296                None,
1297            )
1298            .unwrap();
1299            let cur = ledger.records[&claim].confidence;
1300            assert!(
1301                cur >= prev_conf,
1302                "confidence should be non-decreasing with support"
1303            );
1304            prev_conf = cur;
1305        }
1306    }
1307    // ── C2: Cosine echo detection ──────────────────────
1308
1309    /// C2b: cosine echo — a paraphrase of the claim from the same source
1310    /// should NOT increase distinct_sources.
1311    #[test]
1312    fn c2_cosine_echo_same_source_no_increment() {
1313        use crate::embeddings::MockEmbeddingProvider;
1314        let provider = MockEmbeddingProvider::new(128);
1315        let dir = tmp_dir();
1316        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1317        let mut audit = AuditChain::load_or_init(dir.path());
1318
1319        let claim = content_hash("the Microscope is a cognitive memory engine");
1320        let claim_text = "the Microscope is a cognitive memory engine";
1321        // Paraphrase of the claim — cosine should be high
1322        let support_text = "the Microscope is a cognitive memory system";
1323
1324        link_evidence(
1325            &mut ledger,
1326            &mut audit,
1327            claim,
1328            content_hash(support_text),
1329            EpistemicClass::Observation,
1330            0xAAAA,
1331            ms(0),
1332            Some(support_text),
1333            Some(claim_text),
1334            Some(&provider),
1335        )
1336        .unwrap();
1337        let ds = ledger.records[&claim].distinct_sources;
1338        // Cosine echo: same source + similar text → should NOT count as new
1339        // (source_id match already catches this, but cosine adds defense-in-depth)
1340        assert!(
1341            ds <= 1,
1342            "cosine echo should not inflate distinct_sources beyond 1"
1343        );
1344    }
1345
1346    /// C2c: truly independent observations should increase distinct_sources.
1347    #[test]
1348    fn c2_truly_independent_obs_increments() {
1349        use crate::embeddings::MockEmbeddingProvider;
1350        let provider = MockEmbeddingProvider::new(128);
1351        let dir = tmp_dir();
1352        let mut ledger = EvidenceLedger::load_or_init(dir.path());
1353        let mut audit = AuditChain::load_or_init(dir.path());
1354
1355        let claim = content_hash("the Microscope is a cognitive memory engine");
1356        let claim_text = "the Microscope is a cognitive memory engine";
1357        let obs1 = "the factory opens at 6am every morning";
1358
1359        link_evidence(
1360            &mut ledger,
1361            &mut audit,
1362            claim,
1363            content_hash(obs1),
1364            EpistemicClass::Observation,
1365            0xAAAA,
1366            ms(0),
1367            Some(obs1),
1368            Some(claim_text),
1369            Some(&provider),
1370        )
1371        .unwrap();
1372        let ds1 = ledger.records[&claim].distinct_sources;
1373        assert_eq!(ds1, 1, "first independent observation should count");
1374    }
1375}