1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{FixedSizeBinaryBuilder, StringArray, TimestampMicrosecondArray, UInt32Array};
7use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
8use arrow::record_batch::RecordBatch;
9use graphforge_core::canonical::{
10 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint, uuid_v8,
11};
12use uuid::Uuid;
13
14use crate::{
15 KNOWLEDGE_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
16 check_limit, fixed_column, invalid, require_schema, require_uuid, required_i64, required_text,
17 required_u32, string_column, timestamp_column, u32_column, uuid_at, uuid_field,
18};
19
20pub const ARTIFACT_DERIVATION_CONTRACT_VERSION: u32 = 1;
22pub const DERIVATION_SUBJECT_KIND_REGISTRY_VERSION: u32 = 1;
24pub const DERIVATION_ROLE_REGISTRY_VERSION: u32 = 1;
26
27pub static ARTIFACT_DERIVATION_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
29 Arc::new(Schema::new(vec![
30 uuid_field("derivation_uuid", false),
31 uuid_field("output_uuid", false),
32 Field::new("output_kind", DataType::Utf8, false),
33 uuid_field("input_uuid", false),
34 Field::new("input_kind", DataType::Utf8, false),
35 Field::new("derivation_role", DataType::Utf8, false),
36 Field::new("ordinal", DataType::UInt32, false),
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 ARTIFACT_DERIVATION_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
48 fingerprint(
49 CanonicalDomain::Schema,
50 CANONICAL_CONTRACT_VERSION,
51 b"artifact_derivation/1|derivation_uuid:fixed[16]:required|output_uuid:fixed[16]:required|output_kind:utf8:required|input_uuid:fixed[16]:required|input_kind:utf8:required|derivation_role:utf8:required|ordinal:u32:required|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
52 )
53 .expect("registered artifact derivation schema is within canonical bounds")
54});
55
56#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum DerivationSubjectKind {
60 Source,
62 Artifact,
64 Node,
66 Edge,
68 Assertion,
70 EvidenceLink,
72 AlgorithmRun,
74}
75
76impl DerivationSubjectKind {
77 #[must_use]
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::Source => "source",
82 Self::Artifact => "artifact",
83 Self::Node => "node",
84 Self::Edge => "edge",
85 Self::Assertion => "assertion",
86 Self::EvidenceLink => "evidence_link",
87 Self::AlgorithmRun => "algorithm_run",
88 }
89 }
90
91 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
92 match value {
93 "source" => Ok(Self::Source),
94 "artifact" => Ok(Self::Artifact),
95 "node" => Ok(Self::Node),
96 "edge" => Ok(Self::Edge),
97 "assertion" => Ok(Self::Assertion),
98 "evidence_link" => Ok(Self::EvidenceLink),
99 "algorithm_run" => Ok(Self::AlgorithmRun),
100 _ => Err(invalid("derivation_subject_kind", "unknown closed value")),
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum DerivationRole {
109 Input,
111}
112
113impl DerivationRole {
114 #[must_use]
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::Input => "input",
119 }
120 }
121
122 pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
123 match value {
124 "input" => Ok(Self::Input),
125 _ => Err(invalid("derivation_role", "unknown closed value")),
126 }
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ArtifactDerivation {
133 pub derivation_uuid: Uuid,
135 pub output_uuid: Uuid,
137 pub output_kind: DerivationSubjectKind,
139 pub input_uuid: Uuid,
141 pub input_kind: DerivationSubjectKind,
143 pub derivation_role: DerivationRole,
145 pub ordinal: u32,
147 pub provenance_uuid: Uuid,
149 pub recorded_at_micros: i64,
151 pub contract_version: u32,
153}
154
155impl ArtifactDerivation {
156 #[allow(clippy::too_many_arguments)]
158 pub fn new(
159 output_uuid: Uuid,
160 output_kind: DerivationSubjectKind,
161 input_uuid: Uuid,
162 input_kind: DerivationSubjectKind,
163 derivation_role: DerivationRole,
164 ordinal: u32,
165 provenance_uuid: Uuid,
166 recorded_at_micros: i64,
167 ) -> Result<Self, KnowledgeError> {
168 let derivation_uuid = derivation_identity_uuid(
169 output_uuid,
170 output_kind,
171 input_uuid,
172 input_kind,
173 derivation_role,
174 ordinal,
175 )?;
176 let row = Self {
177 derivation_uuid,
178 output_uuid,
179 output_kind,
180 input_uuid,
181 input_kind,
182 derivation_role,
183 ordinal,
184 provenance_uuid,
185 recorded_at_micros,
186 contract_version: ARTIFACT_DERIVATION_CONTRACT_VERSION,
187 };
188 validate_derivation(&row)?;
189 Ok(row)
190 }
191}
192
193#[derive(Clone, Debug, Default, Eq, PartialEq)]
195pub struct ArtifactDerivationLedger {
196 pub derivations: Vec<ArtifactDerivation>,
198}
199
200impl ArtifactDerivationLedger {
201 pub fn new(mut derivations: Vec<ArtifactDerivation>) -> Result<Self, KnowledgeError> {
203 derivations.sort_by_key(|row| (row.recorded_at_micros, row.derivation_uuid));
204 validate_derivations(&derivations)?;
205 Ok(Self { derivations })
206 }
207
208 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
210 let mut merged = self.derivations.clone();
211 for row in &staged.derivations {
212 if let Some(existing) = merged
213 .iter()
214 .find(|candidate| candidate.derivation_uuid == row.derivation_uuid)
215 {
216 if existing != row {
217 return Err(KnowledgeError::Conflict("derivation_uuid"));
218 }
219 continue;
220 }
221 merged.push(row.clone());
222 }
223 Self::new(merged)
224 }
225
226 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
228 derivation_batch(&self.derivations)
229 }
230
231 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
233 let mut derivations = Vec::new();
234 for batch in batches {
235 require_schema(
236 batch,
237 &ARTIFACT_DERIVATION_SCHEMA,
238 "artifact_derivations.schema",
239 )?;
240 let ids = fixed_column(batch, "derivation_uuid")?;
241 let outputs = fixed_column(batch, "output_uuid")?;
242 let output_kinds = string_column(batch, "output_kind")?;
243 let inputs = fixed_column(batch, "input_uuid")?;
244 let input_kinds = string_column(batch, "input_kind")?;
245 let roles = string_column(batch, "derivation_role")?;
246 let ordinals = u32_column(batch, "ordinal")?;
247 let provenance = fixed_column(batch, "provenance_uuid")?;
248 let recorded = timestamp_column(batch, "recorded_at")?;
249 let versions = u32_column(batch, "contract_version")?;
250 for row in 0..batch.num_rows() {
251 derivations.push(ArtifactDerivation {
252 derivation_uuid: uuid_at(ids, row, "derivation_uuid")?,
253 output_uuid: uuid_at(outputs, row, "output_uuid")?,
254 output_kind: DerivationSubjectKind::parse(required_text(
255 output_kinds,
256 row,
257 "output_kind",
258 )?)?,
259 input_uuid: uuid_at(inputs, row, "input_uuid")?,
260 input_kind: DerivationSubjectKind::parse(required_text(
261 input_kinds,
262 row,
263 "input_kind",
264 )?)?,
265 derivation_role: DerivationRole::parse(required_text(
266 roles,
267 row,
268 "derivation_role",
269 )?)?,
270 ordinal: required_u32(ordinals, row, "ordinal")?,
271 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
272 recorded_at_micros: required_i64(recorded, row, "recorded_at")?,
273 contract_version: required_u32(versions, row, "contract_version")?,
274 });
275 }
276 }
277 Self::new(derivations)
278 }
279}
280
281pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
282 SchemaRegistryEntry {
283 capability_id: "knowledge",
284 capability_version: KNOWLEDGE_CAPABILITY_VERSION,
285 record_family: "artifact_derivations",
286 record_version: ARTIFACT_DERIVATION_CONTRACT_VERSION,
287 schema: Arc::clone(&ARTIFACT_DERIVATION_SCHEMA),
288 schema_fingerprint: *ARTIFACT_DERIVATION_SCHEMA_FINGERPRINT,
289 enum_registry_versions: &[
290 ("output_kind", DERIVATION_SUBJECT_KIND_REGISTRY_VERSION),
291 ("input_kind", DERIVATION_SUBJECT_KIND_REGISTRY_VERSION),
292 ("derivation_role", DERIVATION_ROLE_REGISTRY_VERSION),
293 ],
294 sort_key: &["recorded_at", "derivation_uuid"],
295 diff_identity_fields: &["derivation_uuid"],
296 diff_record_uuid_field: Some("derivation_uuid"),
297 fingerprint_domain: CanonicalDomain::ArtifactDerivation,
298 owner: "graphforge-knowledge",
299 implementation_issue: 1349,
300 max_rows: MAX_KNOWLEDGE_ROWS,
301 }
302}
303
304fn derivation_identity_uuid(
305 output_uuid: Uuid,
306 output_kind: DerivationSubjectKind,
307 input_uuid: Uuid,
308 input_kind: DerivationSubjectKind,
309 derivation_role: DerivationRole,
310 ordinal: u32,
311) -> Result<Uuid, KnowledgeError> {
312 let mut writer = CanonicalWriter::new();
313 writer.raw(output_uuid.as_bytes())?;
314 writer.text(output_kind.as_str())?;
315 writer.raw(input_uuid.as_bytes())?;
316 writer.text(input_kind.as_str())?;
317 writer.text(derivation_role.as_str())?;
318 writer.u32(ordinal)?;
319 let digest = fingerprint(
320 CanonicalDomain::ArtifactDerivation,
321 ARTIFACT_DERIVATION_CONTRACT_VERSION,
322 &writer.finish(),
323 )?;
324 Ok(uuid_v8(digest))
325}
326
327fn validate_derivation(row: &ArtifactDerivation) -> Result<(), KnowledgeError> {
328 if row.contract_version != ARTIFACT_DERIVATION_CONTRACT_VERSION {
329 return Err(invalid(
330 "derivation.contract_version",
331 "unsupported version",
332 ));
333 }
334 require_uuid(row.derivation_uuid, "derivation_uuid")?;
335 require_uuid(row.output_uuid, "output_uuid")?;
336 require_uuid(row.input_uuid, "input_uuid")?;
337 require_uuid(row.provenance_uuid, "provenance_uuid")?;
338 if row.output_uuid == row.input_uuid && row.output_kind == row.input_kind {
339 return Err(invalid("derivation", "self-loop is forbidden"));
340 }
341 let expected = derivation_identity_uuid(
342 row.output_uuid,
343 row.output_kind,
344 row.input_uuid,
345 row.input_kind,
346 row.derivation_role,
347 row.ordinal,
348 )?;
349 if row.derivation_uuid != expected {
350 return Err(invalid("derivation_uuid", "identity mismatch"));
351 }
352 Ok(())
353}
354
355fn validate_derivations(derivations: &[ArtifactDerivation]) -> Result<(), KnowledgeError> {
356 check_limit("artifact_derivations", derivations.len())?;
357 let mut ids = HashSet::with_capacity(derivations.len());
358 let mut ordinals: HashMap<(Uuid, DerivationSubjectKind), HashSet<u32>> = HashMap::new();
359 for row in derivations {
360 validate_derivation(row)?;
361 if !ids.insert(row.derivation_uuid) {
362 return Err(KnowledgeError::Duplicate("derivation_uuid"));
363 }
364 ordinals
365 .entry((row.output_uuid, row.output_kind))
366 .or_default()
367 .insert(row.ordinal);
368 }
369 for ((output_uuid, output_kind), seen) in &ordinals {
370 if seen.len()
371 != derivations
372 .iter()
373 .filter(|row| row.output_uuid == *output_uuid && row.output_kind == *output_kind)
374 .count()
375 {
376 return Err(KnowledgeError::Duplicate("ordinal"));
377 }
378 }
379 detect_cycles(derivations)?;
380 Ok(())
381}
382
383fn detect_cycles(derivations: &[ArtifactDerivation]) -> Result<(), KnowledgeError> {
384 let mut adjacency: HashMap<(Uuid, DerivationSubjectKind), Vec<(Uuid, DerivationSubjectKind)>> =
385 HashMap::new();
386 for row in derivations {
387 adjacency
388 .entry((row.input_uuid, row.input_kind))
389 .or_default()
390 .push((row.output_uuid, row.output_kind));
391 }
392 let mut visiting = HashSet::new();
393 let mut visited = HashSet::new();
394 for start in adjacency.keys().copied().collect::<Vec<_>>() {
395 if visited.contains(&start) {
396 continue;
397 }
398 visiting.clear();
399 if dfs_cycle(start, &adjacency, &mut visiting, &mut visited) {
400 return Err(invalid("derivation", "cycle detected"));
401 }
402 }
403 Ok(())
404}
405
406fn dfs_cycle(
407 node: (Uuid, DerivationSubjectKind),
408 adjacency: &HashMap<(Uuid, DerivationSubjectKind), Vec<(Uuid, DerivationSubjectKind)>>,
409 visiting: &mut HashSet<(Uuid, DerivationSubjectKind)>,
410 visited: &mut HashSet<(Uuid, DerivationSubjectKind)>,
411) -> bool {
412 if visiting.contains(&node) {
413 return true;
414 }
415 if visited.contains(&node) {
416 return false;
417 }
418 visiting.insert(node);
419 if let Some(neighbors) = adjacency.get(&node) {
420 for neighbor in neighbors {
421 if dfs_cycle(*neighbor, adjacency, visiting, visited) {
422 return true;
423 }
424 }
425 }
426 visiting.remove(&node);
427 visited.insert(node);
428 false
429}
430
431fn derivation_batch(rows: &[ArtifactDerivation]) -> Result<RecordBatch, KnowledgeError> {
432 let mut ids = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
433 let mut outputs = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
434 let mut inputs = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
435 let mut provenance = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
436 for row in rows {
437 ids.append_value(row.derivation_uuid.as_bytes())?;
438 outputs.append_value(row.output_uuid.as_bytes())?;
439 inputs.append_value(row.input_uuid.as_bytes())?;
440 provenance.append_value(row.provenance_uuid.as_bytes())?;
441 }
442 RecordBatch::try_new(
443 Arc::clone(&ARTIFACT_DERIVATION_SCHEMA),
444 vec![
445 Arc::new(ids.finish()),
446 Arc::new(outputs.finish()),
447 Arc::new(StringArray::from_iter_values(
448 rows.iter().map(|row| row.output_kind.as_str()),
449 )),
450 Arc::new(inputs.finish()),
451 Arc::new(StringArray::from_iter_values(
452 rows.iter().map(|row| row.input_kind.as_str()),
453 )),
454 Arc::new(StringArray::from_iter_values(
455 rows.iter().map(|row| row.derivation_role.as_str()),
456 )),
457 Arc::new(UInt32Array::from_iter_values(
458 rows.iter().map(|row| row.ordinal),
459 )),
460 Arc::new(provenance.finish()),
461 Arc::new(
462 TimestampMicrosecondArray::from_iter_values(
463 rows.iter().map(|row| row.recorded_at_micros),
464 )
465 .with_timezone("UTC"),
466 ),
467 Arc::new(UInt32Array::from_iter_values(
468 rows.iter().map(|row| row.contract_version),
469 )),
470 ],
471 )
472 .map_err(Into::into)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::tests::uuid7;
479
480 #[test]
481 fn derivation_round_trip_and_cycle_rejection() {
482 let scan = ArtifactDerivation::new(
483 uuid7(10),
484 DerivationSubjectKind::Artifact,
485 uuid7(1),
486 DerivationSubjectKind::Artifact,
487 DerivationRole::Input,
488 0,
489 uuid7(20),
490 100,
491 )
492 .unwrap();
493 let ocr = ArtifactDerivation::new(
494 uuid7(11),
495 DerivationSubjectKind::Artifact,
496 uuid7(10),
497 DerivationSubjectKind::Artifact,
498 DerivationRole::Input,
499 0,
500 uuid7(21),
501 200,
502 )
503 .unwrap();
504 let ledger = ArtifactDerivationLedger::new(vec![scan.clone(), ocr.clone()]).unwrap();
505 let reopened = ArtifactDerivationLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
506 assert_eq!(reopened, ledger);
507 let cycle = ArtifactDerivation::new(
508 uuid7(1),
509 DerivationSubjectKind::Artifact,
510 uuid7(11),
511 DerivationSubjectKind::Artifact,
512 DerivationRole::Input,
513 0,
514 uuid7(22),
515 300,
516 )
517 .unwrap();
518 assert!(matches!(
519 ArtifactDerivationLedger::new(vec![scan, ocr, cycle]),
520 Err(KnowledgeError::Invalid { .. })
521 ));
522 }
523
524 #[test]
525 fn derivation_registries_round_trip_and_reject_unknown_tokens() {
526 for kind in [
527 DerivationSubjectKind::Source,
528 DerivationSubjectKind::Artifact,
529 DerivationSubjectKind::Node,
530 DerivationSubjectKind::Edge,
531 DerivationSubjectKind::Assertion,
532 DerivationSubjectKind::EvidenceLink,
533 DerivationSubjectKind::AlgorithmRun,
534 ] {
535 assert_eq!(DerivationSubjectKind::parse(kind.as_str()).unwrap(), kind);
536 }
537 assert_eq!(
538 DerivationRole::parse(DerivationRole::Input.as_str()).unwrap(),
539 DerivationRole::Input
540 );
541 assert!(DerivationSubjectKind::parse("passage").is_err());
542 assert!(DerivationRole::parse("output").is_err());
543 }
544}