Skip to main content

graphforge_knowledge/
artifact.rs

1//! Immutable research Artifact identities and Arrow encoding (#1349).
2
3use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{
7    Array, FixedSizeBinaryBuilder, StringArray, TimestampMicrosecondArray, UInt32Array, UInt64Array,
8};
9use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
10use arrow::record_batch::RecordBatch;
11use graphforge_core::canonical::{
12    CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
13};
14use uuid::Uuid;
15
16use crate::{
17    KNOWLEDGE_CAPABILITY_VERSION, KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry,
18    check_limit, fixed_column, invalid, optional_fixed_32, optional_text, require_schema,
19    require_uuid, require_v7, required_i64, required_text, required_u32, string_column,
20    timestamp_column, u32_column, uuid_at, uuid_field,
21};
22
23/// Immutable research Artifact record contract.
24pub const ARTIFACT_CONTRACT_VERSION: u32 = 1;
25/// Closed artifact-kind registry version.
26pub const ARTIFACT_KIND_REGISTRY_VERSION: u32 = 1;
27/// Closed payload-kind registry version.
28pub const ARTIFACT_PAYLOAD_KIND_REGISTRY_VERSION: u32 = 1;
29/// Closed availability registry version.
30pub const ARTIFACT_AVAILABILITY_REGISTRY_VERSION: u32 = 1;
31/// Maximum UTF-8 bytes for one bounded media type.
32pub const MAX_ARTIFACT_MEDIA_TYPE_BYTES: usize = 256;
33/// Maximum UTF-8 bytes for one bounded external URI reference.
34pub const MAX_ARTIFACT_EXTERNAL_URI_BYTES: usize = 4_096;
35
36/// Authoritative `knowledge/artifacts.parquet` schema.
37pub static ARTIFACT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
38    Arc::new(Schema::new(vec![
39        uuid_field("artifact_uuid", false),
40        uuid_field("source_uuid", false),
41        Field::new("artifact_kind", DataType::Utf8, false),
42        Field::new("media_type", DataType::Utf8, false),
43        Field::new("payload_kind", DataType::Utf8, false),
44        Field::new("content_sha256", DataType::FixedSizeBinary(32), true),
45        Field::new("content_length", DataType::UInt64, true),
46        Field::new("external_uri", DataType::Utf8, true),
47        Field::new("external_fingerprint", DataType::FixedSizeBinary(32), true),
48        Field::new("availability", DataType::Utf8, false),
49        uuid_field("run_uuid", true),
50        uuid_field("provenance_uuid", false),
51        Field::new(
52            "recorded_at",
53            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
54            false,
55        ),
56        Field::new("contract_version", DataType::UInt32, false),
57    ]))
58});
59
60static ARTIFACT_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
61    fingerprint(
62        CanonicalDomain::Schema,
63        CANONICAL_CONTRACT_VERSION,
64        b"artifact/1|artifact_uuid:fixed[16]:required|source_uuid:fixed[16]:required|artifact_kind:utf8:required|media_type:utf8:required|payload_kind:utf8:required|content_sha256:fixed[32]:nullable|content_length:u64:nullable|external_uri:utf8:nullable|external_fingerprint:fixed[32]:nullable|availability:utf8:required|run_uuid:fixed[16]:nullable|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
65    )
66    .expect("registered artifact schema is within canonical bounds")
67});
68
69/// Closed representation kind for one Artifact.
70#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ArtifactKind {
73    /// Original raw scan capture.
74    RawScan,
75    /// Processed scan derivative.
76    ProcessedScan,
77    /// OCR text output.
78    OcrText,
79    /// Normalized text representation.
80    NormalizedText,
81    /// Passage or excerpt extract.
82    PassageExtract,
83    /// Other closed kind not listed above.
84    Other,
85}
86
87impl ArtifactKind {
88    /// Canonical persisted spelling.
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::RawScan => "raw_scan",
93            Self::ProcessedScan => "processed_scan",
94            Self::OcrText => "ocr_text",
95            Self::NormalizedText => "normalized_text",
96            Self::PassageExtract => "passage_extract",
97            Self::Other => "other",
98        }
99    }
100
101    pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
102        match value {
103            "raw_scan" => Ok(Self::RawScan),
104            "processed_scan" => Ok(Self::ProcessedScan),
105            "ocr_text" => Ok(Self::OcrText),
106            "normalized_text" => Ok(Self::NormalizedText),
107            "passage_extract" => Ok(Self::PassageExtract),
108            "other" => Ok(Self::Other),
109            _ => Err(invalid("artifact_kind", "unknown closed value")),
110        }
111    }
112}
113
114/// Closed payload reference kind.
115#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ArtifactPayloadKind {
118    /// Locally retained bytes addressed by SHA-256.
119    LocalSha256,
120    /// External-only historical reference.
121    ExternalReference,
122    /// Explicitly absent bytes.
123    Absent,
124}
125
126impl ArtifactPayloadKind {
127    /// Canonical persisted spelling.
128    #[must_use]
129    pub const fn as_str(self) -> &'static str {
130        match self {
131            Self::LocalSha256 => "local_sha256",
132            Self::ExternalReference => "external_reference",
133            Self::Absent => "absent",
134        }
135    }
136
137    pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
138        match value {
139            "local_sha256" => Ok(Self::LocalSha256),
140            "external_reference" => Ok(Self::ExternalReference),
141            "absent" => Ok(Self::Absent),
142            _ => Err(invalid("payload_kind", "unknown closed value")),
143        }
144    }
145}
146
147/// Closed byte availability disclosure.
148#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ArtifactAvailability {
151    /// Local bytes verified at publication time.
152    LocalVerified,
153    /// Only an external reference is retained.
154    ExternalOnly,
155    /// Local bytes are missing.
156    MissingLocal,
157    /// Integrity could not be verified.
158    Unverifiable,
159}
160
161impl ArtifactAvailability {
162    /// Canonical persisted spelling.
163    #[must_use]
164    pub const fn as_str(self) -> &'static str {
165        match self {
166            Self::LocalVerified => "local_verified",
167            Self::ExternalOnly => "external_only",
168            Self::MissingLocal => "missing_local",
169            Self::Unverifiable => "unverifiable",
170        }
171    }
172
173    pub(super) fn parse(value: &str) -> Result<Self, KnowledgeError> {
174        match value {
175            "local_verified" => Ok(Self::LocalVerified),
176            "external_only" => Ok(Self::ExternalOnly),
177            "missing_local" => Ok(Self::MissingLocal),
178            "unverifiable" => Ok(Self::Unverifiable),
179            _ => Err(invalid("availability", "unknown closed value")),
180        }
181    }
182}
183
184/// One immutable research Artifact identity.
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct Artifact {
187    /// Caller-supplied UUIDv7 identity and idempotency key.
188    pub artifact_uuid: Uuid,
189    /// Parent Source identity.
190    pub source_uuid: Uuid,
191    /// Closed artifact kind.
192    pub artifact_kind: ArtifactKind,
193    /// MIME-like media type label.
194    pub media_type: String,
195    /// Closed payload reference kind.
196    pub payload_kind: ArtifactPayloadKind,
197    /// Content digest when locally retained.
198    pub content_sha256: Option<[u8; 32]>,
199    /// Content length when locally retained.
200    pub content_length: Option<u64>,
201    /// Historical external URI; never fetched by Core.
202    pub external_uri: Option<String>,
203    /// Known integrity fingerprint for external references.
204    pub external_fingerprint: Option<[u8; 32]>,
205    /// Closed availability disclosure.
206    pub availability: ArtifactAvailability,
207    /// Optional algorithm-run identity for extraction/OCR.
208    pub run_uuid: Option<Uuid>,
209    /// Provenance event that published the Artifact.
210    pub provenance_uuid: Uuid,
211    /// Durable transaction time.
212    pub recorded_at_micros: i64,
213    /// Frozen record contract.
214    pub contract_version: u32,
215}
216
217impl Artifact {
218    /// Construct one validated immutable Artifact.
219    #[allow(clippy::too_many_arguments)]
220    pub fn new(
221        artifact_uuid: Uuid,
222        source_uuid: Uuid,
223        artifact_kind: ArtifactKind,
224        media_type: String,
225        payload_kind: ArtifactPayloadKind,
226        content_sha256: Option<[u8; 32]>,
227        content_length: Option<u64>,
228        external_uri: Option<String>,
229        external_fingerprint: Option<[u8; 32]>,
230        availability: ArtifactAvailability,
231        run_uuid: Option<Uuid>,
232        provenance_uuid: Uuid,
233        recorded_at_micros: i64,
234    ) -> Result<Self, KnowledgeError> {
235        let row = Self {
236            artifact_uuid,
237            source_uuid,
238            artifact_kind,
239            media_type,
240            payload_kind,
241            content_sha256,
242            content_length,
243            external_uri,
244            external_fingerprint,
245            availability,
246            run_uuid,
247            provenance_uuid,
248            recorded_at_micros,
249            contract_version: ARTIFACT_CONTRACT_VERSION,
250        };
251        validate_artifact(&row)?;
252        Ok(row)
253    }
254}
255
256/// Validated immutable Artifact table.
257#[derive(Clone, Debug, Default, Eq, PartialEq)]
258pub struct ArtifactLedger {
259    /// Artifacts ordered by `(recorded_at, artifact_uuid)`.
260    pub artifacts: Vec<Artifact>,
261}
262
263impl ArtifactLedger {
264    /// Validate, sort, and construct an Artifact table.
265    pub fn new(mut artifacts: Vec<Artifact>) -> Result<Self, KnowledgeError> {
266        artifacts.sort_by_key(|row| (row.recorded_at_micros, row.artifact_uuid));
267        validate_artifacts(&artifacts)?;
268        Ok(Self { artifacts })
269    }
270
271    /// Merge staged rows into an existing ledger.
272    pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
273        let mut merged = self.artifacts.clone();
274        for row in &staged.artifacts {
275            if let Some(existing) = merged
276                .iter()
277                .find(|candidate| candidate.artifact_uuid == row.artifact_uuid)
278            {
279                if existing != row {
280                    return Err(KnowledgeError::Conflict("artifact_uuid"));
281                }
282                continue;
283            }
284            merged.push(row.clone());
285        }
286        Self::new(merged)
287    }
288
289    /// Canonical semantic fingerprint over immutable Artifact content, excluding clock fields.
290    ///
291    /// Excludes `provenance_uuid` and `recorded_at_micros` so that a retry with the same
292    /// semantic content but a different clock value compares equal to the committed row.
293    pub fn artifact_fingerprint(&self, artifact_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
294        let row = self
295            .artifacts
296            .iter()
297            .find(|row| row.artifact_uuid == artifact_uuid)
298            .ok_or(KnowledgeError::Dangling("artifact_uuid"))?;
299        let mut writer = CanonicalWriter::new();
300        writer.raw(b"GFAR")?;
301        writer.u32(ARTIFACT_CONTRACT_VERSION)?;
302        writer.raw(row.artifact_uuid.as_bytes())?;
303        writer.raw(row.source_uuid.as_bytes())?;
304        writer.text(row.artifact_kind.as_str())?;
305        writer.text(&row.media_type)?;
306        writer.text(row.payload_kind.as_str())?;
307        match row.content_sha256 {
308            None => writer.u8(0)?,
309            Some(hash) => {
310                writer.u8(1)?;
311                writer.raw(&hash)?;
312            }
313        }
314        match row.content_length {
315            None => writer.u8(0)?,
316            Some(len) => {
317                writer.u8(1)?;
318                writer.u64(len)?;
319            }
320        }
321        match &row.external_uri {
322            None => writer.u8(0)?,
323            Some(uri) => {
324                writer.u8(1)?;
325                writer.text(uri)?;
326            }
327        }
328        match row.external_fingerprint {
329            None => writer.u8(0)?,
330            Some(fp) => {
331                writer.u8(1)?;
332                writer.raw(&fp)?;
333            }
334        }
335        writer.text(row.availability.as_str())?;
336        match row.run_uuid {
337            None => writer.u8(0)?,
338            Some(uuid) => {
339                writer.u8(1)?;
340                writer.raw(uuid.as_bytes())?;
341            }
342        }
343        Ok(fingerprint(
344            CanonicalDomain::ResearchArtifact,
345            CANONICAL_CONTRACT_VERSION,
346            &writer.finish(),
347        )?)
348    }
349
350    /// Encode the authoritative Arrow batch.
351    pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
352        artifact_batch(&self.artifacts)
353    }
354
355    /// Decode one or more Arrow batches.
356    pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
357        let mut artifacts = Vec::new();
358        for batch in batches {
359            require_schema(batch, &ARTIFACT_SCHEMA, "artifacts.schema")?;
360            let ids = fixed_column(batch, "artifact_uuid")?;
361            let sources = fixed_column(batch, "source_uuid")?;
362            let kinds = string_column(batch, "artifact_kind")?;
363            let media_types = string_column(batch, "media_type")?;
364            let payload_kinds = string_column(batch, "payload_kind")?;
365            let content_hashes = fixed_column(batch, "content_sha256")?;
366            let content_lengths = batch
367                .column_by_name("content_length")
368                .ok_or_else(|| invalid("content_length", "missing column"))?
369                .as_any()
370                .downcast_ref::<UInt64Array>()
371                .ok_or_else(|| invalid("content_length", "invalid column type"))?;
372            let external_uris = string_column(batch, "external_uri")?;
373            let external_fingerprints = fixed_column(batch, "external_fingerprint")?;
374            let availability = string_column(batch, "availability")?;
375            let runs = fixed_column(batch, "run_uuid")?;
376            let provenance = fixed_column(batch, "provenance_uuid")?;
377            let recorded = timestamp_column(batch, "recorded_at")?;
378            let versions = u32_column(batch, "contract_version")?;
379            for row in 0..batch.num_rows() {
380                artifacts.push(Artifact {
381                    artifact_uuid: uuid_at(ids, row, "artifact_uuid")?,
382                    source_uuid: uuid_at(sources, row, "source_uuid")?,
383                    artifact_kind: ArtifactKind::parse(required_text(
384                        kinds,
385                        row,
386                        "artifact_kind",
387                    )?)?,
388                    media_type: required_text(media_types, row, "media_type")?.to_string(),
389                    payload_kind: ArtifactPayloadKind::parse(required_text(
390                        payload_kinds,
391                        row,
392                        "payload_kind",
393                    )?)?,
394                    content_sha256: optional_fixed_32(content_hashes, row, "content_sha256")?,
395                    content_length: (!content_lengths.is_null(row))
396                        .then(|| content_lengths.value(row)),
397                    external_uri: optional_text(external_uris, row),
398                    external_fingerprint: optional_fixed_32(
399                        external_fingerprints,
400                        row,
401                        "external_fingerprint",
402                    )?,
403                    availability: ArtifactAvailability::parse(required_text(
404                        availability,
405                        row,
406                        "availability",
407                    )?)?,
408                    run_uuid: optional_uuid_at(runs, row, "run_uuid")?,
409                    provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
410                    recorded_at_micros: required_i64(recorded, row, "recorded_at")?,
411                    contract_version: required_u32(versions, row, "contract_version")?,
412                });
413            }
414        }
415        Self::new(artifacts)
416    }
417
418    /// Index artifacts by Source for bounded lookup.
419    #[must_use]
420    pub fn by_source(&self) -> HashMap<Uuid, Vec<&Artifact>> {
421        let mut grouped = HashMap::new();
422        for artifact in &self.artifacts {
423            grouped
424                .entry(artifact.source_uuid)
425                .or_insert_with(Vec::new)
426                .push(artifact);
427        }
428        grouped
429    }
430}
431
432pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
433    SchemaRegistryEntry {
434        capability_id: "knowledge",
435        capability_version: KNOWLEDGE_CAPABILITY_VERSION,
436        record_family: "artifacts",
437        record_version: ARTIFACT_CONTRACT_VERSION,
438        schema: Arc::clone(&ARTIFACT_SCHEMA),
439        schema_fingerprint: *ARTIFACT_SCHEMA_FINGERPRINT,
440        enum_registry_versions: &[
441            ("artifact_kind", ARTIFACT_KIND_REGISTRY_VERSION),
442            ("payload_kind", ARTIFACT_PAYLOAD_KIND_REGISTRY_VERSION),
443            ("availability", ARTIFACT_AVAILABILITY_REGISTRY_VERSION),
444        ],
445        sort_key: &["recorded_at", "artifact_uuid"],
446        diff_identity_fields: &["artifact_uuid"],
447        diff_record_uuid_field: Some("artifact_uuid"),
448        fingerprint_domain: CanonicalDomain::ResearchArtifact,
449        owner: "graphforge-knowledge",
450        implementation_issue: 1349,
451        max_rows: MAX_KNOWLEDGE_ROWS,
452    }
453}
454
455fn optional_uuid_at(
456    array: &arrow::array::FixedSizeBinaryArray,
457    row: usize,
458    field: &'static str,
459) -> Result<Option<Uuid>, KnowledgeError> {
460    if array.is_null(row) {
461        Ok(None)
462    } else {
463        Ok(Some(uuid_at(array, row, field)?))
464    }
465}
466
467fn validate_artifact(row: &Artifact) -> Result<(), KnowledgeError> {
468    if row.contract_version != ARTIFACT_CONTRACT_VERSION {
469        return Err(invalid("artifact.contract_version", "unsupported version"));
470    }
471    require_v7(row.artifact_uuid, "artifact_uuid")?;
472    require_v7(row.source_uuid, "source_uuid")?;
473    require_uuid(row.provenance_uuid, "provenance_uuid")?;
474    if let Some(run_uuid) = row.run_uuid {
475        require_v7(run_uuid, "run_uuid")?;
476    }
477    validate_bounded_text(
478        "artifact.media_type",
479        &row.media_type,
480        MAX_ARTIFACT_MEDIA_TYPE_BYTES,
481    )?;
482    if let Some(uri) = &row.external_uri {
483        validate_bounded_text(
484            "artifact.external_uri",
485            uri,
486            MAX_ARTIFACT_EXTERNAL_URI_BYTES,
487        )?;
488    }
489    match row.payload_kind {
490        ArtifactPayloadKind::LocalSha256 => {
491            if row.content_sha256.is_none() || row.content_length.is_none() {
492                return Err(invalid(
493                    "payload_kind",
494                    "local_sha256 requires content_sha256 and content_length",
495                ));
496            }
497            if row.external_uri.is_some() {
498                return Err(invalid(
499                    "external_uri",
500                    "local_sha256 artifacts cannot retain external_uri",
501                ));
502            }
503        }
504        ArtifactPayloadKind::ExternalReference => {
505            if row.external_uri.is_none() {
506                return Err(invalid(
507                    "external_uri",
508                    "external_reference requires external_uri",
509                ));
510            }
511            if row.content_sha256.is_some() || row.content_length.is_some() {
512                return Err(invalid(
513                    "content_sha256",
514                    "external_reference cannot retain local bytes",
515                ));
516            }
517        }
518        ArtifactPayloadKind::Absent => {
519            if row.content_sha256.is_some()
520                || row.content_length.is_some()
521                || row.external_uri.is_some()
522                || row.external_fingerprint.is_some()
523            {
524                return Err(invalid(
525                    "payload_kind",
526                    "absent cannot retain payload fields",
527                ));
528            }
529        }
530    }
531    Ok(())
532}
533
534fn validate_artifacts(artifacts: &[Artifact]) -> Result<(), KnowledgeError> {
535    check_limit("artifacts", artifacts.len())?;
536    let mut ids = HashSet::with_capacity(artifacts.len());
537    for row in artifacts {
538        validate_artifact(row)?;
539        if !ids.insert(row.artifact_uuid) {
540            return Err(KnowledgeError::Duplicate("artifact_uuid"));
541        }
542    }
543    Ok(())
544}
545
546fn validate_bounded_text(
547    field: &'static str,
548    value: &str,
549    limit: usize,
550) -> Result<(), KnowledgeError> {
551    if value.is_empty() {
552        return Err(invalid(field, "value must not be empty"));
553    }
554    if value.len() > limit {
555        return Err(KnowledgeError::Limit {
556            participant: field,
557            observed: value.len(),
558            limit,
559        });
560    }
561    Ok(())
562}
563
564fn artifact_batch(rows: &[Artifact]) -> Result<RecordBatch, KnowledgeError> {
565    let mut ids = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
566    let mut sources = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
567    let mut content_hashes = FixedSizeBinaryBuilder::with_capacity(rows.len(), 32);
568    let mut external_fingerprints = FixedSizeBinaryBuilder::with_capacity(rows.len(), 32);
569    let mut runs = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
570    let mut provenance = FixedSizeBinaryBuilder::with_capacity(rows.len(), 16);
571    let mut content_lengths = UInt64Array::builder(rows.len());
572    for row in rows {
573        ids.append_value(row.artifact_uuid.as_bytes())?;
574        sources.append_value(row.source_uuid.as_bytes())?;
575        match row.content_sha256 {
576            Some(value) => content_hashes.append_value(value)?,
577            None => content_hashes.append_null(),
578        }
579        match row.content_length {
580            Some(value) => content_lengths.append_value(value),
581            None => content_lengths.append_null(),
582        }
583        match row.external_fingerprint {
584            Some(value) => external_fingerprints.append_value(value)?,
585            None => external_fingerprints.append_null(),
586        }
587        match row.run_uuid {
588            Some(value) => runs.append_value(value.as_bytes())?,
589            None => runs.append_null(),
590        }
591        provenance.append_value(row.provenance_uuid.as_bytes())?;
592    }
593    RecordBatch::try_new(
594        Arc::clone(&ARTIFACT_SCHEMA),
595        vec![
596            Arc::new(ids.finish()),
597            Arc::new(sources.finish()),
598            Arc::new(StringArray::from_iter_values(
599                rows.iter().map(|row| row.artifact_kind.as_str()),
600            )),
601            Arc::new(StringArray::from_iter_values(
602                rows.iter().map(|row| row.media_type.as_str()),
603            )),
604            Arc::new(StringArray::from_iter_values(
605                rows.iter().map(|row| row.payload_kind.as_str()),
606            )),
607            Arc::new(content_hashes.finish()),
608            Arc::new(content_lengths.finish()),
609            Arc::new(StringArray::from(
610                rows.iter()
611                    .map(|row| row.external_uri.as_deref())
612                    .collect::<Vec<_>>(),
613            )),
614            Arc::new(external_fingerprints.finish()),
615            Arc::new(StringArray::from_iter_values(
616                rows.iter().map(|row| row.availability.as_str()),
617            )),
618            Arc::new(runs.finish()),
619            Arc::new(provenance.finish()),
620            Arc::new(
621                TimestampMicrosecondArray::from_iter_values(
622                    rows.iter().map(|row| row.recorded_at_micros),
623                )
624                .with_timezone("UTC"),
625            ),
626            Arc::new(UInt32Array::from_iter_values(
627                rows.iter().map(|row| row.contract_version),
628            )),
629        ],
630    )
631    .map_err(Into::into)
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::tests::uuid7;
638
639    #[test]
640    fn artifact_round_trip_and_payload_invariants() {
641        let local = Artifact::new(
642            uuid7(1),
643            uuid7(2),
644            ArtifactKind::RawScan,
645            "image/tiff".into(),
646            ArtifactPayloadKind::LocalSha256,
647            Some([7; 32]),
648            Some(1024),
649            None,
650            None,
651            ArtifactAvailability::LocalVerified,
652            None,
653            uuid7(3),
654            100,
655        )
656        .unwrap();
657        let external = Artifact::new(
658            uuid7(4),
659            uuid7(2),
660            ArtifactKind::OcrText,
661            "text/plain".into(),
662            ArtifactPayloadKind::ExternalReference,
663            None,
664            None,
665            Some("https://example.org/historical".into()),
666            None,
667            ArtifactAvailability::ExternalOnly,
668            Some(uuid7(5)),
669            uuid7(6),
670            200,
671        )
672        .unwrap();
673        let ledger = ArtifactLedger::new(vec![local.clone(), external]).unwrap();
674        let reopened = ArtifactLedger::from_batches(&[ledger.batch().unwrap()]).unwrap();
675        assert_eq!(reopened, ledger);
676        assert!(matches!(
677            Artifact::new(
678                uuid7(7),
679                uuid7(2),
680                ArtifactKind::Other,
681                "text/plain".into(),
682                ArtifactPayloadKind::LocalSha256,
683                None,
684                Some(1),
685                None,
686                None,
687                ArtifactAvailability::MissingLocal,
688                None,
689                uuid7(8),
690                1,
691            ),
692            Err(KnowledgeError::Invalid { .. })
693        ));
694    }
695
696    #[test]
697    fn artifact_registries_round_trip_and_reject_unknown_tokens() {
698        for kind in [
699            ArtifactKind::RawScan,
700            ArtifactKind::ProcessedScan,
701            ArtifactKind::OcrText,
702            ArtifactKind::NormalizedText,
703            ArtifactKind::PassageExtract,
704            ArtifactKind::Other,
705        ] {
706            assert_eq!(ArtifactKind::parse(kind.as_str()).unwrap(), kind);
707        }
708        for payload in [
709            ArtifactPayloadKind::LocalSha256,
710            ArtifactPayloadKind::ExternalReference,
711            ArtifactPayloadKind::Absent,
712        ] {
713            assert_eq!(
714                ArtifactPayloadKind::parse(payload.as_str()).unwrap(),
715                payload
716            );
717        }
718        for availability in [
719            ArtifactAvailability::LocalVerified,
720            ArtifactAvailability::ExternalOnly,
721            ArtifactAvailability::MissingLocal,
722            ArtifactAvailability::Unverifiable,
723        ] {
724            assert_eq!(
725                ArtifactAvailability::parse(availability.as_str()).unwrap(),
726                availability
727            );
728        }
729        assert!(ArtifactKind::parse("scan").is_err());
730    }
731}