1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6#[cfg(test)]
7use std::cell::Cell;
8
9use arrow::array::{
10 Array, FixedSizeBinaryArray, FixedSizeBinaryBuilder, StringArray, StringBuilder,
11 TimestampMicrosecondArray, TimestampMicrosecondBuilder, UInt32Array, UInt32Builder,
12};
13use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
14use arrow::record_batch::RecordBatch;
15use graphforge_core::canonical::{
16 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
17};
18use uuid::{Uuid, Version};
19
20use crate::{
21 EPISTEMIC_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
22};
23
24pub const ASSERTION_STATUS_CONTRACT_VERSION: u32 = 1;
26pub const ASSERTION_STATUS_REGISTRY_VERSION: u32 = 1;
28
29pub static ASSERTION_STATUS_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
31 Arc::new(Schema::new(vec![
32 uuid_field("status_event_uuid", false),
33 uuid_field("assertion_uuid", false),
34 Field::new("status", DataType::Utf8, false),
35 uuid_field("confidence_uuid", true),
36 uuid_field("reasoning_uuid", true),
37 uuid_field("provenance_uuid", false),
38 Field::new(
39 "recorded_at",
40 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
41 false,
42 ),
43 Field::new("contract_version", DataType::UInt32, false),
44 ]))
45});
46
47static ASSERTION_STATUS_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
48 fingerprint(
49 CanonicalDomain::AssertionStatus,
50 CANONICAL_CONTRACT_VERSION,
51 b"assertion_status/1|status_event_uuid:fixed[16]:required|assertion_uuid:fixed[16]:required|status:utf8:required|confidence_uuid:fixed[16]:nullable|reasoning_uuid:fixed[16]:nullable|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
52 )
53 .expect("registered assertion-status schema is within canonical bounds")
54});
55
56#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
58pub enum AssertionStatus {
59 Hypothesis,
61 Supported,
63 Refuted,
65 Disputed,
67 Retracted,
69 Superseded,
71}
72
73impl AssertionStatus {
74 #[must_use]
76 pub const fn as_str(self) -> &'static str {
77 match self {
78 Self::Hypothesis => "hypothesis",
79 Self::Supported => "supported",
80 Self::Refuted => "refuted",
81 Self::Disputed => "disputed",
82 Self::Retracted => "retracted",
83 Self::Superseded => "superseded",
84 }
85 }
86
87 fn parse(value: &str) -> Result<Self, KnowledgeError> {
88 match value {
89 "hypothesis" => Ok(Self::Hypothesis),
90 "supported" => Ok(Self::Supported),
91 "refuted" => Ok(Self::Refuted),
92 "disputed" => Ok(Self::Disputed),
93 "retracted" => Ok(Self::Retracted),
94 "superseded" => Ok(Self::Superseded),
95 _ => Err(invalid("assertion_status.status", "unknown registry value")),
96 }
97 }
98
99 #[must_use]
101 pub const fn is_terminal(self) -> bool {
102 matches!(self, Self::Superseded)
103 }
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct AssertionStatusEvent {
109 pub status_event_uuid: Uuid,
111 pub assertion_uuid: Uuid,
113 pub status: AssertionStatus,
115 pub confidence_uuid: Option<Uuid>,
117 pub reasoning_uuid: Option<Uuid>,
119 pub provenance_uuid: Uuid,
121 pub recorded_at_micros: i64,
123 pub contract_version: u32,
125}
126
127impl AssertionStatusEvent {
128 #[allow(clippy::too_many_arguments)]
130 pub fn new(
131 status_event_uuid: Uuid,
132 assertion_uuid: Uuid,
133 status: AssertionStatus,
134 confidence_uuid: Option<Uuid>,
135 reasoning_uuid: Option<Uuid>,
136 provenance_uuid: Uuid,
137 recorded_at_micros: i64,
138 ) -> Result<Self, KnowledgeError> {
139 require_v7(status_event_uuid, "status_event_uuid")?;
140 require_v7(assertion_uuid, "assertion_uuid")?;
141 if let Some(value) = confidence_uuid {
142 require_v7(value, "confidence_uuid")?;
143 }
144 if let Some(value) = reasoning_uuid {
145 require_v7(value, "reasoning_uuid")?;
146 }
147 require_uuid(provenance_uuid, "provenance_uuid")?;
148 Ok(Self {
149 status_event_uuid,
150 assertion_uuid,
151 status,
152 confidence_uuid,
153 reasoning_uuid,
154 provenance_uuid,
155 recorded_at_micros,
156 contract_version: ASSERTION_STATUS_CONTRACT_VERSION,
157 })
158 }
159}
160
161#[derive(Clone, Debug, Default, Eq, PartialEq)]
171pub struct AssertionStatusLedger {
172 events: Vec<AssertionStatusEvent>,
174}
175
176#[cfg(test)]
177thread_local! {
178 static STATUS_ROW_VALIDATION_COUNT: Cell<usize> = const { Cell::new(0) };
179}
180
181impl AssertionStatusLedger {
182 pub fn new(events: Vec<AssertionStatusEvent>) -> Result<Self, KnowledgeError> {
184 Self::new_with_limit(events, MAX_KNOWLEDGE_ROWS)
185 }
186
187 fn new_with_limit(
188 mut events: Vec<AssertionStatusEvent>,
189 max_rows: usize,
190 ) -> Result<Self, KnowledgeError> {
191 if events.len() > max_rows {
192 return Err(KnowledgeError::Limit {
193 participant: "assertion_status_events",
194 observed: events.len(),
195 limit: max_rows,
196 });
197 }
198 let mut ids = HashSet::with_capacity(events.len());
199 for event in &events {
200 validate_event(event)?;
201 if !ids.insert(event.status_event_uuid) {
202 return Err(KnowledgeError::Duplicate("status_event_uuid"));
203 }
204 }
205 events.sort_by_key(|row| (row.recorded_at_micros, row.status_event_uuid));
206 validate_terminal_sequence(&events)?;
207 Ok(Self { events })
208 }
209
210 #[must_use]
212 pub fn events(&self) -> &[AssertionStatusEvent] {
213 &self.events
214 }
215
216 #[must_use]
218 pub fn into_events(self) -> Vec<AssertionStatusEvent> {
219 self.events
220 }
221
222 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
224 self.merge_with_limit(staged, MAX_KNOWLEDGE_ROWS)
225 }
226
227 fn merge_with_limit(&self, staged: &Self, max_rows: usize) -> Result<Self, KnowledgeError> {
228 let mut events = self.events.clone();
229 let terminal_assertions = self
230 .events
231 .iter()
232 .filter(|row| row.status.is_terminal())
233 .map(|row| row.assertion_uuid)
234 .collect::<HashSet<_>>();
235 let mut by_id = events
236 .iter()
237 .cloned()
238 .map(|row| (row.status_event_uuid, row))
239 .collect::<HashMap<_, _>>();
240 for event in &staged.events {
241 if let Some(existing) = by_id.get(&event.status_event_uuid) {
242 if existing != event {
243 return Err(KnowledgeError::Conflict("status_event_uuid"));
244 }
245 } else {
246 if terminal_assertions.contains(&event.assertion_uuid)
247 && !event.status.is_terminal()
248 {
249 return Err(invalid("assertion_status.status", "superseded is terminal"));
250 }
251 validate_event(event)?;
252 events.push(event.clone());
253 by_id.insert(event.status_event_uuid, event.clone());
254 }
255 }
256 if events.len() > max_rows {
257 return Err(KnowledgeError::Limit {
258 participant: "assertion_status_events",
259 observed: events.len(),
260 limit: max_rows,
261 });
262 }
263 events.sort_by_key(|row| (row.recorded_at_micros, row.status_event_uuid));
264 Ok(Self { events })
265 }
266
267 pub fn event_fingerprint(&self, status_event_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
269 let row = self
270 .events
271 .iter()
272 .find(|row| row.status_event_uuid == status_event_uuid)
273 .ok_or(KnowledgeError::Dangling("status_event_uuid"))?;
274 event_fingerprint_for_row(row)
275 }
276
277 pub fn event_fingerprints(&self) -> Result<Vec<(Uuid, [u8; 32])>, KnowledgeError> {
279 self.events
280 .iter()
281 .map(|row| Ok((row.status_event_uuid, event_fingerprint_for_row(row)?)))
282 .collect()
283 }
284
285 #[must_use]
287 pub fn current_for(&self, assertion_uuid: Uuid) -> Option<&AssertionStatusEvent> {
288 self.events
289 .iter()
290 .filter(|row| row.assertion_uuid == assertion_uuid)
291 .max_by_key(|row| (row.recorded_at_micros, row.status_event_uuid))
292 }
293
294 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
296 let mut ids = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
297 let mut assertions = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
298 let mut statuses = StringBuilder::new();
299 let mut confidence = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
300 let mut reasoning = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
301 let mut provenance = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
302 let mut times = TimestampMicrosecondBuilder::new().with_timezone("UTC");
303 let mut versions = UInt32Builder::new();
304 for row in &self.events {
305 ids.append_value(row.status_event_uuid.as_bytes())
306 .map_err(|_| invalid("status_event_uuid", "invalid UUID width"))?;
307 assertions
308 .append_value(row.assertion_uuid.as_bytes())
309 .map_err(|_| invalid("assertion_uuid", "invalid UUID width"))?;
310 statuses.append_value(row.status.as_str());
311 append_optional_uuid(&mut confidence, row.confidence_uuid)?;
312 append_optional_uuid(&mut reasoning, row.reasoning_uuid)?;
313 provenance
314 .append_value(row.provenance_uuid.as_bytes())
315 .map_err(|_| invalid("provenance_uuid", "invalid UUID width"))?;
316 times.append_value(row.recorded_at_micros);
317 versions.append_value(row.contract_version);
318 }
319 RecordBatch::try_new(
320 Arc::clone(&ASSERTION_STATUS_SCHEMA),
321 vec![
322 Arc::new(ids.finish()),
323 Arc::new(assertions.finish()),
324 Arc::new(statuses.finish()),
325 Arc::new(confidence.finish()),
326 Arc::new(reasoning.finish()),
327 Arc::new(provenance.finish()),
328 Arc::new(times.finish()),
329 Arc::new(versions.finish()),
330 ],
331 )
332 .map_err(|_| invalid("assertion_status", "Arrow batch construction failed"))
333 }
334
335 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
337 let mut events = Vec::new();
338 for batch in batches {
339 if batch.schema().as_ref() != ASSERTION_STATUS_SCHEMA.as_ref() {
340 return Err(invalid("assertion_status.schema", "schema mismatch"));
341 }
342 let ids = fixed(batch, "status_event_uuid")?;
343 let assertions = fixed(batch, "assertion_uuid")?;
344 let statuses = string(batch, "status")?;
345 let confidence = fixed(batch, "confidence_uuid")?;
346 let reasoning = fixed(batch, "reasoning_uuid")?;
347 let provenance = fixed(batch, "provenance_uuid")?;
348 let times = timestamp(batch, "recorded_at")?;
349 let versions = uint32(batch, "contract_version")?;
350 for row in 0..batch.num_rows() {
351 events.push(AssertionStatusEvent {
352 status_event_uuid: uuid_at(ids, row, "status_event_uuid")?,
353 assertion_uuid: uuid_at(assertions, row, "assertion_uuid")?,
354 status: AssertionStatus::parse(statuses.value(row))?,
355 confidence_uuid: optional_uuid_at(confidence, row, "confidence_uuid")?,
356 reasoning_uuid: optional_uuid_at(reasoning, row, "reasoning_uuid")?,
357 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
358 recorded_at_micros: times.value(row),
359 contract_version: versions.value(row),
360 });
361 }
362 }
363 Self::new(events)
364 }
365}
366
367fn event_fingerprint_for_row(row: &AssertionStatusEvent) -> Result<[u8; 32], KnowledgeError> {
368 let mut writer = CanonicalWriter::new();
369 writer.raw(row.status_event_uuid.as_bytes())?;
370 writer.raw(row.assertion_uuid.as_bytes())?;
371 writer.text(row.status.as_str())?;
372 optional_uuid(&mut writer, row.confidence_uuid)?;
373 optional_uuid(&mut writer, row.reasoning_uuid)?;
374 writer.raw(row.provenance_uuid.as_bytes())?;
375 writer.i64(row.recorded_at_micros)?;
376 writer.u32(row.contract_version)?;
377 fingerprint(
378 CanonicalDomain::AssertionStatus,
379 CANONICAL_CONTRACT_VERSION,
380 &writer.finish(),
381 )
382 .map_err(Into::into)
383}
384
385pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
386 SchemaRegistryEntry {
387 capability_id: "epistemic",
388 capability_version: EPISTEMIC_CAPABILITY_VERSION,
389 record_family: "assertion_status_events",
390 record_version: ASSERTION_STATUS_CONTRACT_VERSION,
391 schema: Arc::clone(&ASSERTION_STATUS_SCHEMA),
392 schema_fingerprint: *ASSERTION_STATUS_SCHEMA_FINGERPRINT,
393 enum_registry_versions: &[("assertion_status", ASSERTION_STATUS_REGISTRY_VERSION)],
394 sort_key: &["recorded_at", "status_event_uuid"],
395 diff_identity_fields: &["status_event_uuid"],
396 diff_record_uuid_field: Some("status_event_uuid"),
397 fingerprint_domain: CanonicalDomain::AssertionStatus,
398 owner: "graphforge-knowledge",
399 implementation_issue: 777,
400 max_rows: MAX_KNOWLEDGE_ROWS,
401 }
402}
403
404fn validate_event(row: &AssertionStatusEvent) -> Result<(), KnowledgeError> {
405 #[cfg(test)]
406 STATUS_ROW_VALIDATION_COUNT.with(|count| count.set(count.get() + 1));
407 if row.contract_version != ASSERTION_STATUS_CONTRACT_VERSION {
408 return Err(invalid(
409 "assertion_status.contract_version",
410 "unsupported version",
411 ));
412 }
413 require_v7(row.status_event_uuid, "status_event_uuid")?;
414 require_v7(row.assertion_uuid, "assertion_uuid")?;
415 if let Some(value) = row.confidence_uuid {
416 require_v7(value, "confidence_uuid")?;
417 }
418 if let Some(value) = row.reasoning_uuid {
419 require_v7(value, "reasoning_uuid")?;
420 }
421 require_uuid(row.provenance_uuid, "provenance_uuid")
422}
423
424fn validate_terminal_sequence(events: &[AssertionStatusEvent]) -> Result<(), KnowledgeError> {
425 let mut terminal_assertions = HashSet::new();
426 for event in events {
427 if terminal_assertions.contains(&event.assertion_uuid) && !event.status.is_terminal() {
428 return Err(invalid("assertion_status.status", "superseded is terminal"));
429 }
430 if event.status.is_terminal() {
431 terminal_assertions.insert(event.assertion_uuid);
432 }
433 }
434 Ok(())
435}
436
437#[cfg(test)]
438fn reset_status_row_validation_count() {
439 STATUS_ROW_VALIDATION_COUNT.with(|count| count.set(0));
440}
441
442#[cfg(test)]
443fn status_row_validation_count() -> usize {
444 STATUS_ROW_VALIDATION_COUNT.with(Cell::get)
445}
446
447fn optional_uuid(writer: &mut CanonicalWriter, value: Option<Uuid>) -> Result<(), KnowledgeError> {
448 match value {
449 Some(value) => {
450 writer.u8(1)?;
451 writer.raw(value.as_bytes())?;
452 }
453 None => writer.u8(0)?,
454 }
455 Ok(())
456}
457
458fn append_optional_uuid(
459 builder: &mut FixedSizeBinaryBuilder,
460 value: Option<Uuid>,
461) -> Result<(), KnowledgeError> {
462 if let Some(value) = value {
463 builder
464 .append_value(value.as_bytes())
465 .map_err(|_| invalid("assertion_status", "invalid UUID width"))?;
466 } else {
467 builder.append_null();
468 }
469 Ok(())
470}
471
472fn optional_uuid_at(
473 values: &FixedSizeBinaryArray,
474 row: usize,
475 field: &'static str,
476) -> Result<Option<Uuid>, KnowledgeError> {
477 (!values.is_null(row))
478 .then(|| uuid_at(values, row, field))
479 .transpose()
480}
481
482fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
483 if value.get_version() != Some(Version::SortRand) {
484 return Err(invalid(field, "must be UUIDv7"));
485 }
486 require_uuid(value, field)
487}
488
489fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
490 if value.is_nil() {
491 return Err(invalid(field, "must not be nil"));
492 }
493 Ok(())
494}
495
496const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
497 KnowledgeError::Invalid { field, message }
498}
499
500fn uuid_field(name: &str, nullable: bool) -> Field {
501 Field::new(name, DataType::FixedSizeBinary(16), nullable)
502}
503
504fn fixed<'a>(
505 batch: &'a RecordBatch,
506 name: &'static str,
507) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
508 batch
509 .column_by_name(name)
510 .and_then(|value| value.as_any().downcast_ref())
511 .ok_or_else(|| invalid(name, "column type mismatch"))
512}
513
514fn string<'a>(
515 batch: &'a RecordBatch,
516 name: &'static str,
517) -> Result<&'a StringArray, KnowledgeError> {
518 batch
519 .column_by_name(name)
520 .and_then(|value| value.as_any().downcast_ref())
521 .ok_or_else(|| invalid(name, "column type mismatch"))
522}
523
524fn timestamp<'a>(
525 batch: &'a RecordBatch,
526 name: &'static str,
527) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
528 batch
529 .column_by_name(name)
530 .and_then(|value| value.as_any().downcast_ref())
531 .ok_or_else(|| invalid(name, "column type mismatch"))
532}
533
534fn uint32<'a>(
535 batch: &'a RecordBatch,
536 name: &'static str,
537) -> Result<&'a UInt32Array, KnowledgeError> {
538 batch
539 .column_by_name(name)
540 .and_then(|value| value.as_any().downcast_ref())
541 .ok_or_else(|| invalid(name, "column type mismatch"))
542}
543
544fn uuid_at(
545 values: &FixedSizeBinaryArray,
546 row: usize,
547 field: &'static str,
548) -> Result<Uuid, KnowledgeError> {
549 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn uuid7(seed: u8) -> Uuid {
557 let mut bytes = [seed; 16];
558 bytes[6] = (bytes[6] & 0x0f) | 0x70;
559 bytes[8] = (bytes[8] & 0x3f) | 0x80;
560 Uuid::from_bytes(bytes)
561 }
562
563 fn event(id: u8, assertion: u8, status: AssertionStatus, time: i64) -> AssertionStatusEvent {
564 AssertionStatusEvent::new(
565 uuid7(id),
566 uuid7(assertion),
567 status,
568 None,
569 None,
570 uuid7(id.wrapping_add(100)),
571 time,
572 )
573 .unwrap()
574 }
575
576 fn measurement_uuid(seed: u64) -> Uuid {
577 let mut bytes = [0; 16];
578 bytes[8..].copy_from_slice(&seed.to_be_bytes());
579 bytes[6] = 0x70;
580 bytes[8] = (bytes[8] & 0x3f) | 0x80;
581 Uuid::from_bytes(bytes)
582 }
583
584 fn merge_with_full_revalidation(
585 existing: &AssertionStatusLedger,
586 staged: &AssertionStatusLedger,
587 ) -> Result<AssertionStatusLedger, KnowledgeError> {
588 let mut events = existing.events.clone();
589 let mut by_id = events
590 .iter()
591 .cloned()
592 .map(|row| (row.status_event_uuid, row))
593 .collect::<HashMap<_, _>>();
594 for event in staged.events() {
595 if let Some(previous) = by_id.get(&event.status_event_uuid) {
596 if previous != event {
597 return Err(KnowledgeError::Conflict("status_event_uuid"));
598 }
599 } else {
600 events.push(event.clone());
601 by_id.insert(event.status_event_uuid, event.clone());
602 }
603 }
604 AssertionStatusLedger::new(events)
605 }
606
607 fn status_measurement_digest(
608 existing: &AssertionStatusLedger,
609 staged: &AssertionStatusLedger,
610 ) -> String {
611 let mut writer = CanonicalWriter::new();
612 for ledger in [existing, staged] {
613 writer.u64(ledger.events().len() as u64).unwrap();
614 for row in ledger.events() {
615 writer.raw(row.status_event_uuid.as_bytes()).unwrap();
616 writer.raw(row.assertion_uuid.as_bytes()).unwrap();
617 writer.text(row.status.as_str()).unwrap();
618 optional_uuid(&mut writer, row.confidence_uuid).unwrap();
619 optional_uuid(&mut writer, row.reasoning_uuid).unwrap();
620 writer.raw(row.provenance_uuid.as_bytes()).unwrap();
621 writer.i64(row.recorded_at_micros).unwrap();
622 writer.u32(row.contract_version).unwrap();
623 }
624 }
625 let bytes = fingerprint(
626 CanonicalDomain::AssertionStatus,
627 CANONICAL_CONTRACT_VERSION,
628 &writer.finish(),
629 )
630 .unwrap();
631 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
632 }
633
634 fn median_duration(mut durations: Vec<std::time::Duration>) -> std::time::Duration {
635 durations.sort_unstable();
636 durations[durations.len() / 2]
637 }
638
639 #[test]
640 fn round_trip_fingerprint_statusless_and_identical_time_order_are_stable() {
641 assert_eq!(
642 ASSERTION_STATUS_SCHEMA_FINGERPRINT
643 .iter()
644 .map(|byte| format!("{byte:02x}"))
645 .collect::<String>(),
646 "83e696b90c9151fefe6b92c18b752c0d717fa4fd967997f60e995c79d59f54cd"
647 );
648 let ledger = AssertionStatusLedger::new(vec![
649 event(2, 20, AssertionStatus::Supported, 10),
650 event(1, 20, AssertionStatus::Hypothesis, 10),
651 ])
652 .unwrap();
653 let decoded = AssertionStatusLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
654 assert_eq!(decoded, ledger);
655 assert_eq!(
656 decoded.event_fingerprint(uuid7(2)).unwrap(),
657 ledger.event_fingerprint(uuid7(2)).unwrap()
658 );
659 assert_eq!(
660 ledger.current_for(uuid7(20)).unwrap().status_event_uuid,
661 uuid7(2)
662 );
663 assert!(ledger.current_for(uuid7(21)).is_none());
664 }
665
666 #[test]
667 fn every_nonterminal_transition_is_allowed_and_superseded_is_terminal() {
668 let nonterminal = [
669 AssertionStatus::Hypothesis,
670 AssertionStatus::Supported,
671 AssertionStatus::Refuted,
672 AssertionStatus::Disputed,
673 AssertionStatus::Retracted,
674 ];
675 let mut id = 1_u8;
676 for from in nonterminal {
677 for to in nonterminal {
678 assert!(
679 AssertionStatusLedger::new(vec![
680 event(id, id, from, 1),
681 event(id.wrapping_add(1), id, to, 2),
682 ])
683 .is_ok()
684 );
685 id = id.wrapping_add(2);
686 }
687 assert!(
688 AssertionStatusLedger::new(vec![
689 event(id, id, from, 1),
690 event(id.wrapping_add(1), id, AssertionStatus::Superseded, 2),
691 ])
692 .is_ok()
693 );
694 id = id.wrapping_add(2);
695 }
696 for to in [
697 AssertionStatus::Hypothesis,
698 AssertionStatus::Supported,
699 AssertionStatus::Refuted,
700 AssertionStatus::Disputed,
701 AssertionStatus::Retracted,
702 ] {
703 assert!(matches!(
704 AssertionStatusLedger::new(vec![
705 event(id, id, AssertionStatus::Superseded, 1),
706 event(id.wrapping_add(1), id, to, 2),
707 ]),
708 Err(KnowledgeError::Invalid {
709 field: "assertion_status.status",
710 message: "superseded is terminal",
711 })
712 ));
713 id = id.wrapping_add(2);
714 }
715 assert!(
716 AssertionStatusLedger::new(vec![
717 event(id, id, AssertionStatus::Superseded, 1),
718 event(id.wrapping_add(1), id, AssertionStatus::Superseded, 2),
719 ])
720 .is_ok(),
721 "multiple terminal events preserve explicit supersession branches"
722 );
723 let terminal =
724 AssertionStatusLedger::new(vec![event(id, id, AssertionStatus::Superseded, 10)])
725 .unwrap();
726 let second_terminal = AssertionStatusLedger::new(vec![event(
727 id.wrapping_add(2),
728 id,
729 AssertionStatus::Superseded,
730 11,
731 )])
732 .unwrap();
733 assert!(terminal.merge(&second_terminal).is_ok());
734 let backdated = AssertionStatusLedger::new(vec![event(
735 id.wrapping_add(1),
736 id,
737 AssertionStatus::Supported,
738 1,
739 )])
740 .unwrap();
741 assert!(matches!(
742 terminal.merge(&backdated),
743 Err(KnowledgeError::Invalid {
744 field: "assertion_status.status",
745 message: "superseded is terminal",
746 })
747 ));
748 }
749
750 #[test]
751 fn replay_is_idempotent_and_conflicting_identity_is_rejected() {
752 let base = AssertionStatusLedger::new(vec![event(1, 20, AssertionStatus::Hypothesis, 10)])
753 .unwrap();
754 assert_eq!(base.merge(&base).unwrap(), base);
755 let conflict =
756 AssertionStatusLedger::new(vec![event(1, 20, AssertionStatus::Refuted, 10)]).unwrap();
757 assert!(matches!(
758 base.merge(&conflict),
759 Err(KnowledgeError::Conflict("status_event_uuid"))
760 ));
761 }
762
763 #[test]
764 fn status_validator_checks_contract_version() {
765 let mut row = event(1, 20, AssertionStatus::Supported, 1);
766 row.contract_version += 1;
767 assert!(matches!(
768 AssertionStatusLedger::new(vec![row]),
769 Err(KnowledgeError::Invalid {
770 field: "assertion_status.contract_version",
771 ..
772 })
773 ));
774 }
775
776 #[test]
777 fn status_validator_checks_status_event_uuid_version() {
778 let mut row = event(1, 20, AssertionStatus::Supported, 1);
779 row.status_event_uuid = Uuid::from_u128(1);
780 assert!(matches!(
781 AssertionStatusLedger::new(vec![row]),
782 Err(KnowledgeError::Invalid {
783 field: "status_event_uuid",
784 message: "must be UUIDv7",
785 })
786 ));
787 }
788
789 #[test]
790 fn status_validator_checks_assertion_uuid_version() {
791 let mut row = event(1, 20, AssertionStatus::Supported, 1);
792 row.assertion_uuid = Uuid::from_u128(1);
793 assert!(matches!(
794 AssertionStatusLedger::new(vec![row]),
795 Err(KnowledgeError::Invalid {
796 field: "assertion_uuid",
797 message: "must be UUIDv7",
798 })
799 ));
800 }
801
802 #[test]
803 fn status_validator_checks_optional_confidence_uuid_version() {
804 let mut row = event(1, 20, AssertionStatus::Supported, 1);
805 row.confidence_uuid = Some(Uuid::from_u128(1));
806 assert!(matches!(
807 AssertionStatusLedger::new(vec![row]),
808 Err(KnowledgeError::Invalid {
809 field: "confidence_uuid",
810 message: "must be UUIDv7",
811 })
812 ));
813 }
814
815 #[test]
816 fn status_validator_checks_optional_reasoning_uuid_version() {
817 let mut row = event(1, 20, AssertionStatus::Supported, 1);
818 row.reasoning_uuid = Some(Uuid::from_u128(1));
819 assert!(matches!(
820 AssertionStatusLedger::new(vec![row]),
821 Err(KnowledgeError::Invalid {
822 field: "reasoning_uuid",
823 message: "must be UUIDv7",
824 })
825 ));
826 }
827
828 #[test]
829 fn status_validator_checks_non_nil_provenance_uuid() {
830 let mut row = event(1, 20, AssertionStatus::Supported, 1);
831 row.provenance_uuid = Uuid::nil();
832 assert!(matches!(
833 AssertionStatusLedger::new(vec![row]),
834 Err(KnowledgeError::Invalid {
835 field: "provenance_uuid",
836 message: "must not be nil",
837 })
838 ));
839 }
840
841 #[test]
842 fn status_validator_rejects_duplicate_event_identity() {
843 let first = event(1, 20, AssertionStatus::Supported, 1);
844 let mut duplicate = event(2, 21, AssertionStatus::Refuted, 2);
845 duplicate.status_event_uuid = first.status_event_uuid;
846 assert!(matches!(
847 AssertionStatusLedger::new(vec![first, duplicate]),
848 Err(KnowledgeError::Duplicate("status_event_uuid"))
849 ));
850 }
851
852 #[test]
853 fn status_validator_enforces_row_cap() {
854 assert!(matches!(
855 AssertionStatusLedger::new_with_limit(
856 vec![
857 event(1, 20, AssertionStatus::Supported, 1),
858 event(2, 21, AssertionStatus::Refuted, 2),
859 ],
860 1,
861 ),
862 Err(KnowledgeError::Limit {
863 participant: "assertion_status_events",
864 observed: 2,
865 limit: 1,
866 })
867 ));
868 }
869
870 #[test]
871 fn status_validator_sorts_by_time_then_event_uuid() {
872 let late_time_low_uuid = event(1, 20, AssertionStatus::Supported, 20);
873 let early_time_high_uuid = event(4, 20, AssertionStatus::Hypothesis, 10);
874 let early_time_low_uuid = event(2, 20, AssertionStatus::Disputed, 10);
875 let early_time_mid_uuid = event(3, 20, AssertionStatus::Refuted, 10);
876 let ledger = AssertionStatusLedger::new(vec![
877 late_time_low_uuid,
878 early_time_high_uuid,
879 early_time_mid_uuid,
880 early_time_low_uuid,
881 ])
882 .unwrap();
883 assert_eq!(
884 ledger
885 .events()
886 .iter()
887 .map(|row| row.status_event_uuid)
888 .collect::<Vec<_>>(),
889 vec![uuid7(2), uuid7(3), uuid7(4), uuid7(1)]
890 );
891 }
892
893 #[test]
894 fn status_validator_rejects_nonterminal_after_superseded() {
895 assert!(matches!(
896 AssertionStatusLedger::new(vec![
897 event(1, 20, AssertionStatus::Superseded, 1),
898 event(2, 20, AssertionStatus::Supported, 2),
899 ]),
900 Err(KnowledgeError::Invalid {
901 field: "assertion_status.status",
902 message: "superseded is terminal",
903 })
904 ));
905 }
906
907 #[test]
908 fn status_merge_validates_staged_rows_without_revalidating_base_rows() {
909 let replay = event(1, 20, AssertionStatus::Hypothesis, 1);
910 let base = AssertionStatusLedger::new(vec![
911 replay.clone(),
912 event(2, 21, AssertionStatus::Supported, 2),
913 ])
914 .unwrap();
915 let staged =
916 AssertionStatusLedger::new(vec![replay, event(3, 22, AssertionStatus::Refuted, 3)])
917 .unwrap();
918
919 reset_status_row_validation_count();
920 let merged = base.merge(&staged).unwrap();
921
922 assert_eq!(status_row_validation_count(), 1);
923 assert_eq!(merged.events().len(), 3);
924 }
925
926 #[test]
927 fn status_merge_enforces_combined_row_cap_after_replay_elision() {
928 let first = event(1, 20, AssertionStatus::Supported, 1);
929 let base = AssertionStatusLedger::new(vec![
930 first.clone(),
931 event(2, 21, AssertionStatus::Refuted, 2),
932 ])
933 .unwrap();
934 let replay = AssertionStatusLedger::new(vec![first]).unwrap();
935 assert_eq!(base.merge_with_limit(&replay, 2).unwrap().events().len(), 2);
936
937 let staged =
938 AssertionStatusLedger::new(vec![event(3, 22, AssertionStatus::Disputed, 3)]).unwrap();
939 assert!(matches!(
940 base.merge_with_limit(&staged, 2),
941 Err(KnowledgeError::Limit {
942 participant: "assertion_status_events",
943 observed: 3,
944 limit: 2,
945 })
946 ));
947 }
948
949 #[test]
950 #[ignore = "manual quiet-host before/after merge-cost measurement"]
954 fn quiet_host_status_merge_cost_measurement() {
955 use std::time::Instant;
956
957 for existing_count in [10_000_u64, 100_000] {
958 let existing = AssertionStatusLedger::new(
959 (0..existing_count)
960 .map(|seed| {
961 AssertionStatusEvent::new(
962 measurement_uuid(seed + 1),
963 measurement_uuid(2_000_000),
964 AssertionStatus::Supported,
965 None,
966 None,
967 measurement_uuid(seed + 1_000_000),
968 i64::try_from(seed).unwrap(),
969 )
970 .unwrap()
971 })
972 .collect(),
973 )
974 .unwrap();
975 let staged = AssertionStatusLedger::new(vec![
976 AssertionStatusEvent::new(
977 measurement_uuid(existing_count + 1),
978 measurement_uuid(2_000_000),
979 AssertionStatus::Refuted,
980 None,
981 None,
982 measurement_uuid(existing_count + 1_000_000),
983 i64::try_from(existing_count).unwrap(),
984 )
985 .unwrap(),
986 ])
987 .unwrap();
988 let input_digest = status_measurement_digest(&existing, &staged);
989 assert_eq!(
990 merge_with_full_revalidation(&existing, &staged).unwrap(),
991 existing.merge(&staged).unwrap()
992 );
993 let repetitions = 7;
994 let mut before = Vec::with_capacity(repetitions);
995 let mut after = Vec::with_capacity(repetitions);
996 for repetition in 0..repetitions {
997 if repetition % 2 == 0 {
998 let start = Instant::now();
999 std::hint::black_box(merge_with_full_revalidation(&existing, &staged).unwrap());
1000 before.push(start.elapsed());
1001
1002 let start = Instant::now();
1003 std::hint::black_box(existing.merge(&staged).unwrap());
1004 after.push(start.elapsed());
1005 } else {
1006 let start = Instant::now();
1007 std::hint::black_box(existing.merge(&staged).unwrap());
1008 after.push(start.elapsed());
1009
1010 let start = Instant::now();
1011 std::hint::black_box(merge_with_full_revalidation(&existing, &staged).unwrap());
1012 before.push(start.elapsed());
1013 }
1014 }
1015 let before_median = median_duration(before);
1016 let after_median = median_duration(after);
1017 let divisor = existing_count as f64;
1018 eprintln!(
1019 "existing_rows={existing_count} staged_rows=1 repetitions={repetitions} input_sha256={input_digest} baseline_ns={} baseline_ns_per_existing_row={:.3} optimized_ns={} optimized_ns_per_existing_row={:.3}",
1020 before_median.as_nanos(),
1021 before_median.as_nanos() as f64 / divisor,
1022 after_median.as_nanos(),
1023 after_median.as_nanos() as f64 / divisor,
1024 );
1025 }
1026 }
1027
1028 #[test]
1029 fn assertion_status_registry_is_closed_and_terminal_only_for_superseded() {
1030 let values = [
1031 AssertionStatus::Hypothesis,
1032 AssertionStatus::Supported,
1033 AssertionStatus::Refuted,
1034 AssertionStatus::Disputed,
1035 AssertionStatus::Retracted,
1036 AssertionStatus::Superseded,
1037 ];
1038 for value in values {
1039 assert_eq!(AssertionStatus::parse(value.as_str()).unwrap(), value);
1040 assert_eq!(value.is_terminal(), value == AssertionStatus::Superseded);
1041 }
1042 assert!(AssertionStatus::parse("unknown").is_err());
1043 }
1044}