Skip to content

fix(r2rml): browse-parity — native-shape crawl output, bound-root select-map routing, typed refusals, constrained-COUNT fusion - #1525

Closed
aaj3f wants to merge 13 commits into
perf/audit-harnessfrom
perf/browse-parity
Closed

fix(r2rml): browse-parity — native-shape crawl output, bound-root select-map routing, typed refusals, constrained-COUNT fusion#1525
aaj3f wants to merge 13 commits into
perf/audit-harnessfrom
perf/browse-parity

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Browse-parity: virtual datasets answer the Solo "View Instances / browse" contract with native-parity shapes or typed refusals

This is the consolidated browse-parity PR for fluree/db virtual (R2RML/Iceberg) datasets. It closes the D1–D9 defect ledger of the browse-parity assessment (minus the explicitly deferred items) — the gaps a Solo session reported from the deployed virtual-dataset browse surface, verified at rev 733cb2c8e by three tracks (P1 serializer code chain, P2 bound-term path map, P3 twenty-three live queries against the native twin). Design provenance and the AJ green-light: audit-2026-07/parity/E-BROWSE-PARITY-ASSESSMENT.md, audit-2026-07/decisions/DEC-002-browse-parity-impl.md, the verification tracks audit-2026-07/parity/{P1-serializer-verify,P2-boundsubject-pathmap,P3-shape-matrix-empirical}.md, and the originating solo handoff fluree/.byo-iam/DB-HANDOFF-virtual-dataset-queries.md. Ground rule throughout (AJ): parity is ONE-directional — virtual converges to native's EXISTING contract; nothing here changes native syntax, semantics, solution sets, or serialization.

Base is perf/audit-harness (not main): Cluster B reuses the perf line's bound-subject prune (operator.rs subject_constant table + TemplateKey file prune) and F1 extends #1522's fused-aggregate machinery, neither of which exists on main (DEC-002). This is the 5th stacked PR on the audit line; depth is the accepted cost per DEC-001. CI does NOT fire on a non-main base, so every gate below was reproduced locally and recorded in docs/audit-impl/browse-parity-gates.md.

Cluster A (D1+D2+D3) — un-fork the crawl node-document formatter [unswitched]

Defect: a wildcard crawl ({"select": {"?s": ["*"]}}) over a graph source regrouped rows through the FLAT-select formatter, so FK RefObjectMap objects serialized as bare strings ("edw:geography": ".../geography/21801") instead of native's {"@id": …}, format: typed-json was silently ignored (the crawl forked off before format_results' format match), and booleans rendered as the string "true" instead of true. All three share one root: the crawl consumed FormatterConfig on a fork that never reached the typed/hydration arm. Fix: a format::format_node_object_binding facade replaces the two flat-select sites — object IRIs become {"@id": compact_id_iri(…)} using the SAME id-compactor as the root @id (so FK values string-match subject ids and forward navigation works), literals dispatch on config.format, and a stringly boolean is coerced to a real bool in both formats. Unswitched: a switch that preserves silently-wrong output has negative value. Tests: parsed-shape asserts (was substring), a typed-json variant, a boolean-shape case, and a /info⇔crawl @id-datatype invariant.

Cluster B (D4) — route the constant-IRI select-map onto the bound-subject machinery [switched: FLUREE_R2RML_SELECT_MAP_ROUTING, default on]

Defect: a select-map with a constant-IRI key ({"select": {"<iri>": ["*"]}}, also ["@type"] and a forward-predicate list) lowered to Root::Sid → native binary-index hydration → the bare {"@id": <iri>} stub for an EXISTING virtual subject (the deployed "subject detail is empty" bug). It never reached R2RML: detect_wildcard_crawl rejected constant roots (the ?-prefix and mandatory-where gates). Fix (P2 Option A): detect_wildcard_crawl now recognizes a constant-IRI root and expand_bound_subject_select_map runs ONE bound-subject wildcard scan {"@id": <iri>, ?p: ?o}, which the operator prunes to the subject's own table via subject-template reversal, then applies the requested projection at assembly (through the Cluster-A facade, so shapes are native automatically). One pruned scan serves every projection form — avoiding the inner-join drop a per-predicate cross-scan would suffer when a requested predicate is absent. Absent/unreversible subject → the native-parity [{"@id": <iri>}] stub (native's missing-subject contract). NATIVE PARITY NUANCE (caught by blessing the q081/q082 native oracles): native OMITS @id from an explicit projection that doesn't request it — ["@type"]{"@type": …} (no @id), a property list returns only the requested predicates; only the wildcard/id-only forms and an explicit "@id" carry it (tracked via CrawlProjection::Predicates.want_id). Switched (one insurance switch, default on) because it re-routes an existing shipping-but-broken path; OFF restores the stub. Tests: ["*"]/["@type"]/property-list (present subject → node doc with {"@id"} FK refs; absent → stub; explicit @id → emitted), a native-ledger regression guard (maybe_expand_crawl returns None), and a switch-seam detect test.

