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}