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 let mut writer = CanonicalWriter::new();
185 for value in [
186 row.supersession_uuid,
187 row.prior_assertion_uuid,
188 row.replacement_assertion_uuid,
189 row.status_event_uuid,
190 row.reasoning_uuid,
191 row.provenance_uuid,
192 ] {
193 writer.raw(value.as_bytes())?;
194 }
195 writer.i64(row.recorded_at_micros)?;
196 writer.u32(row.contract_version)?;
197 fingerprint(
198 CanonicalDomain::AssertionSupersession,
199 CANONICAL_CONTRACT_VERSION,
200 &writer.finish(),
201 )
202 .map_err(Into::into)
203 }
204
205 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
207 let len = self.relations.len();
208 let mut supersessions = FixedSizeBinaryBuilder::with_capacity(len, 16);
209 let mut priors = FixedSizeBinaryBuilder::with_capacity(len, 16);
210 let mut replacements = FixedSizeBinaryBuilder::with_capacity(len, 16);
211 let mut statuses = FixedSizeBinaryBuilder::with_capacity(len, 16);
212 let mut reasoning = FixedSizeBinaryBuilder::with_capacity(len, 16);
213 let mut provenance = FixedSizeBinaryBuilder::with_capacity(len, 16);
214 let mut times = TimestampMicrosecondBuilder::with_capacity(len).with_timezone("UTC");
215 let mut versions = UInt32Builder::with_capacity(len);
216 for row in &self.relations {
217 for (builder, value, field) in [
218 (
219 &mut supersessions,
220 row.supersession_uuid,
221 "supersession_uuid",
222 ),
223 (
224 &mut priors,
225 row.prior_assertion_uuid,
226 "prior_assertion_uuid",
227 ),
228 (
229 &mut replacements,
230 row.replacement_assertion_uuid,
231 "replacement_assertion_uuid",
232 ),
233 (&mut statuses, row.status_event_uuid, "status_event_uuid"),
234 (&mut reasoning, row.reasoning_uuid, "reasoning_uuid"),
235 (&mut provenance, row.provenance_uuid, "provenance_uuid"),
236 ] {
237 builder
238 .append_value(value.as_bytes())
239 .map_err(|_| invalid(field, "invalid UUID width"))?;
240 }
241 times.append_value(row.recorded_at_micros);
242 versions.append_value(row.contract_version);
243 }
244 RecordBatch::try_new(
245 Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
246 vec![
247 Arc::new(supersessions.finish()),
248 Arc::new(priors.finish()),
249 Arc::new(replacements.finish()),
250 Arc::new(statuses.finish()),
251 Arc::new(reasoning.finish()),
252 Arc::new(provenance.finish()),
253 Arc::new(times.finish()),
254 Arc::new(versions.finish()),
255 ],
256 )
257 .map_err(|_| invalid("assertion_supersession", "Arrow batch construction failed"))
258 }
259
260 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
262 let mut relations = Vec::new();
263 for batch in batches {
264 if batch.schema().as_ref() != ASSERTION_SUPERSESSION_SCHEMA.as_ref() {
265 return Err(invalid("assertion_supersession.schema", "schema mismatch"));
266 }
267 let supersessions = fixed(batch, "supersession_uuid")?;
268 let priors = fixed(batch, "prior_assertion_uuid")?;
269 let replacements = fixed(batch, "replacement_assertion_uuid")?;
270 let statuses = fixed(batch, "status_event_uuid")?;
271 let reasoning = fixed(batch, "reasoning_uuid")?;
272 let provenance = fixed(batch, "provenance_uuid")?;
273 let times = timestamp(batch, "recorded_at")?;
274 let versions = uint32(batch, "contract_version")?;
275 for row in 0..batch.num_rows() {
276 relations.push(AssertionSupersession {
277 supersession_uuid: uuid_at(supersessions, row, "supersession_uuid")?,
278 prior_assertion_uuid: uuid_at(priors, row, "prior_assertion_uuid")?,
279 replacement_assertion_uuid: uuid_at(
280 replacements,
281 row,
282 "replacement_assertion_uuid",
283 )?,
284 status_event_uuid: uuid_at(statuses, row, "status_event_uuid")?,
285 reasoning_uuid: uuid_at(reasoning, row, "reasoning_uuid")?,
286 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
287 recorded_at_micros: times.value(row),
288 contract_version: versions.value(row),
289 });
290 }
291 }
292 Self::new(relations)
293 }
294}
295
296pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
297 SchemaRegistryEntry {
298 capability_id: "epistemic",
299 capability_version: EPISTEMIC_CAPABILITY_VERSION,
300 record_family: "assertion_supersessions",
301 record_version: ASSERTION_SUPERSESSION_CONTRACT_VERSION,
302 schema: Arc::clone(&ASSERTION_SUPERSESSION_SCHEMA),
303 schema_fingerprint: *ASSERTION_SUPERSESSION_SCHEMA_FINGERPRINT,
304 enum_registry_versions: &[(
305 "assertion_supersession_policy",
306 ASSERTION_SUPERSESSION_POLICY_VERSION,
307 )],
308 sort_key: &["recorded_at", "supersession_uuid"],
309 diff_identity_fields: &["supersession_uuid"],
310 diff_record_uuid_field: Some("supersession_uuid"),
311 fingerprint_domain: CanonicalDomain::AssertionSupersession,
312 owner: "graphforge-knowledge",
313 implementation_issue: 778,
314 max_rows: MAX_KNOWLEDGE_ROWS,
315 }
316}
317
318fn validate_acyclic(relations: &[AssertionSupersession]) -> Result<(), KnowledgeError> {
319 let mut adjacency = HashMap::<Uuid, Vec<Uuid>>::new();
320 for row in relations {
321 adjacency
322 .entry(row.prior_assertion_uuid)
323 .or_default()
324 .push(row.replacement_assertion_uuid);
325 }
326 let mut visiting = HashSet::new();
327 let mut acyclic = HashSet::new();
328 for node in adjacency.keys().copied() {
329 if !visit(node, &adjacency, &mut visiting, &mut acyclic) {
330 return Err(invalid("assertion_supersession", "cycle detected"));
331 }
332 }
333 Ok(())
334}
335
336fn visit(
337 node: Uuid,
338 adjacency: &HashMap<Uuid, Vec<Uuid>>,
339 visiting: &mut HashSet<Uuid>,
340 acyclic: &mut HashSet<Uuid>,
341) -> bool {
342 if acyclic.contains(&node) {
343 return true;
344 }
345 if !visiting.insert(node) {
346 return false;
347 }
348 if adjacency.get(&node).is_some_and(|next| {
349 next.iter()
350 .any(|child| !visit(*child, adjacency, visiting, acyclic))
351 }) {
352 return false;
353 }
354 visiting.remove(&node);
355 acyclic.insert(node);
356 true
357}
358
359fn validate_relation(row: &AssertionSupersession) -> Result<(), KnowledgeError> {
360 if row.contract_version != ASSERTION_SUPERSESSION_CONTRACT_VERSION {
361 return Err(invalid(
362 "assertion_supersession.contract_version",
363 "unsupported version",
364 ));
365 }
366 AssertionSupersession::new(
367 row.supersession_uuid,
368 row.prior_assertion_uuid,
369 row.replacement_assertion_uuid,
370 row.status_event_uuid,
371 row.reasoning_uuid,
372 row.provenance_uuid,
373 row.recorded_at_micros,
374 )
375 .map(|_| ())
376}
377
378fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
379 if value.get_version() != Some(Version::SortRand) {
380 return Err(invalid(field, "must be UUIDv7"));
381 }
382 require_uuid(value, field)
383}
384
385fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
386 if value.is_nil() {
387 return Err(invalid(field, "must not be nil"));
388 }
389 Ok(())
390}
391
392const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
393 KnowledgeError::Invalid { field, message }
394}
395
396fn uuid_field(name: &str) -> Field {
397 Field::new(name, DataType::FixedSizeBinary(16), false)
398}
399
400fn fixed<'a>(
401 batch: &'a RecordBatch,
402 name: &'static str,
403) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
404 batch
405 .column_by_name(name)
406 .and_then(|value| value.as_any().downcast_ref())
407 .ok_or_else(|| invalid(name, "column type mismatch"))
408}
409
410fn timestamp<'a>(
411 batch: &'a RecordBatch,
412 name: &'static str,
413) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
414 batch
415 .column_by_name(name)
416 .and_then(|value| value.as_any().downcast_ref())
417 .ok_or_else(|| invalid(name, "column type mismatch"))
418}
419
420fn uint32<'a>(
421 batch: &'a RecordBatch,
422 name: &'static str,
423) -> Result<&'a UInt32Array, KnowledgeError> {
424 batch
425 .column_by_name(name)
426 .and_then(|value| value.as_any().downcast_ref())
427 .ok_or_else(|| invalid(name, "column type mismatch"))
428}
429
430fn uuid_at(
431 values: &FixedSizeBinaryArray,
432 row: usize,
433 field: &'static str,
434) -> Result<Uuid, KnowledgeError> {
435 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn uuid7(seed: u8) -> Uuid {
443 let mut bytes = [seed; 16];
444 bytes[6] = (bytes[6] & 0x0f) | 0x70;
445 bytes[8] = (bytes[8] & 0x3f) | 0x80;
446 Uuid::from_bytes(bytes)
447 }
448
449 fn relation(id: u8, prior: u8, replacement: u8, time: i64) -> AssertionSupersession {
450 AssertionSupersession::new(
451 uuid7(id),
452 uuid7(prior),
453 uuid7(replacement),
454 uuid7(id.wrapping_add(40)),
455 uuid7(id.wrapping_add(80)),
456 uuid7(id.wrapping_add(120)),
457 time,
458 )
459 .unwrap()
460 }
461
462 #[test]
463 fn chains_branches_order_round_trip_and_fingerprint_are_stable() {
464 let ledger = AssertionSupersessionLedger::new(vec![
465 relation(3, 10, 12, 2),
466 relation(2, 10, 11, 1),
467 relation(4, 11, 13, 2),
468 ])
469 .unwrap();
470 assert_eq!(ledger.relations()[0].supersession_uuid, uuid7(2));
471 let decoded =
472 AssertionSupersessionLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
473 assert_eq!(decoded, ledger);
474 assert_eq!(
475 decoded.relation_fingerprint(uuid7(3)).unwrap(),
476 ledger.relation_fingerprint(uuid7(3)).unwrap()
477 );
478 }
479
480 #[test]
481 fn self_links_cycles_duplicate_statuses_and_conflicts_fail() {
482 assert!(
483 AssertionSupersession::new(
484 uuid7(1),
485 uuid7(2),
486 uuid7(2),
487 uuid7(3),
488 uuid7(4),
489 uuid7(5),
490 1,
491 )
492 .is_err()
493 );
494 assert!(
495 AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), relation(2, 11, 10, 2),])
496 .is_err()
497 );
498 let mut duplicate_status = relation(2, 11, 12, 2);
499 duplicate_status.status_event_uuid = uuid7(41);
500 assert!(
501 AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1), duplicate_status,])
502 .is_err()
503 );
504 let base = AssertionSupersessionLedger::new(vec![relation(1, 10, 11, 1)]).unwrap();
505 assert_eq!(base.merge(&base).unwrap(), base);
506 let conflict = AssertionSupersessionLedger::new(vec![relation(1, 10, 12, 1)]).unwrap();
507 assert!(matches!(
508 base.merge(&conflict),
509 Err(KnowledgeError::Conflict("supersession_uuid"))
510 ));
511 }
512}