Skip to content

Opt-in term source spans and a provenance-tracking parser wrapper - #672

Open
ericprud wants to merge 7 commits into
rdfjs:mainfrom
ericprud:term-provenance
Open

Opt-in term source spans and a provenance-tracking parser wrapper#672
ericprud wants to merge 7 commits into
rdfjs:mainfrom
ericprud:term-provenance

Conversation

@ericprud

@ericprud ericprud commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Opt-in term source spans and a provenance-tracking parser wrapper

Editor and validation tooling needs to answer "where in the source did this quad come from?" — for red squiggles, for highlighting a validation result's triple in the document, and for the RDF/JS editor-API conversation some of us have been circling. Stores rightly stay sets of value-equal quads; provenance is a multiset of utterances, and it belongs beside the store, not in it. This PR adds the minimal parser support that makes that possible, plus a reference wrapper.

Why would I want that?

So you can match query or validation results to data and make garrish interfaces (click schema in the left column and "data" in the right, then validate and hover over constraints in the left (schema), triples in the right (data) or TestedTriples in the resulting valiation proof).

What's here

Three layers, each opt-in:

  • N3Lexer: an absolute-offset counter, and — only under a new trackOffsets option — offsetStart/offsetEnd on each token. The default token shape is byte-for-byte unchanged.
  • N3Parser: under a new onQuadSpans(quad, {subject, predicate, object, graph}) option, the parser remembers each term's source token span (a WeakMap populated in _readEntity, literal completion, and synthetic-blank-node creation) and reports per-position spans for every emitted quad. Synthetic terms without a source token (e.g. rdf:first, a) report null.
  • N3ProvenanceParser (new file): wraps Parser to maintain the multiset — a Map from a canonical quad key (value-based, never object identity, so quads reconstructed by Store.getQuads() still resolve) to utterances {quad, subject/predicate/object/graph: Range[]} in absolute character offsets.

Performance

Measured with interleaved runs (5×, medians) over a 500k-triple / 19 MB document, string input:

configuration median vs main
main, no options 910 ms baseline
this branch, options off 915 ms +0.5 % (run ranges fully overlap — noise)
onQuadSpans with a no-op consumer 2 698 ms ~3.0×
full ProvenanceParser 3 892 ms ~4.3×

The off-path cost is one integer add per input advance and one predictable branch per token/term; the trackOffsets gate keeps the token hidden class unchanged, so nothing leaks into unrelated code. The opt-in multipliers are allocation/GC (span objects, WeakMap traffic, canonical keys) and land on editor-sized documents — a 100 KB document pays ~20 ms. There's headroom (packed integer spans, pooled records, lazy keys) if the opt-in path's cost matters to anyone; I kept the first cut obvious rather than clever.

Tests

All existing tests pass unchanged and coverage stays at 100 %. New tests cover the utterance multiset semantics (a quad uttered twice has two utterances), value-keyed lookup through a Store round-trip, TriG graph labels, RDF 1.2 annotations, and span-less synthetic terms.

Who carries it — in preference order

The instrumentation is mechanical. scripts/apply-provenance.mjs expresses all of it as a payload of context-anchored replacements (generated by diffing this branch against main) that reproduces the instrumented files byte-identically from pristine sources, failing loudly the moment an anchor drifts. That separates where the code lives from who maintains it, so there are three arrangements rather than the usual two. Best first:

1. Merge it (this PR). The ecosystem shares one parser instead of a derived one, and the instrumentation is covered by the same tests and the same 100 % gate as everything else. The script stays in-tree as documentation of what the transformation actually is.

2. Don't merge the instrumentation, but carry the script. scripts/ keeps apply-provenance.mjs plus its payload, and a CI job derives the instrumented parser and runs the provenance suite against it. I maintain the payload; rdfjs just runs it. Make it continue-on-error and it never gates your work — it only tells you, in the same run, that a core-parser change broke a downstream derivation. src/ is untouched by the feature, so the +0.5 % conversation goes away entirely.

3. Fully out-of-tree. I keep script, payload and canary in my own repo, tracking your releases. Costs rdfjs nothing, and you find out about a break when I file an issue — or when a user does.

Merging main into this branch this week is a live argument for (2) over (3). #613 drifted none of the anchors, so the byte-identical guarantee held exactly as advertised — but it still broke the derived parser twice in ways no anchor check can catch. It added a new literal code path (predicate literals in N3 mode) that needed instrumenting, so those terms came back span-less; and by fixing a token-swallowing bug it invalidated one of my tests that had been quietly asserting the buggy behaviour. Under (2) your CI would have reported both in the same run that merged #613. Under (3) I found out days later, on my next rebase.

To be clear, (2) and (3) are both fine outcomes and I'll do the work either way; I'd just rather the breakage be visible to the people making the change than discovered downstream.

Downstream consumers (already live)

  • shex.js's editor services map validation results to data-pane source ranges, and its CLI grew a --provenance flag that decorates each matched triple in validation output with its source ranges.
  • lezer-turtle implements the same utterance interface over an incremental CodeMirror-6 grammar; the two parsers are swappable, which is the seed of a parser-agnostic RDF/JS "editor/provenance" interface I'd like to discuss separately.

Reification bugs found while testing against main

That observation about <G> { <a> <b> <c> {| <b> <c> |}. } turned into four separate defects, none of them caused by this branch. Filed and, where the fix was small enough to be reviewable on its own, patched:

