Skip to main content

graphforge_storage/
adjacency.rs

1//! On-disk format for the derived adjacency index (`indexes/adjacency/`, ADR 0005).
2//!
3//! The adjacency index is a derived, rebuildable CSR (compressed sparse row)
4//! representation of the topology. Canonical Parquet under `topology/` remains
5//! the sole source of truth: every file in `indexes/adjacency/` can be
6//! reconstructed from the typed edge tables alone, and an absent index means
7//! "build in memory on demand", never an error.
8//!
9//! ```text
10//! indexes/adjacency/
11//! ├── index_manifest.parquet    ADJACENCY_MANIFEST_SCHEMA (Parquet)
12//! ├── WORKS_AT.out.csr          ADJACENCY_CSR_SCHEMA (Arrow IPC)
13//! ├── WORKS_AT.in.csr
14//! └── _all.out.csr              union across relation types
15//! ```
16//!
17//! # Build ordering convention
18//!
19//! Builders MUST write all CSR files first and `index_manifest.parquet`
20//! **last**. A crash mid-build then leaves the manifest absent or carrying the
21//! old `topology_generation`, so the index reads as stale and the provider
22//! falls back to scan-and-build — a torn build can cost a rebuild, never
23//! correctness.
24//!
25//! # CSR encoding
26//!
27//! A `.csr` file is a single-batch Arrow IPC file with one column,
28//! `adjacency: LargeList<Struct{edge_id, neighbor_id}>` and one row per
29//! surrogate `node_id` in `0..node_count`. The list offsets buffer is the CSR
30//! offsets array; the struct child is the targets array. See
31//! [`ADJACENCY_CSR_SCHEMA`] and `docs/book/architecture/storage.md` §Derived
32//! Indexes. The in-memory [`CsrIndex`] exposes the logical `offsets`/`targets`
33//! model directly, so consumers never deal with the list encoding.
34
35use std::fs::File;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use arrow::array::{
40    Array, LargeListArray, RecordBatch, StringArray, StructArray, TimestampMicrosecondArray,
41    UInt64Array,
42};
43use arrow::buffer::{OffsetBuffer, ScalarBuffer};
44use arrow::datatypes::{DataType, Field};
45use arrow::ipc::reader::FileReader;
46use arrow::ipc::writer::FileWriter;
47use sha2::{Digest, Sha256};
48use tempfile::NamedTempFile;
49
50use graphforge_core::GfError;
51
52use crate::schemas::{ADJACENCY_CSR_SCHEMA, ADJACENCY_MANIFEST_SCHEMA, adjacency_entry_fields};
53use crate::staging::RewriteBatch;
54
55/// Reserved relation-type stem for the union-across-relation-types index
56/// (`_all.out.csr`). Underscore-prefixed names cannot collide with declared
57/// relation types (matching the `_exploratory.parquet` convention).
58pub const ALL_RELATIONS_STEM: &str = "_all";
59
60/// File name of the adjacency index manifest within `indexes/adjacency/`.
61pub const MANIFEST_FILE: &str = "index_manifest.parquet";
62
63fn storage_err(e: impl std::fmt::Display) -> GfError {
64    GfError::Storage(e.to_string())
65}
66
67/// Edge direction a CSR file is keyed by.
68///
69/// `Out` means rows are keyed by `src_id` and neighbors are destinations;
70/// `In` means rows are keyed by `dst_id` and neighbors are sources.
71/// Undirected traversal is served by unioning the two — there is no
72/// undirected file on disk.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub enum Direction {
75    /// Outgoing edges: keyed by `src_id`, neighbor is `dst_id`.
76    Out,
77    /// Incoming edges: keyed by `dst_id`, neighbor is `src_id`.
78    In,
79}
80
81impl Direction {
82    /// The on-disk token (`"out"` | `"in"`) used in file names and the
83    /// manifest `direction` column.
84    #[must_use]
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Out => "out",
88            Self::In => "in",
89        }
90    }
91
92    /// Parse a manifest `direction` token.
93    ///
94    /// # Errors
95    /// Returns [`GfError::Storage`] for anything other than `"out"` / `"in"`.
96    pub fn parse(s: &str) -> Result<Self, GfError> {
97        match s {
98            "out" => Ok(Self::Out),
99            "in" => Ok(Self::In),
100            other => Err(GfError::Storage(format!(
101                "invalid adjacency direction {other:?} (expected \"out\" or \"in\")"
102            ))),
103        }
104    }
105}
106
107/// In-memory CSR adjacency structure, surrogate-keyed.
108///
109/// Neighbors of `node_id = i` are
110/// `(edge_ids[j], neighbor_ids[j]) for j in offsets[i]..offsets[i + 1]`.
111///
112/// # Invariants (enforced on read and write)
113///
114/// - `offsets` is non-empty and `offsets[0] == 0` — the empty graph is
115///   `offsets == [0]` with empty targets, never an empty `offsets`.
116/// - `offsets` is monotonically non-decreasing; a node with no neighbors is
117///   an empty range.
118/// - `*offsets.last() == edge_ids.len() == neighbor_ids.len()`.
119#[derive(Clone, Debug, Default, PartialEq, Eq)]
120pub struct CsrIndex {
121    /// CSR offsets, length `node_count + 1`.
122    pub offsets: Vec<u64>,
123    /// Edge surrogate per adjacency entry, in CSR order.
124    pub edge_ids: Vec<u64>,
125    /// Neighbor node surrogate per adjacency entry, in CSR order.
126    pub neighbor_ids: Vec<u64>,
127}
128
129impl CsrIndex {
130    /// Number of source nodes covered (CSR row count).
131    #[must_use]
132    pub fn node_count(&self) -> u64 {
133        (self.offsets.len().max(1) - 1) as u64
134    }
135
136    /// Number of `(edge, neighbor)` adjacency entries.
137    #[must_use]
138    pub fn edge_count(&self) -> u64 {
139        self.edge_ids.len() as u64
140    }
141
142    /// Check the structural invariants listed on the type.
143    fn validate(&self) -> Result<(), GfError> {
144        if self.offsets.first() != Some(&0) {
145            return Err(GfError::Storage(format!(
146                "invalid CSR: offsets must start with 0 (got {:?})",
147                self.offsets.first()
148            )));
149        }
150        if self.offsets.windows(2).any(|w| w[0] > w[1]) {
151            return Err(GfError::Storage(
152                "invalid CSR: offsets must be monotonically non-decreasing".to_owned(),
153            ));
154        }
155        let last = *self.offsets.last().unwrap_or(&0);
156        if last != self.edge_count() || self.edge_ids.len() != self.neighbor_ids.len() {
157            return Err(GfError::Storage(format!(
158                "invalid CSR: final offset {last} must equal target lengths \
159                 (edge_ids: {}, neighbor_ids: {})",
160                self.edge_ids.len(),
161                self.neighbor_ids.len()
162            )));
163        }
164        Ok(())
165    }
166}
167
168/// One row of `index_manifest.parquet` — the build record for a single CSR
169/// file. See [`ADJACENCY_MANIFEST_SCHEMA`].
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct AdjacencyManifestRow {
172    /// Relation type name, or [`ALL_RELATIONS_STEM`] for the union index.
173    pub relation_type: String,
174    /// Direction the CSR file is keyed by.
175    pub direction: Direction,
176    /// Project topology generation the CSR was built from.
177    pub topology_generation: u64,
178    /// Build wall-clock time, microseconds since the Unix epoch (UTC).
179    /// Caller-supplied; excluded from the determinism guarantee.
180    pub built_at_micros: i64,
181    /// Number of source nodes covered (CSR row count).
182    pub node_count: u64,
183    /// Number of `(edge, neighbor)` adjacency entries.
184    pub edge_count: u64,
185}
186
187/// Bounded freshness state for the derived adjacency artifact.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum AdjacencyFreshnessState {
190    /// The effective base plus delta chain exactly represents current topology.
191    Current,
192    /// No published adjacency manifest exists.
193    Missing,
194    /// A readable artifact exists but cannot be advanced to current topology.
195    Stale,
196    /// The artifact is torn, unreadable, or does not match canonical topology.
197    Incompatible,
198}
199
200impl AdjacencyFreshnessState {
201    /// Stable cross-binding token.
202    #[must_use]
203    pub const fn as_str(self) -> &'static str {
204        match self {
205            Self::Current => "current",
206            Self::Missing => "missing",
207            Self::Stale => "stale",
208            Self::Incompatible => "incompatible",
209        }
210    }
211}
212
213/// Bounded reason accompanying a non-current adjacency state.
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub enum AdjacencyFreshnessReason {
216    /// No manifest has been published.
217    NotBuilt,
218    /// Manifest rows disagree about their base generation.
219    MixedArtifactGeneration,
220    /// The required bounded delta chain is absent, gapped, unreadable, or over limit.
221    IncompleteDeltaChain,
222    /// A manifest-referenced CSR is missing.
223    MissingCsr,
224    /// A manifest or CSR cannot be decoded.
225    UnreadableArtifact,
226    /// The effective CSR differs from canonical topology.
227    ContentMismatch,
228    /// The artifact claims a generation newer than its source topology.
229    FutureArtifactGeneration,
230}
231
232impl AdjacencyFreshnessReason {
233    /// Stable cross-binding token.
234    #[must_use]
235    pub const fn as_str(self) -> &'static str {
236        match self {
237            Self::NotBuilt => "not_built",
238            Self::MixedArtifactGeneration => "mixed_artifact_generation",
239            Self::IncompleteDeltaChain => "incomplete_delta_chain",
240            Self::MissingCsr => "missing_csr",
241            Self::UnreadableArtifact => "unreadable_artifact",
242            Self::ContentMismatch => "content_mismatch",
243            Self::FutureArtifactGeneration => "future_artifact_generation",
244        }
245    }
246}
247
248/// Rust-owned identity and freshness inspection for the adjacency artifact.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub struct AdjacencyInspection {
251    /// Current canonical topology generation.
252    pub source_generation: u64,
253    /// SHA-256 of canonically sorted `(src_id, edge_id, dst_id)` tuples.
254    pub source_fingerprint: String,
255    /// Uniform manifest base generation, when readable.
256    pub artifact_generation: Option<u64>,
257    /// Effective source generation after applying a complete delta chain.
258    pub artifact_effective_generation: Option<u64>,
259    /// SHA-256 of the effective union CSR, when readable and delta-complete.
260    pub artifact_fingerprint: Option<String>,
261    /// Bounded freshness state.
262    pub state: AdjacencyFreshnessState,
263    /// Bounded reason for a non-current state.
264    pub reason: Option<AdjacencyFreshnessReason>,
265}
266
267/// `indexes/adjacency/` within `project_dir`.
268#[must_use]
269pub fn adjacency_dir(project_dir: &Path) -> PathBuf {
270    project_dir.join("indexes").join("adjacency")
271}
272
273/// Path of the CSR file for (`relation_type`, `direction`):
274/// `indexes/adjacency/<REL_TYPE>.<dir>.csr`.
275#[must_use]
276pub fn csr_path(project_dir: &Path, relation_type: &str, direction: Direction) -> PathBuf {
277    adjacency_dir(project_dir).join(format!("{relation_type}.{}.csr", direction.as_str()))
278}
279
280/// Path of `index_manifest.parquet` within `project_dir`.
281#[must_use]
282pub fn manifest_path(project_dir: &Path) -> PathBuf {
283    adjacency_dir(project_dir).join(MANIFEST_FILE)
284}
285
286/// Write `csr` to `path` as a single-batch Arrow IPC file
287/// ([`ADJACENCY_CSR_SCHEMA`]), atomically (sibling temp + rename, the same
288/// pattern as [`RewriteBatch`]; IPC files cannot reuse it directly because it
289/// encodes Parquet).
290///
291/// # Errors
292/// Returns [`GfError::Storage`] if `csr` violates its invariants or on
293/// I/O/encode failure; on failure `path` is untouched.
294pub fn write_csr(path: &Path, csr: &CsrIndex) -> Result<(), GfError> {
295    csr.validate()?;
296
297    let offsets: Vec<i64> = csr
298        .offsets
299        .iter()
300        .map(|&o| i64::try_from(o).map_err(storage_err))
301        .collect::<Result<_, _>>()?;
302    let entries = StructArray::new(
303        adjacency_entry_fields(),
304        vec![
305            Arc::new(UInt64Array::from(csr.edge_ids.clone())),
306            Arc::new(UInt64Array::from(csr.neighbor_ids.clone())),
307        ],
308        None,
309    );
310    let item_field = Arc::new(Field::new(
311        "item",
312        DataType::Struct(adjacency_entry_fields()),
313        false,
314    ));
315    let adjacency = LargeListArray::new(
316        item_field,
317        OffsetBuffer::new(ScalarBuffer::from(offsets)),
318        Arc::new(entries),
319        None,
320    );
321    let batch = RecordBatch::try_new(Arc::clone(&ADJACENCY_CSR_SCHEMA), vec![Arc::new(adjacency)])
322        .map_err(storage_err)?;
323
324    let parent = path.parent().ok_or_else(|| {
325        GfError::Storage(format!(
326            "CSR path {} has no parent directory",
327            path.display()
328        ))
329    })?;
330    std::fs::create_dir_all(parent).map_err(storage_err)?;
331    let file_name = path
332        .file_name()
333        .map_or_else(|| "csr".to_owned(), |n| n.to_string_lossy().into_owned());
334    let tmp = tempfile::Builder::new()
335        .prefix(&format!("{file_name}."))
336        .suffix(".tmp")
337        .tempfile_in(parent)
338        .map_err(storage_err)?;
339
340    let mut writer =
341        FileWriter::try_new(tmp.as_file(), &ADJACENCY_CSR_SCHEMA).map_err(storage_err)?;
342    writer.write(&batch).map_err(storage_err)?;
343    writer.finish().map_err(storage_err)?;
344    persist_temp(tmp, path)
345}
346
347/// Read a CSR file written by [`write_csr`] back into a [`CsrIndex`].
348///
349/// # Errors
350/// Returns [`GfError::Storage`] if the file is missing, is not an Arrow IPC
351/// file with [`ADJACENCY_CSR_SCHEMA`], or decodes to an invalid CSR.
352pub fn read_csr(path: &Path) -> Result<CsrIndex, GfError> {
353    let file = File::open(path)
354        .map_err(|e| GfError::Storage(format!("cannot open CSR file {}: {e}", path.display())))?;
355    let reader = FileReader::try_new(file, None)
356        .map_err(|e| GfError::Storage(format!("invalid CSR file {}: {e}", path.display())))?;
357    if reader.schema().fields() != ADJACENCY_CSR_SCHEMA.fields() {
358        return Err(GfError::Storage(format!(
359            "CSR file {} has unexpected schema {:?}",
360            path.display(),
361            reader.schema()
362        )));
363    }
364
365    let mut csr = CsrIndex {
366        offsets: vec![0],
367        ..CsrIndex::default()
368    };
369    for batch in reader {
370        let batch = batch.map_err(storage_err)?;
371        let adjacency = batch
372            .column(0)
373            .as_any()
374            .downcast_ref::<LargeListArray>()
375            .ok_or_else(|| {
376                GfError::Storage(format!(
377                    "CSR file {}: adjacency column is not a LargeList",
378                    path.display()
379                ))
380            })?;
381        let entries = adjacency
382            .values()
383            .as_any()
384            .downcast_ref::<StructArray>()
385            .ok_or_else(|| {
386                GfError::Storage(format!(
387                    "CSR file {}: adjacency entries are not a Struct",
388                    path.display()
389                ))
390            })?;
391        let (edge_ids, neighbor_ids) = (
392            uint64_column(entries.column(0), "edge_id")?,
393            uint64_column(entries.column(1), "neighbor_id")?,
394        );
395        // Walk per-row through the list offsets rather than copying the child
396        // arrays wholesale: this stays correct for multi-batch files and for
397        // list arrays whose offsets do not start at zero (slices).
398        let value_offsets = adjacency.value_offsets();
399        for row in 0..adjacency.len() {
400            let start = usize::try_from(value_offsets[row]).map_err(storage_err)?;
401            let end = usize::try_from(value_offsets[row + 1]).map_err(storage_err)?;
402            for entry in start..end {
403                csr.edge_ids.push(edge_ids.value(entry));
404                csr.neighbor_ids.push(neighbor_ids.value(entry));
405            }
406            csr.offsets.push(csr.edge_count());
407        }
408    }
409    csr.validate()?;
410    Ok(csr)
411}
412
413/// Replace `index_manifest.parquet` with `rows`, atomically.
414///
415/// Per the build ordering convention (module docs), call this **after** all
416/// CSR files referenced by `rows` have been written.
417///
418/// # Errors
419/// Returns [`GfError::Storage`] on I/O or Parquet-encode failure; on failure
420/// any existing manifest is untouched.
421pub fn write_manifest(project_dir: &Path, rows: &[AdjacencyManifestRow]) -> Result<(), GfError> {
422    let relation_types: StringArray = rows
423        .iter()
424        .map(|r| Some(r.relation_type.as_str()))
425        .collect();
426    let directions: StringArray = rows.iter().map(|r| Some(r.direction.as_str())).collect();
427    let generations: Vec<u64> = rows.iter().map(|r| r.topology_generation).collect();
428    let built_ats: Vec<i64> = rows.iter().map(|r| r.built_at_micros).collect();
429    let node_counts: Vec<u64> = rows.iter().map(|r| r.node_count).collect();
430    let edge_counts: Vec<u64> = rows.iter().map(|r| r.edge_count).collect();
431    let batch = RecordBatch::try_new(
432        Arc::clone(&ADJACENCY_MANIFEST_SCHEMA),
433        vec![
434            Arc::new(relation_types),
435            Arc::new(directions),
436            Arc::new(UInt64Array::from(generations)),
437            Arc::new(TimestampMicrosecondArray::from(built_ats).with_timezone("UTC")),
438            Arc::new(UInt64Array::from(node_counts)),
439            Arc::new(UInt64Array::from(edge_counts)),
440        ],
441    )
442    .map_err(storage_err)?;
443
444    let mut staged = RewriteBatch::new();
445    staged.stage(
446        &manifest_path(project_dir),
447        Arc::clone(&ADJACENCY_MANIFEST_SCHEMA),
448        &batch,
449    )?;
450    staged.commit()
451}
452
453/// Read `index_manifest.parquet`. An absent manifest (or absent
454/// `indexes/adjacency/` directory) returns `Ok(vec![])` — the index simply
455/// has not been built, mirroring the absent-file semantics of the catalog
456/// readers.
457///
458/// # Errors
459/// Returns [`GfError::Storage`] if the file exists but is not a manifest with
460/// [`ADJACENCY_MANIFEST_SCHEMA`].
461pub fn read_manifest(project_dir: &Path) -> Result<Vec<AdjacencyManifestRow>, GfError> {
462    let path = manifest_path(project_dir);
463    if !path.exists() {
464        return Ok(Vec::new());
465    }
466    let file = File::open(&path).map_err(storage_err)?;
467    let reader = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
468        .map_err(storage_err)?
469        .build()
470        .map_err(storage_err)?;
471
472    let mut rows = Vec::new();
473    for batch in reader {
474        let batch = batch.map_err(storage_err)?;
475        if batch.schema().fields() != ADJACENCY_MANIFEST_SCHEMA.fields() {
476            return Err(GfError::Storage(format!(
477                "adjacency manifest {} has unexpected schema {:?}",
478                path.display(),
479                batch.schema()
480            )));
481        }
482        let relation_types = string_column(batch.column(0), "relation_type")?;
483        let directions = string_column(batch.column(1), "direction")?;
484        let generations = uint64_column(batch.column(2), "topology_generation")?;
485        let built_ats = batch
486            .column(3)
487            .as_any()
488            .downcast_ref::<TimestampMicrosecondArray>()
489            .ok_or_else(|| {
490                GfError::Storage("adjacency manifest: built_at is not a timestamp".to_owned())
491            })?;
492        let node_counts = uint64_column(batch.column(4), "node_count")?;
493        let edge_counts = uint64_column(batch.column(5), "edge_count")?;
494        for i in 0..batch.num_rows() {
495            rows.push(AdjacencyManifestRow {
496                relation_type: relation_types.value(i).to_owned(),
497                direction: Direction::parse(directions.value(i))?,
498                topology_generation: generations.value(i),
499                built_at_micros: built_ats.value(i),
500                node_count: node_counts.value(i),
501                edge_count: edge_counts.value(i),
502            });
503        }
504    }
505    Ok(rows)
506}
507
508// ---------------------------------------------------------------------------
509// Index builder (#761)
510// ---------------------------------------------------------------------------
511
512/// One edge occurrence during the index build: `(src_id, edge_id, dst_id)`.
513/// [`csr_from_entries`] re-keys per direction (`src` for `out`, `dst` for `in`).
514pub(crate) type BuildEntry = (u64, u64, u64);
515
516/// Build the full adjacency index for the project under `indexes/adjacency/`:
517/// one `{out, in}` CSR pair per relation type found in `topology/edges/`, plus
518/// the [`ALL_RELATIONS_STEM`] union pair, then `index_manifest.parquet`
519/// **last** (the build-ordering convention in the module docs).
520///
521/// Mode-agnostic: `_exploratory.parquet` rows are grouped by their
522/// `rel_type_name` column; every other file is a typed edge table keyed by its
523/// file stem. Relation names that are not usable as a file stem (path
524/// separators, `..`, empty) or that collide with the reserved
525/// [`ALL_RELATIONS_STEM`] are skipped — those relations are served by
526/// scan-build forever, but their rows still flow into the union index.
527///
528/// The project `topology_generation` is read **before** any edge scan and
529/// stamped into the manifest: a concurrent topology write mid-build bumps the
530/// counter past the stamp, so a racing build can only produce an index that
531/// reads as *stale*, never as falsely fresh.
532///
533/// Determinism (R-ADJ-2): `out` entries sort by `(src_id, edge_id)` and `in`
534/// entries by `(dst_id, edge_id)`, so CSR bytes are reproducible from
535/// `topology/` alone. Because edge files are ascending in `edge_id`, the
536/// per-node entry order equals edge-file row order — the same order the
537/// scan-build path produces.
538///
539/// Returns the manifest rows written. An empty project still writes the
540/// (empty) union pair plus the manifest, so an explicit build always creates a
541/// well-formed capability directory.
542///
543/// # Errors
544/// Returns [`GfError::Storage`] on any read, build, or write failure; the
545/// manifest is only written after every CSR file succeeded.
546pub fn build_adjacency_index(
547    project_dir: &Path,
548    built_at_micros: i64,
549) -> Result<Vec<AdjacencyManifestRow>, GfError> {
550    build_adjacency_index_with_checkpoint(project_dir, built_at_micros, || Ok(()))
551}
552
553/// Cancellation-aware variant of [`build_adjacency_index`].
554pub fn build_adjacency_index_with_checkpoint(
555    project_dir: &Path,
556    built_at_micros: i64,
557    mut checkpoint: impl FnMut() -> Result<(), GfError>,
558) -> Result<Vec<AdjacencyManifestRow>, GfError> {
559    build_adjacency_index_into(project_dir, project_dir, built_at_micros, &mut checkpoint)
560}
561
562/// Build from canonical topology in `source_project_dir` into a separate
563/// private artifact project root. The caller publishes the completed directory.
564pub fn build_adjacency_index_into(
565    source_project_dir: &Path,
566    artifact_project_dir: &Path,
567    built_at_micros: i64,
568    mut checkpoint: impl FnMut() -> Result<(), GfError>,
569) -> Result<Vec<AdjacencyManifestRow>, GfError> {
570    checkpoint()?;
571    // Generation BEFORE the scan — see the race note in the doc comment.
572    let generation = crate::generation::read_topology_generation(source_project_dir)?;
573    let (groups, union_out) = collect_adjacency_groups(source_project_dir)?;
574
575    let adjacency = adjacency_dir(artifact_project_dir);
576    std::fs::create_dir_all(&adjacency).map_err(storage_err)?;
577
578    let mut manifest = Vec::new();
579    {
580        let mut write_pair = |stem: &str, entries: &[BuildEntry]| -> Result<(), GfError> {
581            for direction in [Direction::Out, Direction::In] {
582                checkpoint()?;
583                let csr = csr_from_entries(entries, direction);
584                write_csr(&csr_path(artifact_project_dir, stem, direction), &csr)?;
585                manifest.push(AdjacencyManifestRow {
586                    relation_type: stem.to_owned(),
587                    direction,
588                    topology_generation: generation,
589                    built_at_micros,
590                    node_count: csr.node_count(),
591                    edge_count: csr.edge_count(),
592                });
593            }
594            Ok(())
595        };
596        for (stem, entries) in &groups {
597            write_pair(stem, entries)?;
598        }
599        write_pair(ALL_RELATIONS_STEM, &union_out)?;
600    }
601    checkpoint()?;
602
603    // Manifest LAST: a crash before this point leaves the manifest absent or
604    // old, so a torn build always reads as stale.
605    write_manifest(artifact_project_dir, &manifest)?;
606    checkpoint()?;
607
608    // Phase-1 compaction (#765): the rebuilt base subsumes every delta segment
609    // at or below the generation it was stamped with, so prune them. Segments
610    // written by a concurrent append DURING the build (generation > stamp)
611    // survive, so the new base + those is immediately fresh. Manifest first,
612    // prune after: a crash between leaves dead segments a later prune removes.
613    crate::adjacency_delta::prune_delta_segments(artifact_project_dir, generation);
614    Ok(manifest)
615}
616
617/// Scan `topology/edges/` and group every edge occurrence by relation type:
618/// per-relation entries (stems unusable as file names are skipped, see
619/// [`build_adjacency_index`]) plus the full union. Shared by the builder and
620/// the validator so "what the index SHOULD contain" has exactly one
621/// definition.
622#[allow(clippy::type_complexity)]
623fn collect_adjacency_groups(
624    project_dir: &Path,
625) -> Result<
626    (
627        std::collections::BTreeMap<String, Vec<BuildEntry>>,
628        Vec<BuildEntry>,
629    ),
630    GfError,
631> {
632    use std::collections::BTreeMap;
633
634    let mut groups: BTreeMap<String, Vec<BuildEntry>> = BTreeMap::new();
635    let mut union_out: Vec<BuildEntry> = Vec::new();
636    for path in crate::mutator::parquet_files_in(project_dir, "topology/edges")? {
637        let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
638            continue;
639        };
640        // An unreadable edge file must FAIL the build, not be skipped: a
641        // manifest written without it would stamp the current generation and
642        // make an index missing a relation's edges look fresh.
643        let schema = crate::catalog::discover_parquet_schema(&path).ok_or_else(|| {
644            GfError::Storage(format!(
645                "adjacency build: cannot read parquet schema for {}",
646                path.display()
647            ))
648        })?;
649        let batches = crate::catalog::read_parquet_or_empty(&path, schema).map_err(storage_err)?;
650        let exploratory = stem == "_exploratory";
651        for batch in &batches {
652            let edge_ids = uint64_column(named_column(batch, "edge_id")?, "edge_id")?;
653            let src_ids = uint64_column(named_column(batch, "src_id")?, "src_id")?;
654            let dst_ids = uint64_column(named_column(batch, "dst_id")?, "dst_id")?;
655            let rel_names = if exploratory {
656                Some(string_column(
657                    named_column(batch, "rel_type_name")?,
658                    "rel_type_name",
659                )?)
660            } else {
661                None
662            };
663            for i in 0..batch.num_rows() {
664                let entry = (src_ids.value(i), edge_ids.value(i), dst_ids.value(i));
665                union_out.push(entry);
666                let rel = rel_names.map_or(stem.as_str(), |names| names.value(i));
667                if usable_stem(rel) {
668                    groups.entry(rel.to_owned()).or_default().push(entry);
669                }
670            }
671        }
672    }
673    Ok((groups, union_out))
674}
675
676// ---------------------------------------------------------------------------
677// Index validation (#766)
678// ---------------------------------------------------------------------------
679
680/// One problem found by [`validate_adjacency_index`].
681#[derive(Clone, Debug, PartialEq, Eq)]
682pub enum AdjacencyValidationIssue {
683    /// The manifest was built at a different `topology_generation` than the
684    /// project's current counter — the index is stale, not corrupt.
685    StaleGeneration {
686        /// Generation recorded in the manifest row(s).
687        manifest: u64,
688        /// The project's current counter.
689        current: u64,
690    },
691    /// A manifest row's CSR file is missing on disk.
692    MissingCsr {
693        /// Relation type stem.
694        rel: String,
695        /// CSR direction.
696        direction: Direction,
697    },
698    /// A manifest row's CSR file exists but cannot be read or fails its
699    /// structural invariants.
700    UnreadableCsr {
701        /// Relation type stem.
702        rel: String,
703        /// CSR direction.
704        direction: Direction,
705        /// The underlying read error.
706        error: String,
707    },
708    /// The CSR file's content differs from a fresh in-memory rebuild of the
709    /// same relation/direction — index corruption.
710    Mismatch {
711        /// Relation type stem.
712        rel: String,
713        /// CSR direction.
714        direction: Direction,
715    },
716}
717
718/// Verify a persisted adjacency index against a fresh in-memory rebuild from
719/// `topology/` (ADR 0005 maintenance op): for every manifest row, the CSR
720/// file must exist, parse, and equal the expected CSR byte-for-byte (same
721/// deterministic sort the builder uses). An **absent** index (no manifest)
722/// is clean — there is nothing to validate, not an error.
723///
724/// Returns the list of issues found; an empty list means the index is valid.
725/// A [`StaleGeneration`](AdjacencyValidationIssue::StaleGeneration) issue is
726/// reported once and content checks still run against current topology, so a
727/// stale-but-otherwise-intact index reports exactly one issue.
728///
729/// # Errors
730/// Returns [`GfError::Storage`] when the project itself cannot be read (the
731/// generation counter, the manifest, or a topology edge file) — problems with
732/// the *index* are reported as issues, not errors.
733pub fn validate_adjacency_index(
734    project_dir: &Path,
735) -> Result<Vec<AdjacencyValidationIssue>, GfError> {
736    validate_adjacency_index_against(project_dir, project_dir)
737}
738
739/// Validate a privately staged artifact against canonical source topology.
740pub fn validate_adjacency_index_against(
741    source_project_dir: &Path,
742    artifact_project_dir: &Path,
743) -> Result<Vec<AdjacencyValidationIssue>, GfError> {
744    let manifest = read_manifest(artifact_project_dir)?;
745    if manifest.is_empty() {
746        return Ok(Vec::new()); // no index ⇒ nothing to validate
747    }
748
749    let mut issues = Vec::new();
750    let current = crate::generation::read_topology_generation(source_project_dir)?;
751
752    // Delta-covered (#765): a uniform base older than the counter, with an
753    // intact chain over (base, current], is effectively fresh — the overlay of
754    // base CSR + chain equals a rebuild at `current`. Suppress StaleGeneration
755    // and diff the *effective* CSR; an incomplete/absent chain keeps today's
756    // behavior (StaleGeneration + a content check against current topology, so
757    // a generation bump with unchanged content is exactly one issue).
758    let base = manifest.first().map(|r| r.topology_generation);
759    let uniform = base.is_some_and(|b| manifest.iter().all(|r| r.topology_generation == b));
760    let chain = match base {
761        Some(b) if uniform && b < current => {
762            crate::adjacency_delta::read_delta_chain(artifact_project_dir, b, current)
763        }
764        _ => None,
765    };
766    let delta_covered = chain.is_some();
767    let chain = chain.unwrap_or_default();
768
769    if !delta_covered
770        && let Some(stale) = manifest
771            .iter()
772            .find(|r| r.topology_generation != current)
773            .map(|r| r.topology_generation)
774    {
775        issues.push(AdjacencyValidationIssue::StaleGeneration {
776            manifest: stale,
777            current,
778        });
779    }
780
781    let (groups, union_out) = collect_adjacency_groups(source_project_dir)?;
782    for row in &manifest {
783        let expected_entries: &[BuildEntry] = if row.relation_type == ALL_RELATIONS_STEM {
784            &union_out
785        } else {
786            groups
787                .get(&row.relation_type)
788                .map_or(&[][..], Vec::as_slice)
789        };
790        let expected = csr_from_entries(expected_entries, row.direction);
791        let path = csr_path(artifact_project_dir, &row.relation_type, row.direction);
792        if !path.exists() {
793            issues.push(AdjacencyValidationIssue::MissingCsr {
794                rel: row.relation_type.clone(),
795                direction: row.direction,
796            });
797            continue;
798        }
799        match read_csr(&path) {
800            // When delta-covered, validate the overlay (base CSR + chain), not
801            // the bare base CSR, against the current-topology rebuild.
802            Ok(base_csr) => {
803                let actual = if delta_covered {
804                    crate::adjacency_delta::apply_delta_segments(
805                        &base_csr,
806                        &row.relation_type,
807                        row.direction,
808                        &chain,
809                    )
810                } else {
811                    base_csr
812                };
813                if actual != expected {
814                    issues.push(AdjacencyValidationIssue::Mismatch {
815                        rel: row.relation_type.clone(),
816                        direction: row.direction,
817                    });
818                }
819            }
820            Err(e) => issues.push(AdjacencyValidationIssue::UnreadableCsr {
821                rel: row.relation_type.clone(),
822                direction: row.direction,
823                error: e.to_string(),
824            }),
825        }
826    }
827    Ok(issues)
828}
829
830/// Inspect adjacency identity and freshness using the same complete-delta-chain
831/// rule as validation and execution.
832///
833/// # Errors
834/// Returns a storage error only when canonical topology itself cannot be read.
835pub fn inspect_adjacency_index(project_dir: &Path) -> Result<AdjacencyInspection, GfError> {
836    let source_generation = crate::generation::read_topology_generation(project_dir)?;
837    let (_, mut source_entries) = collect_adjacency_groups(project_dir)?;
838    let source_fingerprint = entries_fingerprint(&mut source_entries);
839    let Ok(manifest) = read_manifest(project_dir) else {
840        return Ok(inspection_without_artifact(
841            source_generation,
842            source_fingerprint,
843            AdjacencyFreshnessState::Incompatible,
844            AdjacencyFreshnessReason::UnreadableArtifact,
845        ));
846    };
847    if manifest.is_empty() {
848        let built = manifest_path(project_dir).exists();
849        return Ok(inspection_without_artifact(
850            source_generation,
851            source_fingerprint,
852            if built {
853                AdjacencyFreshnessState::Incompatible
854            } else {
855                AdjacencyFreshnessState::Missing
856            },
857            if built {
858                AdjacencyFreshnessReason::UnreadableArtifact
859            } else {
860                AdjacencyFreshnessReason::NotBuilt
861            },
862        ));
863    }
864    let base = manifest[0].topology_generation;
865    if manifest.iter().any(|row| row.topology_generation != base) {
866        return Ok(AdjacencyInspection {
867            source_generation,
868            source_fingerprint,
869            artifact_generation: None,
870            artifact_effective_generation: None,
871            artifact_fingerprint: None,
872            state: AdjacencyFreshnessState::Incompatible,
873            reason: Some(AdjacencyFreshnessReason::MixedArtifactGeneration),
874        });
875    }
876    if base > source_generation {
877        return Ok(AdjacencyInspection {
878            source_generation,
879            source_fingerprint,
880            artifact_generation: Some(base),
881            artifact_effective_generation: None,
882            artifact_fingerprint: None,
883            state: AdjacencyFreshnessState::Incompatible,
884            reason: Some(AdjacencyFreshnessReason::FutureArtifactGeneration),
885        });
886    }
887    let chain = if base < source_generation {
888        match crate::adjacency_delta::read_delta_chain(project_dir, base, source_generation) {
889            Some(chain) => chain,
890            None => {
891                return Ok(AdjacencyInspection {
892                    source_generation,
893                    source_fingerprint,
894                    artifact_generation: Some(base),
895                    artifact_effective_generation: None,
896                    artifact_fingerprint: None,
897                    state: AdjacencyFreshnessState::Stale,
898                    reason: Some(AdjacencyFreshnessReason::IncompleteDeltaChain),
899                });
900            }
901        }
902    } else {
903        Vec::new()
904    };
905    let union_path = csr_path(project_dir, ALL_RELATIONS_STEM, Direction::Out);
906    if !union_path.exists() {
907        return Ok(AdjacencyInspection {
908            source_generation,
909            source_fingerprint,
910            artifact_generation: Some(base),
911            artifact_effective_generation: Some(source_generation),
912            artifact_fingerprint: None,
913            state: AdjacencyFreshnessState::Incompatible,
914            reason: Some(AdjacencyFreshnessReason::MissingCsr),
915        });
916    }
917    let Ok(base_csr) = read_csr(&union_path) else {
918        return Ok(AdjacencyInspection {
919            source_generation,
920            source_fingerprint,
921            artifact_generation: Some(base),
922            artifact_effective_generation: Some(source_generation),
923            artifact_fingerprint: None,
924            state: AdjacencyFreshnessState::Incompatible,
925            reason: Some(AdjacencyFreshnessReason::UnreadableArtifact),
926        });
927    };
928    inspect_effective_artifact(
929        project_dir,
930        source_generation,
931        source_fingerprint,
932        base,
933        &base_csr,
934        &chain,
935    )
936}
937
938fn inspect_effective_artifact(
939    project_dir: &Path,
940    source_generation: u64,
941    source_fingerprint: String,
942    base: u64,
943    base_csr: &CsrIndex,
944    chain: &[crate::adjacency_delta::DeltaSegment],
945) -> Result<AdjacencyInspection, GfError> {
946    let effective = crate::adjacency_delta::apply_delta_segments(
947        base_csr,
948        ALL_RELATIONS_STEM,
949        Direction::Out,
950        chain,
951    );
952    let Some(mut artifact_entries) = entries_from_out_csr(&effective) else {
953        return Ok(AdjacencyInspection {
954            source_generation,
955            source_fingerprint,
956            artifact_generation: Some(base),
957            artifact_effective_generation: Some(source_generation),
958            artifact_fingerprint: None,
959            state: AdjacencyFreshnessState::Incompatible,
960            reason: Some(AdjacencyFreshnessReason::UnreadableArtifact),
961        });
962    };
963    let artifact_fingerprint = entries_fingerprint(&mut artifact_entries);
964    let validation_issues = validate_adjacency_index(project_dir)?;
965    let validation_reason = validation_issues.first().map(|issue| match issue {
966        AdjacencyValidationIssue::StaleGeneration { .. } => {
967            AdjacencyFreshnessReason::IncompleteDeltaChain
968        }
969        AdjacencyValidationIssue::MissingCsr { .. } => AdjacencyFreshnessReason::MissingCsr,
970        AdjacencyValidationIssue::UnreadableCsr { .. } => {
971            AdjacencyFreshnessReason::UnreadableArtifact
972        }
973        AdjacencyValidationIssue::Mismatch { .. } => AdjacencyFreshnessReason::ContentMismatch,
974    });
975    let (state, reason) =
976        if artifact_fingerprint == source_fingerprint && validation_reason.is_none() {
977            (AdjacencyFreshnessState::Current, None)
978        } else {
979            (
980                AdjacencyFreshnessState::Incompatible,
981                Some(validation_reason.unwrap_or(AdjacencyFreshnessReason::ContentMismatch)),
982            )
983        };
984    Ok(AdjacencyInspection {
985        source_generation,
986        source_fingerprint,
987        artifact_generation: Some(base),
988        artifact_effective_generation: Some(source_generation),
989        artifact_fingerprint: Some(artifact_fingerprint),
990        state,
991        reason,
992    })
993}
994
995fn inspection_without_artifact(
996    source_generation: u64,
997    source_fingerprint: String,
998    state: AdjacencyFreshnessState,
999    reason: AdjacencyFreshnessReason,
1000) -> AdjacencyInspection {
1001    AdjacencyInspection {
1002        source_generation,
1003        source_fingerprint,
1004        artifact_generation: None,
1005        artifact_effective_generation: None,
1006        artifact_fingerprint: None,
1007        state,
1008        reason: Some(reason),
1009    }
1010}
1011
1012fn entries_from_out_csr(csr: &CsrIndex) -> Option<Vec<BuildEntry>> {
1013    let mut entries = Vec::with_capacity(csr.edge_ids.len());
1014    for (src_index, offsets) in csr.offsets.windows(2).enumerate() {
1015        let src = u64::try_from(src_index).ok()?;
1016        let start = usize::try_from(offsets[0]).ok()?;
1017        let end = usize::try_from(offsets[1]).ok()?;
1018        if start > end || end > csr.edge_ids.len() || end > csr.neighbor_ids.len() {
1019            return None;
1020        }
1021        for index in start..end {
1022            entries.push((src, csr.edge_ids[index], csr.neighbor_ids[index]));
1023        }
1024    }
1025    Some(entries)
1026}
1027
1028fn entries_fingerprint(entries: &mut [BuildEntry]) -> String {
1029    entries.sort_unstable();
1030    let mut digest = Sha256::new();
1031    digest.update(b"graphforge/adjacency-topology/v1\0");
1032    for &(src, edge, dst) in entries.iter() {
1033        digest.update(src.to_le_bytes());
1034        digest.update(edge.to_le_bytes());
1035        digest.update(dst.to_le_bytes());
1036    }
1037    format!("sha256:{:x}", digest.finalize())
1038}
1039
1040/// Build a dense [`CsrIndex`] from `(src_id, edge_id, dst_id)` entries for the
1041/// given direction: `out` is keyed by `src_id` with `dst_id` neighbors, sorted
1042/// by `(src_id, edge_id)`; `in` is keyed by `dst_id` with `src_id` neighbors,
1043/// sorted by `(dst_id, edge_id)`. Surrogate gaps (post-DELETE) are empty rows.
1044pub(crate) fn csr_from_entries(entries: &[BuildEntry], direction: Direction) -> CsrIndex {
1045    let mut keyed: Vec<(u64, u64, u64)> = entries
1046        .iter()
1047        .map(|&(src, edge, dst)| match direction {
1048            Direction::Out => (src, edge, dst),
1049            Direction::In => (dst, edge, src),
1050        })
1051        .collect();
1052    keyed.sort_unstable_by_key(|&(key, edge, _)| (key, edge));
1053
1054    let node_count = keyed.last().map_or(0, |&(key, _, _)| key + 1);
1055    let mut csr = CsrIndex {
1056        offsets: Vec::with_capacity(usize::try_from(node_count).unwrap_or(0) + 1),
1057        edge_ids: Vec::with_capacity(keyed.len()),
1058        neighbor_ids: Vec::with_capacity(keyed.len()),
1059    };
1060    csr.offsets.push(0);
1061    let mut next = 0usize; // index into `keyed`
1062    for node in 0..node_count {
1063        while next < keyed.len() && keyed[next].0 == node {
1064            csr.edge_ids.push(keyed[next].1);
1065            csr.neighbor_ids.push(keyed[next].2);
1066            next += 1;
1067        }
1068        csr.offsets.push(csr.edge_count());
1069    }
1070    csr
1071}
1072
1073/// Whether `rel` is usable as a CSR file stem: a single plain path component
1074/// (no separators, no `..`, non-empty — the same rule `read_edges` applies to
1075/// typed file names) and not the reserved [`ALL_RELATIONS_STEM`].
1076pub(crate) fn usable_stem(rel: &str) -> bool {
1077    if rel == ALL_RELATIONS_STEM {
1078        return false;
1079    }
1080    let mut comps = Path::new(rel).components();
1081    matches!(comps.next(), Some(std::path::Component::Normal(_))) && comps.next().is_none()
1082}
1083
1084/// Borrow a column by name, erroring on absence.
1085fn named_column<'a>(
1086    batch: &'a arrow::record_batch::RecordBatch,
1087    name: &str,
1088) -> Result<&'a arrow::array::ArrayRef, GfError> {
1089    batch
1090        .column_by_name(name)
1091        .ok_or_else(|| GfError::Storage(format!("adjacency build: missing column {name}")))
1092}
1093
1094/// Atomically rename `tmp` into place at `path`.
1095fn persist_temp(tmp: NamedTempFile, path: &Path) -> Result<(), GfError> {
1096    tmp.persist(path)
1097        .map(|_| ())
1098        .map_err(|e| storage_err(e.error))
1099}
1100
1101fn uint64_column<'a>(
1102    column: &'a arrow::array::ArrayRef,
1103    name: &str,
1104) -> Result<&'a UInt64Array, GfError> {
1105    column
1106        .as_any()
1107        .downcast_ref::<UInt64Array>()
1108        .ok_or_else(|| GfError::Storage(format!("adjacency: {name} column is not UInt64")))
1109}
1110
1111fn string_column<'a>(
1112    column: &'a arrow::array::ArrayRef,
1113    name: &str,
1114) -> Result<&'a StringArray, GfError> {
1115    column
1116        .as_any()
1117        .downcast_ref::<StringArray>()
1118        .ok_or_else(|| GfError::Storage(format!("adjacency: {name} column is not Utf8")))
1119}
1120
1121// ---------------------------------------------------------------------------
1122// Tests
1123// ---------------------------------------------------------------------------
1124
1125#[cfg(test)]
1126mod tests {
1127    use tempfile::TempDir;
1128
1129    use super::*;
1130
1131    /// 3 nodes / 4 entries: node 0 → two neighbors, node 1 → none, node 2 → two.
1132    fn sample_csr() -> CsrIndex {
1133        CsrIndex {
1134            offsets: vec![0, 2, 2, 4],
1135            edge_ids: vec![10, 11, 12, 13],
1136            neighbor_ids: vec![1, 2, 0, 1],
1137        }
1138    }
1139
1140    #[test]
1141    fn entries_from_out_csr_rejects_malformed_offset_bounds() {
1142        let past_targets = CsrIndex {
1143            offsets: vec![0, 2],
1144            edge_ids: vec![7],
1145            neighbor_ids: vec![9],
1146        };
1147        assert!(entries_from_out_csr(&past_targets).is_none());
1148
1149        let descending = CsrIndex {
1150            offsets: vec![1, 0],
1151            edge_ids: vec![7],
1152            neighbor_ids: vec![9],
1153        };
1154        assert!(entries_from_out_csr(&descending).is_none());
1155    }
1156
1157    #[test]
1158    fn csr_round_trip_preserves_offsets_and_targets() {
1159        let dir = TempDir::new().unwrap();
1160        let path = csr_path(dir.path(), "KNOWS", Direction::Out);
1161        let csr = sample_csr();
1162        write_csr(&path, &csr).unwrap();
1163        assert_eq!(read_csr(&path).unwrap(), csr);
1164    }
1165
1166    #[test]
1167    fn empty_graph_round_trips_as_offsets_zero() {
1168        let dir = TempDir::new().unwrap();
1169        let path = csr_path(dir.path(), "KNOWS", Direction::In);
1170        let csr = CsrIndex {
1171            offsets: vec![0],
1172            ..CsrIndex::default()
1173        };
1174        write_csr(&path, &csr).unwrap();
1175        let back = read_csr(&path).unwrap();
1176        assert_eq!(back, csr);
1177        assert_eq!(back.node_count(), 0);
1178        assert_eq!(back.edge_count(), 0);
1179    }
1180
1181    #[test]
1182    fn node_with_no_neighbors_round_trips() {
1183        let dir = TempDir::new().unwrap();
1184        let path = csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out);
1185        let csr = sample_csr(); // node 1 has an empty range
1186        write_csr(&path, &csr).unwrap();
1187        let back = read_csr(&path).unwrap();
1188        assert_eq!(back.offsets[1], back.offsets[2], "node 1 has no neighbors");
1189        assert_eq!(back.node_count(), 3);
1190        assert_eq!(back.edge_count(), 4);
1191    }
1192
1193    #[test]
1194    fn write_csr_replaces_existing_file_atomically() {
1195        let dir = TempDir::new().unwrap();
1196        let path = csr_path(dir.path(), "KNOWS", Direction::Out);
1197        write_csr(&path, &sample_csr()).unwrap();
1198
1199        let newer = CsrIndex {
1200            offsets: vec![0, 1],
1201            edge_ids: vec![99],
1202            neighbor_ids: vec![0],
1203        };
1204        write_csr(&path, &newer).unwrap();
1205        assert_eq!(read_csr(&path).unwrap(), newer, "second write wins");
1206
1207        let temps = std::fs::read_dir(adjacency_dir(dir.path()))
1208            .unwrap()
1209            .filter_map(Result::ok)
1210            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
1211            .count();
1212        assert_eq!(temps, 0, "no temp residue");
1213    }
1214
1215    #[test]
1216    fn read_csr_rejects_wrong_schema() {
1217        let dir = TempDir::new().unwrap();
1218        let path = dir.path().join("bogus.csr");
1219        std::fs::write(&path, b"not an arrow ipc file").unwrap();
1220        assert!(matches!(read_csr(&path), Err(GfError::Storage(_))));
1221    }
1222
1223    #[test]
1224    fn read_csr_missing_file_is_an_error() {
1225        let dir = TempDir::new().unwrap();
1226        let path = csr_path(dir.path(), "ABSENT", Direction::Out);
1227        assert!(matches!(read_csr(&path), Err(GfError::Storage(_))));
1228    }
1229
1230    #[test]
1231    fn write_csr_rejects_invalid_offsets() {
1232        let dir = TempDir::new().unwrap();
1233        let path = dir.path().join("bad.csr");
1234
1235        // Non-monotone offsets.
1236        let non_monotone = CsrIndex {
1237            offsets: vec![0, 3, 1],
1238            edge_ids: vec![1],
1239            neighbor_ids: vec![1],
1240        };
1241        assert!(matches!(
1242            write_csr(&path, &non_monotone),
1243            Err(GfError::Storage(_))
1244        ));
1245
1246        // Final offset disagrees with target lengths.
1247        let length_mismatch = CsrIndex {
1248            offsets: vec![0, 2],
1249            edge_ids: vec![1],
1250            neighbor_ids: vec![1],
1251        };
1252        assert!(matches!(
1253            write_csr(&path, &length_mismatch),
1254            Err(GfError::Storage(_))
1255        ));
1256
1257        // Empty offsets (the empty graph must be [0], not []).
1258        let empty_offsets = CsrIndex::default();
1259        assert!(matches!(
1260            write_csr(&path, &empty_offsets),
1261            Err(GfError::Storage(_))
1262        ));
1263
1264        // Targets of differing lengths.
1265        let ragged = CsrIndex {
1266            offsets: vec![0, 2],
1267            edge_ids: vec![1, 2],
1268            neighbor_ids: vec![1],
1269        };
1270        assert!(matches!(
1271            write_csr(&path, &ragged),
1272            Err(GfError::Storage(_))
1273        ));
1274
1275        assert!(!path.exists(), "no file written for invalid CSR");
1276    }
1277
1278    #[test]
1279    fn manifest_round_trip_multi_relation() {
1280        const TS: i64 = 1_700_000_000_000_000;
1281        let dir = TempDir::new().unwrap();
1282        let rows = vec![
1283            AdjacencyManifestRow {
1284                relation_type: "WORKS_AT".to_owned(),
1285                direction: Direction::Out,
1286                topology_generation: 7,
1287                built_at_micros: TS,
1288                node_count: 100,
1289                edge_count: 250,
1290            },
1291            AdjacencyManifestRow {
1292                relation_type: "WORKS_AT".to_owned(),
1293                direction: Direction::In,
1294                topology_generation: 7,
1295                built_at_micros: TS,
1296                node_count: 100,
1297                edge_count: 250,
1298            },
1299            AdjacencyManifestRow {
1300                relation_type: "OWNS".to_owned(),
1301                direction: Direction::Out,
1302                topology_generation: 7,
1303                built_at_micros: TS + 1,
1304                node_count: 40,
1305                edge_count: 41,
1306            },
1307            AdjacencyManifestRow {
1308                relation_type: ALL_RELATIONS_STEM.to_owned(),
1309                direction: Direction::Out,
1310                topology_generation: 7,
1311                built_at_micros: TS + 2,
1312                node_count: 100,
1313                edge_count: 291,
1314            },
1315        ];
1316        write_manifest(dir.path(), &rows).unwrap();
1317        assert_eq!(read_manifest(dir.path()).unwrap(), rows);
1318    }
1319
1320    #[test]
1321    fn read_manifest_absent_returns_empty() {
1322        let dir = TempDir::new().unwrap();
1323        assert_eq!(read_manifest(dir.path()).unwrap(), Vec::new());
1324    }
1325
1326    #[test]
1327    fn write_manifest_replaces_existing() {
1328        let dir = TempDir::new().unwrap();
1329        let first = vec![AdjacencyManifestRow {
1330            relation_type: "KNOWS".to_owned(),
1331            direction: Direction::Out,
1332            topology_generation: 1,
1333            built_at_micros: 0,
1334            node_count: 1,
1335            edge_count: 1,
1336        }];
1337        write_manifest(dir.path(), &first).unwrap();
1338
1339        let second = vec![AdjacencyManifestRow {
1340            relation_type: "KNOWS".to_owned(),
1341            direction: Direction::Out,
1342            topology_generation: 2,
1343            built_at_micros: 1,
1344            node_count: 2,
1345            edge_count: 3,
1346        }];
1347        write_manifest(dir.path(), &second).unwrap();
1348        assert_eq!(read_manifest(dir.path()).unwrap(), second);
1349    }
1350
1351    #[test]
1352    fn read_manifest_rejects_wrong_schema() {
1353        let dir = TempDir::new().unwrap();
1354        // Write a valid Parquet file with the wrong schema at the manifest path.
1355        let schema = Arc::new(arrow::datatypes::Schema::new(vec![Field::new(
1356            "v",
1357            DataType::Int64,
1358            false,
1359        )]));
1360        let batch = RecordBatch::try_new(
1361            Arc::clone(&schema),
1362            vec![Arc::new(arrow::array::Int64Array::from(vec![1]))],
1363        )
1364        .unwrap();
1365        let mut staged = RewriteBatch::new();
1366        staged
1367            .stage(&manifest_path(dir.path()), schema, &batch)
1368            .unwrap();
1369        staged.commit().unwrap();
1370
1371        assert!(matches!(
1372            read_manifest(dir.path()),
1373            Err(GfError::Storage(_))
1374        ));
1375    }
1376
1377    #[test]
1378    fn direction_round_trips_through_str() {
1379        for d in [Direction::Out, Direction::In] {
1380            assert_eq!(Direction::parse(d.as_str()).unwrap(), d);
1381        }
1382        assert!(matches!(
1383            Direction::parse("sideways"),
1384            Err(GfError::Storage(_))
1385        ));
1386    }
1387
1388    #[test]
1389    fn csr_path_layout() {
1390        let p = csr_path(Path::new("/proj"), "WORKS_AT", Direction::In);
1391        assert_eq!(p, Path::new("/proj/indexes/adjacency/WORKS_AT.in.csr"));
1392        assert_eq!(
1393            manifest_path(Path::new("/proj")),
1394            Path::new("/proj/indexes/adjacency/index_manifest.parquet")
1395        );
1396    }
1397
1398    // -----------------------------------------------------------------------
1399    // build_adjacency_index (#761)
1400    // -----------------------------------------------------------------------
1401
1402    use crate::GraphWriter;
1403    use graphforge_core::OntologyMode;
1404    use graphforge_core::TypeId;
1405    use graphforge_core::uuid::{Uuid, new_v7, to_bytes};
1406
1407    /// Fixed timestamp for deterministic fixtures and manifests.
1408    const BUILD_TS: i64 = 1_700_000_000_000_000;
1409
1410    /// Strict-mode diamond a->b, a->c, b->d, c->d plus a parallel a->b and a
1411    /// self-loop d->d, all KNOWS. Returns the surrogate node ids.
1412    fn write_diamond(dir: &Path) -> [u64; 4] {
1413        let mut w = GraphWriter::open_at(dir, OntologyMode::Strict, BUILD_TS).unwrap();
1414        let uuids: Vec<Uuid> = (0..4).map(|_| new_v7()).collect();
1415        let ids: Vec<u64> = uuids
1416            .iter()
1417            .map(|u| w.create_node(*u, TypeId(0)).unwrap())
1418            .collect();
1419        let (a, b, c, d) = (&uuids[0], &uuids[1], &uuids[2], &uuids[3]);
1420        for (src, dst) in [(a, b), (a, c), (b, d), (c, d), (a, b), (d, d)] {
1421            w.create_edge(new_v7(), "KNOWS", src, dst).unwrap();
1422        }
1423        w.flush().unwrap();
1424        [ids[0], ids[1], ids[2], ids[3]]
1425    }
1426
1427    #[test]
1428    fn build_is_deterministic_and_stamps_pre_scan_generation() {
1429        let dir = TempDir::new().unwrap();
1430        write_diamond(dir.path()); // flush -> generation 1
1431
1432        let rows = build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1433        assert!(rows.iter().all(|r| r.topology_generation == 1));
1434        // KNOWS out/in + _all out/in.
1435        assert_eq!(rows.len(), 4);
1436
1437        let knows_out = std::fs::read(csr_path(dir.path(), "KNOWS", Direction::Out)).unwrap();
1438        let all_in =
1439            std::fs::read(csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::In)).unwrap();
1440
1441        // Rebuild: byte-identical CSR files (R-ADJ-2).
1442        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1443        assert_eq!(
1444            std::fs::read(csr_path(dir.path(), "KNOWS", Direction::Out)).unwrap(),
1445            knows_out
1446        );
1447        assert_eq!(
1448            std::fs::read(csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::In)).unwrap(),
1449            all_in
1450        );
1451
1452        // Per-node entries are (key, edge_id)-sorted == edge-file row order.
1453        let csr = read_csr(&csr_path(dir.path(), "KNOWS", Direction::Out)).unwrap();
1454        let knows_manifest = read_manifest(dir.path())
1455            .unwrap()
1456            .into_iter()
1457            .find(|r| r.relation_type == "KNOWS" && r.direction == Direction::Out)
1458            .unwrap();
1459        assert_eq!(knows_manifest.node_count, csr.node_count());
1460        assert_eq!(knows_manifest.edge_count, 6);
1461        let windows: Vec<&[u64]> = csr
1462            .offsets
1463            .windows(2)
1464            .map(|w| &csr.edge_ids[w[0] as usize..w[1] as usize])
1465            .collect();
1466        for per_node in windows {
1467            assert!(
1468                per_node.windows(2).all(|w| w[0] <= w[1]),
1469                "edge ids ascending per node"
1470            );
1471        }
1472    }
1473
1474    #[test]
1475    fn private_staging_never_changes_the_reader_visible_artifact() {
1476        let dir = TempDir::new().unwrap();
1477        write_diamond(dir.path());
1478        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1479        let prior_manifest = std::fs::read(manifest_path(dir.path())).unwrap();
1480        let stage = TempDir::new_in(dir.path().parent().unwrap()).unwrap();
1481        let mut checkpoints = 0;
1482
1483        build_adjacency_index_into(dir.path(), stage.path(), BUILD_TS + 1, || {
1484            checkpoints += 1;
1485            assert_eq!(
1486                std::fs::read(manifest_path(dir.path())).unwrap(),
1487                prior_manifest
1488            );
1489            assert_eq!(
1490                inspect_adjacency_index(dir.path()).unwrap().state,
1491                AdjacencyFreshnessState::Current
1492            );
1493            Ok(())
1494        })
1495        .unwrap();
1496        assert!(checkpoints >= 4);
1497        assert!(
1498            validate_adjacency_index_against(dir.path(), stage.path())
1499                .unwrap()
1500                .is_empty()
1501        );
1502    }
1503
1504    #[test]
1505    fn post_delete_sparse_ids_build_round_trips() {
1506        let dir = TempDir::new().unwrap();
1507        let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Strict, BUILD_TS).unwrap();
1508        let uuids: Vec<Uuid> = (0..4).map(|_| new_v7()).collect();
1509        let ids: Vec<u64> = uuids
1510            .iter()
1511            .map(|u| w.create_node(*u, TypeId(0)).unwrap())
1512            .collect();
1513        for pair in uuids.windows(2) {
1514            w.create_edge(new_v7(), "KNOWS", &pair[0], &pair[1])
1515                .unwrap();
1516        }
1517        w.flush().unwrap();
1518
1519        // DETACH-DELETE the middle node n2: its id becomes a gap.
1520        let node_set: std::collections::HashSet<[u8; 16]> =
1521            std::iter::once(to_bytes(&uuids[1])).collect();
1522        let incident = crate::incident_edge_uuids(dir.path(), &node_set).unwrap();
1523        let edge_set: std::collections::HashSet<[u8; 16]> = incident.into_iter().collect();
1524        crate::delete_nodes_and_edges(dir.path(), &node_set, &edge_set).unwrap();
1525
1526        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1527        let csr = read_csr(&csr_path(dir.path(), "KNOWS", Direction::Out)).unwrap();
1528        let gap = usize::try_from(ids[1]).unwrap();
1529        assert_eq!(
1530            csr.offsets[gap],
1531            csr.offsets[gap + 1],
1532            "deleted id is an empty range"
1533        );
1534        // Survivor n3 -> n4 intact.
1535        let n3 = usize::try_from(ids[2]).unwrap();
1536        let (s, e) = (csr.offsets[n3] as usize, csr.offsets[n3 + 1] as usize);
1537        assert_eq!(&csr.neighbor_ids[s..e], &[ids[3]]);
1538    }
1539
1540    #[test]
1541    fn exploratory_rows_group_by_rel_type_and_union_covers_all() {
1542        let dir = TempDir::new().unwrap();
1543        let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Exploratory, BUILD_TS).unwrap();
1544        let (a, b, c) = (new_v7(), new_v7(), new_v7());
1545        let ids: Vec<u64> = [a, b, c]
1546            .iter()
1547            .map(|u| w.create_node(*u, TypeId(0)).unwrap())
1548            .collect();
1549        w.create_edge(new_v7(), "KNOWS", &a, &b).unwrap();
1550        w.create_edge(new_v7(), "OWNS", &a, &c).unwrap();
1551        w.flush().unwrap();
1552
1553        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1554
1555        let knows = read_csr(&csr_path(dir.path(), "KNOWS", Direction::Out)).unwrap();
1556        assert_eq!(knows.edge_count(), 1, "decoy OWNS row excluded");
1557        let owns = read_csr(&csr_path(dir.path(), "OWNS", Direction::Out)).unwrap();
1558        assert_eq!(owns.edge_count(), 1);
1559
1560        let all = read_csr(&csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap();
1561        assert_eq!(all.edge_count(), 2, "union covers both rel types");
1562        let row = usize::try_from(ids[0]).unwrap();
1563        let (s, e) = (all.offsets[row] as usize, all.offsets[row + 1] as usize);
1564        assert_eq!(&all.edge_ids[s..e], &[1, 2], "union in edge_id order");
1565        assert_eq!(&all.neighbor_ids[s..e], &[ids[1], ids[2]]);
1566    }
1567
1568    #[test]
1569    fn hostile_and_reserved_stems_are_skipped_but_counted_in_union() {
1570        let dir = TempDir::new().unwrap();
1571        let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Exploratory, BUILD_TS).unwrap();
1572        let (a, b, c) = (new_v7(), new_v7(), new_v7());
1573        for u in [a, b, c] {
1574            w.create_node(u, TypeId(0)).unwrap();
1575        }
1576        w.create_edge(new_v7(), "a/b", &a, &b).unwrap();
1577        w.create_edge(new_v7(), ALL_RELATIONS_STEM, &a, &c).unwrap();
1578        w.flush().unwrap();
1579
1580        let rows = build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1581        // Only the union pair: both rel names are unusable as stems.
1582        assert!(rows.iter().all(|r| r.relation_type == ALL_RELATIONS_STEM));
1583        assert_eq!(rows.len(), 2);
1584        let all = read_csr(&csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap();
1585        assert_eq!(
1586            all.edge_count(),
1587            2,
1588            "skipped rels still flow into the union"
1589        );
1590        assert!(
1591            !csr_path(dir.path(), "a/b", Direction::Out).exists(),
1592            "no nested path written for the separator-bearing rel name"
1593        );
1594        assert!(!csr_path(dir.path(), "a", Direction::Out).exists());
1595    }
1596
1597    #[test]
1598    fn build_failure_leaves_no_manifest() {
1599        let dir = TempDir::new().unwrap();
1600        write_diamond(dir.path());
1601        // Pre-create indexes/adjacency as a READ-ONLY dir so write_csr fails.
1602        let adj = adjacency_dir(dir.path());
1603        std::fs::create_dir_all(&adj).unwrap();
1604        let mut perms = std::fs::metadata(&adj).unwrap().permissions();
1605        perms.set_readonly(true);
1606        std::fs::set_permissions(&adj, perms.clone()).unwrap();
1607
1608        let result = build_adjacency_index(dir.path(), BUILD_TS);
1609        // Restore writability so TempDir cleanup succeeds before asserting.
1610        perms.set_readonly(false);
1611        std::fs::set_permissions(&adj, perms).unwrap();
1612
1613        assert!(result.is_err());
1614        assert!(!manifest_path(dir.path()).exists(), "manifest written last");
1615        assert_eq!(read_manifest(dir.path()).unwrap(), Vec::new());
1616    }
1617
1618    #[test]
1619    fn empty_project_builds_union_pair_and_manifest() {
1620        let dir = TempDir::new().unwrap();
1621        let rows = build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1622        assert_eq!(rows.len(), 2);
1623        assert!(rows.iter().all(|r| {
1624            r.relation_type == ALL_RELATIONS_STEM
1625                && r.topology_generation == 0
1626                && r.node_count == 0
1627                && r.edge_count == 0
1628        }));
1629        let all = read_csr(&csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap();
1630        assert_eq!(all.offsets, vec![0]);
1631        assert_eq!(read_manifest(dir.path()).unwrap(), rows);
1632    }
1633
1634    // -----------------------------------------------------------------------
1635    // validate_adjacency_index (#766)
1636    // -----------------------------------------------------------------------
1637
1638    #[test]
1639    fn validate_reports_clean_on_valid_index() {
1640        let dir = TempDir::new().unwrap();
1641        write_diamond(dir.path());
1642        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1643        assert_eq!(validate_adjacency_index(dir.path()).unwrap(), Vec::new());
1644    }
1645
1646    #[test]
1647    fn validate_reports_clean_on_absent_index() {
1648        let dir = TempDir::new().unwrap();
1649        write_diamond(dir.path());
1650        assert_eq!(validate_adjacency_index(dir.path()).unwrap(), Vec::new());
1651    }
1652
1653    #[test]
1654    fn inspection_moves_from_missing_to_current_with_stable_identity() {
1655        let dir = TempDir::new().unwrap();
1656        write_diamond(dir.path());
1657        let missing = inspect_adjacency_index(dir.path()).unwrap();
1658        assert_eq!(missing.state, AdjacencyFreshnessState::Missing);
1659        assert_eq!(missing.reason, Some(AdjacencyFreshnessReason::NotBuilt));
1660        assert!(missing.source_fingerprint.starts_with("sha256:"));
1661
1662        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1663        let current = inspect_adjacency_index(dir.path()).unwrap();
1664        assert_eq!(current.state, AdjacencyFreshnessState::Current);
1665        assert_eq!(current.reason, None);
1666        assert_eq!(current.artifact_generation, Some(current.source_generation));
1667        assert_eq!(
1668            current.artifact_fingerprint.as_deref(),
1669            Some(current.source_fingerprint.as_str())
1670        );
1671    }
1672
1673    #[test]
1674    fn inspection_checks_every_manifest_referenced_csr() {
1675        let dir = TempDir::new().unwrap();
1676        write_diamond(dir.path());
1677        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1678        std::fs::write(csr_path(dir.path(), "KNOWS", Direction::In), b"garbage").unwrap();
1679
1680        let inspection = inspect_adjacency_index(dir.path()).unwrap();
1681        assert_eq!(inspection.state, AdjacencyFreshnessState::Incompatible);
1682        assert_eq!(
1683            inspection.reason,
1684            Some(AdjacencyFreshnessReason::UnreadableArtifact)
1685        );
1686    }
1687
1688    #[test]
1689    fn inspection_accepts_only_a_complete_delta_chain_as_current() {
1690        let dir = TempDir::new().unwrap();
1691        write_diamond(dir.path());
1692        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1693        crate::generation::bump_topology_generation(dir.path()).unwrap();
1694
1695        let inspection = inspect_adjacency_index(dir.path()).unwrap();
1696        assert_eq!(inspection.state, AdjacencyFreshnessState::Stale);
1697        assert_eq!(
1698            inspection.reason,
1699            Some(AdjacencyFreshnessReason::IncompleteDeltaChain)
1700        );
1701    }
1702
1703    #[test]
1704    fn validate_detects_corrupted_csr() {
1705        let dir = TempDir::new().unwrap();
1706        write_diamond(dir.path());
1707        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1708
1709        // Overwrite KNOWS.out.csr with a VALID but WRONG CSR (content swap).
1710        let bogus = CsrIndex {
1711            offsets: vec![0, 1],
1712            edge_ids: vec![99],
1713            neighbor_ids: vec![1],
1714        };
1715        write_csr(&csr_path(dir.path(), "KNOWS", Direction::Out), &bogus).unwrap();
1716
1717        let issues = validate_adjacency_index(dir.path()).unwrap();
1718        assert_eq!(
1719            issues,
1720            vec![AdjacencyValidationIssue::Mismatch {
1721                rel: "KNOWS".to_owned(),
1722                direction: Direction::Out,
1723            }]
1724        );
1725    }
1726
1727    #[test]
1728    fn validate_detects_unreadable_csr() {
1729        let dir = TempDir::new().unwrap();
1730        write_diamond(dir.path());
1731        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1732        std::fs::write(csr_path(dir.path(), "KNOWS", Direction::In), b"garbage").unwrap();
1733
1734        let issues = validate_adjacency_index(dir.path()).unwrap();
1735        assert_eq!(issues.len(), 1);
1736        assert!(matches!(
1737            &issues[0],
1738            AdjacencyValidationIssue::UnreadableCsr { rel, direction: Direction::In, .. }
1739                if rel == "KNOWS"
1740        ));
1741    }
1742
1743    #[test]
1744    fn validate_detects_missing_csr() {
1745        let dir = TempDir::new().unwrap();
1746        write_diamond(dir.path());
1747        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1748        std::fs::remove_file(csr_path(dir.path(), ALL_RELATIONS_STEM, Direction::Out)).unwrap();
1749
1750        let issues = validate_adjacency_index(dir.path()).unwrap();
1751        assert_eq!(
1752            issues,
1753            vec![AdjacencyValidationIssue::MissingCsr {
1754                rel: ALL_RELATIONS_STEM.to_owned(),
1755                direction: Direction::Out,
1756            }]
1757        );
1758    }
1759
1760    #[test]
1761    fn validate_detects_stale_generation_only_once() {
1762        let dir = TempDir::new().unwrap();
1763        write_diamond(dir.path());
1764        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1765        // Bump the counter without touching topology content: the index is
1766        // stale but its content still matches the (unchanged) edge files.
1767        crate::generation::bump_topology_generation(dir.path()).unwrap();
1768
1769        let issues = validate_adjacency_index(dir.path()).unwrap();
1770        assert_eq!(
1771            issues,
1772            vec![AdjacencyValidationIssue::StaleGeneration {
1773                manifest: 1,
1774                current: 2,
1775            }]
1776        );
1777    }
1778
1779    /// #765: a delta-covered index (stale by generation but an intact chain
1780    /// covers the gap) validates **clean** — no `StaleGeneration`, no
1781    /// `Mismatch` — because the base CSR + chain overlay equals a rebuild at
1782    /// the current generation.
1783    #[test]
1784    fn validate_clean_on_delta_covered_index() {
1785        let dir = TempDir::new().unwrap();
1786        write_diamond(dir.path());
1787        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1788
1789        // A pure-append flush writes a delta segment (bumps the generation).
1790        let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Strict, BUILD_TS).unwrap();
1791        let (a, b) = (new_v7(), new_v7());
1792        w.create_node(a, TypeId(0)).unwrap();
1793        w.create_node(b, TypeId(0)).unwrap();
1794        w.create_edge(new_v7(), "KNOWS", &a, &b).unwrap();
1795        w.flush().unwrap();
1796
1797        assert!(
1798            validate_adjacency_index(dir.path()).unwrap().is_empty(),
1799            "base + intact chain == current rebuild ⇒ no issues"
1800        );
1801        let inspection = inspect_adjacency_index(dir.path()).unwrap();
1802        assert_eq!(inspection.state, AdjacencyFreshnessState::Current);
1803        assert_eq!(
1804            inspection.artifact_effective_generation,
1805            Some(inspection.source_generation)
1806        );
1807        assert_eq!(
1808            inspection.artifact_fingerprint.as_deref(),
1809            Some(inspection.source_fingerprint.as_str())
1810        );
1811    }
1812
1813    /// A corrupt base CSR under a delta chain is still caught: the overlay diff
1814    /// against the current rebuild reports a `Mismatch` (delta coverage does not
1815    /// mask corruption).
1816    #[test]
1817    fn validate_detects_mismatch_under_delta_chain() {
1818        let dir = TempDir::new().unwrap();
1819        write_diamond(dir.path());
1820        build_adjacency_index(dir.path(), BUILD_TS).unwrap();
1821        let mut w = GraphWriter::open_at(dir.path(), OntologyMode::Strict, BUILD_TS).unwrap();
1822        let (a, b) = (new_v7(), new_v7());
1823        w.create_node(a, TypeId(0)).unwrap();
1824        w.create_node(b, TypeId(0)).unwrap();
1825        w.create_edge(new_v7(), "KNOWS", &a, &b).unwrap();
1826        w.flush().unwrap();
1827
1828        // Corrupt the base KNOWS.out CSR by overwriting it with an empty index
1829        // (the diamond has only KNOWS, so the _all CSR is identical and would
1830        // not be a corruption).
1831        let knows_out = csr_path(dir.path(), "KNOWS", Direction::Out);
1832        write_csr(&knows_out, &csr_from_entries(&[], Direction::Out)).unwrap();
1833
1834        let issues = validate_adjacency_index(dir.path()).unwrap();
1835        assert!(
1836            issues.contains(&AdjacencyValidationIssue::Mismatch {
1837                rel: "KNOWS".to_owned(),
1838                direction: Direction::Out,
1839            }),
1840            "corruption under a chain is still detected: {issues:?}"
1841        );
1842        let inspection = inspect_adjacency_index(dir.path()).unwrap();
1843        assert_eq!(inspection.state, AdjacencyFreshnessState::Incompatible);
1844        assert_eq!(
1845            inspection.artifact_effective_generation,
1846            Some(inspection.source_generation)
1847        );
1848        assert!(inspection.artifact_fingerprint.is_some());
1849    }
1850}