Cluster C1 (D5) — loud-refuse a dropped VALUES clause on crawls [unswitched]

Defect: the crawl path silently DROPPED a top-level values clause, so {"values": ["?s", [{"@id": "<iri>"}]], "where": …, "select": {"?s": ["*"]}} returned the ENTIRE class (390k rows / 415 MB live) instead of the one bound subject — the only wrong-answer-class item in the ledger, strictly worse than a refusal. Fix: expand_wildcard_crawl detects a present top-level values on a recognized crawl and loud-refuses with the Cluster-D typed error, naming the working WHERE-clause alternative; a flat-select VALUES query is not a crawl select-map and keeps working (regression-guarded). Honoring VALUES (C2) is deferred. Unswitched (silent-wrong is the one unacceptable class).

Cluster D (D6) — typed refusal envelope [unswitched]

Defect: the R2RML bound-object refusal was a stringly QueryError::InvalidQuery → an undifferentiated HTTP 400, and its prose was stale (it blamed "bound subjects", which actually convert fine — the real trigger is variable-predicate + bound term). Solo had to prose-match. Fix: a new structured QueryError::R2rmlUnsupportedPattern { detail } raised at both refusal sites via a shared helper; the machine code is the ServerError @type (the StorageAccessDenied/CatalogCredentialsNotVended precedent, not a new body field): err:r2rml/UnsupportedPattern at HTTP 400, streaming ndjson r2rml_unsupported_pattern. Corrected prose names the real trigger; the Display keeps "cannot be converted to R2RML scans" for solo's prose-match migration. Per DEC-002 the status stays 400 (only a new error_type() arm is added before the generic one). Solo gates on @type: err:r2rml/UnsupportedPattern.

Cluster E1 (D7) — forward the LIMIT budget through the property-var crawl [switched: FLUREE_R2RML_BUDGET_PROPERTY_VAR, default on]

Defect: a property-scoped listing crawl ({"select": {"?s": ["*"]}, "where": {"@id": "?s", "<prop>": "?v"}, "limit": N}) DNF'd (>90s) — it lowers to a selective const-predicate scan plus a variable-predicate FULL-SOURCE wildcard sharing ?s, both estimated equal (DEFAULT_PROPERTY_SCAN_SELECTIVITY), so reorder_patterns kept the emit order and left the wildcard as the INNER scan with an empty seed = an unbudgeted scan over every TriplesMap; the LIMIT budget landed on the outer const-pred scan and never reached it. Fix: estimate_pattern now costs a pruning-key-less variable-predicate R2RML scan (no bound subject, class, class-prune hint, or pinned TriplesMap) as FULL_SCAN, so reorder places it LAST — the LIMIT-budgeted correlated OUTER driven by the selective inner scan, so it terminates. Sound: reordering two co-subject scans preserves the solution set (the top LIMIT truncates the same rows; every driving subject has ≥1 triple so the budget never under-fills). The residual cross-table over-scan (the correlated wildcard still resolves every TriplesMap) is the deferred bound-object prune (X2), a perf follow-up, not a correctness issue. Switched per DEC-002 (perf mechanism); OFF reverts to the equal estimate, byte-identical results. Test: a hermetic planner reorder proof (r2rml_full_source_wildcard_reorders_last_for_budget).

Cluster E2 (D9) — short-circuit a crawl over an unmapped class [unswitched]

