1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{
7 Array, FixedSizeBinaryArray, FixedSizeBinaryBuilder, TimestampMicrosecondArray,
8 TimestampMicrosecondBuilder, UInt32Array, UInt32Builder,
9};
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
11use arrow::record_batch::RecordBatch;
12use graphforge_core::canonical::{
13 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
14};
15use uuid::{Uuid, Version};
16
17use crate::{
18 EPISTEMIC_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
19};
20
21pub const ASSERTION_SUPERSESSION_CONTRACT_VERSION: u32 = 1;
23pub const ASSERTION_SUPERSESSION_POLICY_VERSION: u32 = 1;
25
26pub static ASSERTION_SUPERSESSION_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
28 Arc::new(Schema::new(vec![
29 uuid_field("supersession_uuid"),
30 uuid_field("prior_assertion_uuid"),
31 uuid_field("replacement_assertion_uuid"),
32 uuid_field("status_event_uuid"),
33 uuid_field("reasoning_uuid"),
34 uuid_field("provenance_uuid"),
35 Field::new(
36 "recorded_at",
37 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
38 false,
39 ),
40 Field::new("contract_version", DataType::UInt32, false),
41 ]))
42});
43
44static ASSERTION_SUPERSESSION_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
45 fingerprint(
46 CanonicalDomain::AssertionSupersession,
47 CANONICAL_CONTRACT_VERSION,
48 b"assertion_supersession/1|supersession_uuid:fixed[16]:required|prior_assertion_uuid:fixed[16]:required|replacement_assertion_uuid:fixed[16]:required|status_event_uuid:fixed[16]:required|reasoning_uuid:fixed[16]:required|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
49 )
50 .expect("registered assertion-supersession schema is within canonical bounds")
51});
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct AssertionSupersession {
56 pub supersession_uuid: Uuid,
58 pub prior_assertion_uuid: Uuid,
60 pub replacement_assertion_uuid: Uuid,
62 pub status_event_uuid: Uuid,
64 pub reasoning_uuid: Uuid,
66 pub provenance_uuid: Uuid,
68 pub recorded_at_micros: i64,
70 pub contract_version: u32,
72}
73
74impl AssertionSupersession {
75 pub fn new(
77 supersession_uuid: Uuid,
78 prior_assertion_uuid: Uuid,
79 replacement_assertion_uuid: Uuid,
80 status_event_uuid: Uuid,
81 reasoning_uuid: Uuid,
82 provenance_uuid: Uuid,
83 recorded_at_micros: i64,
84 ) -> Result<Self, KnowledgeError> {
85 for (value, field) in [
86 (supersession_uuid, "supersession_uuid"),
87 (prior_assertion_uuid, "prior_assertion_uuid"),
88 (replacement_assertion_uuid, "replacement_assertion_uuid"),
89 (status_event_uuid, "status_event_uuid"),
90 (reasoning_uuid, "reasoning_uuid"),
91 ] {
92 require_v7(value, field)?;
93 }
94 require_uuid(provenance_uuid, "provenance_uuid")?;
95 if prior_assertion_uuid == replacement_assertion_uuid {
96 return Err(invalid(
97 "replacement_assertion_uuid",
98 "must differ from prior_assertion_uuid",
99 ));
100 }
101 Ok(Self {
102 supersession_uuid,
103 prior_assertion_uuid,
104 replacement_assertion_uuid,
105 status_event_uuid,
106 reasoning_uuid,
107 provenance_uuid,
108 recorded_at_micros,
109 contract_version: ASSERTION_SUPERSESSION_CONTRACT_VERSION,
110 })
111 }
112}
113
114#[derive(Clone, Debug, Default, Eq, PartialEq)]
116pub struct AssertionSupersessionLedger {
117 relations: Vec<AssertionSupersession>,
119}
120
121impl AssertionSupersessionLedger {
122 #[must_use]
124 pub fn relations(&self) -> &[AssertionSupersession] {
125 &self.relations
126 }
127
128 pub fn new(mut relations: Vec<AssertionSupersession>) -> Result<Self, KnowledgeError> {
130 if relations.len() > MAX_KNOWLEDGE_ROWS {
131 return Err(KnowledgeError::Limit {
132 participant: "assertion_supersessions",
133 observed: relations.len(),
134 limit: MAX_KNOWLEDGE_ROWS,
135 });
136 }
137 let mut ids = HashSet::with_capacity(relations.len());
138 let mut status_ids = HashSet::with_capacity(relations.len());
139 for relation in &relations {
140 validate_relation(relation)?;
141 if !ids.insert(relation.supersession_uuid) {
142 return Err(KnowledgeError::Duplicate("supersession_uuid"));
143 }
144 if !status_ids.insert(relation.status_event_uuid) {
145 return Err(KnowledgeError::Duplicate("status_event_uuid"));
146 }
147 }
148 validate_acyclic(&relations)?;
149 relations.sort_by_key(|row| (row.recorded_at_micros, row.supersession_uuid));
150 Ok(Self { relations })
151 }
152
153 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
155 let mut relations = self.relations.clone();
156 let mut by_id = relations
157 .iter()
158 .cloned()
159 .map(|row| (row.supersession_uuid, row))
160 .collect::<HashMap<_, _>>();
161 for relation in &staged.relations {
162 if let Some(existing) = by_id.get(&relation.supersession_uuid) {
163 if existing != relation {
164 return Err(KnowledgeError::Conflict("supersession_uuid"));
165 }
166 } else {
167 relations.push(relation.clone());
168 by_id.insert(relation.supersession_uuid, relation.clone());
169 }
170 }
171 Self::new(relations)
172 }
173
174 pub fn relation_fingerprint(
176 &self,
177 supersession_uuid: Uuid,
178 ) -> Result<[u8; 32], KnowledgeError> {
179 let row = self
180 .relations
181 .iter()
182 .find(|row| row.supersession_uuid == supersession_uuid)
183 .ok_or(KnowledgeError::Dangling("supersession_uuid"))?;
184 relation_fingerprint_for_row(row)
185 }
186
187 pub fn relation_fingerprints(&self) -> Result<Vec<(Uuid, [u8; 32])>, KnowledgeError> {
189 self.relations
190 .iter()
191 .map(|row| Ok((row.supersession_uuid, relation_fingerprint_for_row(row)?)))
192 .collect()
193 }
194
195 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
197 let len = self.relations.len();
198 let mut supersessions = FixedSizeBinaryBuilder::with_capacity(len, 16);
199 let mut priors = FixedSizeBinaryBuilder::with_capacity(len, 16);
200 let mut replacements = FixedSizeBinaryBuilder::with_capacity(len, 16);
201 let mut statuses = FixedSizeBinaryBuilder::with_capacity(len, 16);
202 let mut reasoning = FixedSizeBinaryBuilder::with_capacity(len, 16);
203 let mut provenance = FixedSizeBinaryBuilder::with_capacity(len, 16);
204 let mut times = TimestampMicrosecondBuilder::with_capacity(len).with_timezone("UTC");
205 let mut versions = UInt32Builder::with_capacity(len);
206 for row in &self.relations {
207 for (builder, value, field) in [
208 (
209 &mut supersessions,
210 row.supersession_uuid,
211 "supersession_uuid",
212 ),
213 (
214 &mut priors,
215 row.prior_assertion_uuid,
216 "prior_assertion_uuid",
217 ),
218 (
219 &mut replacements,
220 row.replacement_assertion_uuid,
221 "replacement_assertion_uuid",
222 ),
223 (&mut statuses, row.status_event_uuid, "status_event_uuid"),
224 (&mut reasoning, row.reasoning_uuid, "reasoning_uuid"),
225 (&mut provenance, row.provenance_uuid, "provenance_uuid"),
226 ] {
227 builder
228 .append_value(value.as_bytes())
229 .map_err(|_| invalid(field, "invalid UUID width"))?;
230 }
231 times.append_value(row.recorded_at_micros);
232 versions.append_value(row.contract_version);
233 }
234 RecordBatch::try_new(
235 Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
236 vec![
237 Arc::new(supersessions.finish()),
238 Arc::new(priors.finish()),
239 Arc::new(replacements.finish()),
240 Arc::new(statuses.finish()),
241 Arc::new(reasoning.finish()),
242 Arc::new(provenance.finish()),
243 Arc::new(times.finish()),
244 Arc::new(versions.finish()),
245 ],
246 )
247 .map_err(|_| invalid("assertion_supersession", "Arrow batch construction failed"))
248 }
249
250 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
252 let mut relations = Vec::new();
253 for batch in batches {
254 if batch.schema().as_ref() != ASSERTION_SUPERSESSION_SCHEMA.as_ref() {
255 return Err(invalid("assertion_supersession.schema", "schema mismatch"));
256 }
257 let supersessions = fixed(batch, "supersession_uuid")?;
258 let priors = fixed(batch, "prior_assertion_uuid")?;
259 let replacements = fixed(batch, "replacement_assertion_uuid")?;
260 let statuses = fixed(batch, "status_event_uuid")?;
261 let reasoning = fixed(batch, "reasoning_uuid")?;
262 let provenance = fixed(batch, "provenance_uuid")?;
263 let times = timestamp(batch, "recorded_at")?;
264 let versions = uint32(batch, "contract_version")?;
265 for row in 0..batch.num_rows() {
266 relations.push(AssertionSupersession {
267 supersession_uuid: uuid_at(supersessions, row, "supersession_uuid")?,
268 prior_assertion_uuid: uuid_at(priors, row, "prior_assertion_uuid")?,
269 replacement_assertion_uuid: uuid_at(
270 replacements,
271 row,
272 "replacement_assertion_uuid",
273 )?,
274 status_event_uuid: uuid_at(statuses, row, "status_event_uuid")?,
275 reasoning_uuid: uuid_at(reasoning, row, "reasoning_uuid")?,
276 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
277 recorded_at_micros: times.value(row),
278 contract_version: versions.value(row),
279 });
280 }
281 }
282 Self::new(relations)
283 }
284}
285
286fn relation_fingerprint_for_row(row: &AssertionSupersession) -> Result<[u8; 32], KnowledgeError> {
287 let mut writer = CanonicalWriter::new();
288 for value in [
289 row.supersession_uuid,
290 row.prior_assertion_uuid,
291 row.replacement_assertion_uuid,
292 row.status_event_uuid,
293 row.reasoning_uuid,
294 row.provenance_uuid,
295 ] {
296 writer.raw(value.as_bytes())?;
297 }
298 writer.i64(row.recorded_at_micros)?;
299 writer.u32(row.contract_version)?;
300 fingerprint(
301 CanonicalDomain::AssertionSupersession,
302 CANONICAL_CONTRACT_VERSION,
303 &writer.finish(),
304 )
305 .map_err(Into::into)
306}
307
308pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
309 SchemaRegistryEntry {
310 capability_id: "epistemic",
311 capability_version: EPISTEMIC_CAPABILITY_VERSION,
312 record_family: "assertion_supersessions",
313 record_version: ASSERTION_SUPERSESSION_CONTRACT_VERSION,
314 schema: Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
315 schema_fingerprint: *ASSERTION_SUPERSESSION_SCHEMA_FINGERPRINT,
316 enum_registry_versions: &[(
317 "assertion_supersession_policy",
318 ASSERTION_SUPERSESSION_POLICY_VERSION,
319 )],
320 sort_key: &["recorded_at", "supersession_uuid"],
321 diff_identity_fields: &["supersession_uuid"],
322 diff_record_uuid_field: Some("supersession_uuid"),
323 fingerprint_domain: CanonicalDomain::AssertionSupersession,
324 owner: "graphforge-knowledge",
325 implementation_issue: 778,
326 max_rows: MAX_KNOWLEDGE_ROWS,
327 }
328}
329
330fn validate_acyclic(relations: &[AssertionSupersession]) -> Result<(), KnowledgeError> {
331 let mut adjacency = HashMap::<Uuid, Vec<Uuid>>::new();
332 for row in relations {
333 adjacency
334 .entry(row.prior_assertion_uuid)
335 .or_default()
336 .push(row.replacement_assertion_uuid);
337 }
338 let mut visiting = HashSet::new();
339 let mut acyclic = HashSet::new();
340 for node in adjacency.keys().copied() {
341 if !visit(node, &adjacency, &mut visiting, &mut acyclic) {
342 return Err(invalid("assertion_supersession", "cycle detected"));
343 }
344 }
345 Ok(())
346}
347
348fn visit(
349 node: Uuid,
350 adjacency: &HashMap<Uuid, Vec<Uuid>>,
351 visiting: &mut HashSet<Uuid>,
352 acyclic: &mut HashSet<Uuid>,
353) -> bool {
354 if acyclic.contains(&node) {
355 return true;
356 }
357 if !visiting.insert(node) {
358 return false;
359 }
360 if adjacency.get(&node).is_some_and(|next| {
361 next.iter()
362 .any(|child| !visit(*child, adjacency, visiting, acyclic))
363 }) {
364 return false;
365 }
366 visiting.remove(&node);
367 acyclic.insert(node);
368 true
369}
370
371fn validate_relation(row: &AssertionSupersession) -> Result<(), KnowledgeError> {
372 if row.contract_version != ASSERTION_SUPERSESSION_CONTRACT_VERSION {
373 return Err(invalid(
374 "assertion_supersession.contract_version",
375 "unsupported version",
376 ));
377 }
378 AssertionSupersession::new(
379 row.supersession_uuid,
380 row.prior_assertion_uuid,
381 row.replacement_assertion_uuid,
382 row.status_event_uuid,
383 row.reasoning_uuid,
384 row.provenance_uuid,
385 row.recorded_at_micros,
386 )
387 .map(|_| ())
388}
389
390fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
391 if value.get_version() != Some(Version::SortRand) {
392 return Err(invalid(field, "must be UUIDv7"));
393 }
394 require_uuid(value, field)
395}
396
397fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
398 if value.is_nil() {
399 return Err(invalid(field, "must not be nil"));
400 }
401 Ok(())
402}
403
404const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
405 KnowledgeError::Invalid { field, message }
406}
407
408fn uuid_field(name: &str) -> Field {
409 Field::new(name, DataType::FixedSizeBinary(16), false)
410}
411
412fn fixed<'a>(
413 batch: &'a RecordBatch,
414 name: &'static str,
415) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
416 batch
417 .column_by_name(name)
418 .and_then(|value| value.as_any().downcast_ref())
419 .ok_or_else(|| invalid(name, "column type mismatch"))
420}
421
422fn timestamp<'a>(
423 batch: &'a RecordBatch,
424 name: &'static str,
425) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
426 batch
427 .column_by_name(name)
428 .and_then(|value| value.as_any().downcast_ref())
429 .ok_or_else(|| invalid(name, "column type mismatch"))
430}
431
432fn uint32<'a>(
433 batch: &'a RecordBatch,
434 name: &'static str,
435) -> Result<&'a UInt32Array, KnowledgeError> {
436 batch
437 .column_by_name(name)
438 .and_then(|value| value.as_any().downcast_ref())
439 .ok_or_else(|| invalid(name, "column type mismatch"))
440}
441
442fn uuid_at(
443 values: &FixedSizeBinaryArray,
444 row: usize,
445 field: &'static str,
446) -> Result<Uuid, KnowledgeError> {
447 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 fn uuid7(seed: u8) -> Uuid {
455 let mut bytes = [seed; 16];
456 bytes[6] = (bytes[6] & 0x0f) | 0x70;
457 bytes[8] = (bytes[8] & 0x3f) | 0x80;
458 Uuid::from_bytes(bytes)
459 }
460
461 fn relation(id: u8, prior: u8, replacement: u8, time: i64) -> AssertionSupersession {
462 AssertionSupersession::new(
463 uuid7(id),
464 uuid7(prior),
465 uuid7(replacement),
466 uuid7(id.wrapping_add(40)),
467 uuid7(id.wrapping_add(80)),
468 uuid7(id.wrapping_add(120)),
469 time,
470 )
471 .unwrap()
472 }
473
474 #[test]
475 fn chains_branches_order_round_trip_and_fingerprint_are_stable() {
476 let ledger = AssertionSupersessionLedger::new(vec![
477 relation(3, 10, 12, 2),
478 relation(2, 10, 11, 1),
479 relation(4, 11, 13, 2),
480 ])
481 .unwrap();
482 assert_eq!(ledger.relations()[0].supersession_uuid, uuid7(2));
483 let decoded =
484 AssertionSupersessionLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
485 assert_eq!(decoded, ledger);
486 assert_eq!(
487 decoded.relation_fingerprint(uuid7(3)).unwrap(),
488 ledger.relation_fingerprint(uuid7(3)).unwrap()
489 );
490 }
491
492 #[test]
493 fn self_links_cycles_duplicate_statuses_and_conflicts_fail() {
494 assert!(
495 AssertionSupersession::new(
496 uuid7(1),
497 uuid7(2),
498 uuid7(2),
499 uuid7(3),
500 uuid7(4),
501 uuid7(5),
502 1,
503 )
504 .is_err()
505 );
506 assert!(
507 AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), relation(2, 11, 10, 2),])
508 .is_err()
509 );
510 let mut duplicate_status = relation(2, 11, 12, 2);
511 duplicate_status.status_event_uuid = uuid7(41);
512 assert!(
513 AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), duplicate_status,])
514 .is_err()
515 );
516 let base = AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1)]).unwrap();
517 assert_eq!(base.merge(&base).unwrap(), base);
518 let conflict = AssertionSupersessionLedger::new(vec![relation(1, 10, 12, 1)]).unwrap();
519 assert!(matches!(
520 base.merge(&conflict),
521 Err(KnowledgeError::Conflict("supersession_uuid"))
522 ));
523 }
524}