Skip to main content

graphforge_knowledge/
status.rs

1//! Append-only epistemic assertion-status events.
2
3use 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
24/// Assertion-status record contract.
25pub const ASSERTION_STATUS_CONTRACT_VERSION: u32 = 1;
26/// Closed status registry.
27pub const ASSERTION_STATUS_REGISTRY_VERSION: u32 = 1;
28
29/// Authoritative `knowledge/assertion_status_events.parquet` schema.
30pub 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/// Closed `assertion-status@1` value.
57#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
58pub enum AssertionStatus {
59    /// An explicit hypothesis.
60    Hypothesis,
61    /// Supported by the current interpretation.
62    Supported,
63    /// Refuted by the current interpretation.
64    Refuted,
65    /// Actively disputed.
66    Disputed,
67    /// Explicitly retracted.
68    Retracted,
69    /// Replaced through the atomic supersession operation.
70    Superseded,
71}
72
73impl AssertionStatus {
74    /// Stable persisted spelling.
75    #[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    /// Whether this value is terminal under `assertion-status@1`.
100    #[must_use]
101    pub const fn is_terminal(self) -> bool {
102        matches!(self, Self::Superseded)
103    }
104}
105
106/// One immutable assertion-status event.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct AssertionStatusEvent {
109    /// Caller-supplied UUIDv7 identity/idempotency key.
110    pub status_event_uuid: Uuid,
111    /// Existing immutable knowledge assertion.
112    pub assertion_uuid: Uuid,
113    /// Closed status value.
114    pub status: AssertionStatus,
115    /// Optional existing immutable knowledge confidence assessment.
116    pub confidence_uuid: Option<Uuid>,
117    /// Optional existing immutable epistemic reasoning record.
118    pub reasoning_uuid: Option<Uuid>,
119    /// Existing producing provenance event.
120    pub provenance_uuid: Uuid,
121    /// Mandatory transaction time.
122    pub recorded_at_micros: i64,
123    /// Frozen record contract.
124    pub contract_version: u32,
125}
126
127impl AssertionStatusEvent {
128    /// Validate and construct one immutable event.
129    #[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/// Validated append-only assertion-status participant.
162///
163/// Event storage is immutable through the public API so a validated ledger remains a trusted base:
164///
165/// ```compile_fail
166/// use graphforge_knowledge::AssertionStatusLedger;
167/// let mut ledger = AssertionStatusLedger::default();
168/// ledger.events.clear();
169/// ```
170#[derive(Clone, Debug, Default, Eq, PartialEq)]
171pub struct AssertionStatusLedger {
172    /// Events ordered by `(recorded_at, status_event_uuid)`.
173    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    /// Validate, sort, and construct one complete participant.
183    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    /// Read the validated events in canonical order.
211    #[must_use]
212    pub fn events(&self) -> &[AssertionStatusEvent] {
213        &self.events
214    }
215
216    /// Consume the validated participant and return its events.
217    #[must_use]
218    pub fn into_events(self) -> Vec<AssertionStatusEvent> {
219        self.events
220    }
221
222    /// Merge staged append-only events with exact replay semantics.
223    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    /// Canonical fingerprint over one exact immutable event.
268    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    /// Compute canonical fingerprints in one pass without searching by UUID.
278    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    /// Resolve current status deterministically, returning `None` for statusless assertions.
286    #[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    /// Build the authoritative Arrow batch.
295    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    /// Decode and validate exact Arrow batches.
336    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    // Reproduce on an otherwise idle host:
951    // source /home/ubuntu/.claude/gf-quiet-host.sh && require_quiet_host && \
952    // CARGO_TARGET_DIR=/home/ubuntu/.cache/graphforge-target-1827-validation cargo test --release --locked -p graphforge-knowledge status::tests::quiet_host_status_merge_cost_measurement -- --ignored --nocapture --test-threads=1
953    #[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}