Defect: a crawl over a class with no TriplesMaps (@type Commit) triggered a 16-table wildcard fan-out DNF instead of a fast empty result. Fix: try_fuse_wildcard_class fuses the unsatisfiable class filter onto the wildcard when find_maps_for_class is empty, so the operator resolves to zero TriplesMaps → empty, no scans; a mapped-but-unsafe class still runs the vertical-partition safety check and fans out. Unswitched. Tests: unmapped-class → empty with zero scanned tables; mapped-class control unchanged.

The cfg-orphan fix + a new permanent featureset gate

Inherited from the #1505 SecretRef insertion (f305df4): fluree-db-api/src/lib.rs build() had lost its #[cfg(native)] (no-native builds — solo's per-lambda fast path — failed) and with_secret_resolver was over-gated behind native+iceberg (breaking the no-native BYO-IAM SecretRef surface). Fixed as a separate commit (moved with_secret_resolver after build(), rejoining build()'s #[cfg(native)]; with_secret_resolver is now iceberg-only). GATE-POLICY ADDITION (permanent): the battery now includes cargo check -p fluree-db-api --no-default-features --features aws,iceberg,shacl (solo's real combination), because --all-features and workspace builds both structurally MASK feature-gating bugs via unification. Recommend CI adopt the same check.

F1 (q038-family ungrouped/direct-path constrained COUNT fusion) [rides FLUREE_FUSED_R2RML_AGG]

Folded in per AJ (D8's UI blast-radius promoted it). Corrected diagnosis (see audit-2026-07/00-MASTER-AUDIT.md F1-CORRECTION): the empty-GROUP-BY cost-guard hypothesis is REFUTED — a constant-object triple stays a Pattern::Triple and is ADMITTED at detect_fused_r2rml_aggregate; the real decline is downstream, where a subject-star with no variable-object member rewrites to a class scan + separate per-constant R2rml patterns that fall through to the join arm and materialize. NEW DATUM: the multi-constraint FROM-path member q077 has the SAME no-var-object shape and did NOT truly fuse at 733cb2c8e either (the audit's "q077 fuses" claim does not reproduce in a rewrite trace); this fix closes BOTH q038 and q077. combine_constrained_class_scan recombines the same-subject split into one class-scan-carrying-star_constraints pattern — byte-identical to the var-object fold — so the existing 9b machinery applies each constraint per row. Soundness (D-c5, over-count is the one unacceptable outcome): admits ONLY the exact shape (one subject var, one pure class scan, scalar const-object equalities); every other shape declines to the existing arms; the non-empty fact_constraints keep the delete-blind manifest COUNT shortcut declined.

The browse-shape corpus family (q079–q085) + a JSON-LD harness path

The vbench corpus was SPARQL-only, so fluree-bench-virtual/src/exec.rs now detects a JSON-LD body (first non-comment line begins with {) and runs it through .jsonld() (crawl expansion on virtual, native hydration on native) — the only way to exercise the crawl/node-document path. The node-document array is canonicalized by the existing canon bare-array branch (key-sorted multiset, so key/node order is parity-safe). Seven members promote the P3 shape matrix: q079 class-page crawl (Cluster A, rows_only), q080 subject-detail ["*"] + q081 ["@type"] + q082 property-list (Cluster B, hash-gated Full), q083 property-scoped listing (E1, rows_only), q084 filtered page (rows_only), q085 inbound-edge count (the works-today shape-9 contract, Full). New closed Tag::Browse; native oracles blessed offline from native-sf01; q038's native leg reproduces unchanged (sanity).

Verification

All gates ran LOCALLY (no CI on a non-main base); full record in docs/audit-impl/browse-parity-gates.md. fmt --check clean on all changed files. clippy -p fluree-db-query and -p fluree-db-api --features iceberg (--all-targets --no-deps): 0 lints in changed files (pre-existing warnings only, in files not touched: fused_aggregate.rs:112-116, ledger_info.rs:1626, r2rml.rs:249). Tests: fluree-db-query 1315 lib + integration (0 failed); fluree-db-api --features iceberg lib 820 + grp_graphsource 122 + grp_query 378 + grp_query_sparql 288 + grp_misc 245 (0 failed — one pre-existing FLAKE, it_minmax_fast_path_fired::multilang_min_served_by_fast_path, a native MIN fast-path test untouched by these R2RML-only changes: passes in isolation and on rerun, intermittent under full-suite parallelism); fluree-bench-virtual --bins 35 (corpus now 85). The no-native featureset check passes.

LIVE CONFIRMATION SUBSET (virtual-sf01, single-flight, 1 rep, ~2s paced, release build; compare --gate: 11 records, 0 hash mismatches, 0 perf violations). Headlines: the F1 collapses (q038 57.6s→44ms, q077 64s→81ms, counts/hashes UNCHANGED) and the E1 terminate (q083 DNF>90s→10.0s). The A-cluster live check passed — q079's node documents carry FK refs as {"@id": …} (edw:account, edw:geography), isCurrent: true, and typed dates, no bare strings. Cluster-B subject details (q080/q081/q082) hash-MATCH the native oracle and prune to scan_table n=1 (the subject's own table).

