Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions crates/sparq-canon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ let map = sparq_canon::issued_identifiers(&[q]).unwrap(); // issuer map
return a `CanonicalGraph` (sorted canonical N-Quads lines + re-parsed canonical
triples) — what the ZK per-graph commitment pipeline consumes
(`leaf_index = line index`).
- **Fail-closed on poison graphs** — RDFC-1.0's pathological blow-ups hit the
HNDQ call-limit guard and surface as `CanonError::Canonicalization`; RDF 1.2
triple terms are outside the standard data model, so the standard paths fail
closed with `CanonError::TripleTerm` unless `rdf12-triple-terms` is enabled.
- **Fail-closed on poison graphs** — RDFC-1.0's pathological blow-ups hit the HNDQ call-limit guard
(`CanonError::Canonicalization`). RDF 1.2 triple terms and directional literals (`"…"@en--ltr`) are outside
RDFC-1.0: the standard paths fail closed with `CanonError::TripleTerm` / `CanonError::DirectionalLiteral`
unless the `rdf12-triple-terms` profile is enabled.
- **W3C-conformant** — validated against the official [rdf-canon test suite]
(eval + issued-map + negative cases, SHA-256 and SHA-384) through this crate's
own public API (`tests/rdf_canon_suite.rs`).
Expand Down
45 changes: 44 additions & 1 deletion crates/sparq-canon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@
//!
//! Triple terms (`Term::Triple`, the RDF-1.2 `<<( s p o )>>` object) are
//! **outside** the W3C RDFC-1.0 data model, so the standard paths above fail
//! closed with [`CanonError::TripleTerm`]. Enabling the **opt-in, off-by-default**
//! closed with [`CanonError::TripleTerm`]. RDF-1.2 directional-language
//! literals (`"…"@en--ltr`) are likewise outside that RDF-1.1 data model and
//! fail closed with [`CanonError::DirectionalLiteral`] on the standard paths.
//! Enabling the **opt-in, off-by-default**
//! `rdf12-triple-terms` cargo feature adds a *separate, clearly non-standard* v2
//! profile (`canonicalize_rdf12`, `canonicalize_triples_rdf12`, …) that
//! natively re-implements the RDFC-1.0 algorithm over oxrdf-0.3 and **descends
Expand Down Expand Up @@ -209,6 +212,14 @@ pub enum CanonError {
/// present (not feature-gated), per the `NestedBlankNode` precedent.
/// [FABLE-5] sq-x3oj2.
TripleTermDepthExceeded,
/// An RDF 1.2 directional-language literal (`"…"@lang--ltr` / `--rtl`,
/// [`oxrdf::BaseDirection`]) reached a **standard** (`rdf-canon`-backed)
/// entry point. RDFC-1.0 is defined over RDF 1.1, and the standard path's
/// oxrdf-0.2 bridge cannot represent a base direction, so these paths fail
/// closed with this typed error instead of a generic [`CanonError::Bridge`].
/// The opt-in, non-standard `rdf12-triple-terms` profile (`canonicalize_rdf12`
/// and siblings) canonicalizes directional literals natively. GitHub #5359.
DirectionalLiteral,
/// Bridge serialization/parse failure (should not happen for RDFC-1.0-model
/// content; surfaced rather than swallowed).
Bridge(String),
Expand Down Expand Up @@ -242,6 +253,13 @@ impl std::fmt::Display for CanonError {
triple terms are a stack-overflow vector for recursive descent)"
)
}
CanonError::DirectionalLiteral => {
write!(
f,
"RDF 1.2 directional-language literals are outside the W3C RDFC-1.0 \
data model; enable the `rdf12-triple-terms` profile to canonicalize them"
)
}
CanonError::Bridge(e) => write!(f, "oxrdf bridge error: {e}"),
CanonError::Canonicalization(e) => write!(f, "RDFC-1.0 canonicalization failed: {e}"),
}
Expand Down Expand Up @@ -422,6 +440,7 @@ pub fn graph_triples(g: &Graph) -> Result<Vec<Triple>, CanonError> {
/// literal word `DEFAULT`, so the default graph is emitted explicitly as a
/// 3-term line.
fn bridge_to_02(dataset: &[Quad]) -> Result<Vec<oxrdf02::Quad>, CanonError> {
reject_unbridgeable(dataset.iter().map(|q| &q.object))?;
let doc = serialize_quads(dataset)?;
parse_02(&doc)
}
Expand Down Expand Up @@ -479,6 +498,7 @@ fn serialize_quads(dataset: &[Quad]) -> Result<String, CanonError> {
}

fn bridge_triples_to_02(triples: &[Triple]) -> Result<Vec<oxrdf02::Quad>, CanonError> {
reject_unbridgeable(triples.iter().map(|t| &t.object))?;
let mut doc = String::new();
#[cfg(feature = "bridge-lowcopy")]
use std::fmt::Write as _;
Expand Down Expand Up @@ -538,6 +558,29 @@ fn parse_canonical(canonical: &str) -> Result<CanonicalGraph, CanonError> {
Ok(CanonicalGraph { lines, triples })
}

/// Fails closed on object terms the RDF-1.1 oxrdf-0.2 bridge cannot carry,
/// before serialization: triple terms ([`CanonError::TripleTerm`], which takes
/// precedence) and directional-language literals
/// ([`CanonError::DirectionalLiteral`]). Literals only occur in object
/// position, and triple terms are rejected outright, so the top-level objects
/// are the only places a base direction can appear. GitHub #5359.
fn reject_unbridgeable<'a>(
objects: impl Iterator<Item = &'a oxrdf::Term>,
) -> Result<(), CanonError> {
let mut directional = false;
for o in objects {
match o {
oxrdf::Term::Triple(_) => return Err(CanonError::TripleTerm),
oxrdf::Term::Literal(l) if l.direction().is_some() => directional = true,
_ => {}
}
}
if directional {
return Err(CanonError::DirectionalLiteral);
}
Ok(())
}

fn contains_triple_term(t: &Triple) -> bool {
matches!(t.object, oxrdf::Term::Triple(_))
}
Expand Down
71 changes: 71 additions & 0 deletions crates/sparq-canon/tests/directional_literal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! GitHub #5359: RDF 1.2 directional-language literals (`"…"@en--ltr`) are
//! outside RDFC-1.0's RDF-1.1 data model and cannot cross the standard path's
//! oxrdf-0.2 bridge. Every standard entry point must fail closed with the typed
//! `CanonError::DirectionalLiteral`, not a generic `CanonError::Bridge`.

use oxrdf::{BaseDirection, BlankNode, GraphName, Literal, NamedNode, Quad, Triple};
use sparq_canon::CanonError;

fn directional_triple() -> Triple {
Triple::new(
BlankNode::new("b0").unwrap(),
NamedNode::new("http://ex/p").unwrap(),
Literal::new_directional_language_tagged_literal("hello", "en", BaseDirection::Ltr)
.unwrap(),
)
}

fn directional_quad() -> Quad {
let t = directional_triple();
Quad::new(t.subject, t.predicate, t.object, GraphName::DefaultGraph)
}

fn assert_directional<T: std::fmt::Debug>(r: Result<T, CanonError>) {
match r {
Err(CanonError::DirectionalLiteral) => {}
other => panic!("expected CanonError::DirectionalLiteral, got {other:?}"),
}
}

#[test]
fn standard_dataset_paths_reject_directional_literal_with_typed_error() {
let ds = [directional_quad()];
assert_directional(sparq_canon::canonicalize(&ds));
assert_directional(sparq_canon::canonicalize_quads(&ds));
assert_directional(sparq_canon::issue_quads(&ds));
assert_directional(sparq_canon::issued_identifiers(&ds));
}

#[test]
fn standard_single_graph_paths_reject_directional_literal_with_typed_error() {
let ts = [directional_triple()];
assert_directional(sparq_canon::canonicalize_triples(&ts));
assert_directional(sparq_canon::issue_triples(&ts));
}

#[test]
fn nquads_text_entry_point_rejects_directional_literal_with_typed_error() {
let doc = "_:b0 <http://ex/p> \"hello\"@en--rtl .\n";
assert_directional(sparq_canon::canonicalize_nquads(doc));
}

#[test]
fn directional_literal_error_names_the_rdf12_profile() {
let msg = CanonError::DirectionalLiteral.to_string();
assert!(msg.contains("rdf12-triple-terms"), "{msg}");
}

#[test]
fn plain_language_literal_still_canonicalizes() {
let doc = "_:b0 <http://ex/p> \"hello\"@en .\n";
let canon = sparq_canon::canonicalize_nquads(doc).unwrap();
assert_eq!(canon, "_:c14n0 <http://ex/p> \"hello\"@en .\n");
}

/// The opt-in non-standard profile canonicalizes directional literals natively.
#[cfg(feature = "rdf12-triple-terms")]
#[test]
fn rdf12_profile_canonicalizes_directional_literal() {
let canon = sparq_canon::rdf12::canonicalize_rdf12(&[directional_quad()]).unwrap();
assert_eq!(canon, "_:c14n0 <http://ex/p> \"hello\"@en--ltr .\n");
}
42 changes: 28 additions & 14 deletions crates/sparq-gpu/examples/gpu_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ impl Rng {
}

const PAR_CHUNK: usize = 64 * 1024;
/// A kernel call only fails if the device stalls past `sparq_gpu::POLL_TIMEOUT`;
/// the panic then unwinds safely (a stalled `Gpu` leaks rather than waits on drop).
const GPU_OK: &str = "GPU kernel stalled past POLL_TIMEOUT";

/// A named benchmark leg returning a checksum (asserted equal across legs).
type Variant<'a> = (&'a str, Box<dyn FnMut() -> u64 + 'a>);
Expand Down Expand Up @@ -122,7 +125,7 @@ fn main() {
let col: Vec<u32> = (0..n).map(|_| rng.u32()).collect();
let (lo, hi) = (0u32, u32::MAX / 8);
let resident = gpu.upload_u32(&col);
let _ = gpu.filter_count_u32(&resident, lo, hi); // warm pipeline/caches
let _ = gpu.filter_count_u32(&resident, lo, hi).expect(GPU_OK); // warm pipeline/caches

let mut variants: Vec<Variant> = vec![
("cpu1", Box::new(|| cpu::filter_count_u32(&col, lo, hi))),
Expand All @@ -136,13 +139,13 @@ fn main() {
),
(
"gpu resident",
Box::new(|| gpu.filter_count_u32(&resident, lo, hi)),
Box::new(|| gpu.filter_count_u32(&resident, lo, hi).expect(GPU_OK)),
),
(
"gpu e2e",
Box::new(|| {
gpu.write_u32(&resident, &col);
gpu.filter_count_u32(&resident, lo, hi)
gpu.filter_count_u32(&resident, lo, hi).expect(GPU_OK)
}),
),
];
Expand All @@ -163,7 +166,7 @@ fn main() {
let col: Vec<f64> = (0..n).map(|_| rng.u32() as f64 / 1e3).collect();
let t = u32::MAX as f64 / 1e3 * 0.875;
let resident = gpu.upload_f64(&col);
let _ = gpu.filter_count_f64_gt(&resident, t);
let _ = gpu.filter_count_f64_gt(&resident, t).expect(GPU_OK);

let mut variants: Vec<Variant> = vec![
("cpu1", Box::new(|| cpu::filter_count_f64_gt(&col, t))),
Expand All @@ -177,13 +180,13 @@ fn main() {
),
(
"gpu resident",
Box::new(|| gpu.filter_count_f64_gt(&resident, t)),
Box::new(|| gpu.filter_count_f64_gt(&resident, t).expect(GPU_OK)),
),
(
"gpu e2e",
Box::new(|| {
gpu.write_f64(&resident, &col);
gpu.filter_count_f64_gt(&resident, t)
gpu.filter_count_f64_gt(&resident, t).expect(GPU_OK)
}),
),
];
Expand Down Expand Up @@ -212,7 +215,7 @@ fn main() {
let slots = cpu::build_hash_table(&build_keys, &payloads);
let table = gpu.upload_table(&slots);
let probe_col = gpu.upload_u32(&probe);
let _ = gpu.hash_probe(&table, &probe_col);
let _ = gpu.hash_probe(&table, &probe_col).expect(GPU_OK);

let mut variants: Vec<Variant> = vec![
(
Expand All @@ -235,7 +238,7 @@ fn main() {
(
"gpu resident",
Box::new(|| {
let (m, s) = gpu.hash_probe(&table, &probe_col);
let (m, s) = gpu.hash_probe(&table, &probe_col).expect(GPU_OK);
m.wrapping_add(s)
}),
),
Expand All @@ -244,7 +247,7 @@ fn main() {
Box::new(|| {
gpu.write_table(&table, &slots);
gpu.write_u32(&probe_col, &probe);
let (m, s) = gpu.hash_probe(&table, &probe_col);
let (m, s) = gpu.hash_probe(&table, &probe_col).expect(GPU_OK);
m.wrapping_add(s)
}),
),
Expand All @@ -263,12 +266,15 @@ fn main() {
continue;
}
const G: u32 = 256;
const KEYS_IN_RANGE: &str = "keys are generated `% G`";
let mut rng = Rng(0x0F0F_F0F0_1337_4242);
let keys: Vec<u32> = (0..n).map(|_| rng.u32() % G).collect();
let vals: Vec<u32> = (0..n).map(|_| rng.u32()).collect();
let keys_col = gpu.upload_u32(&keys);
let vals_col = gpu.upload_u32(&vals);
let _ = gpu.group_aggregate(&keys_col, &vals_col, G);
let _ = gpu
.group_aggregate(&keys_col, &vals_col, G)
.expect(KEYS_IN_RANGE);

let fold = |rows: Vec<(u64, u64)>| -> u64 {
rows.iter().fold(0u64, |acc, (c, s)| {
Expand All @@ -286,29 +292,37 @@ fn main() {
let mut variants: Vec<Variant> = vec![
(
"cpu1",
Box::new(|| fold(cpu::group_aggregate(&keys, &vals, G))),
Box::new(|| fold(cpu::group_aggregate(&keys, &vals, G).expect(KEYS_IN_RANGE))),
),
(
"cpuN",
Box::new(|| {
let rows = keys
.par_chunks(PAR_CHUNK)
.zip(vals.par_chunks(PAR_CHUNK))
.map(|(k, v)| cpu::group_aggregate(k, v, G))
.map(|(k, v)| cpu::group_aggregate(k, v, G).expect(KEYS_IN_RANGE))
.reduce(|| vec![(0, 0); G as usize], merge);
fold(rows)
}),
),
(
"gpu resident",
Box::new(|| fold(gpu.group_aggregate(&keys_col, &vals_col, G))),
Box::new(|| {
fold(
gpu.group_aggregate(&keys_col, &vals_col, G)
.expect(KEYS_IN_RANGE),
)
}),
),
(
"gpu e2e",
Box::new(|| {
gpu.write_u32(&keys_col, &keys);
gpu.write_u32(&vals_col, &vals);
fold(gpu.group_aggregate(&keys_col, &vals_col, G))
fold(
gpu.group_aggregate(&keys_col, &vals_col, G)
.expect(KEYS_IN_RANGE),
)
}),
),
];
Expand Down
Loading
Loading