fix(turtle): a redeclared prefix must change what later names mean - #1565
Open
aaj3f wants to merge 4 commits into
Open
fix(turtle): a redeclared prefix must change what later names mean#1565aaj3f wants to merge 4 commits into
aaj3f wants to merge 4 commits into
Conversation
Turtle lets a prefix be rebound part-way through a document. The parser
ignored the rebinding for any prefixed name it had already seen, so
@Prefix e: <http://a/> . e:x <http://p/> "1" .
@Prefix e: <http://b/> . e:x <http://p/> "2" .
put BOTH statements on <http://a/x>. No error, no warning — the document
says one thing and the graph holds another.
Cause: `prefixed_term_cache` is keyed by span text (`e:x`), which identifies
an IRI only relative to the bindings in force when the entry was made, and
nothing invalidated it when a binding changed. `iri_term_cache` was never
affected — it keys on the RESOLVED IRI — and neither was `@base`, since a
namespace is resolved once, when its directive is read. A test pins both, so
the scope of this fix is demonstrated rather than asserted.
Fix: on a prefix directive, clear the expansion cache IF the prefix rebinds to
a different namespace. Only if. Redeclaring the same binding is the common
case, not a curiosity — chunked import prepends the file's prefix block to
every chunk over a pre-seeded map, so the parser sees each prefix declared
twice by construction — and every cached expansion stays correct through it.
The per-term path is untouched: the diff adds no instruction to
`resolve_prefixed_term` or `sink_term_iri`. Added work is one hash lookup per
prefix DIRECTIVE, of which a document has a handful, against a cache consulted
once per prefixed NAME, of which it has millions. Clearing is coarse — a
rebind strands only that prefix's entries, but finding them means scanning
every key — and rare enough that O(cache) once beats a per-lookup epoch check
on the hot path.
Ten tests, eight of which fail without the fix; the two that pass either way
are `@base` and same-binding redeclaration, which is what shows the scope is
right. Found while wiring term validation for H-8; nothing about it is
specific to validation and it reproduces on plain `parse`.
admin.rs carried a literal NUL byte at line 283, inside a comment that meant
to NAME the character: "callers can otherwise sneak a `<NUL>` past". The
neighbouring lines spell the same class correctly (`U+0000..=U+001F`,
`U+007F`), so this is the one that got away.
The cost is not cosmetic, but it is grep-dependent, and the honest version
names which grep. A single NUL makes the file binary to text tools —
`file(1)` reported it as `data` rather than source — and the search behaviour
then splits:
* ugrep 7.5.0 (what `grep` resolves to in this dev environment) exits 1 and
prints NOTHING for a pattern occurring 23 times in the file. Not "binary
file matches", not a warning: a clean, confident "not found".
* BSD grep 2.6.0-FreeBSD at /usr/bin/grep — GNU-compatible, and what CI and
most contributors get — prints "Binary file ... matches" and exits 0. It
withholds the matching lines but does not lie about the match.
So the severe reading applies to anyone whose `grep` is ugrep, and the milder
one to everyone else; both are worse than a file that just works. `file` now
reports UTF-8 text and both greps find all 23. The earlier version of this
message claimed the silent-exit-1 behaviour universally, which was true only
for the shell it was measured in — corrected on review.
Comment-only and byte-precise: one line changes, the NUL replaced by the text
`U+0000`. 670 lib tests unchanged.
Found by the control-byte guard from the RDF-toolkit work, which flagged this
as its second literal NUL — the first was in a Turtle test fixture. Riding
here rather than standing alone because a one-byte PR against main is not
worth its own review slot.
Review fold. The fix was right; what it lacked was anything that could tell if half of it were deleted. THE GAP: `bind_prefix` clears the expansion cache only when a prefix actually rebinds, and that "only when" was invisible in parser output. Clearing on a same-binding redeclaration yields identical triples — just colder — because `iri_term_cache` absorbs the repeated work before it can reach the sink. So a mutation dropping the condition passed every gate in the PR. Confirmed by running it: with the clear made unconditional, all eleven triple-level tests still pass. The guard is the review's `parse_reporting_cache` — test-only, returns the prefixed-name cache hit/miss counters — plus two tests reading them: a same-binding redeclaration must leave the cache warm (1 hit, 1 miss), a real rebinding must drop it (0 hits, 2 misses). The same mutation now fails the first one. That is the whole point: the counters are the only place this behaviour is observable. Also documented, from the review: the comparison in `bind_prefix` is on MEANING, not spelling. `parse_prefix_directive` resolves the namespace before storing it, so `prefixes` holds resolved IRIs — and both directions of that matter. Same spelling under two different bases names two namespaces and MUST clear; two spellings under one base name one namespace and must NOT. Comparing raw text would get each backwards. It was load-bearing and undocumented. Two review probe files adopted as permanent regression tests, credited: - `prefix_redefinition_adversarial.rs` (9 tests) attacks what the fix's own tests could not reach, because they never vary the base: the missed-clear and spurious-clear cases above, A→B→A epochs with overlapping and disjoint names, the coarse whole-cache clear with twenty live prefixes, terms nested in `[ ]` and `( )`, escaped local names, a pre-seeded relative binding, and mixed directive spellings. - `prefix_redefinition_blast_radius.rs` (2 tests) executes the PR's blast-radius claim instead of asserting it, and now quantifies it: of the post-redefinition statements, 0 are mis-resolved inside the chunk carrying the redefinition — the fix holds there — and 1438 are mis-resolved in later chunks, which is the §1.4 chunker gap this PR explicitly does not close. It also confirms the guarded pre-scan path WOULD reject the file, which is what makes "import uses the unguarded reader" the operative fact. That 1438 is an assertion of a live defect, kept live on purpose rather than `#[ignore]`d: an ignored test runs nowhere and rots, while this one fails the moment §1.4 lands and tells whoever closed it to come flip the number to 0. Wording fold: "a rebind invalidates only that prefix's entries, but finding them means scanning every key, so we drop them all", used consistently.
…he fix
The blast-radius test's `wrong_in_carrier == 0` proved nothing. Verified
before fixing: with `bind_prefix` deleted outright — the parser reverted to the
merge base, no fix at all — BOTH tests in the file still passed.
The corpus was the problem. Subjects were `e:s{i}`, a distinct span every time,
and predicates were full IRIs, so no prefixed name was ever looked up twice.
The expansion cache was never consulted for a span it already held, which is
the only shape in which a stale entry can be served. The test walked the whole
blast radius and never touched the defect it was named for.
Every line now carries `e:tag` as its object. It is cached under the first
binding and requested again after the rebinding, so a stale cache serves the
old namespace for a span whose meaning has changed. The new assertion counts
post-redefinition triples inside the CARRIER chunk — the one that actually
contains the redefinition — whose object still resolves to `http://a/tag`:
fix removed (parser at merge base): 379 -> assertion FAILS
fix present: 0 -> assertion passes
`wrong_in_carrier` stays 0 in both cases, which is the demonstration that it
was never the discriminating assertion.
Kept unchanged: the subject-index chunk attribution, and the §1.4 tripwire on
later chunks. The tripwire's count moved 1438 -> 1621 with the corpus, which is
why it remains a printed diagnostic and an inequality rather than an equality —
ordinary chunking changes must not make it flap.
Found by an independent adversarial review of #1565. Two doc corrections ride
along: the meaning-vs-spelling cases are pinned in the ADVERSARIAL file (they
need a moving `@base`, which the other file never varies), and the clearing
comment now states the real trade — `retain` scans every key too, so the cost
is the same order; what it would buy is keeping other prefixes warm, and what
it costs is a predicate that has to compare up to the colon, since the obvious
`starts_with(prefix)` evicts `ex:name` when `e` rebinds. Simplicity over
post-rebind hit rate, not cost over cost.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two correctness fixes against main
Both found while building the RDF toolkit, both independent of it, both small enough that one review slot beats two.
fix(turtle)— a redeclared prefix silently didn't change what later prefixed names meant. Data corruption, no error.fix(api)— a literal NUL byte in anadmin.rscomment made the whole file binary to every text tool, so plaingrepreported "no match" for patterns that occur 23 times in it.1. A redeclared prefix must change what later names mean
Silent data corruption in the Turtle parser. Turtle allows a prefix to be rebound part-way through a document; the parser ignored the rebinding for any prefixed name it had already seen.
Both statements land on
<http://a/x>. The second should be<http://b/x>. No error, no warning — the document says one thing and the graph holds another.Cause
prefixed_term_cacheis keyed by span text (e:x). That text identifies an IRI only relative to the bindings in force when the entry was made, and nothing invalidated it when a binding changed.Two neighbours were not affected, and the PR pins both so the scope is demonstrated rather than asserted:
iri_term_cachekeys on the resolved IRI, so a different base produces a different key and never collided.@baseredeclaration was always correct — a prefix namespace is resolved once, when its directive is read, and prefixed names never consult the base at lookup time.Fix
On a prefix directive, clear the expansion cache if the prefix rebinds to a different namespace. Only if.
Redeclaring the same binding is the common case rather than a curiosity: chunked import prepends the file's prefix block to every chunk over a pre-seeded prefix map, so the parser sees each prefix declared twice by construction. Every cached expansion stays correct through that, and clearing there would cost hit rate across the entire import path for no correctness gain.
The per-term path is untouched. The diff adds no instruction to
resolve_prefixed_termorsink_term_iri— the only executable change is inside the prefix-directive handler. Added work is one hash lookup per prefix directive, of which a document has a handful, against a cache consulted once per prefixed name, of which it has millions. That is a structural property of the diff, not a benchmark result; a wall-clock A/B on this host was noise-dominated (a 4× swing between runs of identical code under concurrent load) and is not offered as evidence.Clearing is coarse on purpose: a rebind invalidates only that prefix's entries, but finding them means scanning every key, so we drop them all. A per-lookup epoch check would make rebinding O(1) at the cost of a comparison on every cache hit — the wrong trade when the overwhelming majority of documents rebind nothing.
The comparison is on meaning, not spelling, and both directions matter.
parse_prefix_directiveresolves the namespace before storing it, soprefixesholds resolved IRIs. The same spelling under two different bases (@prefix e: <a/>under<http://x/>then under<http://y/>) names two namespaces and must clear; two spellings that resolve alike (<http://x/a/>and<a/>under<http://x/>) name one and must not. Comparing raw text would get each backwards.The "only when" needs its own guard
Clearing on a same-binding redeclaration produces identical triples — just colder — because
iri_term_cacheabsorbs the repeated work before it reaches the sink. So a mutation deleting the condition passed every gate in the first version of this PR; confirmed by running it, with all eleven triple-level tests still green.The cache hit/miss counters are the only place the behaviour is observable, so a test-only
parse_reporting_cacheexposes them and two tests read them: same-binding redeclaration must leave the cache warm (1 hit, 1 miss), a real rebinding must drop it (0 hits, 2 misses). The same mutation now fails the first.Blast radius — honest version
This is not serial-only. Bulk import is affected, and this fix is necessary but not sufficient there.
The
PrefixAfterDataguard lives incompute_chunk_boundaries(the whole-file pre-scan behindTurtleChunkReader). Import does not use that reader.fluree-db-api/src/import.rsusesStreamingTurtleReaderexclusively, which has no such guard — verified empirically: a 4000-statement file with a mid-file redefinition is accepted, its extracted prefix block is only the head binding@prefix e: <http://a/> ., and the redefinition is delivered in a chunk like any other data.So a mid-document redefinition reaching import hits two independent defects, and
prefix_redefinition_blast_radius.rsexecutes the split rather than asserting it. The corpus is 4000 statements ofe:s{i} <http://p/> e:tag .with a redefinition half way through, parsed the way import parses it (prelude seeded, prefix block prepended).The
e:tagobject is the load-bearing part. It repeats on every line, so it is cached under the first binding and requested again after the rebinding — which is the only shape in which this bug can appear at all. Counting inside the carrier chunk (the one that actually contains the redefinition), post-redefinition triples whose object still resolves tohttp://a/tag:An earlier version of this test used a literal object and asserted only that no subject was mis-resolved in the carrier chunk. That was vacuous — every subject is a distinct span, so no prefixed name was ever looked up twice and the cache was never consulted for a span it held. It passed against a parser with the fix entirely removed. Caught in review; the corpus and the assertion above are the remediation.
The later-chunk defect is asserted live (
> 0) rather than#[ignore]d: an ignored test runs nowhere and rots, while this one fails the moment §1.4 lands and tells whoever closed it to come here. The 1621 is a printed diagnostic, not part of the assertion, so ordinary chunking changes cannot make it flap.Also noted, not addressed:
PrefixCheckonly matches a directive keyword at line start, so even the guarded pre-scan path would miss… . @prefix e: <…> .written mid-line. Same §1.4 bucket.Discovery
Found while wiring term validation for H-8 (burn-down cause C), when a probe of the prefix path returned two statements on one subject. Nothing about it is specific to validation — it reproduces on plain
parse, onmainat5598ffd6a, with no feature flags.Tests
Ten, in
fluree-graph-turtle/tests/prefix_redefinition.rs. Eight fail without the fix (verified by stashing the parser change and re-running). The two that pass either way are@baseand same-binding redeclaration — the pair that shows the fix is scoped correctly rather than accidentally.Covered: the verbatim repro; SPARQL-style
PREFIX; other prefixes left alone; rebinding back and forth; the default (empty) prefix; namespace-only names (e:); same-binding redeclaration as a no-op; the pre-seeded-map shape chunked import uses;@baseunaffected; and a 1000-statement case where a partial invalidation would show as a partial failure.2. Name U+0000 in a comment instead of embedding one
fluree-db-api/src/admin.rsline 283 carried a literal NUL byte inside a comment that meant to name the character — "callers can otherwise sneak a<NUL>past". The lines immediately above spell the same class correctly (U+0000..=U+001F,U+007F); this is the one that got away.The cost is not cosmetic. A single NUL makes the whole file binary to every text tool:
file(1)reported it asdata, not source.grepresolves to in the dev environment this was found in — exits 1 and prints nothing for a pattern occurring 23 times in the file. Not "binary file matches", not a warning: a clean, confident "not found"./usr/bin/grep, GNU-compatible; what CI and most contributors get) printsBinary file … matchesand exits 0. It withholds the matching lines but does not lie about the match.So the severe reading holds for anyone on ugrep and the milder one for everyone else — both worse than a file that simply works. After:
filereports UTF-8 text and both greps find all 23. (An earlier draft of this claim asserted the silent-exit-1 behaviour universally; it was measured in one shell. Corrected on review.)Comment-only and byte-precise — one line, the NUL replaced by the text
U+0000. 670fluree-db-apilib tests unchanged, full suite re-run at the tip.Found by the control-byte guard built for the RDF-toolkit work, which flagged this as its second literal NUL; the first was in a Turtle test fixture and is fixed on that branch. Riding here rather than standing alone because a one-byte PR against main is not worth its own review slot.
Gates
nextestturtle + graph-ir + json-ld + format + transact + vocabnextest -p fluree-db-api(full)clippy --all-targets --no-depscargo fmt --all --checkcargo check --all-targetson excludedtestsuite-sparql/testsuite-shacl