query shape status wall rows scan_n vs native oracle
q022 sentinel (COUNT by segment) ok 85ms 3 1 hash MATCH
q038 F1 direct COUNT (isCurrent+segment) ok 44ms 1 1 hash MATCH (was 57.6s)
q077 F1 FROM COUNT ok 81ms 1 1 hash MATCH (was 64s)
q078 F1 direct COUNT twin ok 83ms 1 1 hash MATCH
q079 A: class page ["*"] limit 20 ok 247ms 20 1 rows_only (FK={"@id"}, bool=true)
q080 B: subject detail ["*"] ok 1664ms 1 1 hash MATCH
q081 B: ["@type"] ok 1180ms 1 1 hash MATCH
q082 B: property list ok 1065ms 1 1 hash MATCH
q083 E1: property-scoped listing limit 10 ok 10037ms 10 17 rows_only (was DNF >90s)
q084 filtered page limit 20 ok 1439ms 20 1 rows_only
q085 inbound-edge COUNT (shape 9) ok 2399ms 1 5 hash MATCH

A canon key-order gap surfaced during this pass and was fixed in-package (see the canon note below), so the gate above is on the corrected canonicalizer. q083's scan_n=17 is the deferred X2 cross-table over-scan (E1 terminates it, a per-subject file prune would make it fast); every other member prunes.

The parity gate itself needed one fix: canon::canonicalize_graph hashed each node without sorting its keys, so a content-identical node document false-mismatched cross-engine (native emits keys alphabetically, the R2RML crawl in table-column order — q080 was byte-identical content, reordered). The fix is canon-side, deliberately: JSON object key order is semantically VOID in JSON-LD, so native's alphabetical order is incidental, not contractual — forcing the crawl to emit alphabetically would be cosmetic churn with a per-node runtime cost, whereas canon's whole job is SEMANTIC equality. Node keys are now recursively sorted before hashing (array/@list element order preserved — those may be order-bearing); the only Full-gated graph oracle affected was q080 (re-blessed), and no prior member is disturbed (q048/q049 CONSTRUCT are rows_only; the SPARQL-path oracles are untouched). This is the first cross-engine node-document hash comparison the corpus asserts — precisely the "not yet asserted" case canon's own comment named.

Residuals / deferred