#676 rdf:reifies emitted into the default graph instead of the enclosing one #679
<a> <b> <c> ~ <r> . asserts the triple twice and never emits rdf:reifies #680
annotations rejected inside blank node property lists #681
#677 predicate-object list after {| |} silently dropped needs a state change; unpatched
#678 annotations do not nest same; unpatched

Effect on this PR: none, once #679 is accounted for. The three PRs merge cleanly with each other in any order, and scripts/apply-provenance.mjs applies to every one of them individually and combined. #679 did initially drift one anchor — it spanned the whole of _readTripleTerm, including the _emit line that carries the graph, while the transform only wraps the two factory calls — so the anchor is now trimmed to the lines it actually rewrites. Output on main is byte-identical.

The instrumented parser plus this branch's provenance tests pass against each of the three fixes separately and all three together, at 100 % coverage. So whichever of them land, in whatever order, this branch needs no further change.

This is also the concrete argument for arrangement (2) above: #676's fix drifts an anchor that no amount of care in this branch could have anticipated, and the canary reports it in the same run rather than at my next rebase.

Closes #377

Eric Prud'hommeaux and others added 2 commits August 3, 2026 19:27
Lexer: an absolute-offset counter and, under the new trackOffsets
option, offsetStart/offsetEnd on each token (default token shape and
hot path unchanged).

Parser: under the new onQuadSpans option, remember each term's source
token span (WeakMap, populated in _readEntity, literal completion and
synthetic-blank-node creation) and report per-position spans for every
emitted quad. Zero cost when the option is absent.

N3ProvenanceParser: wraps Parser to maintain a multiset of quad
*utterances* on the side - Map from a canonical quad key (value-based,
never object identity, so store-reconstructed quads still resolve) to
{quad, subject/predicate/object/graph: Range[]} with absolute character
offsets. Stores stay plain sets of quads at full speed; the multiset
lives in the wrapper.

All existing tests pass unchanged and coverage stays at 100%; new tests
cover utterance multiset semantics, value-keyed lookup, TriG graph
labels, RDF 1.2 annotations and span-less synthetic terms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apply-provenance.mjs applies the term-span transforms (a payload of
context-anchored replacements generated by diffing this branch against
main, plus the N3ProvenanceParser source) to a pristine N3.js source
tree. Anchors must match exactly once, so upstream drift fails loudly.
By construction, term-provenance == main + this script, byte for byte -
so the instrumentation can also be maintained entirely out-of-tree
against upstream releases if it isn't wanted in-tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread scripts/apply-provenance.mjs Outdated
ericprud and others added 5 commits August 9, 2026 11:22
Co-authored-by: Ted Thibodeau Jr <tthibodeau@openlinksw.com>
rdfjs#613 fixed literal subjects/predicates in N3 mode. Two
consequences for the provenance branch:

- The subject-literal test document `"s" <p> <o> <g> .` only parsed
  before because the old `_completeSubjectLiteral` swallowed the token
  after the literal, shifting `<o>`/`<g>` into predicate/object. With
  the token no longer dropped, the document is an N3-invalid quad. Use
  `"s" <p> <o> .`, which is what the test meant to exercise.

- `_readPredicate`'s new `case 'literal'` is a fourth site that stashes
  `_literalValue` for later completion, so it needs the same
  `_literalSpan` stash as the subject, object, and list-item sites, or
  predicate literals come back span-less. Instrument it and add the
  corresponding payload transform.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Numbers and booleans reach the parser as a single `literal` token whose
`prefix` already carries the datatype, so they skip the
`_literalValue`/`_literalSpan` handshake that the quoted-literal paths
use and were constructed with no span at all: the object of
`<s> <p> 42 .` came back span-less. Note the span directly from the
token at all four such sites (subject, predicate, object, list item);
the token covers exactly the numeric or boolean lexeme, so no offset
arithmetic is needed.

apply-provenance.mjs itself is unchanged -- this is four more
context-anchored replacements in the payload, and the derivation still
reproduces src/ byte-identically from pristine upstream sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The anchor spanned the whole function body, including the _emit line
that carries the reifies quad's graph.  That line is unrelated to the
instrumentation -- the transform only wraps the blankNode() and quad()
calls -- but including it made the anchor drift the moment upstream
touched the graph argument (as the fix for rdfjs#676 does).

Trim it to the three lines actually rewritten.  Output on main is
byte-identical, and the payload now applies cleanly on top of the
reifies-graph, lone-reifier and blank-node-annotation fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this?

Comment thread src/N3Parser.js
Comment on lines +1102 to +1104
const reifier = this._reifier || this._noteSpan(this._factory.blankNode(), null);
this._reifier = null;
this._tripleTerm = this._tripleTerm || this._factory.quad(this._subject, this._predicate, this._object);
this._tripleTerm = this._tripleTerm || this._noteSpan(this._factory.quad(this._subject, this._predicate, this._object), null);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why call notespan here when the term is just returned if the token is null?

Comment thread src/N3ProvenanceParser.js

@jeswr jeswr Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it is appropriate for this to be maintained here - as opposed to a consuming package.

@RubenVerborgh this is a call for you to make

Comment thread src/N3Parser.js
Comment on lines +18 to +21
// Opt-in source-span tracking: after each emitted quad, invoke
// onQuadSpans(quad, {subject, predicate, object, graph}) with the
// lexer's {line, start, end} for each position's source token (null for
// synthetic terms without one). Zero cost when the option is absent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reduce comment verbosity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store Token position in the produces quads

3 participants