Skip to main content

graphforge_knowledge/
valid_time.rs

1//! Optional append-only epistemic assertion valid-time events.
2
3#[cfg(test)]
4use std::cell::Cell;
5use std::collections::{HashMap, HashSet};
6use std::sync::{Arc, LazyLock};
7
8use arrow::array::{
9    Array, FixedSizeBinaryArray, FixedSizeBinaryBuilder, TimestampMicrosecondArray,
10    TimestampMicrosecondBuilder, UInt32Array, UInt32Builder,
11};
12use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
13use arrow::record_batch::RecordBatch;
14use graphforge_core::canonical::{
15    CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
16};
17use uuid::{Uuid, Version};
18
19use crate::{KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry};
20
21/// Optional valid-time capability contract.
22pub const VALID_TIME_CAPABILITY_VERSION: u32 = 1;
23/// Assertion-validity record contract.
24pub const ASSERTION_VALIDITY_CONTRACT_VERSION: u32 = 1;
25/// Half-open interval interpretation policy.
26pub const ASSERTION_VALIDITY_POLICY_VERSION: u32 = 1;
27
28/// Authoritative assertion-validity event schema.
29pub static ASSERTION_VALIDITY_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
30    Arc::new(Schema::new(vec![
31        uuid_field("validity_event_uuid", false),
32        uuid_field("assertion_uuid", false),
33        timestamp_field("valid_from", true),
34        timestamp_field("valid_to", true),
35        uuid_field("reasoning_uuid", true),
36        uuid_field("provenance_uuid", false),
37        timestamp_field("recorded_at", false),
38        Field::new("contract_version", DataType::UInt32, false),
39    ]))
40});
41
42#[cfg(test)]
43std::thread_local! {
44    static EVENT_VALIDATION_CALLS: Cell<usize> = const { Cell::new(0) };
45}
46
47static ASSERTION_VALIDITY_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
48    fingerprint(
49        CanonicalDomain::Schema,
50        CANONICAL_CONTRACT_VERSION,
51        b"assertion_validity/1|validity_event_uuid:fixed[16]:required|assertion_uuid:fixed[16]:required|valid_from:timestamp_us_utc:nullable|valid_to:timestamp_us_utc: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-validity schema is within canonical bounds")
54});
55
56/// One immutable correction to an assertion's valid-time interpretation.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct AssertionValidityEvent {
59    /// Caller-supplied UUIDv7 identity/idempotency key.
60    pub validity_event_uuid: Uuid,
61    /// Existing immutable assertion.
62    pub assertion_uuid: Uuid,
63    /// Inclusive lower bound, or unbounded when absent.
64    pub valid_from_micros: Option<i64>,
65    /// Exclusive upper bound, or unbounded when absent.
66    pub valid_to_micros: Option<i64>,
67    /// Optional existing immutable reasoning record.
68    pub reasoning_uuid: Option<Uuid>,
69    /// Existing producing provenance event.
70    pub provenance_uuid: Uuid,
71    /// Mandatory transaction time.
72    pub recorded_at_micros: i64,
73    /// Frozen record contract.
74    pub contract_version: u32,
75}
76
77impl AssertionValidityEvent {
78    /// Validate and construct one immutable half-open interval event.
79    #[allow(clippy::too_many_arguments)]
80    pub fn new(
81        validity_event_uuid: Uuid,
82        assertion_uuid: Uuid,
83        valid_from_micros: Option<i64>,
84        valid_to_micros: Option<i64>,
85        reasoning_uuid: Option<Uuid>,
86        provenance_uuid: Uuid,
87        recorded_at_micros: i64,
88    ) -> Result<Self, KnowledgeError> {
89        let event = Self {
90            validity_event_uuid,
91            assertion_uuid,
92            valid_from_micros,
93            valid_to_micros,
94            reasoning_uuid,
95            provenance_uuid,
96            recorded_at_micros,
97            contract_version: ASSERTION_VALIDITY_CONTRACT_VERSION,
98        };
99        validate_event(&event)?;
100        Ok(event)
101    }
102
103    /// Evaluate the half-open interval `[valid_from, valid_to)`.
104    ///
105    /// Equal bounds form a valid empty interval. Missing bounds are unbounded.
106    #[must_use]
107    pub fn contains(&self, valid_time_micros: i64) -> bool {
108        self.valid_from_micros
109            .is_none_or(|from| from <= valid_time_micros)
110            && self.valid_to_micros.is_none_or(|to| valid_time_micros < to)
111    }
112}
113
114/// Validated append-only assertion-validity participant.
115#[derive(Clone, Debug, Default, Eq, PartialEq)]
116pub struct AssertionValidityLedger {
117    /// Events ordered by `(recorded_at, validity_event_uuid)`.
118    events: Vec<AssertionValidityEvent>,
119}
120
121impl AssertionValidityLedger {
122    /// The event rows are private so the ledger can trust every row it has
123    /// already accepted when another validated ledger is merged.
124    ///
125    /// ```compile_fail
126    /// use graphforge_knowledge::AssertionValidityLedger;
127    /// let mut ledger = AssertionValidityLedger::default();
128    /// ledger.events.clear();
129    /// ```
130    ///
131    /// The read-only accessor also does not allow callers to alter a row:
132    ///
133    /// ```compile_fail
134    /// use graphforge_knowledge::AssertionValidityLedger;
135    /// let ledger = AssertionValidityLedger::default();
136    /// ledger.events()[0].contract_version = 2;
137    /// ```
138    ///
139    /// Return validated events in canonical order.
140    #[must_use]
141    pub fn events(&self) -> &[AssertionValidityEvent] {
142        &self.events
143    }
144
145    /// Consume this ledger and return its validated events in canonical order.
146    #[must_use]
147    pub fn into_events(self) -> Vec<AssertionValidityEvent> {
148        self.events
149    }
150
151    /// Validate, sort, and construct one complete participant.
152    pub fn new(events: Vec<AssertionValidityEvent>) -> Result<Self, KnowledgeError> {
153        Self::new_with_limit(events, MAX_KNOWLEDGE_ROWS)
154    }
155
156    fn new_with_limit(
157        mut events: Vec<AssertionValidityEvent>,
158        row_limit: usize,
159    ) -> Result<Self, KnowledgeError> {
160        validate_event_count(events.len(), row_limit)?;
161        let mut ids = HashSet::with_capacity(events.len());
162        for event in &events {
163            validate_event(event)?;
164            if !ids.insert(event.validity_event_uuid) {
165                return Err(KnowledgeError::Duplicate("validity_event_uuid"));
166            }
167        }
168        events.sort_by_key(|row| (row.recorded_at_micros, row.validity_event_uuid));
169        Ok(Self { events })
170    }
171
172    /// Merge staged append-only events with exact replay semantics.
173    ///
174    /// Both ledgers can only contain rows accepted by [`Self::new`] or
175    /// [`Self::from_batches`]. Since the row vector is private, previously
176    /// accepted events cannot be changed through the public API. This merge
177    /// checks identity reuse and the combined row limit, and sorts only when
178    /// it appends rows; it does not re-run row validation on either ledger.
179    pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
180        self.merge_with_limit(staged, MAX_KNOWLEDGE_ROWS)
181    }
182
183    fn merge_with_limit(&self, staged: &Self, row_limit: usize) -> Result<Self, KnowledgeError> {
184        let by_id = self
185            .events
186            .iter()
187            .map(|row| (row.validity_event_uuid, row))
188            .collect::<HashMap<_, _>>();
189        let mut appended_count = 0_usize;
190        for event in &staged.events {
191            if let Some(existing) = by_id.get(&event.validity_event_uuid) {
192                if *existing != event {
193                    return Err(KnowledgeError::Conflict("validity_event_uuid"));
194                }
195            } else {
196                appended_count = appended_count.saturating_add(1);
197            }
198        }
199        validate_event_count(self.events.len().saturating_add(appended_count), row_limit)?;
200        if appended_count == 0 {
201            return Ok(Self {
202                events: self.events.clone(),
203            });
204        }
205
206        let mut events = self.events.clone();
207        events.reserve(appended_count);
208        events.extend(
209            staged
210                .events
211                .iter()
212                .filter(|event| !by_id.contains_key(&event.validity_event_uuid))
213                .cloned(),
214        );
215        events.sort_by_key(|row| (row.recorded_at_micros, row.validity_event_uuid));
216        Ok(Self { events })
217    }
218
219    /// Select the validity interpretation visible at transaction cutoff.
220    #[must_use]
221    pub fn current_for_at(
222        &self,
223        assertion_uuid: Uuid,
224        transaction_cutoff_micros: i64,
225    ) -> Option<&AssertionValidityEvent> {
226        self.events
227            .iter()
228            .filter(|row| {
229                row.assertion_uuid == assertion_uuid
230                    && row.recorded_at_micros <= transaction_cutoff_micros
231            })
232            .max_by_key(|row| (row.recorded_at_micros, row.validity_event_uuid))
233    }
234
235    /// Test validity using the interpretation visible at transaction cutoff.
236    #[must_use]
237    pub fn is_valid_at(
238        &self,
239        assertion_uuid: Uuid,
240        transaction_cutoff_micros: i64,
241        valid_time_micros: i64,
242    ) -> Option<bool> {
243        self.current_for_at(assertion_uuid, transaction_cutoff_micros)
244            .map(|event| event.contains(valid_time_micros))
245    }
246
247    /// Canonical fingerprint over one exact immutable event.
248    pub fn event_fingerprint(&self, validity_event_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
249        let row = self
250            .events
251            .iter()
252            .find(|row| row.validity_event_uuid == validity_event_uuid)
253            .ok_or(KnowledgeError::Dangling("validity_event_uuid"))?;
254        validity_fingerprint_for_row(row)
255    }
256
257    /// Compute canonical validity fingerprints in one pass without UUID searches.
258    pub fn event_fingerprints(&self) -> Result<Vec<(Uuid, [u8; 32])>, KnowledgeError> {
259        self.events
260            .iter()
261            .map(|row| Ok((row.validity_event_uuid, validity_fingerprint_for_row(row)?)))
262            .collect()
263    }
264
265    /// Build the authoritative Arrow batch.
266    pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
267        let mut ids = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
268        let mut assertions = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
269        let mut from = TimestampMicrosecondBuilder::new().with_timezone("UTC");
270        let mut to = TimestampMicrosecondBuilder::new().with_timezone("UTC");
271        let mut reasoning = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
272        let mut provenance = FixedSizeBinaryBuilder::with_capacity(self.events.len(), 16);
273        let mut times = TimestampMicrosecondBuilder::new().with_timezone("UTC");
274        let mut versions = UInt32Builder::new();
275        for row in &self.events {
276            append_uuid(&mut ids, row.validity_event_uuid, "validity_event_uuid")?;
277            append_uuid(&mut assertions, row.assertion_uuid, "assertion_uuid")?;
278            from.append_option(row.valid_from_micros);
279            to.append_option(row.valid_to_micros);
280            append_optional_uuid(&mut reasoning, row.reasoning_uuid)?;
281            append_uuid(&mut provenance, row.provenance_uuid, "provenance_uuid")?;
282            times.append_value(row.recorded_at_micros);
283            versions.append_value(row.contract_version);
284        }
285        RecordBatch::try_new(
286            Arc::clone(&ASSERTION_VALIDITY_SCHEMA),
287            vec![
288                Arc::new(ids.finish()),
289                Arc::new(assertions.finish()),
290                Arc::new(from.finish()),
291                Arc::new(to.finish()),
292                Arc::new(reasoning.finish()),
293                Arc::new(provenance.finish()),
294                Arc::new(times.finish()),
295                Arc::new(versions.finish()),
296            ],
297        )
298        .map_err(|_| invalid("assertion_validity", "Arrow batch construction failed"))
299    }
300
301    /// Decode and validate exact Arrow batches.
302    pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
303        let mut events = Vec::new();
304        for batch in batches {
305            if batch.schema().as_ref() != ASSERTION_VALIDITY_SCHEMA.as_ref() {
306                return Err(invalid("assertion_validity.schema", "schema mismatch"));
307            }
308            let ids = fixed(batch, "validity_event_uuid")?;
309            let assertions = fixed(batch, "assertion_uuid")?;
310            let from = timestamp(batch, "valid_from")?;
311            let to = timestamp(batch, "valid_to")?;
312            let reasoning = fixed(batch, "reasoning_uuid")?;
313            let provenance = fixed(batch, "provenance_uuid")?;
314            let times = timestamp(batch, "recorded_at")?;
315            let versions = uint32(batch, "contract_version")?;
316            for row in 0..batch.num_rows() {
317                events.push(AssertionValidityEvent {
318                    validity_event_uuid: uuid_at(ids, row, "validity_event_uuid")?,
319                    assertion_uuid: uuid_at(assertions, row, "assertion_uuid")?,
320                    valid_from_micros: optional_timestamp_at(from, row),
321                    valid_to_micros: optional_timestamp_at(to, row),
322                    reasoning_uuid: optional_uuid_at(reasoning, row, "reasoning_uuid")?,
323                    provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
324                    recorded_at_micros: times.value(row),
325                    contract_version: versions.value(row),
326                });
327            }
328        }
329        Self::new(events)
330    }
331}
332
333fn validity_fingerprint_for_row(row: &AssertionValidityEvent) -> Result<[u8; 32], KnowledgeError> {
334    let mut writer = CanonicalWriter::new();
335    writer.raw(row.validity_event_uuid.as_bytes())?;
336    writer.raw(row.assertion_uuid.as_bytes())?;
337    optional_i64(&mut writer, row.valid_from_micros)?;
338    optional_i64(&mut writer, row.valid_to_micros)?;
339    optional_uuid(&mut writer, row.reasoning_uuid)?;
340    writer.raw(row.provenance_uuid.as_bytes())?;
341    writer.i64(row.recorded_at_micros)?;
342    writer.u32(row.contract_version)?;
343    fingerprint(
344        CanonicalDomain::AssertionValidity,
345        CANONICAL_CONTRACT_VERSION,
346        &writer.finish(),
347    )
348    .map_err(Into::into)
349}
350
351pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
352    SchemaRegistryEntry {
353        capability_id: "valid_time",
354        capability_version: VALID_TIME_CAPABILITY_VERSION,
355        record_family: "assertion_validity_events",
356        record_version: ASSERTION_VALIDITY_CONTRACT_VERSION,
357        schema: Arc::clone(&ASSERTION_VALIDITY_SCHEMA),
358        schema_fingerprint: *ASSERTION_VALIDITY_SCHEMA_FINGERPRINT,
359        enum_registry_versions: &[(
360            "assertion_validity_policy",
361            ASSERTION_VALIDITY_POLICY_VERSION,
362        )],
363        sort_key: &["recorded_at", "validity_event_uuid"],
364        diff_identity_fields: &["validity_event_uuid"],
365        diff_record_uuid_field: Some("validity_event_uuid"),
366        fingerprint_domain: CanonicalDomain::AssertionValidity,
367        owner: "graphforge-knowledge",
368        implementation_issue: 781,
369        max_rows: MAX_KNOWLEDGE_ROWS,
370    }
371}
372
373fn validate_event(row: &AssertionValidityEvent) -> Result<(), KnowledgeError> {
374    #[cfg(test)]
375    EVENT_VALIDATION_CALLS.with(|calls| calls.set(calls.get() + 1));
376
377    if row.contract_version != ASSERTION_VALIDITY_CONTRACT_VERSION {
378        return Err(invalid(
379            "assertion_validity.contract_version",
380            "unsupported version",
381        ));
382    }
383    require_v7(row.validity_event_uuid, "validity_event_uuid")?;
384    require_v7(row.assertion_uuid, "assertion_uuid")?;
385    if let Some(reasoning_uuid) = row.reasoning_uuid {
386        require_v7(reasoning_uuid, "reasoning_uuid")?;
387    }
388    require_uuid(row.provenance_uuid, "provenance_uuid")?;
389    if matches!(
390        (row.valid_from_micros, row.valid_to_micros),
391        (Some(from), Some(to)) if from > to
392    ) {
393        return Err(invalid(
394            "assertion_validity.interval",
395            "valid_from must not exceed valid_to",
396        ));
397    }
398    Ok(())
399}
400
401fn validate_event_count(count: usize, limit: usize) -> Result<(), KnowledgeError> {
402    if count > limit {
403        return Err(KnowledgeError::Limit {
404            participant: "assertion_validity_events",
405            observed: count,
406            limit,
407        });
408    }
409    Ok(())
410}
411
412fn optional_i64(writer: &mut CanonicalWriter, value: Option<i64>) -> Result<(), KnowledgeError> {
413    match value {
414        Some(value) => {
415            writer.u8(1)?;
416            writer.i64(value)?;
417        }
418        None => writer.u8(0)?,
419    }
420    Ok(())
421}
422
423fn optional_uuid(writer: &mut CanonicalWriter, value: Option<Uuid>) -> Result<(), KnowledgeError> {
424    match value {
425        Some(value) => {
426            writer.u8(1)?;
427            writer.raw(value.as_bytes())?;
428        }
429        None => writer.u8(0)?,
430    }
431    Ok(())
432}
433
434fn append_uuid(
435    builder: &mut FixedSizeBinaryBuilder,
436    value: Uuid,
437    field: &'static str,
438) -> Result<(), KnowledgeError> {
439    builder
440        .append_value(value.as_bytes())
441        .map_err(|_| invalid(field, "invalid UUID width"))
442}
443
444fn append_optional_uuid(
445    builder: &mut FixedSizeBinaryBuilder,
446    value: Option<Uuid>,
447) -> Result<(), KnowledgeError> {
448    if let Some(value) = value {
449        append_uuid(builder, value, "reasoning_uuid")?;
450    } else {
451        builder.append_null();
452    }
453    Ok(())
454}
455
456fn optional_uuid_at(
457    values: &FixedSizeBinaryArray,
458    row: usize,
459    field: &'static str,
460) -> Result<Option<Uuid>, KnowledgeError> {
461    (!values.is_null(row))
462        .then(|| uuid_at(values, row, field))
463        .transpose()
464}
465
466fn optional_timestamp_at(values: &TimestampMicrosecondArray, row: usize) -> Option<i64> {
467    (!values.is_null(row)).then(|| values.value(row))
468}
469
470fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
471    if value.get_version() != Some(Version::SortRand) {
472        return Err(invalid(field, "must be UUIDv7"));
473    }
474    require_uuid(value, field)
475}
476
477fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
478    if value.is_nil() {
479        return Err(invalid(field, "must not be nil"));
480    }
481    Ok(())
482}
483
484const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
485    KnowledgeError::Invalid { field, message }
486}
487
488fn uuid_field(name: &str, nullable: bool) -> Field {
489    Field::new(name, DataType::FixedSizeBinary(16), nullable)
490}
491
492fn timestamp_field(name: &str, nullable: bool) -> Field {
493    Field::new(
494        name,
495        DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
496        nullable,
497    )
498}
499
500fn fixed<'a>(
501    batch: &'a RecordBatch,
502    name: &'static str,
503) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
504    batch
505        .column_by_name(name)
506        .and_then(|value| value.as_any().downcast_ref())
507        .ok_or_else(|| invalid(name, "column type mismatch"))
508}
509
510fn timestamp<'a>(
511    batch: &'a RecordBatch,
512    name: &'static str,
513) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
514    batch
515        .column_by_name(name)
516        .and_then(|value| value.as_any().downcast_ref())
517        .ok_or_else(|| invalid(name, "column type mismatch"))
518}
519
520fn uint32<'a>(
521    batch: &'a RecordBatch,
522    name: &'static str,
523) -> Result<&'a UInt32Array, KnowledgeError> {
524    batch
525        .column_by_name(name)
526        .and_then(|value| value.as_any().downcast_ref())
527        .ok_or_else(|| invalid(name, "column type mismatch"))
528}
529
530fn uuid_at(
531    values: &FixedSizeBinaryArray,
532    row: usize,
533    field: &'static str,
534) -> Result<Uuid, KnowledgeError> {
535    Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use std::time::{Duration, Instant};
542
543    fn uuid7(seed: u8) -> Uuid {
544        let mut bytes = [seed; 16];
545        bytes[6] = (bytes[6] & 0x0f) | 0x70;
546        bytes[8] = (bytes[8] & 0x3f) | 0x80;
547        Uuid::from_bytes(bytes)
548    }
549
550    fn event(
551        id: u8,
552        assertion: u8,
553        from: Option<i64>,
554        to: Option<i64>,
555        recorded_at: i64,
556    ) -> AssertionValidityEvent {
557        AssertionValidityEvent::new(
558            uuid7(id),
559            uuid7(assertion),
560            from,
561            to,
562            Some(uuid7(id.wrapping_add(40))),
563            uuid7(id.wrapping_add(80)),
564            recorded_at,
565        )
566        .unwrap()
567    }
568
569    #[test]
570    fn half_open_unbounded_empty_and_invalid_intervals_are_explicit() {
571        let always = event(1, 20, None, None, 1);
572        assert!(always.contains(i64::MIN));
573        assert!(always.contains(i64::MAX));
574
575        let bounded = event(2, 20, Some(10), Some(20), 2);
576        assert!(!bounded.contains(9));
577        assert!(bounded.contains(10));
578        assert!(bounded.contains(19));
579        assert!(!bounded.contains(20));
580
581        let empty = event(3, 20, Some(10), Some(10), 3);
582        assert!(!empty.contains(10));
583        assert!(
584            AssertionValidityEvent::new(
585                uuid7(4),
586                uuid7(20),
587                Some(11),
588                Some(10),
589                None,
590                uuid7(84),
591                4,
592            )
593            .is_err()
594        );
595    }
596
597    #[test]
598    fn transaction_cutoff_and_uuid_tie_breaking_preserve_prior_views() {
599        let ledger = AssertionValidityLedger::new(vec![
600            event(1, 20, Some(0), Some(10), 5),
601            event(2, 20, Some(10), None, 5),
602            event(3, 20, None, Some(0), 8),
603        ])
604        .unwrap();
605        assert_eq!(
606            ledger.current_for_at(uuid7(20), 4),
607            None,
608            "no event is visible before its transaction time"
609        );
610        assert_eq!(
611            ledger
612                .current_for_at(uuid7(20), 5)
613                .unwrap()
614                .validity_event_uuid,
615            uuid7(2)
616        );
617        assert_eq!(ledger.is_valid_at(uuid7(20), 5, 10), Some(true));
618        assert_eq!(ledger.is_valid_at(uuid7(20), 7, 10), Some(true));
619        assert_eq!(ledger.is_valid_at(uuid7(20), 8, 10), Some(false));
620    }
621
622    #[test]
623    fn round_trip_merge_replay_and_fingerprint_are_deterministic() {
624        let first = event(2, 20, Some(10), None, 2);
625        let second = event(1, 21, None, Some(10), 1);
626        let ledger = AssertionValidityLedger::new(vec![first.clone(), second]).unwrap();
627        let decoded = AssertionValidityLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
628        assert_eq!(decoded, ledger);
629        assert_eq!(decoded.events()[0].validity_event_uuid, uuid7(1));
630        assert_eq!(
631            decoded.event_fingerprint(uuid7(2)).unwrap(),
632            ledger.event_fingerprint(uuid7(2)).unwrap()
633        );
634        assert_eq!(
635            ledger
636                .merge(&AssertionValidityLedger::new(vec![first.clone()]).unwrap())
637                .unwrap(),
638            ledger
639        );
640
641        let mut conflicting = first;
642        conflicting.valid_to_micros = Some(30);
643        let conflicting = AssertionValidityLedger::new(vec![conflicting]).unwrap();
644        assert!(matches!(
645            ledger.merge(&conflicting),
646            Err(KnowledgeError::Conflict("validity_event_uuid"))
647        ));
648    }
649
650    fn unvalidated_event(seed: u8) -> AssertionValidityEvent {
651        AssertionValidityEvent {
652            validity_event_uuid: uuid7(seed),
653            assertion_uuid: uuid7(seed.wrapping_add(20)),
654            valid_from_micros: None,
655            valid_to_micros: None,
656            reasoning_uuid: None,
657            provenance_uuid: uuid7(seed.wrapping_add(40)),
658            recorded_at_micros: i64::from(seed),
659            contract_version: ASSERTION_VALIDITY_CONTRACT_VERSION,
660        }
661    }
662
663    fn benchmark_uuid(seed: u64) -> Uuid {
664        let mut bytes = [0; 16];
665        bytes[8..].copy_from_slice(&seed.to_be_bytes());
666        bytes[6] = 0x70;
667        bytes[8] = (bytes[8] & 0x3f) | 0x80;
668        Uuid::from_bytes(bytes)
669    }
670
671    fn merge_with_full_revalidation(
672        existing: &AssertionValidityLedger,
673        staged: &AssertionValidityLedger,
674    ) -> Result<AssertionValidityLedger, KnowledgeError> {
675        let mut events = existing.events.clone();
676        let mut by_id = events
677            .iter()
678            .cloned()
679            .map(|row| (row.validity_event_uuid, row))
680            .collect::<HashMap<_, _>>();
681        for row in staged.events() {
682            if let Some(previous) = by_id.get(&row.validity_event_uuid) {
683                if previous != row {
684                    return Err(KnowledgeError::Conflict("validity_event_uuid"));
685                }
686            } else {
687                events.push(row.clone());
688                by_id.insert(row.validity_event_uuid, row.clone());
689            }
690        }
691        AssertionValidityLedger::new(events)
692    }
693
694    fn median(mut values: Vec<Duration>) -> Duration {
695        values.sort_unstable();
696        values[values.len() / 2]
697    }
698
699    fn digest(existing: &AssertionValidityLedger, staged: &AssertionValidityLedger) -> String {
700        let mut writer = CanonicalWriter::new();
701        for ledger in [existing, staged] {
702            writer.u64(ledger.events().len() as u64).unwrap();
703            for row in ledger.events() {
704                writer.raw(row.validity_event_uuid.as_bytes()).unwrap();
705                writer.raw(row.assertion_uuid.as_bytes()).unwrap();
706                optional_i64(&mut writer, row.valid_from_micros).unwrap();
707                optional_i64(&mut writer, row.valid_to_micros).unwrap();
708                optional_uuid(&mut writer, row.reasoning_uuid).unwrap();
709                writer.raw(row.provenance_uuid.as_bytes()).unwrap();
710                writer.i64(row.recorded_at_micros).unwrap();
711                writer.u32(row.contract_version).unwrap();
712            }
713        }
714        let bytes = fingerprint(
715            CanonicalDomain::AssertionValidity,
716            CANONICAL_CONTRACT_VERSION,
717            &writer.finish(),
718        )
719        .unwrap();
720        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
721    }
722
723    #[test]
724    #[ignore = "manual quiet-host before/after merge-cost measurement"]
725    fn quiet_host_merge_cost_measurement() {
726        for existing_count in [1_000_u64, 10_000] {
727            let existing = AssertionValidityLedger::new(
728                (0..existing_count)
729                    .map(|seed| AssertionValidityEvent {
730                        validity_event_uuid: benchmark_uuid(seed + 1),
731                        assertion_uuid: benchmark_uuid(seed + 20_000),
732                        valid_from_micros: None,
733                        valid_to_micros: None,
734                        reasoning_uuid: None,
735                        provenance_uuid: benchmark_uuid(seed + 40_000),
736                        recorded_at_micros: i64::try_from(seed).unwrap(),
737                        contract_version: ASSERTION_VALIDITY_CONTRACT_VERSION,
738                    })
739                    .collect(),
740            )
741            .unwrap();
742            let staged = AssertionValidityLedger::new(vec![AssertionValidityEvent {
743                validity_event_uuid: benchmark_uuid(existing_count + 1),
744                assertion_uuid: benchmark_uuid(existing_count + 20_001),
745                valid_from_micros: None,
746                valid_to_micros: None,
747                reasoning_uuid: None,
748                provenance_uuid: benchmark_uuid(existing_count + 40_001),
749                recorded_at_micros: i64::try_from(existing_count).unwrap(),
750                contract_version: ASSERTION_VALIDITY_CONTRACT_VERSION,
751            }])
752            .unwrap();
753            assert_eq!(
754                merge_with_full_revalidation(&existing, &staged).unwrap(),
755                existing.merge(&staged).unwrap()
756            );
757            let repetitions = 9;
758            let mut before = Vec::with_capacity(repetitions);
759            let mut after = Vec::with_capacity(repetitions);
760            for _ in 0..repetitions {
761                let start = Instant::now();
762                std::hint::black_box(merge_with_full_revalidation(&existing, &staged).unwrap());
763                before.push(start.elapsed());
764
765                let start = Instant::now();
766                std::hint::black_box(existing.merge(&staged).unwrap());
767                after.push(start.elapsed());
768            }
769            let before_median = median(before);
770            let after_median = median(after);
771            let divisor = existing_count as f64;
772            println!(
773                "existing_rows={existing_count} staged_rows=1 repetitions={repetitions} input_sha256={} before_ns={} before_ns_per_existing_row={:.3} after_ns={} after_ns_per_existing_row={:.3}",
774                digest(&existing, &staged),
775                before_median.as_nanos(),
776                before_median.as_nanos() as f64 / divisor,
777                after_median.as_nanos(),
778                after_median.as_nanos() as f64 / divisor,
779            );
780        }
781    }
782
783    #[test]
784    fn merge_validates_staged_rows_once_and_never_revalidates_existing_rows() {
785        let base = AssertionValidityLedger::new(vec![
786            event(1, 20, None, None, 1),
787            event(2, 21, None, None, 3),
788            event(3, 22, None, None, 5),
789        ])
790        .unwrap();
791        let staged_event = event(4, 23, None, None, 2);
792
793        EVENT_VALIDATION_CALLS.with(|calls| calls.set(0));
794        let staged = AssertionValidityLedger::new(vec![staged_event]).unwrap();
795        EVENT_VALIDATION_CALLS.with(|calls| assert_eq!(calls.get(), 1));
796
797        EVENT_VALIDATION_CALLS.with(|calls| calls.set(0));
798        let merged = base.merge(&staged).unwrap();
799
800        EVENT_VALIDATION_CALLS.with(|calls| assert_eq!(calls.get(), 0));
801        assert_eq!(merged.events().len(), 4);
802        assert_eq!(
803            merged
804                .events()
805                .iter()
806                .map(|row| row.validity_event_uuid)
807                .collect::<Vec<_>>(),
808            vec![uuid7(1), uuid7(4), uuid7(2), uuid7(3)]
809        );
810    }
811
812    #[test]
813    fn merge_enforces_combined_row_limit_after_exact_replays_are_removed() {
814        let base = AssertionValidityLedger::new(vec![
815            event(1, 20, None, None, 1),
816            event(2, 21, None, None, 2),
817        ])
818        .unwrap();
819        let staged = AssertionValidityLedger::new(vec![
820            event(3, 22, None, None, 3),
821            event(4, 23, None, None, 4),
822            event(5, 24, None, None, 5),
823        ])
824        .unwrap();
825
826        assert!(matches!(
827            base.merge_with_limit(&staged, 3),
828            Err(KnowledgeError::Limit {
829                participant: "assertion_validity_events",
830                observed: 5,
831                limit: 3,
832            })
833        ));
834
835        let replayed = base
836            .merge_with_limit(
837                &AssertionValidityLedger::new(vec![event(1, 20, None, None, 1)]).unwrap(),
838                2,
839            )
840            .unwrap();
841        assert_eq!(replayed, base);
842    }
843
844    #[test]
845    fn merge_reports_conflict_before_combined_limit() {
846        let base = AssertionValidityLedger::new(vec![
847            event(1, 20, None, None, 1),
848            event(2, 21, None, None, 2),
849        ])
850        .unwrap();
851        let staged = AssertionValidityLedger::new(vec![
852            event(3, 22, None, None, 3),
853            event(4, 23, None, None, 4),
854            event(1, 25, None, None, 5),
855        ])
856        .unwrap();
857
858        assert!(matches!(
859            base.merge_with_limit(&staged, 3),
860            Err(KnowledgeError::Conflict("validity_event_uuid"))
861        ));
862    }
863
864    #[test]
865    fn event_count_limit_accepts_the_limit_and_rejects_the_first_excess() {
866        assert!(validate_event_count(MAX_KNOWLEDGE_ROWS, MAX_KNOWLEDGE_ROWS).is_ok());
867        assert!(matches!(
868            validate_event_count(MAX_KNOWLEDGE_ROWS + 1, MAX_KNOWLEDGE_ROWS),
869            Err(KnowledgeError::Limit {
870                participant: "assertion_validity_events",
871                observed,
872                limit: MAX_KNOWLEDGE_ROWS,
873            }) if observed == MAX_KNOWLEDGE_ROWS + 1
874        ));
875    }
876
877    #[test]
878    fn constructor_enforces_the_configured_row_limit() {
879        assert!(
880            AssertionValidityLedger::new_with_limit(
881                vec![unvalidated_event(1), unvalidated_event(2)],
882                2,
883            )
884            .is_ok()
885        );
886        assert!(matches!(
887            AssertionValidityLedger::new_with_limit(
888                vec![
889                    unvalidated_event(1),
890                    unvalidated_event(2),
891                    unvalidated_event(3)
892                ],
893                2,
894            ),
895            Err(KnowledgeError::Limit {
896                participant: "assertion_validity_events",
897                observed: 3,
898                limit: 2,
899            })
900        ));
901    }
902
903    #[test]
904    fn duplicate_validity_event_uuid_is_rejected() {
905        let first = event(1, 20, None, None, 1);
906        let duplicate = event(1, 21, None, None, 2);
907        assert!(matches!(
908            AssertionValidityLedger::new(vec![first, duplicate]),
909            Err(KnowledgeError::Duplicate("validity_event_uuid"))
910        ));
911    }
912
913    #[test]
914    fn assertion_validity_contract_version_must_be_supported() {
915        let mut row = unvalidated_event(1);
916        row.contract_version = ASSERTION_VALIDITY_CONTRACT_VERSION + 1;
917        assert!(matches!(
918            AssertionValidityLedger::new(vec![row]),
919            Err(KnowledgeError::Invalid {
920                field: "assertion_validity.contract_version",
921                ..
922            })
923        ));
924    }
925
926    #[test]
927    fn validity_event_uuid_must_be_uuidv7() {
928        let mut row = unvalidated_event(1);
929        row.validity_event_uuid = Uuid::from_u128(1);
930        assert!(matches!(
931            AssertionValidityLedger::new(vec![row]),
932            Err(KnowledgeError::Invalid {
933                field: "validity_event_uuid",
934                ..
935            })
936        ));
937    }
938
939    #[test]
940    fn assertion_uuid_must_be_uuidv7() {
941        let mut row = unvalidated_event(1);
942        row.assertion_uuid = Uuid::from_u128(1);
943        assert!(matches!(
944            AssertionValidityLedger::new(vec![row]),
945            Err(KnowledgeError::Invalid {
946                field: "assertion_uuid",
947                ..
948            })
949        ));
950    }
951
952    #[test]
953    fn optional_reasoning_uuid_must_be_uuidv7() {
954        let mut row = unvalidated_event(1);
955        row.reasoning_uuid = Some(Uuid::from_u128(1));
956        assert!(matches!(
957            AssertionValidityLedger::new(vec![row]),
958            Err(KnowledgeError::Invalid {
959                field: "reasoning_uuid",
960                ..
961            })
962        ));
963    }
964
965    #[test]
966    fn provenance_uuid_must_not_be_nil() {
967        let mut row = unvalidated_event(1);
968        row.provenance_uuid = Uuid::nil();
969        assert!(matches!(
970            AssertionValidityLedger::new(vec![row]),
971            Err(KnowledgeError::Invalid {
972                field: "provenance_uuid",
973                ..
974            })
975        ));
976    }
977
978    #[test]
979    fn valid_time_interval_must_not_run_backwards() {
980        let mut row = unvalidated_event(1);
981        row.valid_from_micros = Some(2);
982        row.valid_to_micros = Some(1);
983        assert!(matches!(
984            AssertionValidityLedger::new(vec![row]),
985            Err(KnowledgeError::Invalid {
986                field: "assertion_validity.interval",
987                ..
988            })
989        ));
990    }
991
992    #[test]
993    fn schema_registry_freezes_capability_family_and_order() {
994        let entry = schema_registry_entry();
995        assert_eq!(entry.capability_id, "valid_time");
996        assert_eq!(entry.capability_version, 1);
997        assert_eq!(entry.record_family, "assertion_validity_events");
998        assert_eq!(entry.sort_key, &["recorded_at", "validity_event_uuid"]);
999        assert_eq!(entry.implementation_issue, 781);
1000    }
1001}