Node-doc key ORDER divergence (native alphabetical vs the R2RML crawl's table-column order) is KNOWN AND ACCEPTED, not a parity gap: JSON-LD object key order is semantically void, and canon normalizes it at hash time (above). If a consumer ever demands byte-order parity with native, the fix would be emission-side key sorting in the crawl behind a switch — deliberately NOT done here (cosmetic, per-node cost, no semantic value). C2 (honor VALUES on the crawl path — C1 loud-refuses until then). X1 (variable-predicate inbound-edge DISCOVERY, shape 8 — needs reverse-template matching across RefObjectMaps; the typed Cluster-D code gates the UI meanwhile). X2 (bound-object scan pruning — the correlated wildcard in E1 still resolves every TriplesMap; a per-subject file prune would make it fast, not just terminating). Solo migration note: gate the browse UI on @type: err:r2rml/UnsupportedPattern at HTTP 400 (Cluster D), and the subject-detail select-map now works directly (Cluster B) so the client-side WHERE-clause rewrite workaround can be retired.

aaj3f added 13 commits July 20, 2026 17:28
The graph-source subgraph-crawl expander formatted OBJECT-position bindings with
the flat-select JSON-LD formatter, which forks off before the format dispatch.
That produced three native-parity defects on virtual-dataset browse:

- D1: an FK/ref object serialized as a bare string, not {"@id": ...}, killing
  forward graph navigation (the ref @id no longer string-matched the target
  subject's @id).
- D2: format: typed-json was silently ignored on the whole crawl path.
- D3: an xsd:boolean object (which the R2RML operator leaves as a string)
  rendered as "true" instead of the JSON boolean true.

Replace the two format_binding_with_result call sites with a node-document-aware
facade format::format_node_object_binding: it wraps object IRIs as
{"@id": compact_id_iri(...)} using the SAME id-compactor the crawl uses for the
root @id (so ref ids are byte-identical to subject ids), dispatches literals on
format.format (typed-json reaches typed.rs), and coerces a stringly boolean back
to a real bool in both formats -- anchored on what native hydration emits. Root
@id/@type handling is unchanged.

Ships UNSWITCHED: these are parity BUG fixes; a switch preserving the
silently-wrong/divergent output has negative value (DEC-002).

Tests: the two ref substring asserts become parsed-shape asserts; add a
typed-json variant, a boolean-shape test, and the /info<->crawl invariant (every
property /info types as @id must serialize as {"@id":...}).
The R2RML rewrite refused a virtual-dataset query carrying unconvertible
patterns with a generic InvalidQuery(String) -> HTTP 400 and no machine code,
and its prose falsely blamed "bound subjects ... or bound objects" -- both of
which actually convert and run (a bound subject `<iri> ?p ?o`, and a
constant-predicate bound object `?s ex:name "value"`). Solo had to prose-match a
message that was both undifferentiated and wrong.

Add a structured QueryError::R2rmlUnsupportedPattern { detail } raised at both
rewrite refusal sites (seeded + batched) via a shared
r2rml_unsupported_pattern_error() helper, so the message cannot drift. The
server maps it to a distinct, stable @type machine code
err:r2rml/UnsupportedPattern (new fluree-vocab R2RML_UNSUPPORTED_PATTERN) while
KEEPING HTTP 400 -- DEC-002 ruling: a well-formed request that is unsupported on
this source, not a distinct status like the 403/507 precedents. The streaming
ndjson path carries the matching code "r2rml_unsupported_pattern".

Corrected prose names the ACTUAL trigger (a VARIABLE predicate paired with a
BOUND term, e.g. `?s ?p <iri>`) and states which shapes DO work. The Display
keeps the "cannot be converted to R2RML scans" phrase existing prose-matchers
rely on during migration.

Ships UNSWITCHED (parity/correctness fix; DEC-002). Solo gates on the body
@type err:r2rml/UnsupportedPattern at HTTP 400.

Tests: server test asserts 400 + the distinct @type + the migration substring.
The subgraph-crawl planner (detect_wildcard_crawl / build_flat_query) never
reads a top-level `values` clause, so a crawl select-map carrying VALUES had it
SILENTLY DROPPED and returned the ENTIRE class instead of the VALUES-constrained
subjects -- a wrong-answer defect (the handoff's proposed VALUES workaround
returns ~390k rows live), strictly worse than a refusal.

Guard expand_wildcard_crawl: once a query is recognized as a crawl this module
handles, a present top-level `values` is refused with the typed
QueryError::R2rmlUnsupportedPattern envelope (err:r2rml/UnsupportedPattern, HTTP
400) BEFORE any scan, naming the working WHERE-clause alternative. The
loud-or-correct precedent: silent-wrong is the one unacceptable class. Honoring
VALUES on the crawl path is deferred (C2).

A flat-select VALUES query is not a crawl select-map, so it is detected None and
flows unchanged to the normal path where VALUES already works -- regression-
guarded.

Ships UNSWITCHED (correctness fix; DEC-002).

Tests: crawl + top-level VALUES -> typed refusal with zero scans (not whole-class
results); flat-select VALUES -> crawl expander declines (Ok(None)).
…sMaps

A subgraph crawl whose @type names a class that ZERO TriplesMaps declare (e.g.
`@type Commit` on an Iceberg mapping) left the injected wildcard scan
unconstrained, fanning out over every TriplesMap -- a 16-table wildcard scan DNF
-- instead of returning the fast empty result the query logically has (no
subject can carry an unmapped class).

In try_fuse_wildcard_class, when find_maps_for_class(class) is empty, fuse the
(unsatisfiable) class_filter onto the wildcard anyway: the operator then
resolves it to zero TriplesMaps and returns empty with NO scans. The
vertical-partition safety check is moot for an unmapped class (no class-declaring
map exists whose sibling could be dropped), so it is skipped only for that case
-- a mapped-but-unsafe class still runs the safety check and fans out as before.

Ships UNSWITCHED (correctness/perf fix; DEC-002). Does not touch legitimate
wildcard crawls (no @type), multi-class datasets, or class fusion for mapped
classes.

Tests: unmapped-class crawl -> empty result with zero scanned tables; mapped
class still scans exactly its class table.
The #1505 SecretRef insertion (f305df4) placed FlureeBuilder::with_secret_resolver
BETWEEN build()'s doc comment and its #[cfg(feature = "native")] attribute, so:
- both #[cfg(native)] and #[cfg(iceberg)] bound with_secret_resolver, over-gating
  the no-native BYO-IAM SecretRef surface behind native (breaking solo's
  per-lambda fast path, which is iceberg-without-native);
- build() was left cfg-less, so a no-native compile pulled in its native-only body
  (storage_path / FileStorage / FileNameService) and failed to build.

Move with_secret_resolver (its own doc + #[cfg(iceberg)]) after build(), rejoining
build()'s doc + #[cfg(native)]. with_secret_resolver is now gated on iceberg ONLY.

Verified: cargo check -p fluree-db-api --no-default-features --features aws,iceberg,shacl
(solo's real combination) FAILED before and PASSES after; native default + iceberg
still builds. The no-native featureset check is now a permanent gate -- --all-features
and workspace builds structurally mask feature-gating bugs via unification.

Rides the browse-parity wave as a separate commit (DEC-002 scope addition,
lead-verified 2026-07-20). The in-place #1505 patch decision belongs to that PR's
owner; this tip fix unblocks solo immediately.
…lass)

Close the F-AUD-8 886x-PARTIAL residual: the ungrouped, direct-path filtered
COUNT `SELECT (COUNT(*)) WHERE { ?s a edw:Customer ; edw:isCurrent true }` (q038)
materialized at ~7k rows/s (57.6s live) while its grouped sibling q022 and the
var-object star already fused.

Trace verdict: the suspected empty-GROUP-BY cost guard is NOT the cause. A
constant-object triple stays a Pattern::Triple (SPARQL lowering never desugars
`isCurrent true` to `?v` + FILTER), so `filter` is None at detection and the
guard never fires -- q038 is admitted. The real decline is downstream: when a
subject-star carries no variable-object member, the R2RML rewrite emits the
class scan and each const-object constraint as SEPARATE R2rml patterns
(rewrite.rs, the var_members.is_empty() branch), which miss the single-[R2rml]
fused gate in resolve_at_open and fall to a materialize. The multi-constraint
FROM-path sibling q077 has the same no-var-object shape and did not truly fuse
either -- this fix closes both.

Fix (form a -- reuse, not new machinery): combine_constrained_class_scan
recombines that same-subject split back into one class-scan-carrying-
star_constraints pattern -- byte-identical to what the rewrite already folds for
the var-object case (q022) -- so the single-table 9b fold applies each
constraint per row via the existing resolve_star_constraint_checks +
row_satisfies_constraints. It runs before the join arm (a subject-star is not an
FK chain). The cost guard is left intact and documented: it still declines a
genuine residual FILTER, which is a different shape.

Soundness (D-c5, over-count = the one unacceptable outcome): admits ONLY the
exact shape (one subject var, one pure class scan, scalar const-object
equalities); every other shape declines to the existing arms.
resolve_star_constraint_checks still declines any RefObjectMap/non-scalar
constraint to the materialize fallback, and the resulting non-empty
fact_constraints keeps the delete-blind manifest COUNT shortcut declined. No
new constraint source.

Switch: rides FLUREE_FUSED_R2RML_AGG as a widening (DEC-002 policy). With the
fold OFF, detect_fused_r2rml_aggregate returns None, the fused operator is never
built, and q038 reverts to the materialized path.

Corpus: add q078 (Graph-path multi-constraint COUNT -- the direct-path twin of
q077) plus its native oracle (current Enterprise customers = 50038, result-hash
identical to q077). q038's existing native oracle is unchanged.

Refs: audit F-AUD-8, DEC-002.
…(Cluster B)

A select-map with a constant-IRI key (`{"select": {"<iri>": ["*"]}}`, also the
`["@type"]` and forward-predicate-list forms) lowered to Root::Sid and hydrated
against the (absent) native binary index, returning the bare `{"@id": <iri>}`
stub for an EXISTING virtual subject (deployed "subject detail is empty" bug, D4).

Route a constant-IRI root through the proven bound-subject wildcard scan
(`{@id: <iri>, ?p: ?o}`) instead: the R2RML operator prunes it to the subject's
own table via subject-template reversal, and the Cluster-A node-object facade
shapes FK refs as {"@id":…} and literals per format. One pruned scan serves every
projection (applied at assembly), avoiding the inner-join drop a per-predicate
scan suffers when a requested predicate is absent. Absent/unreversible subject →
the native-parity `[{"@id": <iri>}]` stub (native's missing-subject contract).

Behind FLUREE_R2RML_SELECT_MAP_ROUTING (default on) — an insurance switch, since
this re-routes an existing shipping-but-broken path; OFF restores the stub. The
native-ledger path is untouched (maybe_expand_crawl's graph-source gate).
…wl (E1)

The property-scoped browse crawl (`{"select": {"?s": ["*"]}, "where": {"@id":
"?s", "<prop>": "?v"}, "limit": N}`) DNF'd (>90s, D7): it lowers to a selective
const-predicate R2RML scan (`?s <prop> ?v`) plus a variable-predicate FULL-SOURCE
wildcard (`?s ?p ?o`) sharing ?s. Both estimated equal
(DEFAULT_PROPERTY_SCAN_SELECTIVITY), so reorder kept the rewrite's emit order
[wildcard, prop] — leaving the wildcard as the INNER scan with an empty seed,
i.e. an UNBUDGETED full scan over every TriplesMap. The LIMIT budget landed on
the outer const-pred scan and never reached the driving wildcard.

Estimate a pruning-key-less variable-predicate R2RML scan (no bound subject,
class, class-prune hint, or pinned TriplesMap) as FULL_SCAN, so reorder places it
LAST: the selective const-pred scan drives (inner), the wildcard becomes the
LIMIT-budgeted correlated OUTER. Sound — reordering two co-subject scans
preserves the solution set (the top LIMIT truncates the same rows; every driving
subject has >=1 triple so the budget never under-fills); it only lets the
existing budget reach a scan that then terminates. The residual cross-table
over-scan (the correlated wildcard still resolves every TriplesMap) is the
deferred bound-object prune (X2), not a correctness issue.

Behind FLUREE_R2RML_BUDGET_PROPERTY_VAR (default on); OFF reverts to the equal
estimate (byte-identical results, unbudgeted browse).
…B parity)

Native's select-map hydration omits @id from an explicit projection that does not
request it — `{"<iri>": ["@type"]}` returns {"@type": …} (no @id), and a
forward-predicate list returns only the requested predicates. Only the wildcard
(and an explicit `"@id"`) carry @id. The initial Cluster B seeded @id
unconditionally, which would have hash-mismatched the native oracle for the
["@type"] and property-list forms.

Track an explicit `"@id"` request (CrawlProjection::Predicates.want_id) and emit
@id in expand_bound_subject_select_map only for the wildcard / id-only forms and
when want_id. The variable-subject crawl is unchanged (it always emits @id as the
node identity). Caught by blessing the native oracles for q081/q082.
…map harness path

Promote the P3 browse shape-matrix into 7 corpus members and teach the vbench
harness to run JSON-LD select-map crawls (the only way to exercise the crawl
expansion / node-document hydration path — the corpus was SPARQL-only).

Harness: exec.rs detects a JSON-LD body (first non-comment line begins with '{')
and runs it through .jsonld() (crawl expansion on virtual, native hydration on
native) instead of .sparql(); the node-document array result is canonicalized by
the existing canon bare-array branch (key-sorted multiset, so key/node order is
parity-safe). A leading '#' comment header is stripped before parse.

Members (native oracles blessed offline from native-sf01): q079 class-page crawl
(Cluster A, rows_only), q080 subject-detail ["*"] + q081 ["@type"] + q082
property-list (Cluster B, hash-gated Full), q083 property-scoped listing (E1,
rows_only), q084 filtered page (rows_only), q085 inbound-edge count (works-today
shape 9, Full). New closed Tag::Browse; smoke covers it via q080; corpus count,
rows_only, and tag-cover meta-tests updated (78 -> 85 queries, 24 tags).

q038's native leg reproduces UNCHANGED (sanity). Gate record in
docs/audit-impl/browse-parity-gates.md.
…ss-engine

The live subset FAILED-PARITY on q080 (subject-detail crawl) only. Root cause was
the parity gate, not Cluster B: canon::canonicalize_graph hashed each JSON-LD node
with serde_json::to_string WITHOUT sorting its keys — it sorts the row multiset but
not keys within a node. Native hydration emits a node's properties in alphabetical
key order; the R2RML crawl emits them in table-column order. q080 was byte-for-byte
content-identical, only reordered — a false mismatch. q080 is the first Full-gated
cross-engine node-document comparison the corpus asserts (the only prior graph
members, q048/q049 CONSTRUCT, are rows_only; the canon comment noted node hash
equality was 'not yet asserted').

Recursively sort object keys before serializing each node (array / @list element
order preserved — only object keys are sorted). Re-bless q080's oracle with the
corrected canon (--force); q081/q082 were already alphabetical so unchanged, and
the SPARQL-path oracles are untouched. Test: node_key_order_does_not_affect_hash.

Live re-run on the corrected canon: compare --gate = 11 records, 0 hash mismatches,
0 perf violations. Full table in docs/audit-impl/browse-parity-gates.md.
… fused-aggregate comment

R-PARITY nonblocking follow-ups (verdict: SHIP).

(1) Cluster B: add the absent-subject parity test for the NON-wildcard select-map
forms. Native returns [{}] (one empty node) for both a missing subject's
["@type"] and a forward-predicate list — an explicit projection does not seed
@id, and no requested property/type is present, but a node IS emitted for the
requested root. VERIFIED against native-sf01 (a missing customer); Cluster B
already produces the same, so this pins the parity (the wildcard-absent form
keeps its [{"@id"}] stub).

(2) fused_aggregate.rs: correct a stale comment (~:1751). It claimed a
constant-object flag DECLINES before the group-key default loop; post-E2/F1
combine_constrained_class_scan FUSES it onto the class scan as a star_constraints
entry that is APPLIED per row (resolve_star_constraint_checks /
row_satisfies_constraints), so it is applied, not declined. Comment-only touch to
base code — a comment now stating the opposite of behavior is a landmine for the
next reader of the group-key default's soundness argument.

Gates (touched crates): fmt clean; clippy 0 new lints; fluree-db-query all bins
0 failed; fluree-db-api lib 821 + grp_graphsource 122, 0 failed.
@aaj3f

aaj3f commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Consolidating at maintainer request. #1525 (the browse-parity package — native-shape crawl output, bound-root select-map routing, typed refusals, and constrained-COUNT fusion) is carried forward in #1528 (the big-Iceberg-audit implementation), which brings the audit program forward as a single reviewable unit. Its verification of record is recorded under R-PARITY in pr-reviews-impl.md (verdict SHIP; the native-untouched rule holds for every cluster), alongside the whole-stack live gate of 77 records / 0 hash mismatches / 0 perf violations. The pre-consolidation branch tip is preserved at tag archive/pre-refactor-2026-07-21/perf_browse-parity — no history was rewritten. Closing in favor of #1528; discussion continues there.

@aaj3f aaj3f closed this Jul 21, 2026
aaj3f added a commit that referenced this pull request Jul 29, 2026
perf(virtual): big-Iceberg-audit implementation — correctness/capacity guards, coverage widenings, harness re-bless, browse-parity, family-C (consolidates the audit program + #1525)
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.

1 participant