diff --git a/CHANGELOG.md b/CHANGELOG.md index 282da3b..e437b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ once we cut v1.0; pre-1.0 minor bumps may include breaking changes. ## [Unreleased] +## [0.6.32] — 2026-08-16 + +The truth cut: canonical graph identity (#117), and two silent failure +modes become refusals (#109, #123). + +### Added + +- **`pgrdf.graph_digest(graph_id) -> text`** — RDFC-1.0 canonical graph + digest: identity of *meaning* that survives reload. Blank nodes are + existential variables whose labels re-mint on every parse, so byte + digests identify one stored copy and nothing more; `graph_digest` + canonically relabels (W3C RDFC-1.0), serializes canonical N-Triples, + and hashes — isomorphic graphs produce **equal** digests, unequal + digests **prove** difference. Algorithm label `rdfc-1.0-sha256`; + values are deliberately incomparable with byte digests or first-degree + structural pins. Asserted triples only (inferred is a check value). + Conformance is proven, not claimed: a vendored ten-case subset of the + W3C rdf-canon suite passes with our digest equal to the sha256 of the + suite's *own* expected canonical documents, byte-for-byte — and the + suite's poison graph (test074, a NegativeEvalTest) hits the resource + budgets and **raises `pgRDF#117`**, which is precisely the conforming + behaviour: refusing under budget is what the spec demands there. + Downstream: adoption pins and fork/severance re-matching upgrade from + evidence-grade to proof-grade the moment a consumer cites this digest. + +### Fixed + +- **`VALUES` binding a graph variable silently widened to every graph** + (#109) — re-confirmed live before coding: three nonexistent graphs in + the `VALUES` and the query answered for every graph in the store. Now + refuses with a stable `pgRDF#109` error naming the rewrite, on both + assembly paths, and increments `filter_clauses_dropped`. `VALUES` on + plain variables is regression-pinned as still applying. +- **The staged loader hung silently — and uncancellably — inside + transaction blocks** (#123): staged workers commit per phase, which a + caller's transaction can never allow; measured 31 minutes at 0% CPU + with `statement_timeout` never firing. A direct + `load_turtle_staged_run` in a transaction block now raises `pgRDF#123` + with the rewrite before any slot is taken, and `load_turtle`'s + N-Triples auto-dispatch falls back to the standard parser there. + The deeper cancellability gap (the coordinator's wait observes no + interrupts in *any* context) is filed as #125 with the measurement. + ## [0.6.31] — 2026-08-16 The loader records the source byte digest (#118). Closes a defect class a diff --git a/Cargo.lock b/Cargo.lock index 1e82662..14d435f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "pgrdf" -version = "0.6.31" +version = "0.6.32" dependencies = [ "oxrdf", "oxttl", diff --git a/Cargo.toml b/Cargo.toml index d5a5497..14dc88e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pgrdf" -version = "0.6.31" +version = "0.6.32" # #72: edition 2024 + resolver "3" — pgrx 0.17+ migrated and 0.19 # restated it; staying on 2021 was drift, not breakage. resolver "3" # is edition 2024's default, declared explicitly because the workspace diff --git a/META.json b/META.json index 01f7824..e7b2cdc 100644 --- a/META.json +++ b/META.json @@ -2,7 +2,7 @@ "name": "pgrdf", "abstract": "Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning", "description": "pgRDF turns one PostgreSQL instance into a complete semantic-web engine — dictionary-encoded hexastore storage, a SPARQL 1.1 query and update engine, a W3C-conformant SHACL Core validator (25/25), and an OWL 2 RL / RDFS reasoner — with no sidecar triple store and no second system to operate. Load Turtle, TriG or N-Quads, then reason over it, validate it, and query it in place, each step a single function call inside one PostgreSQL session. Supports PostgreSQL 14–18; every release is CI-built and signed with SLSA Build Provenance v1.", - "version": "0.6.31", + "version": "0.6.32", "maintainer": [ "Peter Styk" ], @@ -13,7 +13,7 @@ "abstract": "Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning", "file": "pgrdf.control", "docfile": "README.pgxn.md", - "version": "0.6.31" + "version": "0.6.32" } }, "prereqs": { diff --git a/compose/compose.yml b/compose/compose.yml index fdae45c..be371c2 100644 --- a/compose/compose.yml +++ b/compose/compose.yml @@ -108,7 +108,7 @@ services: # stock extensions instead of hiding them. - ./extensions/lib/pgrdf.so:/usr/lib/postgresql/18/lib/pgrdf.so:ro,z - ./extensions/share/extension/pgrdf.control:/usr/share/postgresql/18/extension/pgrdf.control:ro,z - - ./extensions/share/extension/pgrdf--0.6.31.sql:/usr/share/postgresql/18/extension/pgrdf--0.6.31.sql:ro,z + - ./extensions/share/extension/pgrdf--0.6.32.sql:/usr/share/postgresql/18/extension/pgrdf--0.6.32.sql:ro,z # Read-only ontology + regression fixtures, reachable from the # postgres process at /fixtures (see pgrdf.load_turtle path arg). - ../fixtures:/fixtures:ro,z diff --git a/pgrdf.control b/pgrdf.control index 7970179..67fff16 100644 --- a/pgrdf.control +++ b/pgrdf.control @@ -16,7 +16,7 @@ # pgCK's pgck.control carries `requires = 'pgrdf, pgcrypto'`. comment = 'Rust-native PostgreSQL extension for RDF, SPARQL, SHACL and OWL reasoning' -default_version = '0.6.31' +default_version = '0.6.32' # Bare name, NOT '$libdir/pgrdf'. PostgreSQL resolves a module_pathname # containing a separator directly against pkglibdir and never consults # `dynamic_library_path`; only a bare name is searched along it. With the diff --git a/sql/pgrdf--0.5.1--0.6.31.sql b/sql/pgrdf--0.5.1--0.6.32.sql similarity index 98% rename from sql/pgrdf--0.5.1--0.6.31.sql rename to sql/pgrdf--0.5.1--0.6.32.sql index a2ffb08..0750c9d 100644 --- a/sql/pgrdf--0.5.1--0.6.31.sql +++ b/sql/pgrdf--0.5.1--0.6.32.sql @@ -231,3 +231,11 @@ ALTER FUNCTION pgrdf.build_id() IMMUTABLE; -- 0.6.30 -> 0.6.31 delta (#118): loader-recorded source byte digest. ALTER TABLE pgrdf._pgrdf_graphs ADD COLUMN IF NOT EXISTS source_sha256 TEXT; ALTER TABLE pgrdf._pgrdf_graphs ADD COLUMN IF NOT EXISTS source_loads INTEGER; + +-- 0.6.31 -> 0.6.32 delta (#117): RDFC-1.0 canonical graph digest. +CREATE FUNCTION "graph_digest"( + "graph_id" bigint /* i64 */ +) RETURNS TEXT /* String */ +STRICT +LANGUAGE c /* Rust */ +AS 'MODULE_PATHNAME', 'graph_digest_wrapper'; diff --git a/sql/pgrdf--0.6.31--0.6.32.sql b/sql/pgrdf--0.6.31--0.6.32.sql new file mode 100644 index 0000000..e2052dc --- /dev/null +++ b/sql/pgrdf--0.6.31--0.6.32.sql @@ -0,0 +1,23 @@ +-- pgRDF 0.6.31 -> 0.6.32 — the truth cut (#117, #109, #123). +-- +-- #117: pgrdf.graph_digest(graph_id) — RDFC-1.0 canonical graph digest. +-- Identity of MEANING that survives reload: canonical blank-node +-- relabelling (W3C RDFC-1.0), canonical N-Triples, sha256, over the +-- graph's ASSERTED triples only. Algorithm label: rdfc-1.0-sha256 — +-- values are NOT comparable with byte digests or first-degree +-- structural pins, by design. Conformance proven against the W3C +-- rdf-canon suite byte-for-byte; adversarial (poison) structures RAISE +-- pgRDF#117 rather than degrade. +-- +-- #109 and #123 ship in the .so as behaviour, not DDL: a VALUES binding +-- a graph variable now refuses (pgRDF#109) instead of silently +-- answering over every graph; the staged loader refuses in transaction +-- blocks (pgRDF#123) instead of hanging uncancellably, and load_turtle's +-- auto-dispatch falls back to the standard parser there. + +CREATE FUNCTION "graph_digest"( + "graph_id" bigint /* i64 */ +) RETURNS TEXT /* String */ +STRICT +LANGUAGE c /* Rust */ +AS 'MODULE_PATHNAME', 'graph_digest_wrapper'; diff --git a/src/query/executor.rs b/src/query/executor.rs index 5ecb063..aa3f07b 100644 --- a/src/query/executor.rs +++ b/src/query/executor.rs @@ -2344,6 +2344,7 @@ fn translate(q: &Query) -> ExecPlan { /// SELECT clause (`SELECT 1`) so the SQL is well-formed before /// being wrapped in EXISTS(). fn build_ask_probe_sql(ps: &ParsedSelect) -> String { + refuse_values_on_graph_variable(ps, "ASK"); if !ps.union_branches.is_empty() { refuse_group_constructs_over_union(ps, "ASK"); let branch_sqls: Vec = ps @@ -4064,6 +4065,53 @@ fn type_aware_order_terms(lex: &str, ascending: bool) -> Vec { /// (`filter_clauses_dropped`) shows callers are hitting it. Lifting the /// refusal for a construct means actually APPLYING it on the union path, /// with a regression proving the restricted answer. +/// #109: a `VALUES` that binds a variable also used as a `GRAPH` name is +/// not joined into graph resolution — measured (filed on 0.6.27, +/// re-confirmed live on 0.6.31): three nonexistent graphs in the VALUES +/// and the query answered for EVERY graph in the store. The binding +/// contributes nothing and the answer silently widens. Refuse with the +/// rewrite; the real join is #111-adjacent follow-up work. Runs on BOTH +/// assembly paths and unconditionally — the repro has no UNION in it. +fn refuse_values_on_graph_variable(ps: &ParsedSelect, ctx: &str) { + let mut scope_vars: Vec = Vec::new(); + let mut collect_triples = |bgp: &[ScopedTriple]| { + for t in bgp { + if let Some(v) = scope_var_name(&t.scope) { + scope_vars.push(v); + } + } + }; + collect_triples(&ps.bgp); + for ob in &ps.optionals { + collect_triples(&ob.triples); + } + for b in &ps.union_branches { + collect_triples(&b.bgp); + for ob in &b.optionals { + collect_triples(&ob.triples); + } + } + if scope_vars.is_empty() { + return; + } + let all_blocks = ps + .values + .iter() + .chain(ps.union_branches.iter().flat_map(|b| b.values.iter())) + .chain(ps.optionals.iter().flat_map(|ob| ob.values.iter())); + for vb in all_blocks { + if let Some(hit) = vb.variables.iter().find(|v| scope_vars.contains(v)) { + crate::storage::shmem_cache::note_filter_clause_dropped(); + panic!( + "sparql: VALUES binds ?{hit}, which also names a GRAPH scope — the binding \ + is not joined into graph resolution on the {ctx} path and the answer would \ + silently widen to every graph (pgRDF#109). Enumerate explicit GRAPH \ + groups instead. Refusing instead of returning a wrong answer." + ); + } + } +} + fn refuse_group_constructs_over_union(ps: &ParsedSelect, ctx: &str) { let parked = [ (!ps.filters.is_empty(), "FILTER"), @@ -4084,6 +4132,7 @@ fn refuse_group_constructs_over_union(ps: &ParsedSelect, ctx: &str) { } fn build_bgp_sql(ps: &ParsedSelect) -> String { + refuse_values_on_graph_variable(ps, "SELECT/CONSTRUCT"); if !ps.union_branches.is_empty() { refuse_group_constructs_over_union(ps, "SELECT/CONSTRUCT"); if !ps.aggregates.is_empty() { diff --git a/src/query/mod.rs b/src/query/mod.rs index 3ca566e..b2ff232 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -7,3 +7,4 @@ pub mod guc; pub mod parser; pub mod path; pub mod plan_cache; +pub mod values_graph_guard; diff --git a/src/query/values_graph_guard.rs b/src/query/values_graph_guard.rs new file mode 100644 index 0000000..f1536ce --- /dev/null +++ b/src/query/values_graph_guard.rs @@ -0,0 +1,62 @@ +//! #109 — VALUES bound to a graph variable refuses instead of widening. +//! +//! Measured live on 0.6.31 (2026-08-16, re-confirming the 0.6.27 filing): +//! `VALUES ?g { } GRAPH ?g { … }` answered for +//! EVERY shape-bearing graph in the store — the binding contributed +//! nothing, and the caller received a confidently unscoped answer with no +//! signal. Survives the #114 guards (no UNION involved). The read-path +//! member of the reports-not-refuses family. +//! +//! Contract (v0.6.32 recheck): a `VALUES` that binds a variable used as a +//! `GRAPH` name RAISES a stable `pgRDF#109` error naming the rewrite +//! (enumerate the graphs as explicit `GRAPH ` groups, or wait for the +//! join in #111's follow-up). A `VALUES` on a plain (non-graph) variable +//! keeps applying — pinned here, measured working on the bench today. + +#[cfg(any(test, feature = "pg_test"))] +#[pgrx::pg_schema] +mod tests { + use pgrx::prelude::*; + + fn seed(graph_id: i64) { + Spi::run(&format!("SELECT pgrdf.add_graph({graph_id})")).expect("add_graph failed"); + Spi::get_one_with_args::( + "SELECT pgrdf.parse_turtle($1, $2)", + &[ + "@prefix v: .\nv:a v:p v:x .\nv:b v:p v:y .\n".into(), + graph_id.into(), + ], + ) + .expect("seed parse failed"); + } + + /// The repro, exactly as measured: VALUES on the graph variable must + /// refuse — never answer over graphs the binding excluded. + #[pg_test( + error = "sparql: VALUES binds ?g, which also names a GRAPH scope — the binding is not joined into graph resolution on the SELECT/CONSTRUCT path and the answer would silently widen to every graph (pgRDF#109). Enumerate explicit GRAPH groups instead. Refusing instead of returning a wrong answer." + )] + fn values_on_graph_variable_refuses() { + seed(982101); + Spi::run( + "SELECT * FROM pgrdf.sparql('SELECT ?g ?s WHERE { + VALUES ?g { } + GRAPH ?g { ?s ?p ?o } }')", + ) + .expect("unreachable: the call above must raise"); + } + + /// Regression pin (passes today, measured on the bench): VALUES on a + /// plain variable keeps applying inside an explicit GRAPH group. + #[pg_test] + fn values_on_plain_variable_still_applies() { + seed(982102); + let n: i64 = Spi::get_one( + "SELECT count(*)::bigint FROM pgrdf.sparql('SELECT ?s WHERE { + VALUES ?s { } + GRAPH { ?s ?p ?o } }')", + ) + .expect("query failed") + .expect("count NULL"); + assert_eq!(n, 1, "VALUES on a plain variable must keep restricting"); + } +} diff --git a/src/storage/canon.rs b/src/storage/canon.rs new file mode 100644 index 0000000..62a715e --- /dev/null +++ b/src/storage/canon.rs @@ -0,0 +1,606 @@ +//! #117 — RDFC-1.0 canonical graph digest: identity that survives reload. +//! +//! A byte digest over exported N-Triples identifies one stored copy: blank +//! node labels are minted per parse, so the same source loads to different +//! bytes forever (measured: 531/948 of core's triples ride bnodes; two +//! loads of one file differ only in labels). `pgrdf.graph_digest(graph)` +//! answers identity of MEANING: canonical blank-node relabelling per +//! RDFC-1.0 (W3C), canonical N-Quads serialization, sha256. Algorithm +//! label, per the sealed interface contract: `rdfc-1.0-sha256` — values +//! are NOT comparable with first-degree structural pins, by design. +//! +//! Complexity guard: RDFC-1.0 is worst-case exponential on adversarial +//! automorphic bnode structures; the guard RAISES (never degrades) — the +//! fail-closed direction, as everywhere in this engine. +//! +//! Contract (v0.6.32 rechecks, stated before the code): +//! - same source parsed into two fresh graphs ⇒ equal `graph_digest`, +//! while their byte serializations differ (label variance); +//! - genuinely different graphs ⇒ unequal digests (the conclusive +//! direction); +//! - W3C rdf-canon conformance subset passes as regression fixtures. + +use crate::storage::dict::term_type; +use pgrx::prelude::*; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; + +// ───────────────────────────────────────────────────────────────────── +// RDFC-1.0 (W3C RDF Dataset Canonicalization) over pgRDF's quad store. +// Asserted triples only (inferred is a check value, never content — +// I13). Algorithm label: `rdfc-1.0-sha256`; values are NOT comparable +// with first-degree structural pins, by design. +// ───────────────────────────────────────────────────────────────────── + +/// Hard budgets — the complexity guard RAISES, never degrades. RDFC-1.0 +/// is worst-case exponential on adversarial automorphic blank-node +/// structures (the W3C suite's "poison" tests expect an abort: refusing +/// IS the conforming behaviour there). +const MAX_NDEGREE_CALLS: usize = 10_000; +const MAX_PERMUTATION_GROUP: usize = 7; +const MAX_RECURSION_DEPTH: usize = 32; + +#[derive(Clone, PartialEq, Eq, Hash)] +enum CTerm { + Iri(String), + BNode(String), + Lit { + val: String, + dt: Option, + lang: Option, + }, +} + +type Triple = (CTerm, CTerm, CTerm); + +/// Canonical N-Triples escaping for the literal lexical form. +fn nt_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +/// Serialize one term; blank nodes go through `label` so callers control +/// the substitution (`_:a`/`_:z` during first-degree hashing, issued +/// canonical ids at the end). +fn nt_term(t: &CTerm, label: &dyn Fn(&str) -> String) -> String { + match t { + CTerm::Iri(i) => format!("<{i}>"), + CTerm::BNode(b) => format!("_:{}", label(b)), + CTerm::Lit { val, dt, lang } => { + let esc = nt_escape(val); + match (lang, dt) { + (Some(l), _) => format!("\"{esc}\"@{l}"), + (None, Some(d)) if d != "http://www.w3.org/2001/XMLSchema#string" => { + format!("\"{esc}\"^^<{d}>") + } + _ => format!("\"{esc}\""), + } + } + } +} + +fn nt_triple(t: &Triple, label: &dyn Fn(&str) -> String) -> String { + format!( + "{} {} {} .\n", + nt_term(&t.0, label), + nt_term(&t.1, label), + nt_term(&t.2, label) + ) +} + +fn sha256_hex(data: &str) -> String { + let mut h = Sha256::new(); + h.update(data.as_bytes()); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Read a graph's ASSERTED triples with full term structure — the same +/// join shape `serialise_graph_to_ntriples` uses (shacl.rs), minus the +/// inferred rows. +fn read_asserted_triples(graph_id: i64) -> Vec { + let mut triples: Vec = Vec::new(); + Spi::connect(|client| { + let table = client + .select( + "SELECT + s.term_type, s.lexical_value, + p.lexical_value AS p_iri, + o.term_type, o.lexical_value, + dt.lexical_value AS o_dt, + o.language_tag AS o_lang + FROM pgrdf._pgrdf_quads q + JOIN pgrdf._pgrdf_dictionary s ON s.id = q.subject_id + JOIN pgrdf._pgrdf_dictionary p ON p.id = q.predicate_id + JOIN pgrdf._pgrdf_dictionary o ON o.id = q.object_id + LEFT JOIN pgrdf._pgrdf_dictionary dt ON dt.id = o.datatype_iri_id + WHERE q.graph_id = $1 AND q.is_inferred = FALSE", + None, + &[unsafe { + pgrx::datum::DatumWithOid::new( + graph_id, + pgrx::pg_sys::PgBuiltInOids::INT8OID.into(), + ) + }], + ) + .expect("graph_digest: triple read failed"); + for row in table { + let s_type: i16 = row + .get(1) + .ok() + .flatten() + .expect("graph_digest: s.term_type"); + let s_val: String = row.get(2).ok().flatten().expect("graph_digest: s.value"); + let p_iri: String = row.get(3).ok().flatten().expect("graph_digest: p.iri"); + let o_type: i16 = row + .get(4) + .ok() + .flatten() + .expect("graph_digest: o.term_type"); + let o_val: String = row.get(5).ok().flatten().expect("graph_digest: o.value"); + let o_dt: Option = row.get(6).ok().flatten(); + let o_lang: Option = row.get(7).ok().flatten(); + + let s = match s_type { + term_type::URI => CTerm::Iri(s_val), + term_type::BLANK_NODE => CTerm::BNode(s_val), + _ => continue, // literal subject — #88 residue, not canonicalizable + }; + let p = CTerm::Iri(p_iri); + let o = match o_type { + term_type::URI => CTerm::Iri(o_val), + term_type::BLANK_NODE => CTerm::BNode(o_val), + term_type::LITERAL => CTerm::Lit { + val: o_val, + dt: o_dt, + lang: o_lang, + }, + _ => continue, + }; + triples.push((s, p, o)); + } + }); + triples +} + +/// RDFC-1.0 identifier issuer: stable prefix + counter, remembering +/// issue order (the order canonical ids are handed out in matters for +/// hash-n-degree results). +#[derive(Clone)] +struct Issuer { + prefix: String, + counter: usize, + issued: HashMap, + order: Vec, +} + +impl Issuer { + fn new(prefix: &str) -> Self { + Issuer { + prefix: prefix.to_string(), + counter: 0, + issued: HashMap::new(), + order: Vec::new(), + } + } + fn issue(&mut self, id: &str) -> String { + if let Some(v) = self.issued.get(id) { + return v.clone(); + } + let v = format!("{}{}", self.prefix, self.counter); + self.counter += 1; + self.issued.insert(id.to_string(), v.clone()); + self.order.push(id.to_string()); + v + } + fn issued_for(&self, id: &str) -> Option<&String> { + self.issued.get(id) + } +} + +struct CanonState { + triples: Vec, + bnode_quads: HashMap>, + ndegree_calls: usize, +} + +impl CanonState { + /// 4.6 Hash First Degree Quads: serialize every quad mentioning `n` + /// with `n → _:a` and every other bnode `→ _:z`; sort; hash. + fn hash_first_degree(&self, n: &str) -> String { + let mut lines: Vec = self.bnode_quads[n] + .iter() + .map(|&i| { + nt_triple(&self.triples[i], &|b: &str| { + if b == n { + "a".to_string() + } else { + "z".to_string() + } + }) + }) + .collect(); + lines.sort(); + sha256_hex(&lines.concat()) + } + + /// 4.7 Hash Related Blank Node. + fn hash_related( + &mut self, + related: &str, + quad_idx: usize, + issuer: &Issuer, + canonical: &Issuer, + position: char, + ) -> String { + let mut input = String::new(); + input.push(position); + if position != 'g' { + input.push('<'); + if let CTerm::Iri(p) = &self.triples[quad_idx].1 { + input.push_str(p); + } + input.push('>'); + } + if let Some(c) = canonical.issued_for(related) { + input.push_str("_:"); + input.push_str(c); + } else if let Some(t) = issuer.issued_for(related) { + input.push_str("_:"); + input.push_str(t); + } else { + input.push_str(&self.hash_first_degree(related)); + } + sha256_hex(&input) + } + + /// 4.8 Hash N-Degree Quads — the gossip-path tie-breaker, with the + /// fail-closed budget. + fn hash_n_degree( + &mut self, + id: &str, + issuer: Issuer, + canonical: &Issuer, + depth: usize, + ) -> (String, Issuer) { + self.ndegree_calls += 1; + if depth > MAX_RECURSION_DEPTH || self.ndegree_calls > MAX_NDEGREE_CALLS { + error!( + "pgRDF#117: canonicalization budget exceeded (adversarial blank-node \ + structure); refusing rather than degrading" + ); + } + let mut issuer = issuer; + // Group related bnodes by their related-hash. + let mut hn: BTreeMap> = BTreeMap::new(); + let quad_ids = self.bnode_quads[id].clone(); + for qi in quad_ids { + let (s, _p, o) = self.triples[qi].clone(); + for (t, pos) in [(s, 's'), (o, 'o')] { + if let CTerm::BNode(b) = t + && b != id + { + let h = self.hash_related(&b, qi, &issuer, canonical, pos); + let e = hn.entry(h).or_default(); + if !e.contains(&b) { + e.push(b); + } + } + } + } + let mut data = String::new(); + for (related_hash, group) in hn { + data.push_str(&related_hash); + if group.len() > MAX_PERMUTATION_GROUP { + error!( + "pgRDF#117: canonicalization budget exceeded (adversarial blank-node \ + structure); refusing rather than degrading" + ); + } + let mut chosen_path = String::new(); + let mut chosen_issuer: Option = None; + for perm in permutations(&group) { + let mut copy = issuer.clone(); + let mut path = String::new(); + let mut recursion: Vec = Vec::new(); + let mut aborted = false; + for related in &perm { + if let Some(c) = canonical.issued_for(related) { + path.push_str("_:"); + path.push_str(c); + } else { + if copy.issued_for(related).is_none() { + recursion.push(related.clone()); + } + path.push_str("_:"); + path.push_str(©.issue(related)); + } + if !chosen_path.is_empty() + && path.len() >= chosen_path.len() + && path > chosen_path + { + aborted = true; + break; + } + } + if aborted { + continue; + } + for related in &recursion { + let (rh, ri) = self.hash_n_degree(related, copy.clone(), canonical, depth + 1); + path.push_str("_:"); + path.push_str(©.issue(related)); + path.push('<'); + path.push_str(&rh); + path.push('>'); + copy = ri; + if !chosen_path.is_empty() + && path.len() >= chosen_path.len() + && path > chosen_path + { + aborted = true; + break; + } + } + if aborted { + continue; + } + if chosen_path.is_empty() || path < chosen_path { + chosen_path = path; + chosen_issuer = Some(copy); + } + } + data.push_str(&chosen_path); + issuer = chosen_issuer.unwrap_or(issuer); + } + (sha256_hex(&data), issuer) + } +} + +fn permutations(items: &[String]) -> Vec> { + if items.len() <= 1 { + return vec![items.to_vec()]; + } + let mut out = Vec::new(); + for (i, x) in items.iter().enumerate() { + let mut rest: Vec = items.to_vec(); + rest.remove(i); + for mut p in permutations(&rest) { + let mut v = vec![x.clone()]; + v.append(&mut p); + out.push(v); + } + } + out +} + +/// Canonicalize the asserted triples of `graph_id` per RDFC-1.0 and +/// return the sha256 (hex) of the sorted canonical N-Triples document. +/// Algorithm label: `rdfc-1.0-sha256`. Isomorphic graphs — same meaning, +/// any blank-node labels — produce EQUAL digests; unequal digests prove +/// the graphs differ. The complexity guard raises `pgRDF#117` on +/// adversarial structures rather than degrading. +#[search_path(pgrdf, pg_temp)] +#[pg_extern] +fn graph_digest(graph_id: i64) -> String { + let triples = read_asserted_triples(graph_id); + let mut bnode_quads: HashMap> = HashMap::new(); + for (i, t) in triples.iter().enumerate() { + for term in [&t.0, &t.2] { + if let CTerm::BNode(b) = term { + bnode_quads.entry(b.clone()).or_default().push(i); + } + } + } + let mut state = CanonState { + triples, + bnode_quads, + ndegree_calls: 0, + }; + let mut canonical = Issuer::new("c14n"); + + // Steps 3–4: first-degree hashes; issue canonical ids for uniques in + // hash order. + let bnodes: Vec = state.bnode_quads.keys().cloned().collect(); + let mut by_hash: BTreeMap> = BTreeMap::new(); + for b in &bnodes { + by_hash + .entry(state.hash_first_degree(b)) + .or_default() + .push(b.clone()); + } + let mut shared: Vec<(String, Vec)> = Vec::new(); + for (h, mut group) in by_hash { + if group.len() == 1 { + canonical.issue(&group[0]); + } else { + group.sort(); + shared.push((h, group)); + } + } + // Step 5: ties via hash-n-degree; results in hash order, canonical + // ids issued in each result issuer's issue order. + for (_h, group) in shared { + let mut results: Vec<(String, Issuer)> = Vec::new(); + for b in &group { + if canonical.issued_for(b).is_some() { + continue; + } + let mut temp = Issuer::new("b"); + temp.issue(b); + let (hash, temp_issuer) = state.hash_n_degree(b, temp, &canonical, 0); + results.push((hash, temp_issuer)); + } + results.sort_by(|a, b| a.0.cmp(&b.0)); + for (_hash, temp_issuer) in results { + for old in &temp_issuer.order { + canonical.issue(old); + } + } + } + + // Final: serialize with canonical labels, sort, hash. + let mut lines: Vec = state + .triples + .iter() + .map(|t| { + nt_triple(t, &|b: &str| { + canonical + .issued_for(b) + .cloned() + .unwrap_or_else(|| format!("MISSING-{b}")) + }) + }) + .collect(); + lines.sort(); + sha256_hex(&lines.concat()) +} + +#[cfg(any(test, feature = "pg_test"))] +#[pgrx::pg_schema] +mod tests { + use pgrx::prelude::*; + + /// Two anonymous bnodes — each parse mints fresh internal labels, so + /// byte-level content differs across loads while structure is fixed. + const BNODE_TTL: &str = "@prefix c: .\n[ c:p c:o1 ] .\n[ c:p c:o2 ] .\n"; + + fn seed(graph_id: i64, ttl: &str) { + Spi::run(&format!("SELECT pgrdf.add_graph({graph_id})")).expect("add_graph failed"); + Spi::get_one_with_args::( + "SELECT pgrdf.parse_turtle($1, $2)", + &[ttl.into(), graph_id.into()], + ) + .expect("seed parse failed"); + } + + fn digest(graph_id: i64) -> String { + Spi::get_one_with_args("SELECT pgrdf.graph_digest($1)", &[graph_id.into()]) + .expect("graph_digest failed") + .expect("graph_digest returned NULL") + } + + /// The core promise: canonical identity survives the reload that a + /// fork, a spore-germination, or a plain re-load necessarily is. + #[pg_test] + fn reload_equality_survives_relabelling() { + seed(982201, BNODE_TTL); + seed(982202, BNODE_TTL); + let d1 = digest(982201); + let d2 = digest(982202); + assert_eq!(d1, d2, "isomorphic graphs must share a canonical digest"); + assert_eq!(d1.len(), 64, "sha256 hex"); + } + + /// The conclusive direction: different meaning, different digest. + #[pg_test] + fn different_graphs_differ() { + seed(982203, BNODE_TTL); + seed( + 982204, + "@prefix c: .\n[ c:p c:o1 ] .\n[ c:q c:o2 ] .\n", + ); + assert_ne!(digest(982203), digest(982204)); + } + + /// W3C rdf-canon conformance subset (vendored; attribution in + /// tests/fixtures/rdfc10/LICENSE.md). Each case loads the suite's + /// input and asserts our digest equals sha256 of the suite's OWN + /// expected canonical document — byte-for-byte: our canonical + /// serialization is the spec's, or this fails naming the case. + #[pg_test] + fn w3c_rdfc10_conformance_subset() { + const CASES: &[(&str, &str, &str)] = &[ + ( + "001", + include_str!("../../tests/fixtures/rdfc10/test001-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test001-rdfc10.nq"), + ), + ( + "002", + include_str!("../../tests/fixtures/rdfc10/test002-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test002-rdfc10.nq"), + ), + ( + "003", + include_str!("../../tests/fixtures/rdfc10/test003-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test003-rdfc10.nq"), + ), + ( + "004", + include_str!("../../tests/fixtures/rdfc10/test004-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test004-rdfc10.nq"), + ), + ( + "005", + include_str!("../../tests/fixtures/rdfc10/test005-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test005-rdfc10.nq"), + ), + ( + "008", + include_str!("../../tests/fixtures/rdfc10/test008-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test008-rdfc10.nq"), + ), + ( + "009", + include_str!("../../tests/fixtures/rdfc10/test009-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test009-rdfc10.nq"), + ), + ( + "010", + include_str!("../../tests/fixtures/rdfc10/test010-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test010-rdfc10.nq"), + ), + ( + "017", + include_str!("../../tests/fixtures/rdfc10/test017-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test017-rdfc10.nq"), + ), + ( + "020", + include_str!("../../tests/fixtures/rdfc10/test020-in.nq"), + include_str!("../../tests/fixtures/rdfc10/test020-rdfc10.nq"), + ), + ]; + for (i, (name, input, expected)) in CASES.iter().enumerate() { + let gid = 983300 + i as i64; + Spi::run(&format!("SELECT pgrdf.add_graph({gid})")).expect("add_graph failed"); + Spi::get_one_with_args::( + "SELECT pgrdf.parse_turtle($1, $2)", + &[(*input).into(), gid.into()], + ) + .expect("fixture load failed"); + let got = digest(gid); + let want = super::sha256_hex(expected); + assert_eq!(got, want, "W3C rdfc10 test{name} diverged from the suite"); + } + } + + /// test074 — the poison graph (RDFC10NegativeEvalTest): a highly + /// automorphic structure where completing normally under resource + /// limits is NON-conforming. Our budgets raise — refusing IS the + /// spec's expected behaviour here, and the fail-closed doctrine and + /// the conformance requirement are the same sentence. + #[pg_test( + error = "pgRDF#117: canonicalization budget exceeded (adversarial blank-node structure); refusing rather than degrading" + )] + fn w3c_rdfc10_poison_refuses() { + let input = include_str!("../../tests/fixtures/rdfc10/test074-in.nq"); + Spi::run("SELECT pgrdf.add_graph(983399)").expect("add_graph failed"); + Spi::get_one_with_args::( + "SELECT pgrdf.parse_turtle($1, $2)", + &[input.into(), 983399i64.into()], + ) + .expect("poison load failed"); + digest(983399); + } +} diff --git a/src/storage/loader.rs b/src/storage/loader.rs index 557f015..2292f72 100644 --- a/src/storage/loader.rs +++ b/src/storage/loader.rs @@ -2589,7 +2589,14 @@ fn load_turtle( // Turtle file would be silently skipped there), AND no base_iri is set // (staged has no relative-IRI base). Any miss ⇒ the standard full-Turtle // path, unchanged. - if crate::storage::staged::jobctl::is_ready() && base.is_none() { + // #123: auto-selection never picks a path that cannot work — inside a + // transaction block the staged loader's per-phase commits are impossible + // (measured: silent uncancellable hang), so the sniff defers to the + // standard full parser there. + let staged_eligible = crate::storage::staged::jobctl::is_ready() + && base.is_none() + && !unsafe { pgrx::pg_sys::IsTransactionBlock() }; + if staged_eligible { match file_sniffs_as_ntriples(path) { Some(true) => return staged_load_default(path, graph_id), Some(false) => { diff --git a/src/storage/mod.rs b/src/storage/mod.rs index d414eac..76b01b4 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -21,6 +21,7 @@ //! Reference: SPEC.pgRDF.LLD.v0.2 §3, §4.1, §4.3, SPEC.pgRDF.LLD.v0.4 //! §3, §6.3, and `docs/02-storage.md`. +pub mod canon; pub mod construct_ingest; pub mod dict; pub mod graphs; @@ -34,3 +35,4 @@ pub mod shmem_cache; pub mod source_digest; pub mod staged; pub mod stats; +pub mod txn_guard; diff --git a/src/storage/staged/pool.rs b/src/storage/staged/pool.rs index 013a674..24807ef 100644 --- a/src/storage/staged/pool.rs +++ b/src/storage/staged/pool.rs @@ -565,6 +565,18 @@ fn run_phase(job_idx: usize, phase: u8, specs: &[WorkerSpec]) -> PhaseOutcome { #[pg_extern] fn load_turtle_staged_run(path: &str, graph_id: i64, n_workers: default!(i32, 0)) -> pgrx::JsonB { crate::storage::lock::require_unlocked(graph_id, "load_turtle_staged_run"); // #107 + // #123: staged workers COMMIT their own transactions; inside a caller + // transaction block the coordinator waits on workers that wait on the + // caller's locks — measured: a silent hang that statement_timeout never + // cancels (the wait sits in wait_for_shutdown, which does not observe + // interrupts). Refuse with the rewrite, before any slot is taken. + if unsafe { pg_sys::IsTransactionBlock() } { + error!( + "pgRDF#123: the staged loader commits per phase and cannot run inside a \ + transaction block; call it as a single statement, or use \ + pgrdf.load_turtle / pgrdf.parse_turtle here" + ); + } if !jobctl::is_ready() { error!( "pgrdf staged loader requires pgrdf in shared_preload_libraries \ diff --git a/src/storage/txn_guard.rs b/src/storage/txn_guard.rs new file mode 100644 index 0000000..3193ac5 --- /dev/null +++ b/src/storage/txn_guard.rs @@ -0,0 +1,66 @@ +//! #123 — the staged loader refuses in uncommittable transactions. +//! +//! Measured (v0.6.31 cycle, 31 minutes at 0% CPU): a one-statement-per-line +//! file sniffs as N-Triples, the preloaded worker pool reports ready, and +//! `load_turtle` hands the file to the staged loader — whose workers commit +//! their own transactions, inside a caller transaction that can never allow +//! it. Coordinator waits on workers, workers wait on the caller's locks: +//! silent, indefinite. The reports-not-refuses family, in the loader. +//! +//! Contract (the v0.6.32 recheck, stated before the code): +//! - `load_turtle`'s auto-dispatch FALLS BACK to the standard parser +//! inside a transaction block — auto-selection never picks a path that +//! cannot work; +//! - a DIRECT `load_turtle_staged_run` inside a transaction block RAISES +//! a stable `pgRDF#123` error naming the constraint and the rewrite. +//! +//! Every test collars itself with `statement_timeout` so a regression hangs +//! for seconds, not the 31 minutes the discovery cost. + +#[cfg(any(test, feature = "pg_test"))] +#[pgrx::pg_schema] +mod tests { + use pgrx::prelude::*; + use std::io::Write; + + /// Bare one-statement-per-line — sniffs as N-Triples, which is the + /// exact shape that dispatched the original hang. + fn write_nt_fixture(name: &str) -> String { + let path = format!("/tmp/pgrdf-txn-guard-{name}.nt"); + let mut f = std::fs::File::create(&path).expect("fixture create failed"); + f.write_all(b" .\n") + .expect("fixture write failed"); + path + } + + /// The 31-minute hang, collared: inside a transaction block the + /// N-Triples sniff must fall back to the standard parser and complete. + #[pg_test] + fn ntriples_sniff_falls_back_in_txn_block() { + Spi::run("SET statement_timeout = '8s'").expect("timeout collar failed"); + let path = write_nt_fixture("fallback"); + Spi::run("SELECT pgrdf.add_graph(982001)").expect("add_graph failed"); + let n: i64 = Spi::get_one_with_args( + "SELECT pgrdf.load_turtle($1, 982001)", + &[path.as_str().into(), 982001i64.into()], + ) + .expect("load_turtle failed") + .expect("load_turtle returned NULL"); + assert_eq!(n, 1); + } + + /// A direct staged call in a transaction block refuses with the + /// rewrite — it must never wait on workers that cannot proceed. + #[pg_test( + error = "pgRDF#123: the staged loader commits per phase and cannot run inside a transaction block; call it as a single statement, or use pgrdf.load_turtle / pgrdf.parse_turtle here" + )] + fn staged_run_refuses_in_txn_block() { + Spi::run("SET statement_timeout = '8s'").expect("timeout collar failed"); + let path = write_nt_fixture("refuse"); + Spi::run("SELECT pgrdf.add_graph(982002)").expect("add_graph failed"); + Spi::run(&format!( + "SELECT pgrdf.load_turtle_staged_run('{path}', 982002)" + )) + .expect("unreachable: the call above must raise"); + } +} diff --git a/tests/fixtures/rdfc10/LICENSE.md b/tests/fixtures/rdfc10/LICENSE.md new file mode 100644 index 0000000..ff5a272 --- /dev/null +++ b/tests/fixtures/rdfc10/LICENSE.md @@ -0,0 +1,15 @@ +# W3C RDF Dataset Canonicalization test suite — vendored subset + +Source: https://github.com/w3c/rdf-canon (tests/rdfc10), vendored 2026-08-16 +for pgRDF's `graph_digest` (RDFC-1.0) regression fixtures. + +Distributed under the W3C Test Suite License and the W3C 3-clause BSD +License: https://www.w3.org/copyright/test-suites-licenses/ +Copyright © W3C® (MIT, ERCIM, Keio, Beihang). + +THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO +REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. + +Files: testNNN-in.nq (inputs) / testNNN-rdfc10.nq (expected canonical +form); test074-in.nq is the "poison" NegativeEval input, where refusing +under a resource budget is the conforming behaviour. diff --git a/tests/fixtures/rdfc10/test001-in.nq b/tests/fixtures/rdfc10/test001-in.nq new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/rdfc10/test001-rdfc10.nq b/tests/fixtures/rdfc10/test001-rdfc10.nq new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/rdfc10/test002-in.nq b/tests/fixtures/rdfc10/test002-in.nq new file mode 100644 index 0000000..529056c --- /dev/null +++ b/tests/fixtures/rdfc10/test002-in.nq @@ -0,0 +1 @@ + . diff --git a/tests/fixtures/rdfc10/test002-rdfc10.nq b/tests/fixtures/rdfc10/test002-rdfc10.nq new file mode 100644 index 0000000..529056c --- /dev/null +++ b/tests/fixtures/rdfc10/test002-rdfc10.nq @@ -0,0 +1 @@ + . diff --git a/tests/fixtures/rdfc10/test003-in.nq b/tests/fixtures/rdfc10/test003-in.nq new file mode 100644 index 0000000..cb43cd0 --- /dev/null +++ b/tests/fixtures/rdfc10/test003-in.nq @@ -0,0 +1 @@ +_:e0 . diff --git a/tests/fixtures/rdfc10/test003-rdfc10.nq b/tests/fixtures/rdfc10/test003-rdfc10.nq new file mode 100644 index 0000000..5ca4444 --- /dev/null +++ b/tests/fixtures/rdfc10/test003-rdfc10.nq @@ -0,0 +1 @@ +_:c14n0 . diff --git a/tests/fixtures/rdfc10/test004-in.nq b/tests/fixtures/rdfc10/test004-in.nq new file mode 100644 index 0000000..d399911 --- /dev/null +++ b/tests/fixtures/rdfc10/test004-in.nq @@ -0,0 +1,2 @@ +_:e0 . +_:e0 . diff --git a/tests/fixtures/rdfc10/test004-rdfc10.nq b/tests/fixtures/rdfc10/test004-rdfc10.nq new file mode 100644 index 0000000..b8bc831 --- /dev/null +++ b/tests/fixtures/rdfc10/test004-rdfc10.nq @@ -0,0 +1,2 @@ +_:c14n0 . +_:c14n0 . diff --git a/tests/fixtures/rdfc10/test005-in.nq b/tests/fixtures/rdfc10/test005-in.nq new file mode 100644 index 0000000..02cba49 --- /dev/null +++ b/tests/fixtures/rdfc10/test005-in.nq @@ -0,0 +1,3 @@ + . + _:e0 . +_:e0 . diff --git a/tests/fixtures/rdfc10/test005-rdfc10.nq b/tests/fixtures/rdfc10/test005-rdfc10.nq new file mode 100644 index 0000000..695c23d --- /dev/null +++ b/tests/fixtures/rdfc10/test005-rdfc10.nq @@ -0,0 +1,3 @@ + _:c14n0 . + . +_:c14n0 . diff --git a/tests/fixtures/rdfc10/test008-in.nq b/tests/fixtures/rdfc10/test008-in.nq new file mode 100644 index 0000000..6bbe20f --- /dev/null +++ b/tests/fixtures/rdfc10/test008-in.nq @@ -0,0 +1,6 @@ + . + . + "Writer" . + "My Book" . + "Fun" . + "Chapter One" . diff --git a/tests/fixtures/rdfc10/test008-rdfc10.nq b/tests/fixtures/rdfc10/test008-rdfc10.nq new file mode 100644 index 0000000..4f30f5e --- /dev/null +++ b/tests/fixtures/rdfc10/test008-rdfc10.nq @@ -0,0 +1,6 @@ + . + "Writer" . + "My Book" . + "Fun" . + "Chapter One" . + . diff --git a/tests/fixtures/rdfc10/test009-in.nq b/tests/fixtures/rdfc10/test009-in.nq new file mode 100644 index 0000000..91c220d --- /dev/null +++ b/tests/fixtures/rdfc10/test009-in.nq @@ -0,0 +1,9 @@ + "Fun" . + "Chapter One" . + . + "Jane" . + "John" . + . + . + "Writer" . + "My Book" . diff --git a/tests/fixtures/rdfc10/test009-rdfc10.nq b/tests/fixtures/rdfc10/test009-rdfc10.nq new file mode 100644 index 0000000..1c8d94a --- /dev/null +++ b/tests/fixtures/rdfc10/test009-rdfc10.nq @@ -0,0 +1,9 @@ + . + "Writer" . + "My Book" . + "Fun" . + "Chapter One" . + . + "Jane" . + "John" . + . diff --git a/tests/fixtures/rdfc10/test010-in.nq b/tests/fixtures/rdfc10/test010-in.nq new file mode 100644 index 0000000..dfcde95 --- /dev/null +++ b/tests/fixtures/rdfc10/test010-in.nq @@ -0,0 +1 @@ + "2011-01-25T00:00:00+00:00"^^ . diff --git a/tests/fixtures/rdfc10/test010-rdfc10.nq b/tests/fixtures/rdfc10/test010-rdfc10.nq new file mode 100644 index 0000000..dfcde95 --- /dev/null +++ b/tests/fixtures/rdfc10/test010-rdfc10.nq @@ -0,0 +1 @@ + "2011-01-25T00:00:00+00:00"^^ . diff --git a/tests/fixtures/rdfc10/test017-in.nq b/tests/fixtures/rdfc10/test017-in.nq new file mode 100644 index 0000000..afb6fa8 --- /dev/null +++ b/tests/fixtures/rdfc10/test017-in.nq @@ -0,0 +1,2 @@ + _:e0 . + _:e0 . diff --git a/tests/fixtures/rdfc10/test017-rdfc10.nq b/tests/fixtures/rdfc10/test017-rdfc10.nq new file mode 100644 index 0000000..28a87ed --- /dev/null +++ b/tests/fixtures/rdfc10/test017-rdfc10.nq @@ -0,0 +1,2 @@ + _:c14n0 . + _:c14n0 . diff --git a/tests/fixtures/rdfc10/test020-in.nq b/tests/fixtures/rdfc10/test020-in.nq new file mode 100644 index 0000000..b7086a7 --- /dev/null +++ b/tests/fixtures/rdfc10/test020-in.nq @@ -0,0 +1,4 @@ + _:e0 . + _:e1 . +_:e0 _:e2 . +_:e1 _:e2 . diff --git a/tests/fixtures/rdfc10/test020-rdfc10.nq b/tests/fixtures/rdfc10/test020-rdfc10.nq new file mode 100644 index 0000000..97e8e41 --- /dev/null +++ b/tests/fixtures/rdfc10/test020-rdfc10.nq @@ -0,0 +1,4 @@ + _:c14n2 . + _:c14n0 . +_:c14n0 _:c14n1 . +_:c14n2 _:c14n1 . diff --git a/tests/fixtures/rdfc10/test074-in.nq b/tests/fixtures/rdfc10/test074-in.nq new file mode 100644 index 0000000..f3e452e --- /dev/null +++ b/tests/fixtures/rdfc10/test074-in.nq @@ -0,0 +1,100 @@ +_:e0 _:e0 . +_:e0 _:e1 . +_:e0 _:e2 . +_:e0 _:e3 . +_:e0 _:e4 . +_:e0 _:e5 . +_:e0 _:e6 . +_:e0 _:e7 . +_:e0 _:e8 . +_:e0 _:e9 . +_:e1 _:e0 . +_:e1 _:e1 . +_:e1 _:e2 . +_:e1 _:e3 . +_:e1 _:e4 . +_:e1 _:e5 . +_:e1 _:e6 . +_:e1 _:e7 . +_:e1 _:e8 . +_:e1 _:e9 . +_:e2 _:e0 . +_:e2 _:e1 . +_:e2 _:e2 . +_:e2 _:e3 . +_:e2 _:e4 . +_:e2 _:e5 . +_:e2 _:e6 . +_:e2 _:e7 . +_:e2 _:e8 . +_:e2 _:e9 . +_:e3 _:e0 . +_:e3 _:e1 . +_:e3 _:e2 . +_:e3 _:e3 . +_:e3 _:e4 . +_:e3 _:e5 . +_:e3 _:e6 . +_:e3 _:e7 . +_:e3 _:e8 . +_:e3 _:e9 . +_:e4 _:e0 . +_:e4 _:e1 . +_:e4 _:e2 . +_:e4 _:e3 . +_:e4 _:e4 . +_:e4 _:e5 . +_:e4 _:e6 . +_:e4 _:e7 . +_:e4 _:e8 . +_:e4 _:e9 . +_:e5 _:e0 . +_:e5 _:e1 . +_:e5 _:e2 . +_:e5 _:e3 . +_:e5 _:e4 . +_:e5 _:e5 . +_:e5 _:e6 . +_:e5 _:e7 . +_:e5 _:e8 . +_:e5 _:e9 . +_:e6 _:e0 . +_:e6 _:e1 . +_:e6 _:e2 . +_:e6 _:e3 . +_:e6 _:e4 . +_:e6 _:e5 . +_:e6 _:e6 . +_:e6 _:e7 . +_:e6 _:e8 . +_:e6 _:e9 . +_:e7 _:e0 . +_:e7 _:e1 . +_:e7 _:e2 . +_:e7 _:e3 . +_:e7 _:e4 . +_:e7 _:e5 . +_:e7 _:e6 . +_:e7 _:e7 . +_:e7 _:e8 . +_:e7 _:e9 . +_:e8 _:e0 . +_:e8 _:e1 . +_:e8 _:e2 . +_:e8 _:e3 . +_:e8 _:e4 . +_:e8 _:e5 . +_:e8 _:e6 . +_:e8 _:e7 . +_:e8 _:e8 . +_:e8 _:e9 . +_:e9 _:e0 . +_:e9 _:e1 . +_:e9 _:e2 . +_:e9 _:e3 . +_:e9 _:e4 . +_:e9 _:e5 . +_:e9 _:e6 . +_:e9 _:e7 . +_:e9 _:e8 . +_:e9 _:e9 . diff --git a/tests/regression/expected/00-smoke.out b/tests/regression/expected/00-smoke.out index 565d288..ed6a293 100644 --- a/tests/regression/expected/00-smoke.out +++ b/tests/regression/expected/00-smoke.out @@ -1,4 +1,4 @@ -0.6.31 +0.6.32 3 3 -0.6.31 +0.6.32