1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5#[cfg(test)]
6use std::{cell::Cell, thread_local};
7
8use arrow::array::{
9 Array, BinaryArray, BinaryBuilder, FixedSizeBinaryArray, FixedSizeBinaryBuilder, ListArray,
10 ListBuilder, TimestampMicrosecondArray, TimestampMicrosecondBuilder, UInt32Array,
11 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, MAX_CANONICAL_BINARY_BYTES,
17 fingerprint,
18};
19use uuid::{Uuid, Version};
20
21use crate::{
22 EPISTEMIC_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
23};
24
25pub const BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION: u32 = 1;
27
28pub static ALGORITHM_INTERPRETATION_ATTACHMENT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
30 let uuid_list = DataType::List(Arc::new(Field::new(
31 "item",
32 DataType::FixedSizeBinary(16),
33 false,
34 )));
35 Arc::new(Schema::new(vec![
36 uuid_field("attachment_uuid", false),
37 uuid_field("run_uuid", false),
38 uuid_field("source_generation_uuid", false),
39 timestamp_field("transaction_cutoff", false),
40 timestamp_field("valid_time", true),
41 Field::new("policy_version", DataType::UInt32, false),
42 Field::new("policy_bytes", DataType::Binary, false),
43 fingerprint_field("policy_fingerprint", false),
44 fingerprint_field("snapshot_fingerprint", false),
45 fingerprint_field("valid_time_fingerprint", true),
46 fingerprint_field("graph_content_fingerprint", false),
47 fingerprint_field("descriptor_fingerprint", false),
48 Field::new("source_record_uuids", uuid_list, false),
49 uuid_field("provenance_uuid", false),
50 timestamp_field("recorded_at", false),
51 Field::new("contract_version", DataType::UInt32, false),
52 ]))
53});
54
55static SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
56 fingerprint(
57 CanonicalDomain::Schema,
58 CANONICAL_CONTRACT_VERSION,
59 b"belief_projection_attachment/1|attachment_uuid:fixed[16]:required|run_uuid:fixed[16]:required|source_generation_uuid:fixed[16]:required|transaction_cutoff:timestamp_us_utc:required|valid_time:timestamp_us_utc:nullable|policy_version:u32:required|policy_bytes:binary:required|policy_fingerprint:fixed[32]:required|snapshot_fingerprint:fixed[32]:required|valid_time_fingerprint:fixed[32]:nullable|graph_content_fingerprint:fixed[32]:required|descriptor_fingerprint:fixed[32]:required|source_record_uuids:list<fixed[16]>:required|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
60 )
61 .expect("registered belief-projection attachment schema is bounded")
62});
63
64#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct BeliefProjectionAttachment {
67 pub attachment_uuid: Uuid,
69 pub run_uuid: Uuid,
71 pub source_generation_uuid: Uuid,
73 pub transaction_cutoff_micros: i64,
75 pub valid_time_micros: Option<i64>,
77 pub policy_version: u32,
79 pub policy_bytes: Vec<u8>,
81 pub policy_fingerprint: [u8; 32],
83 pub snapshot_fingerprint: [u8; 32],
85 pub valid_time_fingerprint: Option<[u8; 32]>,
87 pub graph_content_fingerprint: [u8; 32],
89 pub descriptor_fingerprint: [u8; 32],
91 pub source_record_uuids: Vec<Uuid>,
93 pub provenance_uuid: Uuid,
95 pub recorded_at_micros: i64,
97 pub contract_version: u32,
99}
100
101impl BeliefProjectionAttachment {
102 #[allow(clippy::too_many_arguments)]
104 pub fn new(
105 attachment_uuid: Uuid,
106 run_uuid: Uuid,
107 source_generation_uuid: Uuid,
108 transaction_cutoff_micros: i64,
109 valid_time_micros: Option<i64>,
110 policy_version: u32,
111 policy_bytes: Vec<u8>,
112 snapshot_fingerprint: [u8; 32],
113 valid_time_fingerprint: Option<[u8; 32]>,
114 graph_content_fingerprint: [u8; 32],
115 descriptor_fingerprint: [u8; 32],
116 mut source_record_uuids: Vec<Uuid>,
117 provenance_uuid: Uuid,
118 recorded_at_micros: i64,
119 ) -> Result<Self, KnowledgeError> {
120 source_record_uuids.sort_unstable();
121 source_record_uuids.dedup();
122 let policy_fingerprint = fingerprint(
123 CanonicalDomain::BeliefProjectionPolicy,
124 CANONICAL_CONTRACT_VERSION,
125 &policy_bytes,
126 )?;
127 let row = Self {
128 attachment_uuid,
129 run_uuid,
130 source_generation_uuid,
131 transaction_cutoff_micros,
132 valid_time_micros,
133 policy_version,
134 policy_bytes,
135 policy_fingerprint,
136 snapshot_fingerprint,
137 valid_time_fingerprint,
138 graph_content_fingerprint,
139 descriptor_fingerprint,
140 source_record_uuids,
141 provenance_uuid,
142 recorded_at_micros,
143 contract_version: BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION,
144 };
145 validate(&row)?;
146 Ok(row)
147 }
148}
149
150#[derive(Clone, Debug, Default, Eq, PartialEq)]
170pub struct BeliefProjectionAttachmentLedger {
171 attachments: Vec<BeliefProjectionAttachment>,
173}
174
175impl BeliefProjectionAttachmentLedger {
176 #[must_use]
178 pub fn attachments(&self) -> &[BeliefProjectionAttachment] {
179 &self.attachments
180 }
181
182 #[must_use]
184 pub fn into_attachments(self) -> Vec<BeliefProjectionAttachment> {
185 self.attachments
186 }
187
188 pub fn new(attachments: Vec<BeliefProjectionAttachment>) -> Result<Self, KnowledgeError> {
190 Self::new_with_limit(attachments, MAX_KNOWLEDGE_ROWS)
191 }
192
193 fn new_with_limit(
194 mut attachments: Vec<BeliefProjectionAttachment>,
195 row_limit: usize,
196 ) -> Result<Self, KnowledgeError> {
197 validate_row_count(attachments.len(), row_limit)?;
198 let mut ids = HashSet::with_capacity(attachments.len());
199 for row in &attachments {
200 validate(row)?;
201 if !ids.insert(row.attachment_uuid) {
202 return Err(KnowledgeError::Duplicate("attachment_uuid"));
203 }
204 }
205 attachments.sort_by_key(|row| (row.recorded_at_micros, row.attachment_uuid));
206 Ok(Self { attachments })
207 }
208
209 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
211 self.merge_with_limit(staged, MAX_KNOWLEDGE_ROWS)
212 }
213
214 fn merge_with_limit(&self, staged: &Self, row_limit: usize) -> Result<Self, KnowledgeError> {
215 let mut rows = Vec::with_capacity(self.attachments.len() + staged.attachments.len());
218 rows.extend(self.attachments.iter().cloned());
219 let mut by_id = HashMap::with_capacity(self.attachments.len() + staged.attachments.len());
220 by_id.extend(
221 self.attachments
222 .iter()
223 .map(|row| (row.attachment_uuid, row)),
224 );
225 for row in &staged.attachments {
226 if let Some(existing) = by_id.get(&row.attachment_uuid) {
227 if *existing != row {
228 return Err(KnowledgeError::TransactionConflict("attachment_uuid"));
229 }
230 } else {
231 rows.push(row.clone());
232 by_id.insert(row.attachment_uuid, row);
233 }
234 }
235 validate_row_count(rows.len(), row_limit)?;
238 rows.sort_by_key(|row| (row.recorded_at_micros, row.attachment_uuid));
239 Ok(Self { attachments: rows })
240 }
241
242 pub fn attachment_fingerprint(&self, id: Uuid) -> Result<[u8; 32], KnowledgeError> {
244 let row = self
245 .attachments
246 .iter()
247 .find(|row| row.attachment_uuid == id)
248 .ok_or(KnowledgeError::Dangling("attachment_uuid"))?;
249 let mut writer = CanonicalWriter::new();
250 writer.raw(row.attachment_uuid.as_bytes())?;
251 writer.raw(row.run_uuid.as_bytes())?;
252 writer.raw(row.source_generation_uuid.as_bytes())?;
253 writer.i64(row.transaction_cutoff_micros)?;
254 optional_i64(&mut writer, row.valid_time_micros)?;
255 writer.u32(row.policy_version)?;
256 writer.binary(&row.policy_bytes)?;
257 writer.raw(&row.policy_fingerprint)?;
258 writer.raw(&row.snapshot_fingerprint)?;
259 optional_fingerprint(&mut writer, row.valid_time_fingerprint)?;
260 writer.raw(&row.graph_content_fingerprint)?;
261 writer.raw(&row.descriptor_fingerprint)?;
262 writer.u64(u64::try_from(row.source_record_uuids.len()).unwrap_or(u64::MAX))?;
263 for source in &row.source_record_uuids {
264 writer.raw(source.as_bytes())?;
265 }
266 writer.raw(row.provenance_uuid.as_bytes())?;
267 writer.i64(row.recorded_at_micros)?;
268 writer.u32(row.contract_version)?;
269 fingerprint(
270 CanonicalDomain::BeliefProjectionAttachment,
271 CANONICAL_CONTRACT_VERSION,
272 &writer.finish(),
273 )
274 .map_err(Into::into)
275 }
276
277 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
279 let len = self.attachments.len();
280 let mut attachment = FixedSizeBinaryBuilder::with_capacity(len, 16);
281 let mut run = FixedSizeBinaryBuilder::with_capacity(len, 16);
282 let mut generation = FixedSizeBinaryBuilder::with_capacity(len, 16);
283 let mut cutoff = TimestampMicrosecondBuilder::new().with_timezone("UTC");
284 let mut valid_time = TimestampMicrosecondBuilder::new().with_timezone("UTC");
285 let mut policy_version = UInt32Builder::new();
286 let mut policy_bytes = BinaryBuilder::new();
287 let mut policy_fp = FixedSizeBinaryBuilder::with_capacity(len, 32);
288 let mut snapshot_fp = FixedSizeBinaryBuilder::with_capacity(len, 32);
289 let mut valid_fp = FixedSizeBinaryBuilder::with_capacity(len, 32);
290 let mut graph_fp = FixedSizeBinaryBuilder::with_capacity(len, 32);
291 let mut descriptor_fp = FixedSizeBinaryBuilder::with_capacity(len, 32);
292 let mut sources = ListBuilder::new(FixedSizeBinaryBuilder::new(16)).with_field(Arc::new(
293 Field::new("item", DataType::FixedSizeBinary(16), false),
294 ));
295 let mut provenance = FixedSizeBinaryBuilder::with_capacity(len, 16);
296 let mut recorded = TimestampMicrosecondBuilder::new().with_timezone("UTC");
297 let mut version = UInt32Builder::new();
298 for row in &self.attachments {
299 append(&mut attachment, row.attachment_uuid.as_bytes())?;
300 append(&mut run, row.run_uuid.as_bytes())?;
301 append(&mut generation, row.source_generation_uuid.as_bytes())?;
302 cutoff.append_value(row.transaction_cutoff_micros);
303 valid_time.append_option(row.valid_time_micros);
304 policy_version.append_value(row.policy_version);
305 policy_bytes.append_value(&row.policy_bytes);
306 append(&mut policy_fp, &row.policy_fingerprint)?;
307 append(&mut snapshot_fp, &row.snapshot_fingerprint)?;
308 append_optional(&mut valid_fp, row.valid_time_fingerprint.as_ref())?;
309 append(&mut graph_fp, &row.graph_content_fingerprint)?;
310 append(&mut descriptor_fp, &row.descriptor_fingerprint)?;
311 for source in &row.source_record_uuids {
312 append(sources.values(), source.as_bytes())?;
313 }
314 sources.append(true);
315 append(&mut provenance, row.provenance_uuid.as_bytes())?;
316 recorded.append_value(row.recorded_at_micros);
317 version.append_value(row.contract_version);
318 }
319 Ok(RecordBatch::try_new(
320 Arc::clone(&ALGORITHM_INTERPRETATION_ATTACHMENT_SCHEMA),
321 vec![
322 Arc::new(attachment.finish()),
323 Arc::new(run.finish()),
324 Arc::new(generation.finish()),
325 Arc::new(cutoff.finish()),
326 Arc::new(valid_time.finish()),
327 Arc::new(policy_version.finish()),
328 Arc::new(policy_bytes.finish()),
329 Arc::new(policy_fp.finish()),
330 Arc::new(snapshot_fp.finish()),
331 Arc::new(valid_fp.finish()),
332 Arc::new(graph_fp.finish()),
333 Arc::new(descriptor_fp.finish()),
334 Arc::new(sources.finish()),
335 Arc::new(provenance.finish()),
336 Arc::new(recorded.finish()),
337 Arc::new(version.finish()),
338 ],
339 )?)
340 }
341
342 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
344 let mut rows = Vec::new();
345 for batch in batches {
346 if batch.schema().as_ref() != ALGORITHM_INTERPRETATION_ATTACHMENT_SCHEMA.as_ref() {
347 return Err(invalid(
348 "belief_projection_attachment.schema",
349 "schema mismatch",
350 ));
351 }
352 let attachment = fixed(batch, "attachment_uuid")?;
353 let run = fixed(batch, "run_uuid")?;
354 let generation = fixed(batch, "source_generation_uuid")?;
355 let cutoff = timestamp(batch, "transaction_cutoff")?;
356 let valid_time = timestamp(batch, "valid_time")?;
357 let policy_version = uint32(batch, "policy_version")?;
358 let policy_bytes = binary(batch, "policy_bytes")?;
359 let policy_fp = fixed(batch, "policy_fingerprint")?;
360 let snapshot_fp = fixed(batch, "snapshot_fingerprint")?;
361 let valid_fp = fixed(batch, "valid_time_fingerprint")?;
362 let graph_fp = fixed(batch, "graph_content_fingerprint")?;
363 let descriptor_fp = fixed(batch, "descriptor_fingerprint")?;
364 let sources = list(batch, "source_record_uuids")?;
365 let provenance = fixed(batch, "provenance_uuid")?;
366 let recorded = timestamp(batch, "recorded_at")?;
367 let versions = uint32(batch, "contract_version")?;
368 for row in 0..batch.num_rows() {
369 rows.push(BeliefProjectionAttachment {
370 attachment_uuid: uuid_at(attachment, row, "attachment_uuid")?,
371 run_uuid: uuid_at(run, row, "run_uuid")?,
372 source_generation_uuid: uuid_at(generation, row, "source_generation_uuid")?,
373 transaction_cutoff_micros: cutoff.value(row),
374 valid_time_micros: optional_timestamp(valid_time, row),
375 policy_version: policy_version.value(row),
376 policy_bytes: policy_bytes.value(row).to_vec(),
377 policy_fingerprint: bytes32(policy_fp, row, "policy_fingerprint")?,
378 snapshot_fingerprint: bytes32(snapshot_fp, row, "snapshot_fingerprint")?,
379 valid_time_fingerprint: optional_bytes32(
380 valid_fp,
381 row,
382 "valid_time_fingerprint",
383 )?,
384 graph_content_fingerprint: bytes32(graph_fp, row, "graph_content_fingerprint")?,
385 descriptor_fingerprint: bytes32(descriptor_fp, row, "descriptor_fingerprint")?,
386 source_record_uuids: uuid_list_at(sources, row)?,
387 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
388 recorded_at_micros: recorded.value(row),
389 contract_version: versions.value(row),
390 });
391 }
392 }
393 Self::new(rows)
394 }
395}
396
397pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
398 SchemaRegistryEntry {
399 capability_id: "epistemic",
400 capability_version: EPISTEMIC_CAPABILITY_VERSION,
401 record_family: "algorithm_interpretation_attachments",
402 record_version: BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION,
403 schema: Arc::clone(&ALGORITHM_INTERPRETATION_ATTACHMENT_SCHEMA),
404 schema_fingerprint: *SCHEMA_FINGERPRINT,
405 enum_registry_versions: &[],
406 sort_key: &["recorded_at", "attachment_uuid"],
407 diff_identity_fields: &["attachment_uuid"],
408 diff_record_uuid_field: Some("attachment_uuid"),
409 fingerprint_domain: CanonicalDomain::BeliefProjectionAttachment,
410 owner: "graphforge-knowledge",
411 implementation_issue: 2004,
412 max_rows: MAX_KNOWLEDGE_ROWS,
413 }
414}
415
416fn validate(row: &BeliefProjectionAttachment) -> Result<(), KnowledgeError> {
417 #[cfg(test)]
418 VALIDATION_CALLS.with(|calls| calls.set(calls.get() + 1));
419
420 if row.contract_version != BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION {
421 return Err(invalid(
422 "belief_projection_attachment.contract_version",
423 "unsupported version",
424 ));
425 }
426 require_v7(row.attachment_uuid, "attachment_uuid")?;
427 require_v7(row.run_uuid, "run_uuid")?;
428 require_uuid(row.source_generation_uuid, "source_generation_uuid")?;
429 require_uuid(row.provenance_uuid, "provenance_uuid")?;
430 if row.policy_version == 0 {
431 return Err(invalid(
432 "belief_projection_attachment.policy_version",
433 "must be positive",
434 ));
435 }
436 validate_policy_len(row.policy_bytes.len())?;
437 let expected = fingerprint(
438 CanonicalDomain::BeliefProjectionPolicy,
439 CANONICAL_CONTRACT_VERSION,
440 &row.policy_bytes,
441 )?;
442 if row.policy_fingerprint != expected {
443 return Err(invalid(
444 "belief_projection_attachment.policy_fingerprint",
445 "does not match policy bytes",
446 ));
447 }
448 if row
449 .source_record_uuids
450 .windows(2)
451 .any(|pair| pair[0] >= pair[1])
452 {
453 return Err(invalid(
454 "belief_projection_attachment.source_record_uuids",
455 "must be sorted and deduplicated",
456 ));
457 }
458 for source in &row.source_record_uuids {
459 require_uuid(*source, "source_record_uuid")?;
460 }
461 Ok(())
462}
463
464#[cfg(test)]
465thread_local! {
466 static VALIDATION_CALLS: Cell<usize> = const { Cell::new(0) };
467}
468
469#[cfg(test)]
470fn reset_validation_calls() {
471 VALIDATION_CALLS.with(|calls| calls.set(0));
472}
473
474#[cfg(test)]
475fn validation_call_count() -> usize {
476 VALIDATION_CALLS.with(Cell::get)
477}
478
479fn validate_row_count(count: usize, limit: usize) -> Result<(), KnowledgeError> {
480 if count > limit {
481 Err(KnowledgeError::Limit {
482 participant: "algorithm_interpretation_attachments",
483 observed: count,
484 limit,
485 })
486 } else {
487 Ok(())
488 }
489}
490
491fn validate_policy_len(length: usize) -> Result<(), KnowledgeError> {
492 if u64::try_from(length).unwrap_or(u64::MAX) > MAX_CANONICAL_BINARY_BYTES {
493 Err(KnowledgeError::Limit {
494 participant: "policy_bytes",
495 observed: length,
496 limit: usize::try_from(MAX_CANONICAL_BINARY_BYTES).unwrap_or(usize::MAX),
497 })
498 } else {
499 Ok(())
500 }
501}
502
503fn optional_i64(writer: &mut CanonicalWriter, value: Option<i64>) -> Result<(), KnowledgeError> {
504 match value {
505 Some(value) => {
506 writer.u8(1)?;
507 writer.i64(value)?;
508 }
509 None => writer.u8(0)?,
510 }
511 Ok(())
512}
513fn optional_fingerprint(
514 writer: &mut CanonicalWriter,
515 value: Option<[u8; 32]>,
516) -> Result<(), KnowledgeError> {
517 match value {
518 Some(value) => {
519 writer.u8(1)?;
520 writer.raw(&value)?;
521 }
522 None => writer.u8(0)?,
523 }
524 Ok(())
525}
526fn append(builder: &mut FixedSizeBinaryBuilder, value: &[u8]) -> Result<(), KnowledgeError> {
527 builder
528 .append_value(value)
529 .map_err(|_| invalid("belief_projection_attachment", "invalid fixed-width value"))
530}
531fn append_optional(
532 builder: &mut FixedSizeBinaryBuilder,
533 value: Option<&[u8; 32]>,
534) -> Result<(), KnowledgeError> {
535 if let Some(value) = value {
536 append(builder, value)
537 } else {
538 builder.append_null();
539 Ok(())
540 }
541}
542fn uuid_field(name: &str, nullable: bool) -> Field {
543 Field::new(name, DataType::FixedSizeBinary(16), nullable)
544}
545fn fingerprint_field(name: &str, nullable: bool) -> Field {
546 Field::new(name, DataType::FixedSizeBinary(32), nullable)
547}
548fn timestamp_field(name: &str, nullable: bool) -> Field {
549 Field::new(
550 name,
551 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
552 nullable,
553 )
554}
555fn fixed<'a>(
556 batch: &'a RecordBatch,
557 name: &'static str,
558) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
559 batch
560 .column_by_name(name)
561 .and_then(|v| v.as_any().downcast_ref())
562 .ok_or_else(|| invalid(name, "column type mismatch"))
563}
564fn timestamp<'a>(
565 batch: &'a RecordBatch,
566 name: &'static str,
567) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
568 batch
569 .column_by_name(name)
570 .and_then(|v| v.as_any().downcast_ref())
571 .ok_or_else(|| invalid(name, "column type mismatch"))
572}
573fn uint32<'a>(
574 batch: &'a RecordBatch,
575 name: &'static str,
576) -> Result<&'a UInt32Array, KnowledgeError> {
577 batch
578 .column_by_name(name)
579 .and_then(|v| v.as_any().downcast_ref())
580 .ok_or_else(|| invalid(name, "column type mismatch"))
581}
582fn binary<'a>(
583 batch: &'a RecordBatch,
584 name: &'static str,
585) -> Result<&'a BinaryArray, KnowledgeError> {
586 batch
587 .column_by_name(name)
588 .and_then(|v| v.as_any().downcast_ref())
589 .ok_or_else(|| invalid(name, "column type mismatch"))
590}
591fn list<'a>(batch: &'a RecordBatch, name: &'static str) -> Result<&'a ListArray, KnowledgeError> {
592 batch
593 .column_by_name(name)
594 .and_then(|v| v.as_any().downcast_ref())
595 .ok_or_else(|| invalid(name, "column type mismatch"))
596}
597fn uuid_at(
598 values: &FixedSizeBinaryArray,
599 row: usize,
600 field: &'static str,
601) -> Result<Uuid, KnowledgeError> {
602 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
603}
604fn bytes32(
605 values: &FixedSizeBinaryArray,
606 row: usize,
607 field: &'static str,
608) -> Result<[u8; 32], KnowledgeError> {
609 values
610 .value(row)
611 .try_into()
612 .map_err(|_| invalid(field, "invalid fingerprint width"))
613}
614fn optional_bytes32(
615 values: &FixedSizeBinaryArray,
616 row: usize,
617 field: &'static str,
618) -> Result<Option<[u8; 32]>, KnowledgeError> {
619 (!values.is_null(row))
620 .then(|| bytes32(values, row, field))
621 .transpose()
622}
623fn optional_timestamp(values: &TimestampMicrosecondArray, row: usize) -> Option<i64> {
624 (!values.is_null(row)).then(|| values.value(row))
625}
626fn uuid_list_at(values: &ListArray, row: usize) -> Result<Vec<Uuid>, KnowledgeError> {
627 let value = values.value(row);
628 let fixed = value
629 .as_any()
630 .downcast_ref::<FixedSizeBinaryArray>()
631 .ok_or_else(|| invalid("source_record_uuids", "item type mismatch"))?;
632 (0..fixed.len())
633 .map(|index| uuid_at(fixed, index, "source_record_uuid"))
634 .collect()
635}
636fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
637 if value.get_version() != Some(Version::SortRand) {
638 return Err(invalid(field, "must be UUIDv7"));
639 }
640 require_uuid(value, field)
641}
642fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
643 if value.is_nil() {
644 Err(invalid(field, "must not be nil"))
645 } else {
646 Ok(())
647 }
648}
649const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
650 KnowledgeError::Invalid { field, message }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use std::time::{Duration, Instant};
657 fn uuid7(seed: u8) -> Uuid {
658 let mut bytes = [seed; 16];
659 bytes[6] = (bytes[6] & 0x0f) | 0x70;
660 bytes[8] = (bytes[8] & 0x3f) | 0x80;
661 Uuid::from_bytes(bytes)
662 }
663 fn attachment(id: u8, sources: Vec<Uuid>) -> BeliefProjectionAttachment {
664 BeliefProjectionAttachment::new(
665 uuid7(id),
666 uuid7(id + 1),
667 uuid7(id + 2),
668 10,
669 Some(11),
670 1,
671 b"policy-v1".to_vec(),
672 [3; 32],
673 Some([4; 32]),
674 [5; 32],
675 [6; 32],
676 sources,
677 uuid7(id + 3),
678 20,
679 )
680 .unwrap()
681 }
682
683 fn attachment_at(id: u8, recorded_at_micros: i64) -> BeliefProjectionAttachment {
684 let mut row = attachment(id, vec![]);
685 row.recorded_at_micros = recorded_at_micros;
686 row
687 }
688
689 fn non_v7_uuid(seed: u8) -> Uuid {
690 let mut bytes = [seed; 16];
691 bytes[6] = (bytes[6] & 0x0f) | 0x40;
692 bytes[8] = (bytes[8] & 0x3f) | 0x80;
693 Uuid::from_bytes(bytes)
694 }
695
696 fn benchmark_uuid(seed: u64) -> Uuid {
697 let mut bytes = [0; 16];
698 bytes[8..].copy_from_slice(&seed.to_be_bytes());
699 bytes[6] = 0x70;
700 bytes[8] = (bytes[8] & 0x3f) | 0x80;
701 Uuid::from_bytes(bytes)
702 }
703
704 fn benchmark_attachment(seed: u64) -> BeliefProjectionAttachment {
705 let policy_bytes = b"benchmark-policy".to_vec();
706 BeliefProjectionAttachment {
707 attachment_uuid: benchmark_uuid(seed + 1),
708 run_uuid: benchmark_uuid(seed + 2),
709 source_generation_uuid: benchmark_uuid(seed + 3),
710 transaction_cutoff_micros: 10,
711 valid_time_micros: Some(11),
712 policy_version: 1,
713 policy_fingerprint: fingerprint(
714 CanonicalDomain::BeliefProjectionPolicy,
715 CANONICAL_CONTRACT_VERSION,
716 &policy_bytes,
717 )
718 .unwrap(),
719 policy_bytes,
720 snapshot_fingerprint: [3; 32],
721 valid_time_fingerprint: Some([4; 32]),
722 graph_content_fingerprint: [5; 32],
723 descriptor_fingerprint: [6; 32],
724 source_record_uuids: vec![benchmark_uuid(seed + 4)],
725 provenance_uuid: benchmark_uuid(seed + 5),
726 recorded_at_micros: i64::try_from(seed).unwrap(),
727 contract_version: BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION,
728 }
729 }
730
731 fn merge_with_full_revalidation(
732 existing: &BeliefProjectionAttachmentLedger,
733 staged: &BeliefProjectionAttachmentLedger,
734 ) -> Result<BeliefProjectionAttachmentLedger, KnowledgeError> {
735 let mut rows = existing.attachments.clone();
736 let mut by_id = rows
737 .iter()
738 .cloned()
739 .map(|row| (row.attachment_uuid, row))
740 .collect::<HashMap<_, _>>();
741 for row in &staged.attachments {
742 if let Some(previous) = by_id.get(&row.attachment_uuid) {
743 if previous != row {
744 return Err(KnowledgeError::TransactionConflict("attachment_uuid"));
745 }
746 } else {
747 rows.push(row.clone());
748 by_id.insert(row.attachment_uuid, row.clone());
749 }
750 }
751 BeliefProjectionAttachmentLedger::new(rows)
752 }
753
754 fn median(mut values: Vec<Duration>) -> Duration {
755 values.sort_unstable();
756 values[values.len() / 2]
757 }
758
759 fn input_digest(
760 existing: &BeliefProjectionAttachmentLedger,
761 staged: &BeliefProjectionAttachmentLedger,
762 ) -> String {
763 let mut writer = CanonicalWriter::new();
764 for ledger in [existing, staged] {
765 writer.u64(ledger.attachments.len() as u64).unwrap();
766 for row in &ledger.attachments {
767 writer.raw(row.attachment_uuid.as_bytes()).unwrap();
768 writer.raw(row.run_uuid.as_bytes()).unwrap();
769 writer.raw(row.source_generation_uuid.as_bytes()).unwrap();
770 writer.i64(row.transaction_cutoff_micros).unwrap();
771 writer
772 .u8(u8::from(row.valid_time_micros.is_some()))
773 .unwrap();
774 if let Some(value) = row.valid_time_micros {
775 writer.i64(value).unwrap();
776 }
777 writer.u32(row.policy_version).unwrap();
778 writer.binary(&row.policy_bytes).unwrap();
779 writer.raw(&row.policy_fingerprint).unwrap();
780 writer.raw(&row.snapshot_fingerprint).unwrap();
781 writer
782 .u8(u8::from(row.valid_time_fingerprint.is_some()))
783 .unwrap();
784 if let Some(value) = row.valid_time_fingerprint {
785 writer.raw(&value).unwrap();
786 }
787 writer.raw(&row.graph_content_fingerprint).unwrap();
788 writer.raw(&row.descriptor_fingerprint).unwrap();
789 writer.u64(row.source_record_uuids.len() as u64).unwrap();
790 for source in &row.source_record_uuids {
791 writer.raw(source.as_bytes()).unwrap();
792 }
793 writer.raw(row.provenance_uuid.as_bytes()).unwrap();
794 writer.i64(row.recorded_at_micros).unwrap();
795 writer.u32(row.contract_version).unwrap();
796 }
797 }
798 fingerprint(
799 CanonicalDomain::BeliefProjectionAttachment,
800 CANONICAL_CONTRACT_VERSION,
801 &writer.finish(),
802 )
803 .unwrap()
804 .iter()
805 .map(|byte| format!("{byte:02x}"))
806 .collect()
807 }
808
809 #[test]
810 fn sources_are_canonical_and_arrow_round_trip_is_exact() {
811 let row = attachment(1, vec![uuid7(9), uuid7(8), uuid7(9)]);
812 assert_eq!(row.source_record_uuids, vec![uuid7(8), uuid7(9)]);
813 let ledger = BeliefProjectionAttachmentLedger::new(vec![row]).unwrap();
814 let decoded =
815 BeliefProjectionAttachmentLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
816 assert_eq!(decoded, ledger);
817 assert_eq!(
818 decoded.attachment_fingerprint(uuid7(1)).unwrap(),
819 ledger.attachment_fingerprint(uuid7(1)).unwrap()
820 );
821 }
822
823 #[test]
824 fn replay_is_idempotent_and_conflict_has_transaction_code() {
825 let row = attachment(10, vec![]);
826 let ledger = BeliefProjectionAttachmentLedger::new(vec![row.clone()]).unwrap();
827 assert_eq!(
828 ledger
829 .merge(&BeliefProjectionAttachmentLedger::new(vec![row.clone()]).unwrap())
830 .unwrap(),
831 ledger
832 );
833 let mut different = row;
834 different.graph_content_fingerprint = [99; 32];
835 let error = ledger
836 .merge(&BeliefProjectionAttachmentLedger::new(vec![different]).unwrap())
837 .unwrap_err();
838 assert_eq!(error.code(), "GF_TRANSACTION_CONFLICT");
839 }
840
841 #[test]
842 fn registry_is_epistemic_owned_and_frozen() {
843 let entry = schema_registry_entry();
844 assert_eq!(entry.capability_id, "epistemic");
845 assert_eq!(entry.record_family, "algorithm_interpretation_attachments");
846 assert_eq!(entry.sort_key, &["recorded_at", "attachment_uuid"]);
847 assert_eq!(entry.implementation_issue, 2004);
848 }
849
850 #[test]
851 fn defensive_attachment_validation_and_optional_fingerprints_are_exact() {
852 let row = attachment(30, vec![uuid7(40)]);
853 assert!(matches!(
854 BeliefProjectionAttachmentLedger::new(vec![row.clone(), row.clone()]),
855 Err(KnowledgeError::Duplicate("attachment_uuid"))
856 ));
857
858 let mut invalid = row.clone();
859 invalid.contract_version += 1;
860 assert!(
861 validate(&invalid)
862 .unwrap_err()
863 .to_string()
864 .contains("unsupported version")
865 );
866 invalid = row.clone();
867 invalid.policy_version = 0;
868 assert!(
869 validate(&invalid)
870 .unwrap_err()
871 .to_string()
872 .contains("must be positive")
873 );
874 invalid = row.clone();
875 invalid.policy_fingerprint = [0; 32];
876 assert!(
877 validate(&invalid)
878 .unwrap_err()
879 .to_string()
880 .contains("does not match policy bytes")
881 );
882 invalid = row.clone();
883 invalid.source_record_uuids = vec![uuid7(42), uuid7(41)];
884 assert!(
885 validate(&invalid)
886 .unwrap_err()
887 .to_string()
888 .contains("sorted and deduplicated")
889 );
890 invalid = row.clone();
891 invalid.source_record_uuids = vec![uuid7(41), uuid7(41)];
892 assert!(
893 validate(&invalid)
894 .unwrap_err()
895 .to_string()
896 .contains("sorted and deduplicated")
897 );
898 invalid = row;
899 invalid.source_record_uuids = vec![Uuid::nil()];
900 assert!(
901 validate(&invalid)
902 .unwrap_err()
903 .to_string()
904 .contains("must not be nil")
905 );
906
907 let without_optional = BeliefProjectionAttachment::new(
908 uuid7(50),
909 uuid7(51),
910 uuid7(52),
911 10,
912 None,
913 1,
914 b"policy-v1".to_vec(),
915 [3; 32],
916 None,
917 [5; 32],
918 [6; 32],
919 vec![],
920 uuid7(53),
921 20,
922 )
923 .unwrap();
924 BeliefProjectionAttachmentLedger::new(vec![without_optional])
925 .unwrap()
926 .attachment_fingerprint(uuid7(50))
927 .unwrap();
928
929 let wrong = RecordBatch::new_empty(Arc::new(Schema::empty()));
930 assert!(
931 BeliefProjectionAttachmentLedger::from_batches(&[wrong])
932 .unwrap_err()
933 .to_string()
934 .contains("schema mismatch")
935 );
936 }
937
938 #[test]
939 fn attachment_and_run_uuids_must_be_v7() {
940 let row = attachment(31, vec![]);
941 let mut invalid = row.clone();
942 invalid.attachment_uuid = non_v7_uuid(1);
943 assert!(
944 validate(&invalid)
945 .unwrap_err()
946 .to_string()
947 .contains("must be UUIDv7")
948 );
949 invalid = row;
950 invalid.run_uuid = non_v7_uuid(2);
951 assert!(
952 validate(&invalid)
953 .unwrap_err()
954 .to_string()
955 .contains("must be UUIDv7")
956 );
957 }
958
959 #[test]
960 fn attachment_contract_version_must_be_supported() {
961 let mut invalid = attachment(40, vec![]);
962 invalid.contract_version = BELIEF_PROJECTION_ATTACHMENT_CONTRACT_VERSION + 1;
963 assert!(
964 validate(&invalid)
965 .unwrap_err()
966 .to_string()
967 .contains("unsupported version")
968 );
969 }
970
971 #[test]
972 fn duplicate_attachment_uuid_is_rejected() {
973 let first = attachment(41, vec![]);
974 let mut duplicate = first.clone();
975 duplicate.recorded_at_micros += 1;
976 assert!(matches!(
977 BeliefProjectionAttachmentLedger::new(vec![first, duplicate]),
978 Err(KnowledgeError::Duplicate("attachment_uuid"))
979 ));
980 }
981
982 #[test]
983 fn source_generation_and_provenance_uuids_must_be_non_nil() {
984 let row = attachment(32, vec![]);
985 let mut invalid = row.clone();
986 invalid.source_generation_uuid = Uuid::nil();
987 assert!(
988 validate(&invalid)
989 .unwrap_err()
990 .to_string()
991 .contains("must not be nil")
992 );
993 invalid = row;
994 invalid.provenance_uuid = Uuid::nil();
995 assert!(
996 validate(&invalid)
997 .unwrap_err()
998 .to_string()
999 .contains("must not be nil")
1000 );
1001 }
1002
1003 #[test]
1004 fn policy_version_must_be_positive() {
1005 let mut invalid = attachment(33, vec![]);
1006 invalid.policy_version = 0;
1007 assert!(
1008 validate(&invalid)
1009 .unwrap_err()
1010 .to_string()
1011 .contains("must be positive")
1012 );
1013 }
1014
1015 #[test]
1016 fn policy_byte_limit_rejects_the_first_excess_byte() {
1017 let maximum = usize::try_from(MAX_CANONICAL_BINARY_BYTES).unwrap();
1018 assert!(validate_policy_len(maximum).is_ok());
1019 assert!(matches!(
1020 validate_policy_len(maximum + 1),
1021 Err(KnowledgeError::Limit {
1022 participant: "policy_bytes",
1023 observed,
1024 limit,
1025 }) if observed == maximum + 1 && limit == maximum
1026 ));
1027 let mut invalid = attachment(39, vec![]);
1028 invalid.policy_bytes = vec![0; maximum + 1];
1029 assert!(
1030 validate(&invalid)
1031 .unwrap_err()
1032 .to_string()
1033 .contains("policy_bytes")
1034 );
1035 }
1036
1037 #[test]
1038 fn policy_fingerprint_must_match_policy_bytes() {
1039 let mut invalid = attachment(34, vec![]);
1040 invalid.policy_fingerprint = [0; 32];
1041 assert!(
1042 validate(&invalid)
1043 .unwrap_err()
1044 .to_string()
1045 .contains("does not match policy bytes")
1046 );
1047 }
1048
1049 #[test]
1050 fn source_record_uuids_must_be_sorted_unique_and_non_nil() {
1051 let row = attachment(35, vec![uuid7(40), uuid7(41)]);
1052 let mut invalid = row.clone();
1053 invalid.source_record_uuids = vec![uuid7(41), uuid7(40)];
1054 assert!(
1055 validate(&invalid)
1056 .unwrap_err()
1057 .to_string()
1058 .contains("sorted and deduplicated")
1059 );
1060 invalid = row.clone();
1061 invalid.source_record_uuids = vec![uuid7(40), uuid7(40)];
1062 assert!(
1063 validate(&invalid)
1064 .unwrap_err()
1065 .to_string()
1066 .contains("sorted and deduplicated")
1067 );
1068 invalid = row;
1069 invalid.source_record_uuids = vec![Uuid::nil()];
1070 assert!(
1071 validate(&invalid)
1072 .unwrap_err()
1073 .to_string()
1074 .contains("must not be nil")
1075 );
1076 }
1077
1078 #[test]
1079 fn row_limit_constructor_and_merge_reject_first_excess() {
1080 let rows = vec![attachment(36, vec![]), attachment(37, vec![])];
1081 assert!(BeliefProjectionAttachmentLedger::new_with_limit(rows.clone(), 2).is_ok());
1082 assert!(matches!(
1083 BeliefProjectionAttachmentLedger::new_with_limit(rows.clone(), 1),
1084 Err(KnowledgeError::Limit {
1085 observed: 2,
1086 limit: 1,
1087 ..
1088 })
1089 ));
1090 let existing = BeliefProjectionAttachmentLedger::new_with_limit(rows, 2).unwrap();
1091 let staged = BeliefProjectionAttachmentLedger::new(vec![attachment(38, vec![])]).unwrap();
1092 assert!(matches!(
1093 existing.merge_with_limit(&staged, 2),
1094 Err(KnowledgeError::Limit {
1095 observed: 3,
1096 limit: 2,
1097 ..
1098 })
1099 ));
1100 let replay =
1101 BeliefProjectionAttachmentLedger::new(vec![existing.attachments()[0].clone()]).unwrap();
1102 assert_eq!(existing.merge_with_limit(&replay, 2).unwrap(), existing);
1103
1104 let novel = attachment_at(39, 10);
1105 let mut conflicting = attachment_at(36, 30);
1106 conflicting.graph_content_fingerprint = [99; 32];
1107 let staged = BeliefProjectionAttachmentLedger::new(vec![novel, conflicting]).unwrap();
1108 assert!(matches!(
1109 existing.merge_with_limit(&staged, 2),
1110 Err(KnowledgeError::TransactionConflict("attachment_uuid"))
1111 ));
1112 }
1113
1114 #[test]
1115 fn incremental_merge_checks_staged_rows_once_and_never_existing_rows() {
1116 let first = attachment(60, vec![]);
1117 let replay = first.clone();
1118 let later = attachment_at(70, 30);
1119 let earlier = attachment_at(80, 15);
1120 reset_validation_calls();
1121 let existing = BeliefProjectionAttachmentLedger::new(vec![first]).unwrap();
1122 assert_eq!(validation_call_count(), 1);
1123 reset_validation_calls();
1124 let staged = BeliefProjectionAttachmentLedger::new(vec![later, replay, earlier]).unwrap();
1125 assert_eq!(validation_call_count(), 3);
1126 reset_validation_calls();
1127 let merged = existing.merge(&staged).unwrap();
1128 assert_eq!(validation_call_count(), 0);
1129 assert_eq!(
1130 merged
1131 .attachments()
1132 .iter()
1133 .map(|r| r.attachment_uuid)
1134 .collect::<Vec<_>>(),
1135 vec![uuid7(80), uuid7(60), uuid7(70)]
1136 );
1137 assert_eq!(merged.clone().into_attachments().len(), 3);
1138 }
1139
1140 #[test]
1141 fn constructor_validates_each_new_row_once() {
1142 let rows = vec![attachment(100, vec![]), attachment(101, vec![])];
1143 reset_validation_calls();
1144 let ledger = BeliefProjectionAttachmentLedger::new(rows).unwrap();
1145 assert_eq!(ledger.attachments().len(), 2);
1146 assert_eq!(validation_call_count(), 2);
1147 }
1148
1149 #[test]
1150 fn constructor_sorts_rows_by_recorded_time_then_uuid() {
1151 let late = attachment_at(110, 30);
1152 let same_time_lower_uuid = attachment_at(100, 30);
1153 let same_time_higher_uuid = attachment_at(120, 30);
1154 let ledger = BeliefProjectionAttachmentLedger::new(vec![
1155 same_time_higher_uuid,
1156 late,
1157 same_time_lower_uuid,
1158 ])
1159 .unwrap();
1160 assert_eq!(
1161 ledger
1162 .attachments()
1163 .iter()
1164 .map(|row| row.attachment_uuid)
1165 .collect::<Vec<_>>(),
1166 vec![uuid7(100), uuid7(110), uuid7(120)]
1167 );
1168 }
1169
1170 #[test]
1171 #[ignore = "manual quiet-host before/after merge-cost measurement"]
1172 fn quiet_host_merge_cost_measurement() {
1173 for existing_count in [1_000_u64, 10_000] {
1174 let existing = BeliefProjectionAttachmentLedger::new(
1175 (0..existing_count).map(benchmark_attachment).collect(),
1176 )
1177 .unwrap();
1178 let mut staged_row = benchmark_attachment(existing_count + 10);
1179 staged_row.recorded_at_micros = i64::try_from(existing_count / 2).unwrap();
1180 let staged = BeliefProjectionAttachmentLedger::new(vec![staged_row]).unwrap();
1181 let baseline = merge_with_full_revalidation(&existing, &staged).unwrap();
1182 let incremental = existing.merge(&staged).unwrap();
1183 assert_eq!(baseline, incremental);
1184 let repetitions = 9;
1185 let mut before = Vec::with_capacity(repetitions);
1186 let mut after = Vec::with_capacity(repetitions);
1187 for _ in 0..repetitions {
1188 let start = Instant::now();
1189 std::hint::black_box(merge_with_full_revalidation(&existing, &staged).unwrap());
1190 before.push(start.elapsed());
1191 let start = Instant::now();
1192 std::hint::black_box(existing.merge(&staged).unwrap());
1193 after.push(start.elapsed());
1194 }
1195 let before_median = median(before);
1196 let after_median = median(after);
1197 let divisor = existing_count as f64;
1198 println!(
1199 "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}",
1200 input_digest(&existing, &staged),
1201 before_median.as_nanos(),
1202 before_median.as_nanos() as f64 / divisor,
1203 after_median.as_nanos(),
1204 after_median.as_nanos() as f64 / divisor,
1205 );
1206 }
1207 }
1208}