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//! That tolerance is *measured*, not silent: pass an [`IriAudit`] to any of the
13//! `*_audited` entry points and the parse counts every IRI outside the
14//! N-Triples `IRIREF` grammar / RFC 3987 (see [`crate::iri`]) — or, with
15//! `strict`, refuses the input on the first one. `rete build` always audits.
16//!
17//! Assembly degrades with the build features: without the `compression`
18//! feature (e.g. on wasm) sections are written with the `NONE` codec — larger
19//! files, byte-compatible readers.
20
21use crate::{Dictionary, DictionaryBuilder, GraphIndexBuilder, PyramidAlgo, DEFAULT_TILE_BUDGET};
22
23/// A parsed triple as three canonical term tokens.
24pub type RawTriple = (String, String, String);
25
26/// A parsed quad: the triple plus an optional graph term (`None` = default graph).
27pub type RawQuad = (String, String, String, Option<String>);
28
29/// Which **surface** a `<< … >>` in Turtle/TriG input is read as — the input
30/// half of `rete export --quoted-triple-syntax`, same vocabulary, same two
31/// values.
32///
33/// The flag exists because the two standards give one piece of syntax two
34/// meanings, and no amount of looking at the bytes can tell them apart:
35///
36/// | written | under [`RdfStar`](Self::RdfStar) | under [`Rdf12`](Self::Rdf12) |
37/// |--------------------|----------------------------------|---------------------------------------|
38/// | `<< s p o >>` | a **quoted triple**, one term | a **reifier**: `_:r rdf:reifies <<( s p o )>>`, plus a blank node standing where it was |
39/// | `<<( s p o )>>` | a syntax error | a **triple term**, object position only |
40/// | `{\| … \|}` | a syntax error | an annotation on the statement before it |
41///
42/// One file, two readings, two different graphs — see the round-trip matrix in
43/// `crates/rete-cli/tests/quoted_triple_surfaces.rs`, which asserts both.
44///
45/// `<<( s p o )>>` is unambiguous: it is RDF 1.2's and nothing else's. That
46/// asymmetry is why [`RdfStar`](Self::RdfStar) is the **default**. Reading an
47/// RDF-star file as RDF 1.2 silently yields a different graph; reading an
48/// RDF 1.2 file as RDF-star is a hard parse error that names the flag. Only one
49/// of the two mistakes is survivable, so the default is the one that makes the
50/// other mistake loud.
51///
52/// **N-Triples and N-Quads ignore this entirely.** Their reader is rete's own
53/// (`take_term`), it has accepted both `<< s p o >>` and `<<( s p o )>>` since
54/// #262, and RDF 1.2 N-Triples has no reifier syntax for `<< … >>` to be — so
55/// there is no ambiguity to resolve and nothing to choose. The same goes for
56/// RDF/XML.
57///
58/// Whichever surface reads the file, what gets **stored** is the same canonical
59/// token `<<s p o>>`. rete's term model is a superset of both: a triple term is
60/// a term, and RDF 1.2 reification is ordinary RDF — a blank node, a predicate
61/// and a term — which rete already stored before this flag existed.
62#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
63pub enum QuotedTripleSurface {
64 /// `<< s p o >>` is a **quoted triple** (RDF-star), in subject or object
65 /// position — rete's own storage token. The default, and byte-for-byte the
66 /// behaviour every rete release before this flag had.
67 #[default]
68 RdfStar,
69 /// `<<( s p o )>>` is a **triple term** and `<< s p o >>` is a **reifier**
70 /// (RDF 1.2), with `{| … |}` annotations read too.
71 Rdf12,
72}
73
74impl QuotedTripleSurface {
75 /// Parse the `--quoted-triple-syntax` value, `None` for anything else.
76 /// Shared by `rete build`, `rete validate` and `rete export` so the flag
77 /// cannot come to mean two things in two directions.
78 pub fn parse(s: &str) -> Option<Self> {
79 match s {
80 crate::card_derive::QUOTED_TRIPLE_SURFACE_RDF12 => Some(Self::Rdf12),
81 crate::card_derive::QUOTED_TRIPLE_SURFACE_RDF_STAR => Some(Self::RdfStar),
82 _ => None,
83 }
84 }
85
86 /// The flag spelling of this surface — the inverse of [`Self::parse`].
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::Rdf12 => crate::card_derive::QUOTED_TRIPLE_SURFACE_RDF12,
90 Self::RdfStar => crate::card_derive::QUOTED_TRIPLE_SURFACE_RDF_STAR,
91 }
92 }
93}
94
95/// The one-line hint appended to a Turtle/TriG parse error when the input looks
96/// like RDF 1.2 and was read as RDF-star — i.e. the exact mistake
97/// [`QuotedTripleSurface`]'s default makes, made loud.
98const RDF12_SURFACE_HINT: &str = "\nhint: this input contains `<<(`, the RDF 1.2 triple-term \
99 syntax — which is what `rete export` writes by default. Reading it takes \
100 `--quoted-triple-syntax rdf12`; the default, `rdf-star`, reads `<< s p o >>` as a quoted \
101 triple instead.";
102
103/// Does `text` look like it carries RDF 1.2 triple terms? A deliberately dumb
104/// substring test: `<<(` cannot begin anything else in Turtle, and this is only
105/// ever consulted to *explain a parse that already failed*, never to decide how
106/// to read a file. A `<<(` inside a string literal would make it a false
107/// positive on some other error's hint, which costs one confusing sentence and
108/// nothing else.
109fn looks_like_rdf12(text: &str) -> bool {
110 text.contains("<<(")
111}
112
113/// Why an ingest failed. Parser errors from `oxttl`/`oxrdfxml` and I/O errors
114/// are flattened to their `Display` string rather than wrapped: the variants
115/// then carry no foreign types, which keeps the error usable from the wasm
116/// bindings and stable across upstream parser upgrades.
117#[derive(Debug, thiserror::Error)]
118#[non_exhaustive]
119pub enum IngestError {
120 /// A malformed N-Triples/N-Quads line: its 1-based line number and a short
121 /// reason (`"bad subject"`, `"missing trailing '.'"`, …).
122 #[error("line {0}: {1}")]
123 Line(usize, &'static str),
124 /// The Turtle parser rejected the input.
125 #[error("turtle: {0}")]
126 Turtle(String),
127 /// The TriG parser rejected the input.
128 #[error("trig: {0}")]
129 TriG(String),
130 /// The RDF/XML parser rejected the input.
131 #[error("rdf/xml: {0}")]
132 RdfXml(String),
133 /// The requested format is not one this crate can parse.
134 #[error("unknown input format: {0} (expected nt, nq, ttl, trig, or rdf/xml)")]
135 UnknownFormat(String),
136 /// [`QuotedTripleSurface::Rdf12`] was asked for on a Turtle/TriG input, but
137 /// this build of `rete-core` has the `rdf12-turtle` feature off, so the
138 /// RDF 1.2 reader was not compiled in. Refused by name rather than read as
139 /// RDF-star, which would be the wrong graph told in silence.
140 #[error(
141 "this build cannot read the RDF 1.2 Turtle/TriG surface: rete-core was compiled without \
142 the `rdf12-turtle` feature (N-Triples/N-Quads still accept `<<( s p o )>>`)"
143 )]
144 Rdf12TurtleUnavailable,
145 /// The input could not be read (streaming builds surface the path here).
146 #[error("io: {0}")]
147 Io(String),
148 /// An IRI outside the N-Triples/N-Quads `IRIREF` grammar / RFC 3987, refused
149 /// because the caller asked for a strict [`IriAudit`] (`rete build
150 /// --strict`). Without `strict` the same finding is only counted.
151 /// `location` is `line N` for the line-based syntaxes, the format name for
152 /// the others (oxttl reports its own positions).
153 #[error("{location}: invalid IRI {token} — {reason}")]
154 InvalidIri {
155 /// Where it was seen: `line 42`, or the format name.
156 location: String,
157 /// The offending term token, verbatim.
158 token: String,
159 /// Which rule it breaks ([`crate::iri::IriDefect::reason`]).
160 reason: &'static str,
161 },
162}
163
164/// Policy **and** tally for the invalid-IRI check that runs during ingest.
165///
166/// rete's reader is deliberately tolerant of IRIs a strict parser rejects (see
167/// [`crate::iri`]), which is what lets it hold every published dataset — and
168/// also what let three of the `scholar/` exports produce N-Quads Oxigraph
169/// refuses to load. Passing an audit makes the tolerance *visible*: the build
170/// still succeeds and stores exactly what it was given, but it can now say how
171/// much of what it stored is not valid RDF. `strict` turns the same finding into
172/// a refusal, on the first offending statement.
173#[derive(Debug, Default)]
174pub struct IriAudit {
175 /// Fail the parse on the first invalid IRI instead of counting it.
176 pub strict: bool,
177 /// What the parse saw. Constant memory — see [`crate::iri::IriReport`].
178 pub report: crate::iri::IriReport,
179}
180
181impl IriAudit {
182 /// Count invalid IRIs; never fail (`rete build`'s default).
183 pub fn counting() -> Self {
184 Self::default()
185 }
186
187 /// Refuse the input at the first invalid IRI (`rete build --strict`).
188 pub fn refusing() -> Self {
189 Self {
190 strict: true,
191 report: crate::iri::IriReport::default(),
192 }
193 }
194}
195
196/// Run one statement past the audit: record any invalid IRI, and in `strict`
197/// mode turn it into an error naming the offending token. `location` is only
198/// called on the error path, so the common case allocates nothing.
199fn audit_quad<F: FnOnce() -> String>(
200 audit: Option<&mut IriAudit>,
201 s: &str,
202 p: &str,
203 o: &str,
204 g: Option<&str>,
205 location: F,
206) -> Result<(), IngestError> {
207 let Some(a) = audit else { return Ok(()) };
208 let Some(defect) = a.report.observe_quad(s, p, o, g) else {
209 return Ok(());
210 };
211 if !a.strict {
212 return Ok(());
213 }
214 let token = [Some(s), Some(p), Some(o), g]
215 .into_iter()
216 .flatten()
217 .find(|t| crate::iri::term_defect(t).is_some())
218 .unwrap_or("<?>")
219 .to_string();
220 Err(IngestError::InvalidIri {
221 location: location(),
222 token,
223 reason: defect.reason(),
224 })
225}
226
227/// Estimate the statement count of an N-Triples/N-Quads text from its newline
228/// count, so the output `Vec` can be pre-sized — a big build otherwise pays
229/// repeated `Vec` doublings, each briefly holding ~2× the (large) spine. An
230/// over-estimate by a few blank/comment lines is harmless (it's a capacity hint).
231fn estimate_statements(input: &str) -> usize {
232 bytecount_newlines(input).max(1)
233}
234
235/// Count `\n` bytes — one linear pass, far cheaper than the parse it sizes.
236fn bytecount_newlines(input: &str) -> usize {
237 input.as_bytes().iter().filter(|&&b| b == b'\n').count()
238}
239
240/// Parse one N-Quads line into a quad, or `None` for a blank/comment line.
241/// Shared by the whole-text [`parse_quads`] and the streaming [`parse_reader`].
242fn parse_nq_line(
243 raw: &str,
244 lineno: usize,
245 audit: Option<&mut IriAudit>,
246) -> Result<Option<RawQuad>, IngestError> {
247 let line = raw.trim();
248 if line.is_empty() || line.starts_with('#') {
249 return Ok(None);
250 }
251 let stripped = line
252 .strip_suffix('.')
253 .ok_or(IngestError::Line(lineno, "missing trailing '.'"))?
254 .trim_end();
255 let (s, rest) = take_term(stripped).ok_or(IngestError::Line(lineno, "bad subject"))?;
256 let (p, rest) =
257 take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad predicate"))?;
258 let (o, rest) = take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad object"))?;
259 let rest = rest.trim();
260 let graph = if rest.is_empty() {
261 None
262 } else {
263 let (g, tail) = take_term(rest).ok_or(IngestError::Line(lineno, "bad graph"))?;
264 if !tail.trim().is_empty() {
265 return Err(IngestError::Line(lineno, "trailing content after graph"));
266 }
267 Some(g)
268 };
269 audit_quad(audit, &s, &p, &o, graph.as_deref(), || {
270 format!("line {lineno}")
271 })?;
272 Ok(Some((s, p, o, graph)))
273}
274
275/// Parse one N-Triples line into a triple, or `None` for a blank/comment line.
276fn parse_nt_line(
277 raw: &str,
278 lineno: usize,
279 audit: Option<&mut IriAudit>,
280) -> Result<Option<RawTriple>, IngestError> {
281 let line = raw.trim();
282 if line.is_empty() || line.starts_with('#') {
283 return Ok(None);
284 }
285 let stripped = line
286 .strip_suffix('.')
287 .ok_or(IngestError::Line(lineno, "missing trailing '.'"))?
288 .trim_end();
289 let (s, rest) = take_term(stripped).ok_or(IngestError::Line(lineno, "bad subject"))?;
290 let (p, rest) =
291 take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad predicate"))?;
292 let (o, rest) = take_term(rest.trim_start()).ok_or(IngestError::Line(lineno, "bad object"))?;
293 if !rest.trim().is_empty() {
294 return Err(IngestError::Line(lineno, "trailing content after object"));
295 }
296 audit_quad(audit, &s, &p, &o, None, || format!("line {lineno}"))?;
297 Ok(Some((s, p, o)))
298}
299
300/// Parse N-Quads text: `subject predicate object [graph] .` per line.
301pub fn parse_quads(input: &str) -> Result<Vec<RawQuad>, IngestError> {
302 parse_quads_audited(input, None)
303}
304
305/// [`parse_quads`] with an optional [`IriAudit`].
306pub fn parse_quads_audited(
307 input: &str,
308 mut audit: Option<&mut IriAudit>,
309) -> Result<Vec<RawQuad>, IngestError> {
310 let mut out = Vec::with_capacity(estimate_statements(input));
311 for (i, raw) in input.lines().enumerate() {
312 if let Some(q) = parse_nq_line(raw, i + 1, audit.as_deref_mut())? {
313 out.push(q);
314 }
315 }
316 Ok(out)
317}
318
319/// Parse N-Triples text into raw term-token triples, skipping blank/comment lines.
320pub fn parse(input: &str) -> Result<Vec<RawTriple>, IngestError> {
321 parse_audited(input, None)
322}
323
324/// [`parse`] with an optional [`IriAudit`].
325pub fn parse_audited(
326 input: &str,
327 mut audit: Option<&mut IriAudit>,
328) -> Result<Vec<RawTriple>, IngestError> {
329 let mut out = Vec::with_capacity(estimate_statements(input));
330 for (i, raw) in input.lines().enumerate() {
331 if let Some(t) = parse_nt_line(raw, i + 1, audit.as_deref_mut())? {
332 out.push(t);
333 }
334 }
335 Ok(out)
336}
337
338/// **Stream-parse** N-Triples (`"nt"`) or N-Quads (`"nq"`) from a reader, one
339/// line at a time, so the whole input text is **never resident** — the big-build
340/// memory win over reading the file into a `String` first (each line String is
341/// transient, freed every iteration). `cap` pre-sizes the output `Vec` (e.g.
342/// `file_len / 64`) to avoid reallocation doublings of the large spine. Turtle is
343/// not streamable here (oxttl needs the whole input); callers use the text path
344/// for `"ttl"`.
345pub fn parse_reader<R: std::io::BufRead>(
346 reader: R,
347 format: &str,
348 cap: usize,
349) -> Result<Vec<RawQuad>, IngestError> {
350 parse_reader_audited(reader, format, cap, None)
351}
352
353/// [`parse_reader`] with an optional [`IriAudit`].
354pub fn parse_reader_audited<R: std::io::BufRead>(
355 reader: R,
356 format: &str,
357 cap: usize,
358 audit: Option<&mut IriAudit>,
359) -> Result<Vec<RawQuad>, IngestError> {
360 parse_reader_audited_surface(reader, format, cap, audit, QuotedTripleSurface::default())
361}
362
363/// [`parse_reader_audited`] reading `<< … >>` in the given
364/// [`QuotedTripleSurface`] (Turtle/TriG only — see the enum).
365pub fn parse_reader_audited_surface<R: std::io::BufRead>(
366 reader: R,
367 format: &str,
368 cap: usize,
369 audit: Option<&mut IriAudit>,
370 surface: QuotedTripleSurface,
371) -> Result<Vec<RawQuad>, IngestError> {
372 let mut out = Vec::with_capacity(cap);
373 stream_reader_audited_surface(reader, format, audit, surface, &mut |q| out.push(q))?;
374 Ok(out)
375}
376
377/// **Stream** N-Triples (`"nt"`) / N-Quads (`"nq"`) from a reader, invoking `f`
378/// once per parsed quad — like [`parse_reader`] but **without collecting** into a
379/// `Vec`. Each quad's term Strings are owned by `f` (and dropped when it returns
380/// if it doesn't retain them), so a caller that only needs to *observe* every
381/// term — e.g. the two-pass [`assemble_dataset_streaming`] building its
382/// dictionary — never materializes the whole quad multiset. The big-graph,
383/// low-RAM ingest primitive. Blank/comment lines are skipped; a parse error stops
384/// the stream and is returned.
385///
386/// Every format streams: the line-based ones a line at a time, Turtle / TriG /
387/// RDF-XML through their `oxttl`/`oxrdfxml` reader parsers, which pull from the
388/// reader incrementally. Nothing here holds the input text — so a 60 GB Turtle
389/// file is as ingestible as a 60 GB `.nt` one, which is what lets the external
390/// (memory-bounded) build accept them.
391pub fn stream_reader<R: std::io::BufRead>(
392 reader: R,
393 format: &str,
394 f: &mut dyn FnMut(RawQuad),
395) -> Result<(), IngestError> {
396 stream_reader_audited(reader, format, None, f)
397}
398
399/// [`stream_reader`] with an optional [`IriAudit`].
400///
401/// Every syntax is audited, not only the line-based ones. `oxttl`/`oxrdfxml`
402/// validate IRIs themselves and reject an invalid one before it reaches us, so
403/// in practice the Turtle/TriG/RDF-XML tallies stay at zero — but auditing them
404/// too means the count is a property of the *file*, not of which reader happened
405/// to produce it, and a future parser change cannot silently reopen the hole.
406pub fn stream_reader_audited<R: std::io::BufRead>(
407 reader: R,
408 format: &str,
409 audit: Option<&mut IriAudit>,
410 f: &mut dyn FnMut(RawQuad),
411) -> Result<(), IngestError> {
412 stream_reader_audited_surface(reader, format, audit, QuotedTripleSurface::default(), f)
413}
414
415/// [`stream_reader_audited`] reading `<< … >>` in the given
416/// [`QuotedTripleSurface`].
417///
418/// The surface reaches only the `"ttl"` and `"trig"` arms: the line-based
419/// readers are rete's own and take both spellings unconditionally, and RDF/XML
420/// has no syntax for either. Under [`QuotedTripleSurface::Rdf12`] Turtle/TriG go
421/// through `oxttl` 0.2 instead of 0.1, and the RDF 1.2 triple terms it hands
422/// back (`<<( s p o )>>`) are folded to rete's stored token on the way out, so
423/// the dictionary a file lands in does not depend on which reader read it.
424///
425/// **This entry point does not carry the RDF 1.2 hint** that the text path adds
426/// to a failed RDF-star parse: it is handed a reader, not a string, and sniffing
427/// the input would mean buffering the 60 GB file this function exists to avoid
428/// buffering. `rete build --memory-budget-mb file.ttl` therefore reports the raw
429/// parser error; every other route reports the hint.
430pub fn stream_reader_audited_surface<R: std::io::BufRead>(
431 reader: R,
432 format: &str,
433 mut audit: Option<&mut IriAudit>,
434 surface: QuotedTripleSurface,
435 f: &mut dyn FnMut(RawQuad),
436) -> Result<(), IngestError> {
437 if surface == QuotedTripleSurface::Rdf12 && matches!(format, "ttl" | "trig") {
438 return rdf12::stream(reader, format, audit, f);
439 }
440 match format {
441 "nt" | "nq" => {
442 for (i, line) in reader.lines().enumerate() {
443 let line = line.map_err(|e| IngestError::Io(e.to_string()))?;
444 if format == "nq" {
445 if let Some(q) = parse_nq_line(&line, i + 1, audit.as_deref_mut())? {
446 f(q);
447 }
448 } else if let Some((s, p, o)) = parse_nt_line(&line, i + 1, audit.as_deref_mut())? {
449 f((s, p, o, None));
450 }
451 }
452 Ok(())
453 }
454 "ttl" => turtle_star(reader, audit, f),
455 "trig" => trig_star(reader, audit, f),
456 "rdfxml" => {
457 for r in oxrdfxml::RdfXmlParser::new().for_reader(reader) {
458 let t = r.map_err(|e| IngestError::RdfXml(e.to_string()))?;
459 let q = (
460 t.subject.to_string(),
461 t.predicate.to_string(),
462 t.object.to_string(),
463 None,
464 );
465 audit_quad(audit.as_deref_mut(), &q.0, &q.1, &q.2, None, || {
466 "rdf/xml".into()
467 })?;
468 f(q);
469 }
470 Ok(())
471 }
472 other => Err(IngestError::UnknownFormat(other.to_string())),
473 }
474}
475
476/// Stream **RDF-star** Turtle: `with_quoted_triples` accepts `<< s p o >>` in
477/// subject/object position, and oxrdf 0.2's `Term::Triple` Displays as the
478/// canonical `<<…>>` token rete's own N-Triples-star tokenizer emits — so no
479/// translation is needed on the way out.
480///
481/// Its own function, and called directly from both the reader path and the text
482/// path, so that the wasm engine — which reaches only the text path and has no
483/// RDF 1.2 reader compiled in — links this and nothing else.
484fn turtle_star<R: std::io::BufRead>(
485 reader: R,
486 mut audit: Option<&mut IriAudit>,
487 f: &mut dyn FnMut(RawQuad),
488) -> Result<(), IngestError> {
489 for r in oxttl::TurtleParser::new()
490 .with_quoted_triples()
491 .for_reader(reader)
492 {
493 let t = r.map_err(|e| IngestError::Turtle(e.to_string()))?;
494 let q = (
495 t.subject.to_string(),
496 t.predicate.to_string(),
497 t.object.to_string(),
498 None,
499 );
500 audit_quad(audit.as_deref_mut(), &q.0, &q.1, &q.2, None, || {
501 "turtle".into()
502 })?;
503 f(q);
504 }
505 Ok(())
506}
507
508/// [`turtle_star`] for TriG — Turtle plus named-graph blocks (`<g> { … }`), the
509/// shape most large RDF dumps ship in.
510fn trig_star<R: std::io::BufRead>(
511 reader: R,
512 mut audit: Option<&mut IriAudit>,
513 f: &mut dyn FnMut(RawQuad),
514) -> Result<(), IngestError> {
515 for r in oxttl::TriGParser::new()
516 .with_quoted_triples()
517 .for_reader(reader)
518 {
519 let q = r.map_err(|e| IngestError::TriG(e.to_string()))?;
520 let q = (
521 q.subject.to_string(),
522 q.predicate.to_string(),
523 q.object.to_string(),
524 graph_token(&q.graph_name),
525 );
526 audit_quad(
527 audit.as_deref_mut(),
528 &q.0,
529 &q.1,
530 &q.2,
531 q.3.as_deref(),
532 || "trig".into(),
533 )?;
534 f(q);
535 }
536 Ok(())
537}
538
539/// The canonical graph token for a parsed quad — `None` for the default graph,
540/// otherwise the same `<iri>` / `_:b` spelling the N-Quads reader produces, so
541/// TriG and N-Quads inputs land identical dictionary keys. `GraphName`'s own
542/// `Display` renders the default graph as `DEFAULT`, which must never become a
543/// term, hence the explicit match.
544fn graph_token(g: &oxrdf::GraphName) -> Option<String> {
545 match g {
546 oxrdf::GraphName::DefaultGraph => None,
547 other => Some(other.to_string()),
548 }
549}
550
551/// The **RDF 1.2** Turtle/TriG reader — `oxttl` 0.2 with its `rdf-12` feature,
552/// living beside the `oxttl` 0.1 reader above rather than replacing it.
553///
554/// Everything RDF 1.2 adds over RDF 1.1 arrives through this one module:
555/// `<<( s p o )>>` triple terms, `<< s p o >>` reifiers (which expand to
556/// `_:r rdf:reifies <<( s p o )>>` plus the statement that mentioned them),
557/// `{| … |}` annotations, and `"…"@lang--dir` directional literals. None of it
558/// needs a storage change, which is the point: a reifier is a blank node, an
559/// IRI and a term, and rete has stored those since v0.
560///
561/// The one translation is at the term boundary. `oxrdf` 0.3 renders a triple
562/// term as `<<( s p o )>>`; rete's dictionary key is `<<s p o>>`. [`take_term`]
563/// already reads both — that is what made RDF 1.2 N-Quads ingestible in #262 —
564/// so the fold is a re-scan of a token oxttl has already validated.
565#[cfg(feature = "rdf12-turtle")]
566mod rdf12 {
567 use super::{audit_quad, take_term, IngestError, IriAudit, RawQuad};
568
569 /// Fold `oxrdf` 0.3's `<<( s p o )>>` rendering of a triple term into rete's
570 /// stored `<<s p o>>`, recursively (a nested triple term folds with it).
571 /// Every other token — the overwhelming majority, and all of them in a file
572 /// with no triple terms — is returned untouched, not even copied.
573 pub(super) fn fold_triple_term(t: String) -> String {
574 if !t.starts_with("<<(") {
575 return t;
576 }
577 match take_term(&t) {
578 Some((token, rest)) if rest.trim().is_empty() => token,
579 // oxttl only ever hands back a well-formed term, so this is
580 // unreachable; returning the original beats mangling it silently.
581 _ => t,
582 }
583 }
584
585 /// `super::graph_token` for `oxrdf` 0.3's `GraphName` — the same rule, a
586 /// different crate version, which is exactly why it cannot be shared.
587 fn graph_token(g: &oxrdf12::GraphName) -> Option<String> {
588 match g {
589 oxrdf12::GraphName::DefaultGraph => None,
590 other => Some(other.to_string()),
591 }
592 }
593
594 /// Stream one RDF 1.2 Turtle (`"ttl"`) or TriG (`"trig"`) input, auditing
595 /// every quad the way the RDF-star reader does.
596 pub(super) fn stream<R: std::io::BufRead>(
597 reader: R,
598 format: &str,
599 mut audit: Option<&mut IriAudit>,
600 f: &mut dyn FnMut(RawQuad),
601 ) -> Result<(), IngestError> {
602 if format == "ttl" {
603 for r in oxttl12::TurtleParser::new().for_reader(reader) {
604 let t = r.map_err(|e| IngestError::Turtle(e.to_string()))?;
605 let q = (
606 t.subject.to_string(),
607 t.predicate.to_string(),
608 fold_triple_term(t.object.to_string()),
609 None,
610 );
611 audit_quad(audit.as_deref_mut(), &q.0, &q.1, &q.2, None, || {
612 "turtle".into()
613 })?;
614 f(q);
615 }
616 } else {
617 for r in oxttl12::TriGParser::new().for_reader(reader) {
618 let q = r.map_err(|e| IngestError::TriG(e.to_string()))?;
619 let q = (
620 q.subject.to_string(),
621 q.predicate.to_string(),
622 fold_triple_term(q.object.to_string()),
623 graph_token(&q.graph_name),
624 );
625 audit_quad(
626 audit.as_deref_mut(),
627 &q.0,
628 &q.1,
629 &q.2,
630 q.3.as_deref(),
631 || "trig".into(),
632 )?;
633 f(q);
634 }
635 }
636 Ok(())
637 }
638}
639
640/// The stand-in for [`rdf12`] in a build with `rdf12-turtle` off: it refuses by
641/// name. Reading the file as RDF-star instead would answer a question nobody
642/// asked, with a different graph.
643#[cfg(not(feature = "rdf12-turtle"))]
644mod rdf12 {
645 use super::{IngestError, IriAudit, RawQuad};
646
647 pub(super) fn stream<R: std::io::BufRead>(
648 _reader: R,
649 _format: &str,
650 _audit: Option<&mut IriAudit>,
651 _f: &mut dyn FnMut(RawQuad),
652 ) -> Result<(), IngestError> {
653 Err(IngestError::Rdf12TurtleUnavailable)
654 }
655}
656
657/// Parse Turtle into canonical N-Triples-token triples via oxttl, reading
658/// `<< s p o >>` as an RDF-star quoted triple. [`parse_turtle_surface`] chooses.
659pub fn parse_turtle(text: &str) -> Result<Vec<RawTriple>, IngestError> {
660 parse_turtle_surface(text, QuotedTripleSurface::default())
661}
662
663/// [`parse_turtle`] in the given [`QuotedTripleSurface`].
664pub fn parse_turtle_surface(
665 text: &str,
666 surface: QuotedTripleSurface,
667) -> Result<Vec<RawTriple>, IngestError> {
668 Ok(parse_text_surface(text, "ttl", surface)?
669 .into_iter()
670 .map(|(s, p, o, _)| (s, p, o))
671 .collect())
672}
673
674/// Parse TriG into canonical quads via oxttl — Turtle plus named-graph blocks
675/// (`<g> { … }`), the shape most large RDF dumps ship in. Reads `<< s p o >>` as
676/// an RDF-star quoted triple; [`parse_trig_surface`] chooses.
677pub fn parse_trig(text: &str) -> Result<Vec<RawQuad>, IngestError> {
678 parse_trig_surface(text, QuotedTripleSurface::default())
679}
680
681/// [`parse_trig`] in the given [`QuotedTripleSurface`].
682pub fn parse_trig_surface(
683 text: &str,
684 surface: QuotedTripleSurface,
685) -> Result<Vec<RawQuad>, IngestError> {
686 parse_text_surface(text, "trig", surface)
687}
688
689/// The shared Turtle/TriG text path: stream the string through whichever reader
690/// `surface` selects, and — this is the part only the text path can do, because
691/// only it holds the input — say so when an RDF-star parse fails on what is
692/// plainly RDF 1.2.
693///
694/// It does not audit: the Turtle/TriG text callers audit over the finished quads
695/// (see [`parse_statements_audited_surface`]), and auditing here too would count
696/// every IRI twice.
697///
698/// The RDF-star arm calls `oxttl` 0.1 **directly** rather than routing through
699/// [`stream_reader_audited_surface`]. That is not style: this is the only ingest
700/// path the wasm engine reaches, and going through the generic reader would
701/// monomorphize it over `&[u8]` and pull the whole `match format` — every
702/// syntax's reader — into a module that previously linked one Turtle parser.
703/// Measured at +17 KB of wasm for code no browser can run, since the RDF 1.2
704/// reader is not compiled there at all. Keeping the arm direct keeps the
705/// browser artifacts what they were.
706fn parse_text_surface(
707 text: &str,
708 format: &str,
709 surface: QuotedTripleSurface,
710) -> Result<Vec<RawQuad>, IngestError> {
711 let mut out = Vec::new();
712 let r = if surface == QuotedTripleSurface::Rdf12 {
713 rdf12::stream(text.as_bytes(), format, None, &mut |q| out.push(q))
714 } else if format == "ttl" {
715 turtle_star(text.as_bytes(), None, &mut |q| out.push(q))
716 } else {
717 trig_star(text.as_bytes(), None, &mut |q| out.push(q))
718 };
719 match r {
720 Ok(()) => Ok(out),
721 // The default surface met `<<(`: that is an RDF 1.2 file being read as
722 // RDF-star, the one mistake this flag exists to prevent, and the parse
723 // error alone ("unexpected character") would send the reader hunting for
724 // a typo that is not there.
725 Err(IngestError::Turtle(m))
726 if surface == QuotedTripleSurface::RdfStar && looks_like_rdf12(text) =>
727 {
728 Err(IngestError::Turtle(format!("{m}{RDF12_SURFACE_HINT}")))
729 }
730 Err(IngestError::TriG(m))
731 if surface == QuotedTripleSurface::RdfStar && looks_like_rdf12(text) =>
732 {
733 Err(IngestError::TriG(format!("{m}{RDF12_SURFACE_HINT}")))
734 }
735 Err(e) => Err(e),
736 }
737}
738
739/// Parse RDF/XML into canonical N-Triples-token triples via oxrdfxml. This is how
740/// most OWL ontologies ship (`.rdf`/`.owl`/`.xml` with an `rdf:RDF` root) — so rete
741/// ingests them directly, no external conversion. (OWL/XML — the non-RDF functional
742/// XML serialization — is a different language; convert it with owlready2 first.)
743pub fn parse_rdfxml(text: &str) -> Result<Vec<RawTriple>, IngestError> {
744 let mut out = Vec::new();
745 for r in oxrdfxml::RdfXmlParser::new().for_reader(text.as_bytes()) {
746 let t = r.map_err(|e| IngestError::RdfXml(e.to_string()))?;
747 out.push((
748 t.subject.to_string(),
749 t.predicate.to_string(),
750 t.object.to_string(),
751 ));
752 }
753 Ok(out)
754}
755
756/// Parse one text input by format name (`"nt"`, `"nq"`, `"ttl"`, `"trig"`, or
757/// `"rdfxml"`) into quads (triples land in the default graph).
758pub fn parse_statements(text: &str, format: &str) -> Result<Vec<RawQuad>, IngestError> {
759 parse_statements_audited(text, format, None)
760}
761
762/// [`parse_statements`] with an optional [`IriAudit`]. The line-based syntaxes
763/// audit as they parse (so `strict` fails on the offending line); the others are
764/// audited over the parsed quads, which is where `oxttl` has already had its say.
765pub fn parse_statements_audited(
766 text: &str,
767 format: &str,
768 audit: Option<&mut IriAudit>,
769) -> Result<Vec<RawQuad>, IngestError> {
770 parse_statements_audited_surface(text, format, audit, QuotedTripleSurface::default())
771}
772
773/// [`parse_statements_audited`] reading `<< … >>` in the given
774/// [`QuotedTripleSurface`]. This is the entry point `rete build` and
775/// `rete validate` use for everything but a streamed line-based input, and the
776/// one that turns "RDF 1.2 file, RDF-star reader" into an error that names the
777/// flag instead of a bare syntax complaint.
778pub fn parse_statements_audited_surface(
779 text: &str,
780 format: &str,
781 mut audit: Option<&mut IriAudit>,
782 surface: QuotedTripleSurface,
783) -> Result<Vec<RawQuad>, IngestError> {
784 let quads = match format {
785 "nq" => return parse_quads_audited(text, audit),
786 "nt" => {
787 return Ok(parse_audited(text, audit)?
788 .into_iter()
789 .map(|(s, p, o)| (s, p, o, None))
790 .collect())
791 }
792 // Straight to the quad path for both: going via `parse_turtle_surface`
793 // would build a `Vec<RawQuad>`, rebuild it as a `Vec<RawTriple>` to
794 // satisfy that function's signature, and rebuild it as quads again —
795 // three copies of every term String on the biggest input rete takes.
796 "ttl" | "trig" => parse_text_surface(text, format, surface)?,
797 "rdfxml" => parse_rdfxml(text)?
798 .into_iter()
799 .map(|(s, p, o)| (s, p, o, None))
800 .collect(),
801 other => return Err(IngestError::UnknownFormat(other.to_string())),
802 };
803 for (s, p, o, g) in &quads {
804 audit_quad(audit.as_deref_mut(), s, p, o, g.as_deref(), || {
805 format.to_string()
806 })?;
807 }
808 Ok(quads)
809}
810
811/// Take one term from the front of `s`, returning `(term, remainder)`.
812pub(crate) fn take_term(s: &str) -> Option<(String, &str)> {
813 let bytes = s.as_bytes();
814 let first = *bytes.first()?;
815 match first {
816 // A quoted triple / triple term, in EITHER surface — the inner terms are
817 // themselves terms (so this recurses; nesting works). `<<` starts with `<`
818 // and an IRI scan would stop at the first inner `>`, so it must be handled
819 // before the plain-IRI case.
820 // * RDF-star: `<< subject predicate object >>`
821 // * RDF 1.2: `<<( subject predicate object )>>` (triple term)
822 // Both re-emit the SAME canonical token `<<s p o>>`, so a file written in
823 // either surface — and a query written either way — dedupe and match. This
824 // makes RDF 1.2 N-Triples interoperable with the RDF-star we already store,
825 // with no format change and no dependency swap.
826 b'<' if bytes.get(1) == Some(&b'<') => {
827 // Distinguish `<<(` (RDF 1.2) from `<<` (RDF-star) by the char after `<<`.
828 let (inner, rdf12) = match s[2..].strip_prefix('(') {
829 Some(after) => (after.trim_start(), true),
830 None => (s[2..].trim_start(), false),
831 };
832 let (subj, r) = take_term(inner)?;
833 let (pred, r) = take_term(r.trim_start())?;
834 let (obj, r) = take_term(r.trim_start())?;
835 let r = r.trim_start();
836 let rest = if rdf12 {
837 r.strip_prefix(")>>")?
838 } else {
839 r.strip_prefix(">>")?
840 };
841 // Canonical surface = oxrdf's `Triple` Display: `<<s p o>>` (tight
842 // brackets, single spaces between components) — the same token for both
843 // input surfaces, so N-Triples-star, Turtle-star, and RDF 1.2 triple
844 // terms all resolve to one dictionary entry.
845 Some((format!("<<{subj} {pred} {obj}>>"), rest))
846 }
847 b'<' => {
848 // IRI ref: up to the closing '>'.
849 let end = s.find('>')?;
850 Some((s[..=end].to_string(), &s[end + 1..]))
851 }
852 b'_' => {
853 // Blank node: up to whitespace.
854 let end = s.find(char::is_whitespace).unwrap_or(s.len());
855 Some((s[..end].to_string(), &s[end..]))
856 }
857 b'"' => {
858 // Literal: closing unescaped quote, then optional ^^<dt> or @lang.
859 let mut i = 1;
860 let b = s.as_bytes();
861 while i < b.len() {
862 match b[i] {
863 b'\\' => i += 2, // skip escaped char
864 b'"' => break,
865 _ => i += 1,
866 }
867 }
868 if i >= b.len() {
869 return None; // unterminated
870 }
871 let mut end = i + 1; // past closing quote
872 if s[end..].starts_with("^^<") {
873 let close = s[end..].find('>')? + end;
874 // RDF 1.1: `"x"^^xsd:string` IS `"x"`; keep the one canonical
875 // spelling, so the two never become two dictionary terms and
876 // a plain query constant matches either input.
877 if &s[end + 3..close] == "http://www.w3.org/2001/XMLSchema#string" {
878 return Some((s[..end].to_string(), &s[close + 1..]));
879 }
880 end = close + 1;
881 } else if s[end..].starts_with('@') {
882 // Language tag: '@' then BCP-47 subtags `[a-zA-Z0-9-]+`. Stop at
883 // the first char that can't be part of a tag — normally the
884 // whitespace before the predicate, but ALSO the `>>` that closes
885 // a quoted triple when this literal is its object (`"x"@en>>`),
886 // where there is no separating whitespace.
887 let mut j = end + 1; // past '@'
888 while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'-') {
889 j += 1;
890 }
891 end = j;
892 }
893 Some((s[..end].to_string(), &s[end..]))
894 }
895 _ => None,
896 }
897}
898
899/// Split a canonical quoted-triple token `<<s p o>>` (RDF-star) into its three
900/// component term tokens, or `None` if `t` is not a quoted triple. Reuses
901/// [`take_term`] for term-boundary scanning, so nested quoting parses correctly.
902/// The inverse of the `<<…>>` construction; used by the SUBJECT/PREDICATE/OBJECT
903/// SPARQL-star builtins.
904pub(crate) fn quoted_triple_parts(t: &str) -> Option<(String, String, String)> {
905 let inner = t.strip_prefix("<<")?.strip_suffix(">>")?.trim();
906 let (s, r) = take_term(inner)?;
907 let (p, r) = take_term(r.trim_start())?;
908 let (o, r) = take_term(r.trim_start())?;
909 if !r.trim().is_empty() {
910 return None;
911 }
912 Some((s, p, o))
913}
914
915/// Counts describing an assembled file, for status lines and UIs.
916///
917/// In the [`BuildStats`] a metadata callback receives, `statements` and
918/// `default_triples` are the counts **ingested** — the input multiset, before
919/// the index deduplicates it. In the `BuildStats` an assemble function
920/// *returns*, they are the counts actually **written** (see
921/// [`FinalCounts`]), so a status line reports what the file holds. Use
922/// [`FinalCounts`] rather than these when a payload must agree with the header.
923#[derive(Debug, Clone, Copy)]
924pub struct BuildStats {
925 /// Total statements, across the default graph and every named one.
926 pub statements: usize,
927 /// Statements that landed in the default graph.
928 pub default_triples: usize,
929 /// How many named graphs the input mentioned; one index is written per graph.
930 pub named_graphs: usize,
931 /// Distinct terms in the shared dictionary.
932 pub terms: usize,
933 /// Levels in the community pyramid, or `0` when the build skipped it.
934 pub pyramid_levels: u16,
935 /// Whether the file got a label index. The record says so even when the
936 /// caller (a binding) set no option for it.
937 pub label_index: bool,
938}
939
940/// How a build stores the **named graphs** (the default graph is unaffected).
941#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
942pub enum GraphLayout {
943 /// One permutation index per graph, in the NAMED_GRAPHS section (kind 5):
944 /// the graph IRI as text, then a full container per graph. What every rete
945 /// before 0.4 writes and reads.
946 #[default]
947 PerGraph,
948 /// The **quad index** (kind 10, SPEC.md §6.5): every named-graph quad
949 /// stored once, the graph as a fourth column of graph-leading permutations
950 /// in shared tiles, behind a sorted graph directory; graph names become
951 /// dictionary terms. Far smaller and randomly addressable when graphs are
952 /// many and small. Readers before 0.4.0 refuse these files (loudly — see
953 /// `docs/compatibility.md`). Needs the 3- or 6-permutation set.
954 Quad,
955}
956
957/// One pass's named-graph quads, in the layout being built.
958enum NamedQuads {
959 PerGraph(std::collections::BTreeMap<String, Vec<(u32, u32, u32)>>),
960 Quad {
961 /// `(graph subject id, s, p, o)`.
962 quads: Vec<[u32; 4]>,
963 /// Distinct graphs seen (a bitset over subject ids).
964 seen: Vec<u64>,
965 graphs: usize,
966 /// The last graph name and its id: N-Quads dumps keep a graph's
967 /// statements together, so most quads skip the dictionary lookup.
968 last: Option<(String, u32)>,
969 },
970}
971
972impl NamedQuads {
973 fn new(layout: GraphLayout, dict: &Dictionary) -> Self {
974 match layout {
975 GraphLayout::PerGraph => NamedQuads::PerGraph(Default::default()),
976 GraphLayout::Quad => NamedQuads::Quad {
977 quads: Vec::new(),
978 seen: vec![
979 0u64;
980 (dict.shared_count() as usize + dict.subject_only_count() as usize) / 64
981 + 2
982 ],
983 graphs: 0,
984 last: None,
985 },
986 }
987 }
988
989 fn push(&mut self, dict: &Dictionary, graph: String, t: (u32, u32, u32)) {
990 match self {
991 NamedQuads::PerGraph(m) => m.entry(graph).or_default().push(t),
992 NamedQuads::Quad {
993 quads,
994 seen,
995 graphs,
996 last,
997 } => {
998 let g = match last {
999 Some((name, id)) if *name == graph => *id,
1000 _ => {
1001 let id = dict.subject_id(&graph).expect("observed graph name");
1002 *last = Some((graph, id));
1003 id
1004 }
1005 };
1006 let (w, b) = (g as usize / 64, g % 64);
1007 if let Some(word) = seen.get_mut(w) {
1008 if *word & (1 << b) == 0 {
1009 *word |= 1 << b;
1010 *graphs += 1;
1011 }
1012 }
1013 quads.push([g, t.0, t.1, t.2]);
1014 }
1015 }
1016 }
1017
1018 fn graph_count(&self) -> usize {
1019 match self {
1020 NamedQuads::PerGraph(m) => m.len(),
1021 NamedQuads::Quad { graphs, .. } => *graphs,
1022 }
1023 }
1024
1025 fn quad_count(&self) -> usize {
1026 match self {
1027 NamedQuads::PerGraph(m) => m.values().map(Vec::len).sum(),
1028 NamedQuads::Quad { quads, .. } => quads.len(),
1029 }
1030 }
1031}
1032
1033/// The **deduplicated** counts of the file being written — what the header
1034/// records, and therefore what any embedded metadata has to report.
1035///
1036/// The input multiset is not the file: every permutation index sorts and
1037/// deduplicates, so a harvest that pages with overlapping windows writes fewer
1038/// quads than it ingested. These counts are known only once the indexes exist,
1039/// which is why a payload that carries them is derived in two stages (see
1040/// [`IntoMetadata`]).
1041#[derive(Debug, Clone, Copy)]
1042pub struct FinalCounts {
1043 /// Unique triples in the default graph.
1044 pub default_triples: u64,
1045 /// Unique quads across the default graph and every named graph — exactly
1046 /// the header's `quad_count`.
1047 pub quads: u64,
1048}
1049
1050/// What a build's metadata callback may return.
1051///
1052/// A `Vec<u8>` is the payload verbatim — the common case (no metadata, or a
1053/// payload that carries no counts). [`DeferredMetadata`] is a payload that
1054/// *does* carry counts: the callback derives everything it needs while the
1055/// source quads are still resident, and returns a finalizer that the writer
1056/// calls with the [`FinalCounts`], which only exist once the indexes have
1057/// deduplicated the input. Without that second stage a card can only report the
1058/// ingested count, which over-reports any input containing duplicates.
1059pub trait IntoMetadata {
1060 /// Produce the metadata section payload for a file with these counts.
1061 fn into_metadata(self, counts: FinalCounts) -> Vec<u8>;
1062}
1063
1064impl IntoMetadata for Vec<u8> {
1065 fn into_metadata(self, _counts: FinalCounts) -> Vec<u8> {
1066 self
1067 }
1068}
1069
1070/// A metadata payload whose final form needs the written (deduplicated) counts.
1071/// See [`IntoMetadata`].
1072pub struct DeferredMetadata(Box<dyn FnOnce(FinalCounts) -> Vec<u8>>);
1073
1074impl DeferredMetadata {
1075 /// Defer `f` until the indexes are built and the true counts are known.
1076 pub fn new(f: impl FnOnce(FinalCounts) -> Vec<u8> + 'static) -> Self {
1077 DeferredMetadata(Box::new(f))
1078 }
1079
1080 /// No metadata section — byte-identical to a metadata-free build. Lets one
1081 /// `match` arm opt out while the other defers, without a type mismatch.
1082 pub fn none() -> Self {
1083 DeferredMetadata::new(|_| Vec::new())
1084 }
1085}
1086
1087impl IntoMetadata for DeferredMetadata {
1088 fn into_metadata(self, counts: FinalCounts) -> Vec<u8> {
1089 (self.0)(counts)
1090 }
1091}
1092
1093/// [`assemble_dataset`] for the writers that finish as format `0x06` (the
1094/// bindings and the browser): named graphs go in the quad index, the only
1095/// layout `0x06` has. Everything else is `assemble_dataset`'s.
1096pub fn assemble_dataset_quad(quads: Vec<RawQuad>, metadata: &[u8]) -> (Vec<u8>, BuildStats) {
1097 let blob = metadata.to_vec();
1098 assemble_dataset_with_quad(quads, move |_, _| blob)
1099}
1100
1101/// [`assemble_dataset_with`] with the named graphs in the quad index.
1102pub fn assemble_dataset_with_quad<M: IntoMetadata>(
1103 quads: Vec<RawQuad>,
1104 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1105) -> (Vec<u8>, BuildStats) {
1106 assemble_dataset_with_opts_algo_quad(quads, true, false, None, PyramidAlgo::Louvain, metadata)
1107}
1108
1109/// [`assemble_dataset_with_opts_algo`] for the `0x06` writers, with **the layout
1110/// `rete build --label-index` writes**: the named graphs in the quad index, the
1111/// pyramid block carrying only the community material, the SCHEMA section and
1112/// the label index. The browser engine and the Python, R and Java clients all
1113/// assemble through this, so one input and one set of options is one content
1114/// hash whichever front end built it (the browser and Java, having no zstd
1115/// encoder, write their sections uncompressed and so differ in bytes). The label
1116/// index is on because label prefix search is what a graph built in memory is
1117/// opened to do (the playground, the MCP server), and the legacy label block it
1118/// replaces is gone from the community-only pyramid block.
1119pub fn assemble_dataset_with_opts_algo_quad<M: IntoMetadata>(
1120 quads: Vec<RawQuad>,
1121 with_pyramid: bool,
1122 with_text_index: impl crate::text_index::IntoTextIndex,
1123 type_override: Option<&str>,
1124 algo: PyramidAlgo,
1125 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1126) -> (Vec<u8>, BuildStats) {
1127 let text_index = crate::text_index::IntoTextIndex::into_text_index(with_text_index);
1128 let opts = AssembleOptions {
1129 with_pyramid,
1130 with_text_index: text_index.is_some(),
1131 text_index_scope: text_index.as_ref(),
1132 type_override,
1133 algo,
1134 graph_layout: GraphLayout::Quad,
1135 label_index: true,
1136 ..AssembleOptions::sectioned()
1137 };
1138 assemble_dataset_opts(quads, &opts, metadata)
1139}
1140
1141/// Assemble a complete `.rete` file image from parsed quads: one shared
1142/// dictionary, the default-graph index, one index per named graph, and the
1143/// community pyramid. `metadata` is the opaque metadata-section payload (the
1144/// CLI puts a JSON Dataset Card there); pass `&[]` for none — that is
1145/// byte-identical to a metadata-free build.
1146pub fn assemble_dataset(quads: Vec<RawQuad>, metadata: &[u8]) -> (Vec<u8>, BuildStats) {
1147 let blob = metadata.to_vec();
1148 assemble_dataset_with(quads, move |_, _| blob)
1149}
1150
1151/// Like [`assemble_dataset`], but the metadata payload is derived from the
1152/// [`BuildStats`] and the source quads while they are still resident — for
1153/// metadata that describes the graph (the Dataset Card). Returning an empty
1154/// `Vec` is byte-identical to a metadata-free build; return a
1155/// [`DeferredMetadata`] for a payload that must also carry the file's
1156/// deduplicated counts, which exist only once the indexes are built.
1157pub fn assemble_dataset_with<M: IntoMetadata>(
1158 quads: Vec<RawQuad>,
1159 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1160) -> (Vec<u8>, BuildStats) {
1161 assemble_dataset_with_opts(quads, true, false, None, metadata)
1162}
1163
1164/// Like [`assemble_dataset_with`], but `with_pyramid = false` skips the Louvain
1165/// community pyramid entirely — no pyramid section is written (header length 0).
1166/// SPARQL / SHACL / triple / reachability queries don't use the pyramid, so a
1167/// pyramid-less file is fully queryable and markedly smaller (the pyramid is the
1168/// largest section on highly-connected graphs). Only the community / summary /
1169/// progressive paths need it.
1170pub fn assemble_dataset_with_opts<M: IntoMetadata>(
1171 quads: Vec<RawQuad>,
1172 with_pyramid: bool,
1173 with_text_index: impl crate::text_index::IntoTextIndex,
1174 type_override: Option<&str>,
1175 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1176) -> (Vec<u8>, BuildStats) {
1177 assemble_dataset_with_opts_algo(
1178 quads,
1179 with_pyramid,
1180 with_text_index,
1181 type_override,
1182 PyramidAlgo::Louvain,
1183 metadata,
1184 )
1185}
1186
1187/// Like [`assemble_dataset_with_opts`], but selects the community [`PyramidAlgo`]
1188/// (the in-memory build path for `rete build --pyramid-algo …`).
1189#[allow(clippy::too_many_arguments)]
1190pub fn assemble_dataset_with_opts_algo<M: IntoMetadata>(
1191 quads: Vec<RawQuad>,
1192 with_pyramid: bool,
1193 with_text_index: impl crate::text_index::IntoTextIndex,
1194 type_override: Option<&str>,
1195 algo: PyramidAlgo,
1196 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1197) -> (Vec<u8>, BuildStats) {
1198 assemble_dataset_with_perms(
1199 quads,
1200 with_pyramid,
1201 with_text_index,
1202 type_override,
1203 algo,
1204 crate::index::PermSet::ALL,
1205 metadata,
1206 )
1207}
1208
1209/// Like [`assemble_dataset_with_opts_algo`], but writes only the permutations in
1210/// `perms` (`rete build --permutations 3`). Every other byte of the file is
1211/// unchanged; [`crate::index::PermSet::ALL`] reproduces the default build
1212/// exactly, down to the header byte.
1213#[allow(clippy::too_many_arguments)]
1214pub fn assemble_dataset_with_perms<M: IntoMetadata>(
1215 quads: Vec<RawQuad>,
1216 with_pyramid: bool,
1217 with_text_index: impl crate::text_index::IntoTextIndex,
1218 type_override: Option<&str>,
1219 algo: PyramidAlgo,
1220 perms: crate::index::PermSet,
1221 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1222) -> (Vec<u8>, BuildStats) {
1223 assemble_dataset_with_layout(
1224 quads,
1225 with_pyramid,
1226 with_text_index,
1227 type_override,
1228 algo,
1229 perms,
1230 GraphLayout::PerGraph,
1231 metadata,
1232 )
1233}
1234
1235/// Like [`assemble_dataset_with_perms`], storing the named graphs in `graphs`
1236/// layout. [`GraphLayout::PerGraph`] reproduces it exactly.
1237///
1238/// # Panics
1239///
1240/// With [`GraphLayout::Quad`] and a permutation set other than
1241/// [`PermSet::CORE`](crate::index::PermSet::CORE) or
1242/// [`PermSet::ALL`](crate::index::PermSet::ALL), when the input has named graphs.
1243#[allow(clippy::too_many_arguments)]
1244pub fn assemble_dataset_with_layout<M: IntoMetadata>(
1245 quads: Vec<RawQuad>,
1246 with_pyramid: bool,
1247 with_text_index: impl crate::text_index::IntoTextIndex,
1248 type_override: Option<&str>,
1249 algo: PyramidAlgo,
1250 perms: crate::index::PermSet,
1251 graphs: GraphLayout,
1252 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1253) -> (Vec<u8>, BuildStats) {
1254 let text_index = crate::text_index::IntoTextIndex::into_text_index(with_text_index);
1255 let opts = AssembleOptions {
1256 with_pyramid,
1257 with_text_index: text_index.is_some(),
1258 text_index_scope: text_index.as_ref(),
1259 type_override,
1260 algo,
1261 perms,
1262 graph_layout: graphs,
1263 ..AssembleOptions::legacy()
1264 };
1265 assemble_dataset_opts(quads, &opts, metadata)
1266}
1267
1268/// Everything an in-memory build decides besides its input. The fields the
1269/// older `assemble_*` entry points take positionally, plus the sections added
1270/// after the generation-1 freeze.
1271#[derive(Debug, Clone, Copy)]
1272pub struct AssembleOptions<'a> {
1273 /// Build the community pyramid (kind 4).
1274 pub with_pyramid: bool,
1275 /// Build the full-text index (kind 6, or kind 11 when its scope leaves
1276 /// out some predicates).
1277 pub with_text_index: bool,
1278 /// The scope of that index (`--text-index-graphs`, `--text-index-include`,
1279 /// `--text-index-exclude`); `None` is the default scope, the only one
1280 /// before 0.4.0: the default graph, every predicate.
1281 pub text_index_scope: Option<&'a crate::text_index::TextIndexScope>,
1282 /// The type predicate for the schema (e.g. `wdt:P31`); `rdf:type` if `None`.
1283 pub type_override: Option<&'a str>,
1284 /// The community algorithm of the pyramid.
1285 pub algo: PyramidAlgo,
1286 /// Which index permutations to write.
1287 pub perms: crate::index::PermSet,
1288 /// How the named graphs are stored: one index per graph (kind 5) or the
1289 /// quad index (kind 10, `rete build --named-graphs quad`).
1290 pub graph_layout: GraphLayout,
1291 /// What the pyramid-meta carries besides the summary.
1292 pub pyramid_layout: crate::file::PyramidLayout,
1293 /// Write the LABEL_INDEX section (kind 8, `rete build --label-index`).
1294 pub label_index: bool,
1295 /// Write the SCHEMA section (kind 9; `rete build` writes it unless
1296 /// `--no-schema`).
1297 pub schema: bool,
1298}
1299
1300impl AssembleOptions<'_> {
1301 /// What every `assemble_*` entry point wrote before kinds 8 and 9 existed:
1302 /// a Louvain pyramid with the combined pyramid-meta, no text index, all six
1303 /// permutations, one index per named graph, no LABEL_INDEX or SCHEMA
1304 /// section.
1305 pub fn legacy() -> Self {
1306 AssembleOptions {
1307 with_pyramid: true,
1308 with_text_index: false,
1309 text_index_scope: None,
1310 type_override: None,
1311 algo: PyramidAlgo::Louvain,
1312 perms: crate::index::PermSet::ALL,
1313 graph_layout: GraphLayout::PerGraph,
1314 pyramid_layout: crate::file::PyramidLayout::Combined,
1315 label_index: false,
1316 schema: false,
1317 }
1318 }
1319
1320 /// What `rete build` writes: the pyramid-meta carries only the community
1321 /// material, the schema has its own section, and the label index is
1322 /// opt-in.
1323 pub fn sectioned() -> Self {
1324 AssembleOptions {
1325 pyramid_layout: crate::file::PyramidLayout::CommunityOnly,
1326 schema: true,
1327 ..Self::legacy()
1328 }
1329 }
1330}
1331
1332/// Assemble a file image from parsed quads under explicit [`AssembleOptions`].
1333///
1334/// # Panics
1335///
1336/// With [`GraphLayout::Quad`] and a permutation set other than
1337/// [`PermSet::CORE`](crate::index::PermSet::CORE) or
1338/// [`PermSet::ALL`](crate::index::PermSet::ALL), when the input has named graphs.
1339pub fn assemble_dataset_opts<M: IntoMetadata>(
1340 quads: Vec<RawQuad>,
1341 opts: &AssembleOptions<'_>,
1342 metadata: impl FnOnce(&BuildStats, &[RawQuad]) -> M,
1343) -> (Vec<u8>, BuildStats) {
1344 use crate::build_progress::{self as progress, Stat, Ticker, Unit};
1345 use crate::build_trace::Phase;
1346 let graphs = opts.graph_layout;
1347 let n = Some(quads.len() as u64);
1348 progress::begin(Phase::Dictionary, n, Unit::Statements);
1349 let mut ticker = Ticker::new();
1350 let mut db = DictionaryBuilder::new();
1351 for (s, p, o, g) in &quads {
1352 db.observe(s, p, o);
1353 if let (GraphLayout::Quad, Some(g)) = (graphs, g) {
1354 db.observe_graph(g);
1355 }
1356 ticker.tick(1);
1357 }
1358 ticker.flush();
1359 let dict = db.build();
1360 crate::build_trace::lap(Phase::Dictionary);
1361 progress::set_stat(Stat::Terms, dict.term_count().into());
1362
1363 progress::begin(Phase::Encode, n, Unit::Statements);
1364 let mut default_triples = Vec::new();
1365 let mut named = NamedQuads::new(graphs, &dict);
1366 for (s, p, o, g) in &quads {
1367 let t = dict.encode(s, p, o).expect("observed term");
1368 match g {
1369 None => default_triples.push(t),
1370 Some(graph) => named.push(&dict, graph.clone(), t),
1371 }
1372 ticker.tick(1);
1373 }
1374 drop(ticker);
1375 crate::build_trace::lap(Phase::Encode);
1376
1377 // Derive the metadata blob (the Dataset Card) from the raw quads NOW, while
1378 // they are resident — then DROP them before the memory-heavy pyramid + index
1379 // phases. On a big build the string quads are the largest working set (every
1380 // term an owned String, heavily duplicated) and are fully redundant with the
1381 // dictionary + id-triples once encoded, so freeing them here cuts peak RAM by
1382 // their whole size. `pyramid_levels` is not known yet (0 in the callback);
1383 // it is filled into the returned `stats` by `finish_assembly`, and no metadata
1384 // callback depends on it (the card derives from the quads + term/graph counts).
1385 let stats = BuildStats {
1386 statements: quads.len(),
1387 default_triples: default_triples.len(),
1388 named_graphs: named.graph_count(),
1389 terms: dict.term_count() as usize,
1390 pyramid_levels: 0,
1391 label_index: opts.label_index,
1392 };
1393 let pending = metadata(&stats, &quads);
1394 crate::build_trace::lap(crate::build_trace::Phase::CardDerive);
1395 drop(quads);
1396
1397 finish_assembly(dict, default_triples, named, opts, pending, stats)
1398}
1399
1400/// **Two-pass, low-RAM** assembly: build a `.rete` by **streaming** the input(s)
1401/// twice instead of holding every parsed quad in memory. `stream` is invoked
1402/// **twice** and MUST replay the exact same quads in the same order each time —
1403/// pass 1 observes every term into the dictionary; pass 2 encodes them to
1404/// id-triples. The raw string quads (every term an owned String, heavily
1405/// duplicated — by far the largest working set on a big graph) are **never
1406/// collected**, so peak RAM is bounded by the dictionary + id-triples + index
1407/// rather than the string-quad multiset. Output is **byte-identical** to
1408/// [`assemble_dataset_with_opts`] on the same quads (same dictionary, same
1409/// id-triples in file order, same downstream pipeline).
1410///
1411/// The metadata callback derives the Dataset Card from the built dictionary +
1412/// default-graph id-triples (resolving terms through the dictionary), since the
1413/// raw quads were never retained. `stream` propagates parse/IO errors.
1414pub fn assemble_dataset_streaming<S, M: IntoMetadata>(
1415 stream: S,
1416 with_pyramid: bool,
1417 with_text_index: impl crate::text_index::IntoTextIndex,
1418 type_override: Option<&str>,
1419 metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> M,
1420) -> Result<(Vec<u8>, BuildStats), IngestError>
1421where
1422 S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
1423{
1424 assemble_dataset_streaming_algo(
1425 stream,
1426 with_pyramid,
1427 with_text_index,
1428 type_override,
1429 PyramidAlgo::Louvain,
1430 metadata,
1431 )
1432}
1433
1434/// Like [`assemble_dataset_streaming`], but selects the community [`PyramidAlgo`]
1435/// (the streaming, low-RAM build path for `rete build --pyramid-algo …`).
1436#[allow(clippy::too_many_arguments)]
1437pub fn assemble_dataset_streaming_algo<S, M: IntoMetadata>(
1438 stream: S,
1439 with_pyramid: bool,
1440 with_text_index: impl crate::text_index::IntoTextIndex,
1441 type_override: Option<&str>,
1442 algo: PyramidAlgo,
1443 metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> M,
1444) -> Result<(Vec<u8>, BuildStats), IngestError>
1445where
1446 S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
1447{
1448 assemble_dataset_streaming_with_perms(
1449 stream,
1450 with_pyramid,
1451 with_text_index,
1452 type_override,
1453 algo,
1454 crate::index::PermSet::ALL,
1455 metadata,
1456 )
1457}
1458
1459/// Like [`assemble_dataset_streaming_algo`], but writes only the permutations in
1460/// `perms` — the two-pass low-RAM twin of [`assemble_dataset_with_perms`].
1461#[allow(clippy::too_many_arguments)]
1462pub fn assemble_dataset_streaming_with_perms<S, M: IntoMetadata>(
1463 stream: S,
1464 with_pyramid: bool,
1465 with_text_index: impl crate::text_index::IntoTextIndex,
1466 type_override: Option<&str>,
1467 algo: PyramidAlgo,
1468 perms: crate::index::PermSet,
1469 metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> M,
1470) -> Result<(Vec<u8>, BuildStats), IngestError>
1471where
1472 S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
1473{
1474 assemble_dataset_streaming_with_layout(
1475 stream,
1476 with_pyramid,
1477 with_text_index,
1478 type_override,
1479 algo,
1480 perms,
1481 GraphLayout::PerGraph,
1482 metadata,
1483 )
1484}
1485
1486/// Like [`assemble_dataset_streaming_with_perms`], storing the named graphs in
1487/// `graphs` layout — the two-pass low-RAM twin of
1488/// [`assemble_dataset_with_layout`]. [`GraphLayout::Quad`] with a permutation
1489/// set other than the 3- or 6-permutation one is an error.
1490#[allow(clippy::too_many_arguments)]
1491pub fn assemble_dataset_streaming_with_layout<S, M: IntoMetadata>(
1492 stream: S,
1493 with_pyramid: bool,
1494 with_text_index: impl crate::text_index::IntoTextIndex,
1495 type_override: Option<&str>,
1496 algo: PyramidAlgo,
1497 perms: crate::index::PermSet,
1498 graphs: GraphLayout,
1499 metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> M,
1500) -> Result<(Vec<u8>, BuildStats), IngestError>
1501where
1502 S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
1503{
1504 let text_index = crate::text_index::IntoTextIndex::into_text_index(with_text_index);
1505 let opts = AssembleOptions {
1506 with_pyramid,
1507 with_text_index: text_index.is_some(),
1508 text_index_scope: text_index.as_ref(),
1509 type_override,
1510 algo,
1511 perms,
1512 graph_layout: graphs,
1513 ..AssembleOptions::legacy()
1514 };
1515 assemble_dataset_streaming_opts(stream, &opts, metadata)
1516}
1517
1518/// The two-pass low-RAM twin of [`assemble_dataset_opts`]. A quad-index layout
1519/// with a permutation set other than the 3- or 6-permutation one is an error.
1520pub fn assemble_dataset_streaming_opts<S, M: IntoMetadata>(
1521 mut stream: S,
1522 opts: &AssembleOptions<'_>,
1523 metadata: impl FnOnce(&BuildStats, &Dictionary, &[(u32, u32, u32)]) -> M,
1524) -> Result<(Vec<u8>, BuildStats), IngestError>
1525where
1526 S: FnMut(&mut dyn FnMut(RawQuad)) -> Result<(), IngestError>,
1527{
1528 let graphs = opts.graph_layout;
1529 if graphs == GraphLayout::Quad && crate::quad::check_perms(opts.perms).is_err() {
1530 return Err(IngestError::Io(
1531 "the quad index needs the 3- or 6-permutation set".into(),
1532 ));
1533 }
1534 // Pass 1: observe every term (the dictionary dedups; the string quads are
1535 // freed line by line and never collected). With the quad index, graph
1536 // names are terms too. The stream sets each pass's progress total (the
1537 // input bytes it will read).
1538 use crate::build_progress::{self as progress, Stat, Unit};
1539 use crate::build_trace::Phase;
1540 progress::begin(Phase::ParseDictionary, None, Unit::None);
1541 let mut db = DictionaryBuilder::new();
1542 stream(&mut |(s, p, o, g)| {
1543 db.observe(&s, &p, &o);
1544 if let (GraphLayout::Quad, Some(g)) = (graphs, g) {
1545 db.observe_graph(&g);
1546 }
1547 })?;
1548 let dict = db.build();
1549 crate::build_trace::lap(Phase::ParseDictionary);
1550 progress::set_stat(Stat::Terms, dict.term_count().into());
1551
1552 // Pass 2: encode each quad to an id-triple, bucketing named graphs.
1553 progress::begin(Phase::ParseEncode, None, Unit::None);
1554 let mut default_triples: Vec<(u32, u32, u32)> = Vec::new();
1555 let mut named = NamedQuads::new(graphs, &dict);
1556 stream(&mut |(s, p, o, g)| {
1557 let t = dict.encode(&s, &p, &o).expect("observed term");
1558 match g {
1559 None => default_triples.push(t),
1560 Some(graph) => named.push(&dict, graph, t),
1561 }
1562 })?;
1563 crate::build_trace::lap(crate::build_trace::Phase::ParseEncode);
1564
1565 let statements = default_triples.len() + named.quad_count();
1566 let stats = BuildStats {
1567 statements,
1568 default_triples: default_triples.len(),
1569 named_graphs: named.graph_count(),
1570 terms: dict.term_count() as usize,
1571 pyramid_levels: 0,
1572 label_index: opts.label_index,
1573 };
1574 let pending = metadata(&stats, &dict, &default_triples);
1575 crate::build_trace::lap(crate::build_trace::Phase::CardDerive);
1576 Ok(finish_assembly(
1577 dict,
1578 default_triples,
1579 named,
1580 opts,
1581 pending,
1582 stats,
1583 ))
1584}
1585
1586/// The shared tail of every build path: from a finished dictionary + encoded
1587/// id-triples (default graph + named), build the community pyramid, the optional
1588/// full-text index, the permutation indexes, and serialize the file image. Takes
1589/// the id-triples **by value** so they are freed as the permutations consume them.
1590fn finish_assembly<M: IntoMetadata>(
1591 dict: Dictionary,
1592 mut default_triples: Vec<(u32, u32, u32)>,
1593 named: NamedQuads,
1594 opts: &AssembleOptions<'_>,
1595 pending: M,
1596 mut stats: BuildStats,
1597) -> (Vec<u8>, BuildStats) {
1598 use crate::build_progress::{self as progress, Unit};
1599 use crate::build_trace::Phase;
1600 let has_named = named.graph_count() > 0;
1601 let perms = opts.perms;
1602
1603 // The pyramid and the full-text index are built FIRST, while the dictionary and
1604 // the default id-triples are both still resident — both need the two together.
1605 // The pyramid sees the triples in input order (Louvain is order-sensitive).
1606 let (meta, levels) = if opts.with_pyramid {
1607 progress::begin(Phase::Pyramid, None, Unit::None);
1608 crate::file::build_pyramid_meta_layout(
1609 &dict,
1610 &default_triples,
1611 DEFAULT_TILE_BUDGET,
1612 opts.type_override,
1613 opts.algo,
1614 opts.pyramid_layout,
1615 )
1616 } else {
1617 (Vec::new(), 0)
1618 };
1619 stats.pyramid_levels = levels;
1620 if opts.with_pyramid {
1621 crate::build_trace::lap(crate::build_trace::Phase::Pyramid);
1622 }
1623
1624 // The full-text index over the literals of the requested scope: the
1625 // default graph's (the only scope before 0.4.0), optionally every named
1626 // graph's too, optionally only some predicates'.
1627 let with_text_index = opts
1628 .with_text_index
1629 .then(|| opts.text_index_scope.cloned().unwrap_or_default());
1630 // The named graphs' triples, for an index whose scope covers them: per
1631 // graph, or (in the quad layout) the quads' `(s, p, o)`, copied only when
1632 // the scope needs them; the index does not care which graph a word came
1633 // from.
1634 let quad_spo: Vec<(u32, u32, u32)>;
1635 let named_triples: Vec<&[(u32, u32, u32)]> = match &named {
1636 NamedQuads::PerGraph(m) => m.values().map(Vec::as_slice).collect(),
1637 NamedQuads::Quad { quads, .. } => {
1638 quad_spo = if with_text_index
1639 .as_ref()
1640 .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs)
1641 {
1642 quads.iter().map(|q| (q[1], q[2], q[3])).collect()
1643 } else {
1644 Vec::new()
1645 };
1646 vec![quad_spo.as_slice()]
1647 }
1648 };
1649 if with_text_index.is_some() {
1650 progress::begin(Phase::TextIndex, None, Unit::None);
1651 }
1652 let text_index = match &with_text_index {
1653 Some(scope) => crate::text_index::build_section(
1654 &dict,
1655 scope,
1656 &default_triples,
1657 named_triples.into_iter(),
1658 crate::file::writer_codec(),
1659 ),
1660 None => Vec::new(),
1661 };
1662 if with_text_index.is_some() {
1663 crate::build_trace::lap(crate::build_trace::Phase::TextIndex);
1664 }
1665 let text_index_partial = with_text_index
1666 .as_ref()
1667 .is_some_and(crate::text_index::TextIndexScope::restricts_predicates);
1668 let text_index_named = with_text_index
1669 .as_ref()
1670 .is_some_and(crate::text_index::TextIndexScope::covers_named_graphs);
1671
1672 // The LABEL_INDEX and SCHEMA sections are computed over the DEDUPLICATED
1673 // default graph in SPO order (and the schema's second pass in POS order),
1674 // the order every index build re-sorts into anyway — which is what lets
1675 // the memory-bounded build, reading its sorted permutation streams, write
1676 // the very same bytes. Both need the dictionary, so they run before it is
1677 // dropped. Without either section the triples stay in input order.
1678 let mut label_section = Vec::new();
1679 let mut schema_section = Vec::new();
1680 if opts.label_index || opts.schema {
1681 progress::begin(Phase::LabelsSchema, None, Unit::None);
1682 crate::schema_section::sort_dedup_spo(&mut default_triples);
1683 if opts.label_index {
1684 let entries = crate::label_index::entries_from_sorted_triples(&dict, &default_triples);
1685 label_section =
1686 crate::label_index::encode_section(&entries, crate::file::writer_codec());
1687 }
1688 if opts.schema {
1689 schema_section = crate::schema_section::compute_in_memory(
1690 &dict,
1691 &mut default_triples,
1692 opts.type_override,
1693 )
1694 .encode();
1695 }
1696 crate::build_trace::lap(crate::build_trace::Phase::LabelsSchema);
1697 }
1698
1699 // From here the dictionary is only needed for its own serialized bytes. Encode
1700 // it, capture the header term count, then DROP it before building the
1701 // permutation indexes — which work purely on id-triples and never touch the
1702 // dictionary. On a large graph this frees the single biggest resident structure
1703 // (the dictionary) right when the index sort needs the headroom. The output is
1704 // byte-for-byte identical to serializing the dictionary inline.
1705 let codec = crate::file::writer_codec();
1706 progress::begin(Phase::DictionarySection, None, Unit::None);
1707 let dict_container = crate::file::encode_dict_container(&dict, codec);
1708 let term_count = dict.term_count() as u64;
1709 let has_quoted_triples = dict.has_quoted_triples();
1710 drop(dict);
1711 crate::build_trace::lap(crate::build_trace::Phase::DictionarySection);
1712
1713 // Build each graph's six permutations one-at-a-time on a large graph (a single
1714 // permuted copy resident at a time, each sort still parallel) instead of all
1715 // six concurrently; below the threshold the faster all-permutations-parallel
1716 // build is used (its 6x transient copy is negligible there). `build_seq` is
1717 // byte-identical to `build`.
1718 // Every graph's triples, once per permutation (the quad index's quads
1719 // once, as it finishes).
1720 let (per_graph, quad_rows): (u64, u64) = match &named {
1721 NamedQuads::PerGraph(m) => (m.values().map(|t| t.len() as u64).sum(), 0),
1722 NamedQuads::Quad { quads, .. } => (0, quads.len() as u64),
1723 };
1724 let rows = (default_triples.len() as u64 + per_graph) * perms.len() as u64 + quad_rows;
1725 progress::begin(Phase::IndexPermutations, Some(rows), Unit::Records);
1726 let build_index = |triples: Vec<(u32, u32, u32)>| -> crate::GraphIndex {
1727 let n = triples.len();
1728 let b = GraphIndexBuilder::from_triples(triples)
1729 .with_perms(perms)
1730 .with_progress();
1731 if n > LOWMEM_TRIPLE_THRESHOLD {
1732 b.build_seq()
1733 } else {
1734 b.build()
1735 }
1736 };
1737 let def = build_index(default_triples);
1738 // The named graphs: one index per graph, or one quad index for all of them
1739 // (no per-graph index is ever built on that path).
1740 let (named_indexes, quad_index): (Vec<(String, crate::GraphIndex)>, _) = match named {
1741 NamedQuads::PerGraph(named) => (
1742 named
1743 .into_iter()
1744 .map(|(g, ts)| (g, build_index(ts)))
1745 .collect(),
1746 None,
1747 ),
1748 NamedQuads::Quad { quads, .. } => {
1749 let n = quads.len() as u64;
1750 let q = crate::quad::encode_quad_index(quads, perms, codec)
1751 .expect("the quad index needs the 3- or 6-permutation set");
1752 progress::advance(n);
1753 (Vec::new(), Some(q))
1754 }
1755 };
1756 let named_quads = match &quad_index {
1757 Some(q) => q.quad_count,
1758 None => named_indexes
1759 .iter()
1760 .map(|(_, ix)| ix.triple_count() as u64)
1761 .sum::<u64>(),
1762 };
1763 crate::build_trace::lap(crate::build_trace::Phase::IndexPermutations);
1764
1765 // Only now is the DEDUPLICATED size of the file known: every index sorts and
1766 // dedups, so an input containing duplicate statements (a harvest that pages
1767 // with overlapping windows, a merge of overlapping dumps) writes fewer quads
1768 // than it ingested. `write_dataset_from_parts` stamps exactly this sum into
1769 // the header, so a metadata payload finalized with it cannot disagree with
1770 // the header — which is what an embedded Dataset Card used to do.
1771 let counts = FinalCounts {
1772 default_triples: def.triple_count() as u64,
1773 quads: def.triple_count() as u64 + named_quads,
1774 };
1775 let blob = pending.into_metadata(counts);
1776 progress::begin(Phase::Serialize, None, Unit::None);
1777 // Report what was WRITTEN, not what was read: the returned stats drive the
1778 // CLI's "wrote …: N quads" line, which described the file, not the input.
1779 stats.statements = counts.quads as usize;
1780 stats.default_triples = counts.default_triples as usize;
1781
1782 let bytes = crate::file::write_dataset_from_parts(
1783 &dict_container,
1784 term_count,
1785 &def,
1786 match &quad_index {
1787 Some(q) => crate::file::NamedPart::Quad(q),
1788 None => crate::file::NamedPart::PerGraph(&named_indexes),
1789 },
1790 has_named,
1791 has_quoted_triples,
1792 &meta,
1793 levels,
1794 &blob,
1795 &text_index,
1796 text_index_partial,
1797 text_index_named,
1798 codec,
1799 );
1800 // Kinds 8 and 9 go after everything else: they are outside the content
1801 // hash, so appending them moves nothing and changes no hash.
1802 let bytes = if label_section.is_empty() && schema_section.is_empty() {
1803 bytes
1804 } else {
1805 crate::file::append_sections(
1806 &bytes,
1807 &[
1808 (crate::header::SectionKind::LabelIndex, &label_section),
1809 (crate::header::SectionKind::Schema, &schema_section),
1810 ],
1811 )
1812 .expect("appending to an image this function just wrote")
1813 };
1814 crate::build_trace::lap(crate::build_trace::Phase::Serialize);
1815 (bytes, stats)
1816}
1817
1818/// Above this default-graph triple count, the index is built one permutation at a
1819/// time (low peak RAM) instead of all six in parallel. Chosen so typical builds
1820/// keep the faster parallel path while multi-hundred-million-triple graphs stay
1821/// within a bounded memory budget.
1822const LOWMEM_TRIPLE_THRESHOLD: usize = 30_000_000;
1823
1824#[cfg(test)]
1825mod tests {
1826 use super::*;
1827 use crate::Rete;
1828
1829 #[test]
1830 fn parses_iris_bnodes_literals() {
1831 let input = r#"
1832 # a comment
1833 <http://ex/Alice> <http://ex/knows> <http://ex/Bob> .
1834 <http://ex/Alice> <http://ex/age> "30"^^<http://www.w3.org/2001/XMLSchema#integer> .
1835 <http://ex/Bob> <http://ex/label> "Bob"@en .
1836 _:b0 <http://ex/p> "plain" .
1837 "#;
1838 let t = parse(input).unwrap();
1839 assert_eq!(t.len(), 4);
1840 assert_eq!(t[0].0, "<http://ex/Alice>");
1841 assert_eq!(t[0].2, "<http://ex/Bob>");
1842 assert_eq!(t[1].2, "\"30\"^^<http://www.w3.org/2001/XMLSchema#integer>");
1843 assert_eq!(t[2].2, "\"Bob\"@en");
1844 assert_eq!(t[3].0, "_:b0");
1845 assert_eq!(t[3].2, "\"plain\"");
1846 }
1847
1848 #[test]
1849 fn literal_with_spaces_and_escaped_quote() {
1850 let input = r#"<http://ex/s> <http://ex/p> "a \"quoted\" phrase here" ."#;
1851 let t = parse(input).unwrap();
1852 assert_eq!(t.len(), 1);
1853 assert_eq!(t[0].2, r#""a \"quoted\" phrase here""#);
1854 }
1855
1856 /// RDF-star: a quoted triple whose object is a **language-tagged literal**
1857 /// sits directly against the closing `>>` with no separating whitespace
1858 /// (`"name"@fr>>`). The language-tag scan must stop at `>` — a regression
1859 /// guard for the greedy scan-to-whitespace that swallowed `@fr>>` as the tag.
1860 #[test]
1861 fn quoted_triple_langtagged_object() {
1862 let s = r#"<<<http://ex/sp> <http://ex/name> "Hirondelle rustique"@fr>>"#;
1863 let (tok, rest) = take_term(s).unwrap();
1864 assert_eq!(
1865 tok,
1866 r#"<<<http://ex/sp> <http://ex/name> "Hirondelle rustique"@fr>>"#
1867 );
1868 assert_eq!(rest, "");
1869 // and it round-trips through a full annotation line
1870 let line = r#"<<<http://ex/sp> <http://ex/name> "Oreneta vulgar"@ca>> <http://purl.org/dc/terms/source> "Catalogue of Life" ."#;
1871 let t = parse(line).unwrap();
1872 assert_eq!(t.len(), 1);
1873 assert_eq!(
1874 t[0].0,
1875 r#"<<<http://ex/sp> <http://ex/name> "Oreneta vulgar"@ca>>"#
1876 );
1877 assert_eq!(t[0].2, r#""Catalogue of Life""#);
1878 // a plain lang-tagged object still terminates at whitespace
1879 assert_eq!(take_term(r#""x"@pt-BR ."#).unwrap().0, r#""x"@pt-BR"#);
1880 }
1881
1882 /// RDF 1.2 triple-term surface `<<( s p o )>>` parses to the SAME canonical
1883 /// token as the RDF-star `<< s p o >>`, so the two are interoperable (a query
1884 /// written either way matches data ingested either way).
1885 #[test]
1886 fn rdf12_triple_term_same_token_as_rdf_star() {
1887 let rdf12 = r#"<http://ex/r> <http://ex/p> <<( <http://ex/s> <http://ex/q> "o" )>> ."#;
1888 let star = r#"<http://ex/r> <http://ex/p> << <http://ex/s> <http://ex/q> "o" >> ."#;
1889 let a = parse(rdf12).unwrap();
1890 let b = parse(star).unwrap();
1891 assert_eq!(a.len(), 1);
1892 assert_eq!(a[0].2, r#"<<<http://ex/s> <http://ex/q> "o">>"#);
1893 assert_eq!(
1894 a[0].2, b[0].2,
1895 "RDF 1.2 and RDF-star must dedupe to one token"
1896 );
1897 // take_term consumes exactly the triple term (nothing left dangling).
1898 let (tok, rest) =
1899 take_term(r#"<<( <http://ex/s> <http://ex/q> <http://ex/o> )>>"#).unwrap();
1900 assert_eq!(tok, "<<<http://ex/s> <http://ex/q> <http://ex/o>>>");
1901 assert_eq!(rest, "");
1902 }
1903
1904 #[test]
1905 fn rejects_missing_dot() {
1906 assert!(parse("<a> <b> <c>").is_err());
1907 }
1908
1909 #[test]
1910 fn parses_quads_with_and_without_graph() {
1911 let input = "<http://ex/a> <http://ex/p> <http://ex/b> .\n\
1912 <http://ex/a> <http://ex/p> <http://ex/c> <http://ex/g> .";
1913 let q = parse_quads(input).unwrap();
1914 assert_eq!(q.len(), 2);
1915 assert_eq!(q[0].3, None); // default graph
1916 assert_eq!(q[1].3, Some("<http://ex/g>".to_string()));
1917 }
1918
1919 #[test]
1920 fn parse_reader_matches_text_parse() {
1921 // The streaming reader must produce exactly the same quads as parsing the
1922 // whole text — including blank/comment skipping, a named graph, and CRLF.
1923 let nt = "<http://ex/a> <http://ex/p> <http://ex/b> .\r\n\
1924 # comment\n\
1925 \n\
1926 _:b0 <http://ex/q> \"lit\"@en .\n";
1927 let via_text = parse_statements(nt, "nt").unwrap();
1928 let via_reader = parse_reader(std::io::Cursor::new(nt), "nt", 0).unwrap();
1929 assert_eq!(via_text, via_reader);
1930
1931 let nq = "<http://ex/a> <http://ex/p> <http://ex/b> .\n\
1932 <http://ex/a> <http://ex/p> <http://ex/c> <http://ex/g> .\n";
1933 assert_eq!(
1934 parse_quads(nq).unwrap(),
1935 parse_reader(std::io::Cursor::new(nq), "nq", 0).unwrap()
1936 );
1937 // Turtle and TriG stream too — that is what lets the external build take a
1938 // multi-gigabyte `.ttl.gz` without expanding it to N-Triples first. Reading
1939 // a structured syntax from a reader must agree with parsing its whole text.
1940 let ttl = "@prefix ex: <http://ex/> .\nex:A ex:knows ex:B , ex:C .\n";
1941 assert_eq!(
1942 parse_statements(ttl, "ttl").unwrap(),
1943 parse_reader(std::io::Cursor::new(ttl), "ttl", 0).unwrap()
1944 );
1945 let trig = "@prefix ex: <http://ex/> .\nex:g { ex:A ex:knows ex:B . }\n";
1946 let streamed = parse_reader(std::io::Cursor::new(trig), "trig", 0).unwrap();
1947 assert_eq!(parse_statements(trig, "trig").unwrap(), streamed);
1948 assert_eq!(streamed[0].3.as_deref(), Some("<http://ex/g>"));
1949 // An unknown format is still a hard error, not a silent guess.
1950 assert!(parse_reader(std::io::Cursor::new(nt), "jsonld", 0).is_err());
1951 }
1952
1953 #[test]
1954 fn parse_statements_dispatches_by_format() {
1955 let nt = "<http://ex/a> <http://ex/p> <http://ex/b> .";
1956 assert_eq!(parse_statements(nt, "nt").unwrap().len(), 1);
1957 let ttl = "@prefix ex: <http://ex/> .\nex:A ex:knows ex:B , ex:C .";
1958 assert_eq!(parse_statements(ttl, "ttl").unwrap().len(), 2);
1959 // N-Triples is a subset of both Turtle and TriG, so the same text parses
1960 // under all three — and lands in the default graph under each.
1961 assert_eq!(parse_statements(nt, "trig").unwrap().len(), 1);
1962 assert!(parse_statements(nt, "trig").unwrap()[0].3.is_none());
1963 assert!(parse_statements(nt, "jsonld").is_err());
1964 }
1965
1966 /// A TriG graph block carries its graph name through as the same canonical
1967 /// token N-Quads would produce — so the two syntaxes agree on dictionary keys
1968 /// and a `.trig` shard federates with a `.nq` one.
1969 #[test]
1970 fn trig_graph_names_match_nquads() {
1971 let trig = "@prefix ex: <http://ex/> .\nex:g { ex:a ex:p ex:b . }\nex:a ex:p ex:c .\n";
1972 let nq = "<http://ex/a> <http://ex/p> <http://ex/b> <http://ex/g> .\n\
1973 <http://ex/a> <http://ex/p> <http://ex/c> .\n";
1974 let mut from_trig = parse_statements(trig, "trig").unwrap();
1975 let mut from_nq = parse_statements(nq, "nq").unwrap();
1976 from_trig.sort();
1977 from_nq.sort();
1978 assert_eq!(from_trig, from_nq);
1979 }
1980
1981 /// RDF/XML (how most OWL ontologies ship) parses to the same canonical tokens,
1982 /// including the abbreviated typed-node syntax and `rdf:resource` references.
1983 #[test]
1984 fn parses_rdfxml_owl() {
1985 let xml = r#"<?xml version="1.0"?>
1986 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
1987 xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
1988 xmlns:owl="http://www.w3.org/2002/07/owl#">
1989 <owl:Class rdf:about="http://ex/Dog">
1990 <rdfs:subClassOf rdf:resource="http://ex/Animal"/>
1991 <rdfs:label>Dog</rdfs:label>
1992 </owl:Class>
1993 </rdf:RDF>"#;
1994 let triples = parse_rdfxml(xml).unwrap();
1995 // rdf:type owl:Class, rdfs:subClassOf, rdfs:label = 3 triples.
1996 assert_eq!(triples.len(), 3);
1997 assert!(triples.iter().any(|(s, p, o)| s == "<http://ex/Dog>"
1998 && p == "<http://www.w3.org/2000/01/rdf-schema#subClassOf>"
1999 && o == "<http://ex/Animal>"));
2000 // Same data through the format dispatcher (triples → default graph).
2001 assert_eq!(parse_statements(xml, "rdfxml").unwrap().len(), 3);
2002 // Malformed XML is a clear RdfXml error, not a silent empty parse.
2003 assert!(matches!(
2004 parse_statements("<rdf:RDF><not closed", "rdfxml"),
2005 Err(IngestError::RdfXml(_))
2006 ));
2007 }
2008
2009 /// Regression coverage for RUSTSEC-2026-0195: namespace declarations from
2010 /// an untrusted RDF/XML document must not make parsing panic or exhaust the
2011 /// bounded test process.
2012 #[test]
2013 fn rdfxml_namespace_fanout_is_bounded() {
2014 let declarations = (0..2_000)
2015 .map(|i| format!(r#" xmlns:p{i}="http://example.test/{i}/""#))
2016 .collect::<String>();
2017 let xml = format!(
2018 r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"{declarations}/>"#
2019 );
2020 let result = parse_statements(&xml, "rdfxml");
2021 assert!(result.is_ok() || result.unwrap_err().to_string().contains("namespace"));
2022 }
2023
2024 /// Regression coverage for RUSTSEC-2026-0194: duplicate-name checking must
2025 /// complete for a large valid start tag without panicking or exhausting the
2026 /// bounded test process.
2027 #[test]
2028 fn rdfxml_attribute_fanout_completes() {
2029 let attributes = (0..2_000)
2030 .map(|i| format!(r#" p:a{i}="{i}""#))
2031 .collect::<String>();
2032 let xml = format!(
2033 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>"#
2034 );
2035 assert!(parse_statements(&xml, "rdfxml").is_ok());
2036 }
2037
2038 /// Text in, queryable file image out — the whole in-memory build path the
2039 /// wasm `build()` binding uses, including a named graph.
2040 #[test]
2041 fn assemble_dataset_round_trips() {
2042 let text = "<http://ex/a> <http://ex/knows> <http://ex/b> .\n\
2043 <http://ex/b> <http://ex/knows> <http://ex/c> .\n\
2044 <http://ex/a> <http://ex/age> \"30\"^^<http://www.w3.org/2001/XMLSchema#integer> .\n\
2045 <http://ex/a> <http://ex/p> <http://ex/d> <http://ex/g1> .";
2046 let quads = parse_statements(text, "nq").unwrap();
2047 let (bytes, stats) = assemble_dataset(quads, &[]);
2048 assert_eq!(stats.statements, 4);
2049 assert_eq!(stats.default_triples, 3);
2050 assert_eq!(stats.named_graphs, 1);
2051 assert!(stats.terms >= 7);
2052
2053 let rete = Rete::open(&bytes).unwrap();
2054 assert_eq!(
2055 rete.query(None, Some("<http://ex/knows>"), None).len(),
2056 2,
2057 "default-graph pattern query"
2058 );
2059 assert_eq!(rete.graph_names(), vec!["<http://ex/g1>"]);
2060 let out = crate::eval_query(
2061 &rete,
2062 "SELECT ?x WHERE { ?x <http://ex/knows> ?y . ?y <http://ex/knows> ?z }",
2063 )
2064 .unwrap();
2065 match out {
2066 crate::QueryOutput::Select(_, rows) => assert_eq!(rows.len(), 1),
2067 other => panic!("expected select result, got {other:?}"),
2068 }
2069 }
2070
2071 /// The exact minimal input the playground Build tab uses: two triples, no
2072 /// literals, no rdf:type, no named graph — the smallest graph that still
2073 /// builds a community pyramid. (The wasm `build()` panic this guards was a
2074 /// `std::time::Instant::now()` in the pyramid timing path; native has a clock
2075 /// so this passes here, while the playground harness exercises the wasm path.)
2076 #[test]
2077 fn assemble_minimal_typeless_graph() {
2078 let text = "<http://ex/A> <http://ex/knows> <http://ex/B> .\n\
2079 <http://ex/B> <http://ex/knows> <http://ex/C> .\n";
2080 let quads = parse_statements(text, "nt").unwrap();
2081 let (bytes, stats) = assemble_dataset(quads, &[]);
2082 assert_eq!(stats.default_triples, 2);
2083 let rete = Rete::open(&bytes).unwrap();
2084 assert_eq!(rete.query(None, Some("<http://ex/knows>"), None).len(), 2);
2085 }
2086}