Skip to main content

rete_core/
ingest.rs

1//! Ingestion: parse RDF text (N-Triples / N-Quads / Turtle) and assemble a
2//! complete `.rete` file image. Shared by the CLI's `build`/`validate` commands
3//! and the wasm bindings (the playground's in-browser builder).
4//!
5//! The N-Triples/N-Quads reader is line-based and keeps terms as their exact
6//! canonical token strings (`<iri>`, `_:bnode`, `"lit"`, `"lit"^^<dt>`,
7//! `"lit"@lang`) so they double as dictionary keys and so a `query` can match
8//! by the same string. This is not a full RDF 1.1 parser — it covers the
9//! canonical N-Triples surface (and is deliberately tolerant of IRIs a strict
10//! parser would reject), enough for v0 ingestion. Turtle goes through `oxttl`.
11//!
12//! Assembly degrades with the build features: without the `compression`
13//! feature (e.g. on wasm) sections are written with the `NONE` codec — larger
14//! files, byte-compatible readers.
15
16use crate::{
17    build_pyramid_meta_algo, Dictionary, DictionaryBuilder, GraphIndexBuilder, PyramidAlgo,
18    DEFAULT_TILE_BUDGET,
19};
20
21/// A parsed triple as three canonical term tokens.
22pub type RawTriple = (String, String, String);
23
24/// A parsed quad: the triple plus an optional graph term (`None` = default graph).
25pub type RawQuad = (String, String, String, Option<String>);
26
27/// Why an ingest failed. Parser errors from `oxttl`/`oxrdfxml` and I/O errors
28/// are flattened to their `Display` string rather than wrapped: the variants
29/// then carry no foreign types, which keeps the error usable from the wasm
30/// bindings and stable across upstream parser upgrades.
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum IngestError {
34    /// A malformed N-Triples/N-Quads line: its 1-based line number and a short
35    /// reason (`"bad subject"`, `"missing trailing '.'"`, …).
36    #[error("line {0}: {1}")]
37    Line(usize, &'static str),
38    /// The Turtle parser rejected the input.
39    #[error("turtle: {0}")]
40    Turtle(String),
41    /// The RDF/XML parser rejected the input.
42    #[error("rdf/xml: {0}")]
43    RdfXml(String),
44    /// The requested format is not one this crate can parse.
45    #[error("unknown input format: {0} (expected nt, nq, ttl, or rdf/xml)")]
46    UnknownFormat(String),
47    /// The input could not be read (streaming builds surface the path here).
48    #[error("io: {0}")]
49    Io(String),
50}
51
52/// Estimate the statement count of an N-Triples/N-Quads text from its newline
53/// count, so the output `Vec` can be pre-sized — a big build otherwise pays
54/// repeated `Vec` doublings, each briefly holding ~2× the (large) spine. An
55/// over-estimate by a few blank/comment lines is harmless (it's a capacity hint).
56fn estimate_statements(input: &str) -> usize {
57    bytecount_newlines(input).max(1)
58}
59
60/// Count `\n` bytes — one linear pass, far cheaper than the parse it sizes.
61fn bytecount_newlines(input: &str) -> usize {
62    input.as_bytes().iter().filter(|&&b| b == b'\n').count()
63}
64
65/// Parse one N-Quads line into a quad, or `None` for a blank/comment line.
66/// Shared by the whole-text [`parse_quads`] and the streaming [`parse_reader`].
67fn parse_nq_line(raw: &str, lineno: usize) -> Result<Option<RawQuad>, IngestError> {
68    let line = raw.trim();
69    if line.is_empty() || line.starts_with('#') {
70        return Ok(None);
71    }
72    let stripped = line
73        .strip_suffix('.')
74        .ok_or(IngestError::Line(lineno, "missing trailing '.'"))?
75        .trim_end();
76    let (s, rest) = take_term(stripped).ok_or(IngestError::Line(lineno, "bad subject"))?;
77    let (p, rest) =
78        take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad predicate"))?;
79    let (o, rest) = take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad object"))?;
80    let rest = rest.trim();
81    let graph = if rest.is_empty() {
82        None
83    } else {
84        let (g, tail) = take_term(rest).ok_or(IngestError::Line(lineno, "bad graph"))?;
85        if !tail.trim().is_empty() {
86            return Err(IngestError::Line(lineno, "trailing content after graph"));
87        }
88        Some(g)
89    };
90    Ok(Some((s, p, o, graph)))
91}
92
93/// Parse one N-Triples line into a triple, or `None` for a blank/comment line.
94fn parse_nt_line(raw: &str, lineno: usize) -> Result<Option<RawTriple>, IngestError> {
95    let line = raw.trim();
96    if line.is_empty() || line.starts_with('#') {
97        return Ok(None);
98    }
99    let stripped = line
100        .strip_suffix('.')
101        .ok_or(IngestError::Line(lineno, "missing trailing '.'"))?
102        .trim_end();
103    let (s, rest) = take_term(stripped).ok_or(IngestError::Line(lineno, "bad subject"))?;
104    let (p, rest) =
105        take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad predicate"))?;
106    let (o, rest) = take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad object"))?;
107    if !rest.trim().is_empty() {
108        return Err(IngestError::Line(lineno, "trailing content after object"));
109    }
110    Ok(Some((s, p, o)))
111}
112
113/// Parse N-Quads text: `subject predicate object [graph] .` per line.
114pub fn parse_quads(input: &str) -> Result<Vec<RawQuad>, IngestError> {
115    let mut out = Vec::with_capacity(estimate_statements(input));
116    for (i, raw) in input.lines().enumerate() {
117        if let Some(q) = parse_nq_line(raw, i + 1)? {
118            out.push(q);
119        }
120    }
121    Ok(out)
122}
123
124/// Parse N-Triples text into raw term-token triples, skipping blank/comment lines.
125pub fn parse(input: &str) -> Result<Vec<RawTriple>, IngestError> {
126    let mut out = Vec::with_capacity(estimate_statements(input));
127    for (i, raw) in input.lines().enumerate() {
128        if let Some(t) = parse_nt_line(raw, i + 1)? {
129            out.push(t);
130        }
131    }
132    Ok(out)
133}
134
135/// **Stream-parse** N-Triples (`"nt"`) or N-Quads (`"nq"`) from a reader, one
136/// line at a time, so the whole input text is **never resident** — the big-build
137/// memory win over reading the file into a `String` first (each line String is
138/// transient, freed every iteration). `cap` pre-sizes the output `Vec` (e.g.
139/// `file_len / 64`) to avoid reallocation doublings of the large spine. Turtle is
140/// not streamable here (oxttl needs the whole input); callers use the text path
141/// for `"ttl"`.
142pub fn parse_reader<R: std::io::BufRead>(
143    reader: R,
144    format: &str,
145    cap: usize,
146) -> Result<Vec<RawQuad>, IngestError> {
147    let mut out = Vec::with_capacity(cap);
148    stream_reader(reader, format, &mut |q| out.push(q))?;
149    Ok(out)
150}
151
152/// **Stream** N-Triples (`"nt"`) / N-Quads (`"nq"`) from a reader, invoking `f`
153/// once per parsed quad — like [`parse_reader`] but **without collecting** into a
154/// `Vec`. Each quad's term Strings are owned by `f` (and dropped when it returns
155/// if it doesn't retain them), so a caller that only needs to *observe* every
156/// term — e.g. the two-pass [`assemble_dataset_streaming`] building its
157/// dictionary — never materializes the whole quad multiset. The big-graph,
158/// low-RAM ingest primitive. Blank/comment lines are skipped; a parse error stops
159/// the stream and is returned.
160pub fn stream_reader<R: std::io::BufRead>(
161    reader: R,
162    format: &str,
163    f: &mut dyn FnMut(RawQuad),
164) -> Result<(), IngestError> {
165    if format != "nt" && format != "nq" {
166        return Err(IngestError::UnknownFormat(format.to_string()));
167    }
168    for (i, line) in reader.lines().enumerate() {
169        let line = line.map_err(|e| IngestError::Io(e.to_string()))?;
170        if format == "nq" {
171            if let Some(q) = parse_nq_line(&line, i + 1)? {
172                f(q);
173            }
174        } else if let Some((s, p, o)) = parse_nt_line(&line, i + 1)? {
175            f((s, p, o, None));
176        }
177    }
178    Ok(())
179}
180
181/// Parse Turtle into canonical N-Triples-token triples via oxttl.
182pub fn parse_turtle(text: &str) -> Result<Vec<RawTriple>, IngestError> {
183    let mut out = Vec::new();
184    // `with_quoted_triples` accepts RDF-star quoted triples (`<< s p o >>`) in
185    // subject/object position; oxrdf's `Term::Triple` then Displays as the
186    // canonical `<< … >>` token our N-Triples-star tokenizer also emits.
187    for r in oxttl::TurtleParser::new()
188        .with_quoted_triples()
189        .for_reader(text.as_bytes())
190    {
191        let t = r.map_err(|e| IngestError::Turtle(e.to_string()))?;
192        out.push((
193            t.subject.to_string(),
194            t.predicate.to_string(),
195            t.object.to_string(),
196        ));
197    }
198    Ok(out)
199}
200
201/// Parse RDF/XML into canonical N-Triples-token triples via oxrdfxml. This is how
202/// most OWL ontologies ship (`.rdf`/`.owl`/`.xml` with an `rdf:RDF` root) — so rete
203/// ingests them directly, no external conversion. (OWL/XML — the non-RDF functional
204/// XML serialization — is a different language; convert it with owlready2 first.)
205pub fn parse_rdfxml(text: &str) -> Result<Vec<RawTriple>, IngestError> {
206    let mut out = Vec::new();
207    for r in oxrdfxml::RdfXmlParser::new().for_reader(text.as_bytes()) {
208        let t = r.map_err(|e| IngestError::RdfXml(e.to_string()))?;
209        out.push((
210            t.subject.to_string(),
211            t.predicate.to_string(),
212            t.object.to_string(),
213        ));
214    }
215    Ok(out)
216}
217
218/// Parse one text input by format name (`"nt"`, `"nq"`, `"ttl"`, or `"rdfxml"`)
219/// into quads (triples land in the default graph).
220pub fn parse_statements(text: &str, format: &str) -> Result<Vec<RawQuad>, IngestError> {
221    match format {
222        "nq" => parse_quads(text),
223        "ttl" => Ok(parse_turtle(text)?
224            .into_iter()
225            .map(|(s, p, o)| (s, p, o, None))
226            .collect()),
227        "rdfxml" => Ok(parse_rdfxml(text)?
228            .into_iter()
229            .map(|(s, p, o)| (s, p, o, None))
230            .collect()),
231        "nt" => Ok(parse(text)?
232            .into_iter()
233            .map(|(s, p, o)| (s, p, o, None))
234            .collect()),
235        other => Err(IngestError::UnknownFormat(other.to_string())),
236    }
237}
238
239/// Take one term from the front of `s`, returning `(term, remainder)`.
240pub(crate) fn take_term(s: &str) -> Option<(String, &str)> {
241    let bytes = s.as_bytes();
242    let first = *bytes.first()?;
243    match first {
244        // A quoted triple / triple term, in EITHER surface — the inner terms are
245        // themselves terms (so this recurses; nesting works). `<<` starts with `<`
246        // and an IRI scan would stop at the first inner `>`, so it must be handled
247        // before the plain-IRI case.
248        //   * RDF-star:  `<< subject predicate object >>`
249        //   * RDF 1.2:   `<<( subject predicate object )>>`  (triple term)
250        // Both re-emit the SAME canonical token `<<s p o>>`, so a file written in
251        // either surface — and a query written either way — dedupe and match. This
252        // makes RDF 1.2 N-Triples interoperable with the RDF-star we already store,
253        // with no format change and no dependency swap.
254        b'<' if bytes.get(1) == Some(&b'<') => {
255            // Distinguish `<<(` (RDF 1.2) from `<<` (RDF-star) by the char after `<<`.
256            let (inner, rdf12) = match s[2..].strip_prefix('(') {
257                Some(after) => (after.trim_start(), true),
258                None => (s[2..].trim_start(), false),
259            };
260            let (subj, r) = take_term(inner)?;
261            let (pred, r) = take_term(r.trim_start())?;
262            let (obj, r) = take_term(r.trim_start())?;
263            let r = r.trim_start();
264            let rest = if rdf12 {
265                r.strip_prefix(")>>")?
266            } else {
267                r.strip_prefix(">>")?
268            };
269            // Canonical surface = oxrdf's `Triple` Display: `<<s p o>>` (tight
270            // brackets, single spaces between components) — the same token for both
271            // input surfaces, so N-Triples-star, Turtle-star, and RDF 1.2 triple
272            // terms all resolve to one dictionary entry.
273            Some((format!("<<{subj} {pred} {obj}>>"), rest))
274        }
275        b'<' => {
276            // IRI ref: up to the closing '>'.
277            let end = s.find('>')?;
278            Some((s[..=end].to_string(), &s[end + 1..]))
279        }
280        b'_' => {
281            // Blank node: up to whitespace.
282            let end = s.find(char::is_whitespace).unwrap_or(s.len());
283            Some((s[..end].to_string(), &s[end..]))
284        }
285        b'"' => {
286            // Literal: closing unescaped quote, then optional ^^<dt> or @lang.
287            let mut i = 1;
288            let b = s.as_bytes();
289            while i < b.len() {
290                match b[i] {
291                    b'\\' => i += 2, // skip escaped char
292                    b'"' => break,
293                    _ => i += 1,
294                }
295            }
296            if i >= b.len() {
297                return None; // unterminated
298            }
299            let mut end = i + 1; // past closing quote
300            if s[end..].starts_with("^^<") {
301                let close = s[end..].find('>')? + end;
302                end = close + 1;
303            } else if s[end..].starts_with('@') {
304                // Language tag: '@' then BCP-47 subtags `[a-zA-Z0-9-]+`. Stop at
305                // the first char that can't be part of a tag — normally the
306                // whitespace before the predicate, but ALSO the `>>` that closes
307                // a quoted triple when this literal is its object (`"x"@en>>`),
308                // where there is no separating whitespace.
309                let mut j = end + 1; // past '@'
310                while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'-') {
311                    j += 1;
312                }
313                end = j;
314            }
315            Some((s[..end].to_string(), &s[end..]))
316        }
317        _ => None,
318    }
319}
320
321/// Split a canonical quoted-triple token `<<s p o>>` (RDF-star) into its three
322/// component term tokens, or `None` if `t` is not a quoted triple. Reuses
323/// [`take_term`] for term-boundary scanning, so nested quoting parses correctly.
324/// The inverse of the `<<…>>` construction; used by the SUBJECT/PREDICATE/OBJECT
325/// SPARQL-star builtins.
326pub(crate) fn quoted_triple_parts(t: &str) -> Option<(String, String, String)> {
327    let inner = t.strip_prefix("<<")?.strip_suffix(">>")?.trim();
328    let (s, r) = take_term(inner)?;
329    let (p, r) = take_term(r.trim_start())?;
330    let (o, r) = take_term(r.trim_start())?;
331    if !r.trim().is_empty() {
332        return None;
333    }
334    Some((s, p, o))
335}
336
337/// Counts describing an assembled file, for status lines and UIs.
338#[derive(Debug, Clone, Copy)]
339pub struct BuildStats {
340    /// Total statements ingested, across the default graph and every named one.
341    pub statements: usize,
342    /// Statements that landed in the default graph.
343    pub default_triples: usize,
344    /// How many named graphs the input mentioned; one index is written per graph.
345    pub named_graphs: usize,
346    /// Distinct terms in the shared dictionary.
347    pub terms: usize,
348    /// Levels in the community pyramid, or `0` when the build skipped it.
349    pub pyramid_levels: u16,
350}
351
352/// Assemble a complete `.rete` file image from parsed quads: one shared
353/// dictionary, the default-graph index, one index per named graph, and the
354/// community pyramid. `metadata` is the opaque metadata-section payload (the
355/// CLI puts a JSON Dataset Card there); pass `&[]` for none — that is
356/// byte-identical to a metadata-free build.
357pub fn assemble_dataset(quads: Vec<RawQuad>, metadata: &[u8]) -> (Vec<u8>, BuildStats) {
358    let blob = metadata.to_vec();
359    assemble_dataset_with(quads, move |_, _| blob)
360}
361
362/// Like [`assemble_dataset`], but the metadata payload is derived from the
363/// [`BuildStats`] right before serialization — for metadata that embeds counts
364/// only known after the dictionary and indexes are built (the Dataset Card).
365/// Returning an empty `Vec` is byte-identical to a metadata-free build.
366pub fn assemble_dataset_with(
367    quads: Vec<RawQuad>,
368    metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> Vec<u8>,
369) -> (Vec<u8>, BuildStats) {
370    assemble_dataset_with_opts(quads, true, false, None, metadata)
371}
372
373/// Like [`assemble_dataset_with`], but `with_pyramid = false` skips the Louvain
374/// community pyramid entirely — no pyramid section is written (header length 0).
375/// SPARQL / SHACL / triple / reachability queries don't use the pyramid, so a
376/// pyramid-less file is fully queryable and markedly smaller (the pyramid is the
377/// largest section on highly-connected graphs). Only the community / summary /
378/// progressive paths need it.
379pub fn assemble_dataset_with_opts(
380    quads: Vec<RawQuad>,
381    with_pyramid: bool,
382    with_text_index: bool,
383    type_override: Option<&str>,
384    metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> Vec<u8>,
385) -> (Vec<u8>, BuildStats) {
386    assemble_dataset_with_opts_algo(
387        quads,
388        with_pyramid,
389        with_text_index,
390        type_override,
391        PyramidAlgo::Louvain,
392        metadata,
393    )
394}
395
396/// Like [`assemble_dataset_with_opts`], but selects the community [`PyramidAlgo`]
397/// (the in-memory build path for `rete build --pyramid-algo …`).
398#[allow(clippy::too_many_arguments)]
399pub fn assemble_dataset_with_opts_algo(
400    quads: Vec<RawQuad>,
401    with_pyramid: bool,
402    with_text_index: bool,
403    type_override: Option<&str>,
404    algo: PyramidAlgo,
405    metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> Vec<u8>,
406) -> (Vec<u8>, BuildStats) {
407    use std::collections::BTreeMap;
408
409    let mut db = DictionaryBuilder::new();
410    for (s, p, o, _) in &quads {
411        db.observe(s, p, o);
412    }
413    let dict = db.build();
414
415    let mut default_triples = Vec::new();
416    let mut named: BTreeMap<String, Vec<(u32, u32, u32)>> = BTreeMap::new();
417    for (s, p, o, g) in &quads {
418        let t = dict.encode(s, p, o).expect("observed term");
419        match g {
420            None => default_triples.push(t),
421            Some(graph) => named.entry(graph.clone()).or_default().push(t),
422        }
423    }
424
425    // Derive the metadata blob (the Dataset Card) from the raw quads NOW, while
426    // they are resident — then DROP them before the memory-heavy pyramid + index
427    // phases. On a big build the string quads are the largest working set (every
428    // term an owned String, heavily duplicated) and are fully redundant with the
429    // dictionary + id-triples once encoded, so freeing them here cuts peak RAM by
430    // their whole size. `pyramid_levels` is not known yet (0 in the callback);
431    // it is filled into the returned `stats` by `finish_assembly`, and no metadata
432    // callback depends on it (the card derives from the quads + term/graph counts).
433    let stats = BuildStats {
434        statements: quads.len(),
435        default_triples: default_triples.len(),
436        named_graphs: named.len(),
437        terms: dict.term_count() as usize,
438        pyramid_levels: 0,
439    };
440    let blob = metadata(&stats, &quads);
441    drop(quads);
442
443    finish_assembly(
444        dict,
445        default_triples,
446        named,
447        with_pyramid,
448        with_text_index,
449        type_override,
450        algo,
451        blob,
452        stats,
453    )
454}
455
456/// **Two-pass, low-RAM** assembly: build a `.rete` by **streaming** the input(s)
457/// twice instead of holding every parsed quad in memory. `stream` is invoked
458/// **twice** and MUST replay the exact same quads in the same order each time —
459/// pass 1 observes every term into the dictionary; pass 2 encodes them to
460/// id-triples. The raw string quads (every term an owned String, heavily
461/// duplicated — by far the largest working set on a big graph) are **never
462/// collected**, so peak RAM is bounded by the dictionary + id-triples + index
463/// rather than the string-quad multiset. Output is **byte-identical** to
464/// [`assemble_dataset_with_opts`] on the same quads (same dictionary, same
465/// id-triples in file order, same downstream pipeline).
466///
467/// The metadata callback derives the Dataset Card from the built dictionary +
468/// default-graph id-triples (resolving terms through the dictionary), since the
469/// raw quads were never retained. `stream` propagates parse/IO errors.
470pub fn assemble_dataset_streaming<S>(
471    stream: S,
472    with_pyramid: bool,
473    with_text_index: bool,
474    type_override: Option<&str>,
475    metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> Vec<u8>,
476) -> Result<(Vec<u8>, BuildStats), IngestError>
477where
478    S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
479{
480    assemble_dataset_streaming_algo(
481        stream,
482        with_pyramid,
483        with_text_index,
484        type_override,
485        PyramidAlgo::Louvain,
486        metadata,
487    )
488}
489
490/// Like [`assemble_dataset_streaming`], but selects the community [`PyramidAlgo`]
491/// (the streaming, low-RAM build path for `rete build --pyramid-algo …`).
492#[allow(clippy::too_many_arguments)]
493pub fn assemble_dataset_streaming_algo<S>(
494    mut stream: S,
495    with_pyramid: bool,
496    with_text_index: bool,
497    type_override: Option<&str>,
498    algo: PyramidAlgo,
499    metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> Vec<u8>,
500) -> Result<(Vec<u8>, BuildStats), IngestError>
501where
502    S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
503{
504    use std::collections::BTreeMap;
505
506    // Pass 1: observe every term (the dictionary dedups; the string quads are
507    // freed line by line and never collected).
508    let mut db = DictionaryBuilder::new();
509    stream(&mut |(s, p, o, _g)| db.observe(&s, &p, &o))?;
510    let dict = db.build();
511
512    // Pass 2: encode each quad to an id-triple, bucketing named graphs.
513    let mut default_triples: Vec<(u32, u32, u32)> = Vec::new();
514    let mut named: BTreeMap<String, Vec<(u32, u32, u32)>> = BTreeMap::new();
515    stream(&mut |(s, p, o, g)| {
516        let t = dict.encode(&s, &p, &o).expect("observed term");
517        match g {
518            None => default_triples.push(t),
519            Some(graph) => named.entry(graph).or_default().push(t),
520        }
521    })?;
522
523    let statements = default_triples.len() + named.values().map(Vec::len).sum::<usize>();
524    let stats = BuildStats {
525        statements,
526        default_triples: default_triples.len(),
527        named_graphs: named.len(),
528        terms: dict.term_count() as usize,
529        pyramid_levels: 0,
530    };
531    let blob = metadata(&stats, &dict, &default_triples);
532    Ok(finish_assembly(
533        dict,
534        default_triples,
535        named,
536        with_pyramid,
537        with_text_index,
538        type_override,
539        algo,
540        blob,
541        stats,
542    ))
543}
544
545/// The shared tail of every build path: from a finished dictionary + encoded
546/// id-triples (default graph + named), build the community pyramid, the optional
547/// full-text index, the permutation indexes, and serialize the file image. Takes
548/// the id-triples **by value** so they are freed as the permutations consume them.
549#[allow(clippy::too_many_arguments)]
550fn finish_assembly(
551    dict: Dictionary,
552    default_triples: Vec<(u32, u32, u32)>,
553    named: std::collections::BTreeMap<String, Vec<(u32, u32, u32)>>,
554    with_pyramid: bool,
555    with_text_index: bool,
556    type_override: Option<&str>,
557    algo: PyramidAlgo,
558    blob: Vec<u8>,
559    mut stats: BuildStats,
560) -> (Vec<u8>, BuildStats) {
561    let has_named = !named.is_empty();
562
563    // The pyramid and the full-text index are built FIRST, while the dictionary and
564    // the default id-triples are both still resident — both need the two together.
565    let (meta, levels) = if with_pyramid {
566        build_pyramid_meta_algo(
567            &dict,
568            &default_triples,
569            DEFAULT_TILE_BUDGET,
570            type_override,
571            algo,
572        )
573    } else {
574        (Vec::new(), 0)
575    };
576    stats.pyramid_levels = levels;
577
578    let text_index = if with_text_index {
579        crate::file::compute_text_index(&dict, &default_triples)
580    } else {
581        Vec::new()
582    };
583
584    // From here the dictionary is only needed for its own serialized bytes. Encode
585    // it, capture the header term count, then DROP it before building the
586    // permutation indexes — which work purely on id-triples and never touch the
587    // dictionary. On a large graph this frees the single biggest resident structure
588    // (the dictionary) right when the index sort needs the headroom. The output is
589    // byte-for-byte identical to serializing the dictionary inline.
590    let codec = crate::file::writer_codec();
591    let dict_container = crate::file::encode_dict_container(&dict, codec);
592    let term_count = dict.term_count() as u64;
593    let has_quoted_triples = dict.has_quoted_triples();
594    drop(dict);
595
596    // Build each graph's six permutations one-at-a-time on a large graph (a single
597    // permuted copy resident at a time, each sort still parallel) instead of all
598    // six concurrently; below the threshold the faster all-permutations-parallel
599    // build is used (its 6x transient copy is negligible there). `build_seq` is
600    // byte-identical to `build`.
601    let build_index = |triples: Vec<(u32, u32, u32)>| -> crate::GraphIndex {
602        let n = triples.len();
603        let b = GraphIndexBuilder::from_triples(triples);
604        if n > LOWMEM_TRIPLE_THRESHOLD {
605            b.build_seq()
606        } else {
607            b.build()
608        }
609    };
610    let def = build_index(default_triples);
611    let named_indexes: Vec<(String, crate::GraphIndex)> = named
612        .into_iter()
613        .map(|(g, ts)| (g, build_index(ts)))
614        .collect();
615
616    let bytes = crate::file::write_dataset_from_parts(
617        &dict_container,
618        term_count,
619        &def,
620        &named_indexes,
621        has_named,
622        has_quoted_triples,
623        &meta,
624        levels,
625        &blob,
626        &text_index,
627        codec,
628    );
629    (bytes, stats)
630}
631
632/// Above this default-graph triple count, the index is built one permutation at a
633/// time (low peak RAM) instead of all six in parallel. Chosen so typical builds
634/// keep the faster parallel path while multi-hundred-million-triple graphs stay
635/// within a bounded memory budget.
636const LOWMEM_TRIPLE_THRESHOLD: usize = 30_000_000;
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::Rete;
642
643    #[test]
644    fn parses_iris_bnodes_literals() {
645        let input = r#"
646            # a comment
647            <http://ex/Alice> <http://ex/knows> <http://ex/Bob> .
648            <http://ex/Alice> <http://ex/age> "30"^^<http://www.w3.org/2001/XMLSchema#integer> .
649            <http://ex/Bob> <http://ex/label> "Bob"@en .
650            _:b0 <http://ex/p> "plain" .
651        "#;
652        let t = parse(input).unwrap();
653        assert_eq!(t.len(), 4);
654        assert_eq!(t[0].0, "<http://ex/Alice>");
655        assert_eq!(t[0].2, "<http://ex/Bob>");
656        assert_eq!(t[1].2, "\"30\"^^<http://www.w3.org/2001/XMLSchema#integer>");
657        assert_eq!(t[2].2, "\"Bob\"@en");
658        assert_eq!(t[3].0, "_:b0");
659        assert_eq!(t[3].2, "\"plain\"");
660    }
661
662    #[test]
663    fn literal_with_spaces_and_escaped_quote() {
664        let input = r#"<http://ex/s> <http://ex/p> "a \"quoted\" phrase here" ."#;
665        let t = parse(input).unwrap();
666        assert_eq!(t.len(), 1);
667        assert_eq!(t[0].2, r#""a \"quoted\" phrase here""#);
668    }
669
670    /// RDF-star: a quoted triple whose object is a **language-tagged literal**
671    /// sits directly against the closing `>>` with no separating whitespace
672    /// (`"name"@fr>>`). The language-tag scan must stop at `>` — a regression
673    /// guard for the greedy scan-to-whitespace that swallowed `@fr>>` as the tag.
674    #[test]
675    fn quoted_triple_langtagged_object() {
676        let s = r#"<<<http://ex/sp> <http://ex/name> "Hirondelle rustique"@fr>>"#;
677        let (tok, rest) = take_term(s).unwrap();
678        assert_eq!(
679            tok,
680            r#"<<<http://ex/sp> <http://ex/name> "Hirondelle rustique"@fr>>"#
681        );
682        assert_eq!(rest, "");
683        // and it round-trips through a full annotation line
684        let line = r#"<<<http://ex/sp> <http://ex/name> "Oreneta vulgar"@ca>> <http://purl.org/dc/terms/source> "Catalogue of Life" ."#;
685        let t = parse(line).unwrap();
686        assert_eq!(t.len(), 1);
687        assert_eq!(
688            t[0].0,
689            r#"<<<http://ex/sp> <http://ex/name> "Oreneta vulgar"@ca>>"#
690        );
691        assert_eq!(t[0].2, r#""Catalogue of Life""#);
692        // a plain lang-tagged object still terminates at whitespace
693        assert_eq!(take_term(r#""x"@pt-BR ."#).unwrap().0, r#""x"@pt-BR"#);
694    }
695
696    /// RDF 1.2 triple-term surface `<<( s p o )>>` parses to the SAME canonical
697    /// token as the RDF-star `<< s p o >>`, so the two are interoperable (a query
698    /// written either way matches data ingested either way).
699    #[test]
700    fn rdf12_triple_term_same_token_as_rdf_star() {
701        let rdf12 = r#"<http://ex/r> <http://ex/p> <<( <http://ex/s> <http://ex/q> "o" )>> ."#;
702        let star = r#"<http://ex/r> <http://ex/p> << <http://ex/s> <http://ex/q> "o" >> ."#;
703        let a = parse(rdf12).unwrap();
704        let b = parse(star).unwrap();
705        assert_eq!(a.len(), 1);
706        assert_eq!(a[0].2, r#"<<<http://ex/s> <http://ex/q> "o">>"#);
707        assert_eq!(
708            a[0].2, b[0].2,
709            "RDF 1.2 and RDF-star must dedupe to one token"
710        );
711        // take_term consumes exactly the triple term (nothing left dangling).
712        let (tok, rest) =
713            take_term(r#"<<( <http://ex/s> <http://ex/q> <http://ex/o> )>>"#).unwrap();
714        assert_eq!(tok, "<<<http://ex/s> <http://ex/q> <http://ex/o>>>");
715        assert_eq!(rest, "");
716    }
717
718    #[test]
719    fn rejects_missing_dot() {
720        assert!(parse("<a> <b> <c>").is_err());
721    }
722
723    #[test]
724    fn parses_quads_with_and_without_graph() {
725        let input = "<http://ex/a> <http://ex/p> <http://ex/b> .\n\
726                     <http://ex/a> <http://ex/p> <http://ex/c> <http://ex/g> .";
727        let q = parse_quads(input).unwrap();
728        assert_eq!(q.len(), 2);
729        assert_eq!(q[0].3, None); // default graph
730        assert_eq!(q[1].3, Some("<http://ex/g>".to_string()));
731    }
732
733    #[test]
734    fn parse_reader_matches_text_parse() {
735        // The streaming reader must produce exactly the same quads as parsing the
736        // whole text — including blank/comment skipping, a named graph, and CRLF.
737        let nt = "<http://ex/a> <http://ex/p> <http://ex/b> .\r\n\
738                  # comment\n\
739                  \n\
740                  _:b0 <http://ex/q> \"lit\"@en .\n";
741        let via_text = parse_statements(nt, "nt").unwrap();
742        let via_reader = parse_reader(std::io::Cursor::new(nt), "nt", 0).unwrap();
743        assert_eq!(via_text, via_reader);
744
745        let nq = "<http://ex/a> <http://ex/p> <http://ex/b> .\n\
746                  <http://ex/a> <http://ex/p> <http://ex/c> <http://ex/g> .\n";
747        assert_eq!(
748            parse_quads(nq).unwrap(),
749            parse_reader(std::io::Cursor::new(nq), "nq", 0).unwrap()
750        );
751        // Turtle is not streamable here.
752        assert!(parse_reader(std::io::Cursor::new(nt), "ttl", 0).is_err());
753    }
754
755    #[test]
756    fn parse_statements_dispatches_by_format() {
757        let nt = "<http://ex/a> <http://ex/p> <http://ex/b> .";
758        assert_eq!(parse_statements(nt, "nt").unwrap().len(), 1);
759        let ttl = "@prefix ex: <http://ex/> .\nex:A ex:knows ex:B , ex:C .";
760        assert_eq!(parse_statements(ttl, "ttl").unwrap().len(), 2);
761        assert!(parse_statements(nt, "trig").is_err());
762    }
763
764    /// RDF/XML (how most OWL ontologies ship) parses to the same canonical tokens,
765    /// including the abbreviated typed-node syntax and `rdf:resource` references.
766    #[test]
767    fn parses_rdfxml_owl() {
768        let xml = r#"<?xml version="1.0"?>
769            <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
770                     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
771                     xmlns:owl="http://www.w3.org/2002/07/owl#">
772              <owl:Class rdf:about="http://ex/Dog">
773                <rdfs:subClassOf rdf:resource="http://ex/Animal"/>
774                <rdfs:label>Dog</rdfs:label>
775              </owl:Class>
776            </rdf:RDF>"#;
777        let triples = parse_rdfxml(xml).unwrap();
778        // rdf:type owl:Class, rdfs:subClassOf, rdfs:label = 3 triples.
779        assert_eq!(triples.len(), 3);
780        assert!(triples.iter().any(|(s, p, o)| s == "<http://ex/Dog>"
781            && p == "<http://www.w3.org/2000/01/rdf-schema#subClassOf>"
782            && o == "<http://ex/Animal>"));
783        // Same data through the format dispatcher (triples → default graph).
784        assert_eq!(parse_statements(xml, "rdfxml").unwrap().len(), 3);
785        // Malformed XML is a clear RdfXml error, not a silent empty parse.
786        assert!(matches!(
787            parse_statements("<rdf:RDF><not closed", "rdfxml"),
788            Err(IngestError::RdfXml(_))
789        ));
790    }
791
792    /// Regression coverage for RUSTSEC-2026-0195: namespace declarations from
793    /// an untrusted RDF/XML document must not make parsing panic or exhaust the
794    /// bounded test process.
795    #[test]
796    fn rdfxml_namespace_fanout_is_bounded() {
797        let declarations = (0..2_000)
798            .map(|i| format!(r#" xmlns:p{i}="http://example.test/{i}/""#))
799            .collect::<String>();
800        let xml = format!(
801            r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"{declarations}/>"#
802        );
803        let result = parse_statements(&xml, "rdfxml");
804        assert!(result.is_ok() || result.unwrap_err().to_string().contains("namespace"));
805    }
806
807    /// Regression coverage for RUSTSEC-2026-0194: duplicate-name checking must
808    /// complete for a large valid start tag without panicking or exhausting the
809    /// bounded test process.
810    #[test]
811    fn rdfxml_attribute_fanout_completes() {
812        let attributes = (0..2_000)
813            .map(|i| format!(r#" p:a{i}="{i}""#))
814            .collect::<String>();
815        let xml = format!(
816            r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:p="http://example.test/"><rdf:Description rdf:about="http://example.test/s"{attributes}/></rdf:RDF>"#
817        );
818        assert!(parse_statements(&xml, "rdfxml").is_ok());
819    }
820
821    /// Text in, queryable file image out — the whole in-memory build path the
822    /// wasm `build()` binding uses, including a named graph.
823    #[test]
824    fn assemble_dataset_round_trips() {
825        let text = "<http://ex/a> <http://ex/knows> <http://ex/b> .\n\
826                    <http://ex/b> <http://ex/knows> <http://ex/c> .\n\
827                    <http://ex/a> <http://ex/age> \"30\"^^<http://www.w3.org/2001/XMLSchema#integer> .\n\
828                    <http://ex/a> <http://ex/p> <http://ex/d> <http://ex/g1> .";
829        let quads = parse_statements(text, "nq").unwrap();
830        let (bytes, stats) = assemble_dataset(quads, &[]);
831        assert_eq!(stats.statements, 4);
832        assert_eq!(stats.default_triples, 3);
833        assert_eq!(stats.named_graphs, 1);
834        assert!(stats.terms >= 7);
835
836        let rete = Rete::open(&bytes).unwrap();
837        assert_eq!(
838            rete.query(None, Some("<http://ex/knows>"), None).len(),
839            2,
840            "default-graph pattern query"
841        );
842        assert_eq!(rete.graph_names(), vec!["<http://ex/g1>"]);
843        let out = crate::eval_query(
844            &rete,
845            "SELECT ?x WHERE { ?x <http://ex/knows> ?y . ?y <http://ex/knows> ?z }",
846        )
847        .unwrap();
848        match out {
849            crate::QueryOutput::Select(_, rows) => assert_eq!(rows.len(), 1),
850            other => panic!("expected select result, got {other:?}"),
851        }
852    }
853
854    /// The exact minimal input the playground Build tab uses: two triples, no
855    /// literals, no rdf:type, no named graph — the smallest graph that still
856    /// builds a community pyramid. (The wasm `build()` panic this guards was a
857    /// `std::time::Instant::now()` in the pyramid timing path; native has a clock
858    /// so this passes here, while the playground harness exercises the wasm path.)
859    #[test]
860    fn assemble_minimal_typeless_graph() {
861        let text = "<http://ex/A> <http://ex/knows> <http://ex/B> .\n\
862                    <http://ex/B> <http://ex/knows> <http://ex/C> .\n";
863        let quads = parse_statements(text, "nt").unwrap();
864        let (bytes, stats) = assemble_dataset(quads, &[]);
865        assert_eq!(stats.default_triples, 2);
866        let rete = Rete::open(&bytes).unwrap();
867        assert_eq!(rete.query(None, Some("<http://ex/knows>"), None).len(), 2);
868    }
869}