Skip to main content

zeph_memory/graph/ingest/
report.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Result and progress types for `SemanticMemory::ingest_documents` (spec-067 §2.3).
5
6use std::fmt;
7
8/// Opaque identifier for a single ingest run.
9///
10/// Used as a rollback key: all entities and edges written during one `ingest_documents`
11/// call share the same `ImportBatchId`, so the entire batch can be removed atomically
12/// via `zeph knowledge rollback --batch-id <id>`.
13///
14/// The underlying format is a `UUIDv4` string, consistent with the `import_batch_id` column
15/// documented in [`super::ledger::IngestLedger`] and with the rollback CLI (which treats
16/// the column as an opaque `TEXT` key). ULID literals appear in ledger doctests as
17/// illustrative placeholders only — the authoritative format in `knowledge.rs` is
18/// `uuid::Uuid::new_v4().to_string()`.
19///
20/// # Examples
21///
22/// ```no_run
23/// use zeph_memory::graph::ingest::ImportBatchId;
24///
25/// let id = ImportBatchId::new();
26/// println!("batch: {id}");
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct ImportBatchId(String);
30
31impl ImportBatchId {
32    /// Generates a new random `ImportBatchId` (`UUIDv4`).
33    #[must_use]
34    pub fn new() -> Self {
35        Self(uuid::Uuid::new_v4().to_string())
36    }
37
38    /// Returns the underlying string value.
39    #[must_use]
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43}
44
45impl Default for ImportBatchId {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl fmt::Display for ImportBatchId {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57/// Incremental progress event emitted by [`crate::semantic::SemanticMemory::ingest_documents`].
58///
59/// Sent on the `mpsc::Sender<IngestProgress>` channel passed to `ingest_documents`.
60/// The channel is advisory — a full or dropped receiver never fails the batch.
61///
62/// # Wire contract
63///
64/// Events arrive in this order per batch:
65/// `Started` → (`DocumentSkipped` | `DocumentDone` | `DocumentFailed` | `DocumentRejected`)* → `Finished`
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub enum IngestProgress {
69    /// Emitted once before any document is processed.
70    Started {
71        /// Total number of documents in the batch (after intra-batch dedup).
72        total: usize,
73    },
74    /// A document was skipped because its `(source_uri, content_hash)` pair is already
75    /// in the ingest ledger, or it was a duplicate within the same batch.
76    DocumentSkipped {
77        /// Source locator of the skipped document.
78        uri: String,
79    },
80    /// A document was successfully extracted and stored.
81    DocumentDone {
82        /// Source locator of the processed document.
83        uri: String,
84        /// Number of graph entities upserted.
85        entities: usize,
86        /// Number of graph edges inserted.
87        edges: usize,
88    },
89    /// A document failed to extract or store; the batch continues.
90    DocumentFailed {
91        /// Source locator of the failed document.
92        uri: String,
93        /// Human-readable failure reason.
94        reason: String,
95    },
96    /// A document was rejected by the post-extract validator (sanitizer).
97    ///
98    /// Rejected documents write nothing to the graph or ledger. They are counted
99    /// separately from failures in [`IngestReport`] so operators can distinguish
100    /// sanitizer drops from extraction errors.
101    DocumentRejected {
102        /// Source locator of the rejected document.
103        uri: String,
104        /// Rejection reason from the validator.
105        reason: String,
106    },
107    /// Emitted once after all documents have been processed.
108    Finished,
109}
110
111/// Per-document failure record included in [`IngestReport`].
112#[derive(Debug, Clone)]
113pub struct IngestFailure {
114    /// Source locator of the failed document.
115    pub uri: String,
116    /// Human-readable failure reason.
117    pub reason: String,
118}
119
120/// Entity degree entry for dry-run hub-degree projection (FR-026).
121#[derive(Debug, Clone)]
122pub struct HubDegree {
123    /// Entity name.
124    pub entity: String,
125    /// Projected edge degree (number of edges connecting to this entity in the dry run).
126    pub degree: usize,
127}
128
129/// Aggregate result returned by [`crate::semantic::SemanticMemory::ingest_documents`].
130///
131/// In dry-run mode (`dry_run = true`), `entities_total` and `edges_total` are projected
132/// post-sanitization counts and `hub_degree` contains the top-N entities by degree.
133/// No writes occur.
134///
135/// In live mode, `entities_total` and `edges_total` reflect actual upserts/inserts.
136///
137/// # Dry-run accuracy
138///
139/// Dry-run counts are post-sanitization but pre-quality-gate upper bounds. The live write
140/// path applies self-loop rejection and admission control in `insert_edges` /
141/// `upsert_entities` that may reduce the final entity and edge counts relative to the
142/// dry-run projection. Rejected documents do NOT contribute to `entities_total` or
143/// `edges_total` in either mode.
144#[derive(Debug, Clone, Default)]
145pub struct IngestReport {
146    /// Batch identifier shared by all documents processed in this call.
147    pub batch_id: String,
148    /// Total number of documents in the input (after intra-batch dedup).
149    pub documents_total: usize,
150    /// Number of documents skipped (already ingested or intra-batch duplicate).
151    pub skipped: usize,
152    /// Number of documents successfully processed.
153    pub succeeded: usize,
154    /// Number of documents rejected by the post-extract validator (sanitizer).
155    ///
156    /// Rejected documents write nothing to the graph or ledger and contribute zero
157    /// to `entities_total` / `edges_total`. They are distinct from `failed` documents,
158    /// which represent extraction or storage errors.
159    pub rejected: usize,
160    /// Per-document failure records.
161    pub failed: Vec<IngestFailure>,
162    /// Total entities upserted (live) or projected (dry-run).
163    ///
164    /// Excludes rejected documents. In dry-run mode this is a post-sanitization
165    /// upper bound; the live run may produce fewer entities after admission control.
166    pub entities_total: usize,
167    /// Total edges inserted (live) or projected (dry-run).
168    ///
169    /// Excludes rejected documents. In dry-run mode this is a post-sanitization
170    /// upper bound; the live run may produce fewer edges after self-loop rejection
171    /// and admission control.
172    pub edges_total: usize,
173    /// `true` when `ingest_documents` was called with `dry_run = true`.
174    pub dry_run: bool,
175    /// Top-N entities by projected edge degree. Populated only in dry-run mode (FR-026).
176    ///
177    /// Degrees are computed from post-sanitization extractor output (rejected documents
178    /// do not contribute). They represent pre-quality-gate upper bounds — not the final
179    /// graph topology.
180    pub hub_degree: Vec<HubDegree>,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{ImportBatchId, IngestFailure, IngestReport};
186
187    #[test]
188    fn import_batch_id_is_unique() {
189        let a = ImportBatchId::new();
190        let b = ImportBatchId::new();
191        assert_ne!(a, b);
192        // UUIDv4: 36 chars with 4 hyphens
193        assert_eq!(a.as_str().len(), 36);
194        assert_eq!(a.as_str().chars().filter(|&c| c == '-').count(), 4);
195    }
196
197    #[test]
198    fn ingest_report_rejected_arithmetic_invariant() {
199        let mut report = IngestReport {
200            documents_total: 4,
201            ..IngestReport::default()
202        };
203        report.skipped += 1;
204        report.succeeded += 1;
205        report.rejected += 1;
206        report.failed.push(IngestFailure {
207            uri: "u".into(),
208            reason: "r".into(),
209        });
210        assert_eq!(
211            report.skipped + report.succeeded + report.rejected + report.failed.len(),
212            report.documents_total
213        );
214    }
215}