1use sha2::{Digest, Sha256};
23use std::collections::HashMap;
24use std::fs;
25use std::path::Path;
26
27use crate::fingerprint::fnv1a_hash;
28
29pub const EVIDENCE_MAGIC: &[u8; 4] = b"EVD1";
33pub const AUDIT_MAGIC: &[u8; 4] = b"EVL1";
35
36pub const DEFAULT_SIM_THRESHOLD: f32 = 0.85;
38
39#[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 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 pub fn into_flags(self, existing: u8) -> u8 {
70 (existing & 0xF8) | (self as u8 & 0x07)
71 }
72
73 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#[derive(Debug, Clone)]
107pub struct EvidenceRecord {
108 pub content_hash: u64,
110 pub class: EpistemicClass,
112 pub source_id: u64,
114 pub support_count: u32,
116 pub refute_count: u32,
118 pub distinct_sources: u32,
120 pub first_seen_ms: u64,
122 pub last_support_ms: u64,
123 pub last_refute_ms: u64,
124 pub confidence: u8,
126 pub flags: u8,
128}
129
130pub 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
161pub struct EvidenceLedger {
165 pub records: HashMap<u64, EvidenceRecord>,
166}
167
168const LEDGER_RECORD_SIZE: usize = 8 + 1 + 8 + 4 + 4 + 4 + 8 + 8 + 8 + 1 + 1 + 2;
174
175impl EvidenceLedger {
176 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 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 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]); }
294
295#[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#[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#[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; const AUDIT_CHUNK_SIZE: usize = 32 + AUDIT_CHUNK_PAYLOAD_SIZE + 32; pub struct AuditChain {
332 pub chunks: Vec<AuditChunk>,
333}
334
335impl AuditChain {
336 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(¬e_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]); 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 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 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 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(¬e_len.to_le_bytes());
480 buf.extend_from_slice(¬e_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(¬e_len.to_le_bytes());
502 buf.extend_from_slice(¬e_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
509pub fn content_hash(text: &str) -> u64 {
513 fnv1a_hash(text.trim().as_bytes())
514}
515
516#[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 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 if support_hash == claim_hash {
549 return Err("self-reference: support_hash == claim_hash".into());
550 }
551
552 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 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 claim.confidence = confidence(claim, now_ms);
584
585 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#[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 let same_source = ledger
618 .records
619 .get(&support_hash)
620 .map(|r| r.source_id == support_source)
621 .unwrap_or(false);
622
623 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 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
652pub 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
673pub 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
703pub 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(()); }
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
725pub fn class_of_flags(flags: u8) -> EpistemicClass {
727 EpistemicClass::from_flags(flags)
728}
729
730pub fn flags_with_class(flags: u8, class: EpistemicClass) -> u8 {
732 class.into_flags(flags)
733}
734
735#[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 #[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 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 for energy_i in 1..=100 {
781 let _rec = ledger.records.get_mut(&claim_hash).unwrap();
782 let _ = energy_i; }
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 #[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)); 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 #[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_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_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 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 #[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 #[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 #[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 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 audit.chunks.swap(1, 2);
1155 let result = audit.verify();
1156 assert!(result.is_err(), "C5: reordered chain should fail");
1157 }
1158
1159 #[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 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(&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 #[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 #[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 #[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); assert_eq!(EpistemicClass::from_flags(flags), class);
1270 assert_eq!(flags & 0xF8, 0xF8, "upper bits preserved");
1271 }
1272 }
1273
1274 #[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 #[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 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 assert!(
1341 ds <= 1,
1342 "cosine echo should not inflate distinct_sources beyond 1"
1343 );
1344 }
1345
1346 #[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}