use std::{
fs,
path::{Path, PathBuf},
};
use serde_json::Value;
use super::CugraphAlgorithm;
pub const CUGRAPH_DATA_DIR_FROM_CARGO_MANIFEST: &str = "fixture/graph/cugraph";
pub const CUGRAPH_DATA_MANIFEST_PATH: &str = "fixture/graph/cugraph/manifest.json";
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParityComparator {
ExactInt,
Numeric,
PartitionEquivalence,
PartitionQuality,
SeededSequence,
RankCorrelation,
EdgeSubgraph,
TopKVertices,
SimilarityMultiset,
}
impl ParityComparator {
pub const fn as_str(self) -> &'static str {
match self {
Self::ExactInt => "exact_int",
Self::Numeric => "numeric",
Self::PartitionEquivalence => "partition_equivalence",
Self::PartitionQuality => "partition_quality",
Self::SeededSequence => "seeded_sequence",
Self::RankCorrelation => "rank_correlation",
Self::EdgeSubgraph => "edge_subgraph",
Self::TopKVertices => "top_k_vertices",
Self::SimilarityMultiset => "similarity_multiset",
}
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParityTruthSource {
OracleJson,
RefCsv { path: &'static str },
HandVerified { document_json: &'static str },
}
impl ParityTruthSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::OracleJson => "oracle_json",
Self::RefCsv { .. } => "ref_csv",
Self::HandVerified { .. } => "hand_verified",
}
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParityTolerance {
pub abs: f64,
pub rel: f64,
}
impl ParityTolerance {
pub const fn exact() -> Self {
Self { abs: 0.0, rel: 0.0 }
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub struct ParitySpec {
pub label: &'static str,
pub algorithm: CugraphAlgorithm,
pub dataset: &'static str,
pub params_json: &'static str,
pub seed: Option<u64>,
pub truth_source: ParityTruthSource,
pub oracle_relpath: Option<&'static str>,
pub comparator: ParityComparator,
pub tolerance: ParityTolerance,
}
#[doc(hidden)]
pub const CUGRAPH_PARITY_SPECS: &[ParitySpec] = &[
ParitySpec {
label: "pagerank/karate@alpha=0.85",
algorithm: CugraphAlgorithm::PageRank,
dataset: "karate",
params_json: r#"{"alpha":0.85,"epsilon":1e-05,"max_iterations":100}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/pagerank__alpha_0_85.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "personalized_pagerank/karate@vertex=0",
algorithm: CugraphAlgorithm::PersonalizedPageRank,
dataset: "karate",
params_json: r#"{"alpha":0.85,"epsilon":1e-05,"max_iterations":100,"personalization_table":"ppr_seeds","personalization_vertex_col":"vertex","personalization_value_col":"value","personalization_vertices":[0],"personalization_values":[1.0]}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/personalized_pagerank__vertex_0.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "bfs/karate@src=0",
algorithm: CugraphAlgorithm::Bfs,
dataset: "karate",
params_json: r#"{"source_vertex":0,"depth_limit":null}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/bfs__source_0.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "louvain/karate@default",
algorithm: CugraphAlgorithm::Louvain,
dataset: "karate",
params_json: r#"{"max_level":100,"threshold":1e-07,"resolution":1.0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/louvain__resolution_1_0.json"),
comparator: ParityComparator::PartitionEquivalence,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "eigenvector_centrality/karate@default",
algorithm: CugraphAlgorithm::EigenvectorCentrality,
dataset: "karate",
params_json: r#"{"epsilon":1e-06,"max_iterations":200}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/eigenvector_centrality__default.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "hits/karate@default",
algorithm: CugraphAlgorithm::Hits,
dataset: "karate",
params_json: r#"{"epsilon":1e-05,"max_iterations":100,"normalize":false}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/hits__default.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "betweenness_centrality/karate@seeds=0,1,2",
algorithm: CugraphAlgorithm::BetweennessCentrality,
dataset: "karate",
params_json: r#"{"seeds":[0,1,2],"normalized":true,"include_endpoints":false,"exact_vertex_threshold":100000}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/betweenness_centrality__seeds_0_1_2.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "edge_betweenness_centrality/karate@seeds=0,1,2",
algorithm: CugraphAlgorithm::EdgeBetweennessCentrality,
dataset: "karate",
params_json: r#"{"seeds":[0,1,2],"normalized":true,"exact_vertex_threshold":100000}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/edge_betweenness_centrality__seeds_0_1_2.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-5, rel: 1.0e-3 },
},
ParitySpec {
label: "katz_centrality/karate@ref",
algorithm: CugraphAlgorithm::KatzCentrality,
dataset: "karate",
params_json: r#"{"alpha":0.01,"beta":1.0,"epsilon":1e-06,"max_iterations":1000}"#,
seed: None,
truth_source: ParityTruthSource::RefCsv { path: "ref/katz/karate.csv" },
oracle_relpath: Some("karate/katz_centrality__default.json"),
comparator: ParityComparator::TopKVertices,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "sssp/karate@source=0",
algorithm: CugraphAlgorithm::Sssp,
dataset: "karate",
params_json: r#"{"source_vertex":0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/sssp__source_0.json"),
comparator: ParityComparator::Numeric,
tolerance: ParityTolerance { abs: 1.0e-9, rel: 1.0e-9 },
},
ParitySpec {
label: "degrees_all/karate@default",
algorithm: CugraphAlgorithm::DegreesAll,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/degrees_all__default.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "in_degrees_all/karate@default",
algorithm: CugraphAlgorithm::InDegreesAll,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/in_degrees_all__default.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "out_degrees_all/karate@default",
algorithm: CugraphAlgorithm::OutDegreesAll,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/out_degrees_all__default.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "weakly_connected_components/karate@default",
algorithm: CugraphAlgorithm::WeaklyConnectedComponents,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/weakly_connected_components__default.json"),
comparator: ParityComparator::PartitionEquivalence,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "strongly_connected_components/karate@default",
algorithm: CugraphAlgorithm::StronglyConnectedComponents,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/strongly_connected_components__default.json"),
comparator: ParityComparator::PartitionEquivalence,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "triangle_count_all/karate@default",
algorithm: CugraphAlgorithm::TriangleCountAll,
dataset: "karate",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/triangle_count_all__default.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "core_number/karate@default",
algorithm: CugraphAlgorithm::CoreNumber,
dataset: "karate",
params_json: r#"{"degree_type":"in_out"}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/core_number__default.json"),
comparator: ParityComparator::ExactInt,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "k_core/karate@k=2",
algorithm: CugraphAlgorithm::KCore,
dataset: "karate",
params_json: r#"{"k":2,"degree_type":"in_out"}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/k_core__k_2.json"),
comparator: ParityComparator::EdgeSubgraph,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "minimum_spanning_tree/toy_graph@default",
algorithm: CugraphAlgorithm::MinimumSpanningTree,
dataset: "toy_graph",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("toy_graph/minimum_spanning_tree__default.json"),
comparator: ParityComparator::EdgeSubgraph,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "leiden/karate@default",
algorithm: CugraphAlgorithm::Leiden,
dataset: "karate",
params_json: r#"{"max_level":100,"resolution":1.0,"seed":0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/leiden__default.json"),
comparator: ParityComparator::PartitionEquivalence,
tolerance: ParityTolerance::exact(),
},
ParitySpec {
label: "ecg/karate@default",
algorithm: CugraphAlgorithm::Ecg,
dataset: "karate",
params_json: r#"{"min_weight":0.001,"ensemble_size":10,"max_level":100,"threshold":1e-07,"resolution":1.0,"seed":0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/ecg__default.json"),
comparator: ParityComparator::PartitionQuality,
tolerance: ParityTolerance { abs: 5.0e-2, rel: 0.0 },
},
ParitySpec {
label: "spectral_modularity_maximization/karate@default",
algorithm: CugraphAlgorithm::SpectralModularityMaximization,
dataset: "karate",
params_json: r#"{"n_clusters":2,"n_eigenvectors":2,"evs_tolerance":0.01,"seed":0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/spectral_modularity_maximization__default.json"),
comparator: ParityComparator::PartitionQuality,
tolerance: ParityTolerance { abs: 5.0e-2, rel: 0.0 },
},
ParitySpec {
label: "force_atlas2/karate@default",
algorithm: CugraphAlgorithm::ForceAtlas2,
dataset: "karate",
params_json: r#"{"max_iter":500,"outbound_attraction_distribution":false,"lin_log_mode":false,"prevent_overlapping":false,"overlap_scaling_ratio":2.0,"edge_weight_influence":1.0,"jitter_tolerance":1.0,"barnes_hut_optimize":false,"barnes_hut_theta":0.5,"scaling_ratio":2.0,"strong_gravity_mode":false,"gravity":1.0,"verbose":false,"seed":0}"#,
seed: None,
truth_source: ParityTruthSource::OracleJson,
oracle_relpath: Some("karate/force_atlas2__default.json"),
comparator: ParityComparator::RankCorrelation,
tolerance: ParityTolerance { abs: 0.20, rel: 0.02 },
},
ParitySpec {
label: "jaccard/toy_graph@explicit_pairs",
algorithm: CugraphAlgorithm::Jaccard,
dataset: "toy_graph",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::HandVerified {
document_json: r#"{"result":{"first":[0,0,3,0,1],"second":[3,3,0,0,4],"similarity":[0.6666666666666666,0.6666666666666666,0.6666666666666666,1.0,0.0]}}"#,
},
oracle_relpath: None,
comparator: ParityComparator::SimilarityMultiset,
tolerance: ParityTolerance { abs: 1.0e-6, rel: 1.0e-6 },
},
ParitySpec {
label: "sorensen/toy_graph@explicit_pairs",
algorithm: CugraphAlgorithm::Sorensen,
dataset: "toy_graph",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::HandVerified {
document_json: r#"{"result":{"first":[0,0,3,0,1],"second":[3,3,0,0,4],"similarity":[0.8,0.8,0.8,1.0,0.0]}}"#,
},
oracle_relpath: None,
comparator: ParityComparator::SimilarityMultiset,
tolerance: ParityTolerance { abs: 1.0e-6, rel: 1.0e-6 },
},
ParitySpec {
label: "overlap/toy_graph@explicit_pairs",
algorithm: CugraphAlgorithm::Overlap,
dataset: "toy_graph",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::HandVerified {
document_json: r#"{"result":{"first":[0,0,3,0,1],"second":[3,3,0,0,4],"similarity":[1.0,1.0,1.0,1.0,0.0]}}"#,
},
oracle_relpath: None,
comparator: ParityComparator::SimilarityMultiset,
tolerance: ParityTolerance { abs: 1.0e-6, rel: 1.0e-6 },
},
ParitySpec {
label: "cosine/toy_graph@explicit_pairs",
algorithm: CugraphAlgorithm::Cosine,
dataset: "toy_graph",
params_json: r#"{}"#,
seed: None,
truth_source: ParityTruthSource::HandVerified {
document_json: r#"{"result":{"first":[0,0,3,0,1],"second":[3,3,0,0,4],"similarity":[0.816496580927726,0.816496580927726,0.816496580927726,1.0,0.0]}}"#,
},
oracle_relpath: None,
comparator: ParityComparator::SimilarityMultiset,
tolerance: ParityTolerance { abs: 1.0e-6, rel: 1.0e-6 },
},
];
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct ParityDataset {
pub id: String,
pub path: String,
pub format: String,
pub edge_columns: Vec<String>,
pub vertices: u64,
pub edges: u64,
pub directed: bool,
pub weighted: bool,
pub sha256: String,
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct ParityReference {
pub id: String,
pub path: String,
pub format: String,
pub columns: Vec<String>,
pub dataset: String,
pub sha256: String,
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct ParityManifest {
pub source_repo: String,
pub license: String,
pub datasets: Vec<ParityDataset>,
pub references: Vec<ParityReference>,
}
impl ParityManifest {
pub fn dataset(&self, id: &str) -> Option<&ParityDataset> {
self.datasets.iter().find(|d| d.id == id)
}
}
#[doc(hidden)]
#[derive(Debug)]
pub enum ParityManifestError {
MissingManifest { path: PathBuf, source: std::io::Error },
InvalidManifest { path: PathBuf, source: serde_json::Error },
}
impl std::fmt::Display for ParityManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingManifest { path, source } => {
write!(f, "cuGraph manifest missing at {}: {source}", path.display())
},
Self::InvalidManifest { path, source } => {
write!(f, "cuGraph manifest at {} could not be parsed: {source}", path.display())
},
}
}
}
impl std::error::Error for ParityManifestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::MissingManifest { source, .. } => Some(source),
Self::InvalidManifest { source, .. } => Some(source),
}
}
}
pub fn data_root() -> PathBuf {
if let Ok(env) = std::env::var("ALGEON_CUGRAPH_DATA_ROOT") {
return PathBuf::from(env);
}
let repository = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("adapter manifest must be two levels below the repository root");
repository.join(CUGRAPH_DATA_DIR_FROM_CARGO_MANIFEST)
}
pub fn load_manifest(data_root: &Path) -> Result<ParityManifest, ParityManifestError> {
let manifest_path = data_root.join("manifest.json");
let raw = fs::read_to_string(&manifest_path)
.map_err(|source| ParityManifestError::MissingManifest { path: manifest_path.clone(), source })?;
let value: Value = serde_json::from_str(&raw)
.map_err(|source| ParityManifestError::InvalidManifest { path: manifest_path.clone(), source })?;
let datasets = value
.get("datasets")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.map(parity_dataset_from_json)
.collect::<Vec<_>>();
let references = value
.get("references")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.map(parity_reference_from_json)
.collect::<Vec<_>>();
Ok(ParityManifest {
source_repo: value.get("source_repo").and_then(|v| v.as_str()).unwrap_or("").to_string(),
license: value.get("license").and_then(|v| v.as_str()).unwrap_or("").to_string(),
datasets,
references,
})
}
fn parity_dataset_from_json(v: &Value) -> ParityDataset {
ParityDataset {
id: v.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
path: v.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(),
format: v.get("format").and_then(|v| v.as_str()).unwrap_or("").to_string(),
edge_columns: v
.get("edge_columns")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|s| s.as_str().map(str::to_string))
.collect(),
vertices: v.get("vertices").and_then(|v| v.as_u64()).unwrap_or(0),
edges: v.get("edges").and_then(|v| v.as_u64()).unwrap_or(0),
directed: v.get("directed").and_then(|v| v.as_bool()).unwrap_or(false),
weighted: v.get("weighted").and_then(|v| v.as_bool()).unwrap_or(false),
sha256: v.get("sha256").and_then(|v| v.as_str()).unwrap_or("").to_string(),
}
}
fn parity_reference_from_json(v: &Value) -> ParityReference {
ParityReference {
id: v.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
path: v.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string(),
format: v.get("format").and_then(|v| v.as_str()).unwrap_or("").to_string(),
columns: v
.get("columns")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|s| s.as_str().map(str::to_string))
.collect(),
dataset: v.get("dataset").and_then(|v| v.as_str()).unwrap_or("").to_string(),
sha256: v.get("sha256").and_then(|v| v.as_str()).unwrap_or("").to_string(),
}
}
pub fn oracle_path(data_root: &Path, spec: &ParitySpec) -> Option<PathBuf> {
spec.oracle_relpath.map(|path| data_root.join("oracle").join(path))
}
#[doc(hidden)]
#[derive(Debug)]
pub enum RefCsvError {
Missing { path: PathBuf, source: std::io::Error },
InvalidVertex { path: PathBuf, line: usize, value: String },
}
impl std::fmt::Display for RefCsvError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing { path, source } => {
write!(f, "reference CSV missing at {}: {source}", path.display())
},
Self::InvalidVertex { path, line, value } => {
write!(f, "reference CSV at {} line {line} has non-integer vertex {value:?}", path.display())
},
}
}
}
impl std::error::Error for RefCsvError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Missing { source, .. } => Some(source),
Self::InvalidVertex { .. } => None,
}
}
}
pub fn load_ref_csv_vertex_list(data_root: &Path, relpath: &str) -> Result<Vec<i64>, RefCsvError> {
let path = data_root.join(relpath);
let raw = fs::read_to_string(&path).map_err(|source| RefCsvError::Missing { path: path.clone(), source })?;
let mut vertices = Vec::new();
for (idx, line) in raw.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value = trimmed.parse::<i64>().map_err(|_| RefCsvError::InvalidVertex {
path: path.clone(),
line: idx + 1,
value: trimmed.to_string(),
})?;
vertices.push(value);
}
Ok(vertices)
}
#[doc(hidden)]
#[derive(Debug)]
pub enum OracleLookup {
Present { document: Value, path: PathBuf },
Missing { path: PathBuf },
Mismatch { path: PathBuf, reason: String },
Invalid { path: PathBuf, reason: String },
NotApplicable,
}
pub fn load_oracle(data_root: &Path, spec: &ParitySpec, manifest: Option<&ParityManifest>) -> OracleLookup {
let Some(path) = oracle_path(data_root, spec) else {
return OracleLookup::NotApplicable;
};
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return OracleLookup::Missing { path },
Err(error) => return OracleLookup::Invalid { path, reason: format!("read failed: {error}") },
};
let document: Value = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(error) => return OracleLookup::Invalid { path, reason: format!("invalid JSON: {error}") },
};
if let Err(reason) = check_oracle_header(&document, spec, manifest) {
return OracleLookup::Mismatch { path, reason };
}
OracleLookup::Present { document, path }
}
fn check_oracle_header(document: &Value, spec: &ParitySpec, manifest: Option<&ParityManifest>) -> Result<(), String> {
let header = document.get("header").ok_or_else(|| "oracle JSON is missing a header object".to_string())?;
let dataset = header.get("dataset").and_then(|v| v.as_str()).unwrap_or("");
if dataset != spec.dataset {
return Err(format!("oracle header dataset='{dataset}' but parity spec expects '{}'", spec.dataset));
}
let algorithm = header.get("algorithm").and_then(|v| v.as_str()).unwrap_or("");
let expected_algorithm = spec.algorithm.native_algorithm().as_str();
if algorithm != expected_algorithm {
return Err(format!("oracle header algorithm='{algorithm}' but parity spec expects '{expected_algorithm}'"));
}
let params = header.get("params").cloned().unwrap_or(Value::Null);
let expected_params: Value = serde_json::from_str(spec.params_json)
.map_err(|error| format!("parity spec params_json is not valid JSON: {error}"))?;
if !json_equal_numerically(¶ms, &expected_params) {
return Err(format!("oracle header params {params} but parity spec expects {expected_params}"));
}
if let Some(manifest) = manifest {
let dataset_entry = manifest.dataset(spec.dataset).ok_or_else(|| {
format!(
"parity spec dataset '{}' is not present in the manifest; refresh the dataset list before validating the oracle",
spec.dataset,
)
})?;
let header_sha = header.get("dataset_sha256").and_then(|v| v.as_str()).unwrap_or("");
if header_sha != dataset_entry.sha256 {
return Err(format!(
"oracle header dataset_sha256='{header_sha}' but manifest expects '{}' for dataset '{}'; \
the local dataset bytes changed since this oracle was generated; refresh it",
dataset_entry.sha256, spec.dataset,
));
}
}
Ok(())
}
fn json_equal_numerically(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(a), Value::Number(b)) => match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) => x == y,
_ => a == b,
},
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(l, r)| json_equal_numerically(l, r))
},
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len() && a.iter().all(|(k, v)| b.get(k).is_some_and(|w| json_equal_numerically(v, w)))
},
_ => left == right,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parity_specs_have_unique_oracle_paths() {
let mut seen = std::collections::BTreeSet::new();
for spec in CUGRAPH_PARITY_SPECS {
let Some(path) = spec.oracle_relpath else {
continue;
};
assert!(seen.insert(path), "oracle path {} appears twice in parity specs", path);
}
}
#[test]
fn json_equal_numerically_treats_integer_and_float_as_equivalent() {
assert!(json_equal_numerically(
&serde_json::json!({"alpha": 0.85, "max_iterations": 100}),
&serde_json::json!({"alpha": 0.85, "max_iterations": 100.0}),
));
assert!(!json_equal_numerically(&serde_json::json!({"alpha": 0.85}), &serde_json::json!({"alpha": 0.86}),));
}
#[test]
fn ref_csv_loader_reads_vertex_list_top_to_bottom() {
let root = data_root();
let vertices = load_ref_csv_vertex_list(&root, "ref/katz/karate.csv")
.expect("local Katz reference CSV must parse cleanly");
assert!(!vertices.is_empty(), "karate Katz reference must list at least one vertex");
assert_eq!(vertices.first().copied(), Some(33), "first ref vertex is the top-Katz vertex");
assert!(vertices.iter().all(|v| (0..=33).contains(v)), "karate vertices are 0..=33; got {vertices:?}");
}
#[test]
fn ref_csv_loader_reports_unparseable_line() {
let tmp_dir = std::env::temp_dir();
let file_name = format!("cugraph_parity_invalid_{}.csv", std::process::id());
let tmp = tmp_dir.join(&file_name);
std::fs::write(&tmp, "33\nnot-an-int\n0\n").unwrap();
let err = load_ref_csv_vertex_list(&tmp_dir, &file_name)
.expect_err("non-integer vertex must surface as InvalidVertex");
match err {
RefCsvError::InvalidVertex { line, value, .. } => {
assert_eq!(line, 2);
assert_eq!(value, "not-an-int");
},
other => panic!("expected InvalidVertex, got {other:?}"),
}
let _ = std::fs::remove_file(tmp);
}
}