Federated planning
Cost-based federated SPARQL source selection + bind-vs-hash join planning over already-fetched source descriptors, plus an ANAPSID-style non-blocking streaming join with operator spill, via the opt-in sparq-fedplan crate. Use when planning a federated BGP across multiple SPARQL endpoints from their served statistics (VoID property/class partitions + mined scs: characteristic sets): deciding which sources can contribute to each triple pattern (HiBISCuS recall-safe pruning + CostFed skew-aware cardinality), choosing a join order with per-join bind-vs-hash-vs-streaming algorithm selection (characteristic-set star cardinality for intermediate sizes), and executing a memory-bounded non-blocking symmetric hash join over incrementally-arriving sub-results (StreamJoin, spill to a backing store, result multiset-equal to a blocking join). Pure + deterministic planning, no network I/O. Off by default; does not touch sparq-core/sparq-engine's lean build. Also covers live adaptive RE-planning at stage boundaries (mid-execution plan switching when observed cardinalities diverge from estimates, or when a source is observed to be slow — per-source EWMA-smoothed latency is folded into the cost model as a documented heuristic bias toward faster sources, smoothing out transient spikes) via the further opt-in adaptive-replan feature (AdaptiveExecutor) — sound because BGP join is order-independent (latency changes ordering/cost only, never results), with mid-operator swap + live source failover deferred.From its SKILL.md
npx -y skills add sparq-org/sparq --skill federated-planningAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
44.7 KB, as published. Nobody here has run it
sparq-fedplan — cost-based federated source selection + join planning
sparq-fedplan plans a federated SPARQL Basic Graph Pattern (BGP) across several
remote endpoints from statistics already in hand — it never contacts the network. A
caller fetches each source's descriptor once (the W3C VoID document a sparq-server
serves at /.well-known/void, including the mined scs: characteristic sets), and the
planner decides, deterministically:
- which sources can contribute to each triple pattern, and
- a join order + per-join bind-vs-hash algorithm over the selected sources.
It is the opt-in public surface for cost-based federation planning. Add it
explicitly and enable the fedplan feature; it is not in sparq's default build
(sparq-core/sparq-engine stay lean, the wasm artifact is unchanged unless you pull it
in). There is no sparq-core/sparq-engine dependency — it plans over descriptors only.
Add the dependency
[dependencies]
sparq-fedplan = { path = "crates/sparq-fedplan", features = ["fedplan"] }
oxrdf = { version = "0.3", features = ["rdf-12"] }
The whole planner is behind the fedplan feature (off by default), so even a crate that
depends on sparq-fedplan pays nothing for it unless the feature is enabled.
Build source descriptors
Either programmatically via the builder, or by parsing the served N-Triples document.
// `SourceDescriptorBuilder` is the (public, nameable) return type of `.builder(..)`; you only
// need to import it if you hold the builder in a `let` rather than chaining straight to `.build()`.
use sparq_fedplan::{SourceDescriptor, SourceDescriptorBuilder, SourceId, PredPartition, ClassPartition};
// Programmatic (VoID property/class partitions).
let src = SourceDescriptor::builder(SourceId::new("https://a.example/sparql"))
.total_triples(10_000)
.predicate(PredPartition { predicate: "http://xmlns.com/foaf/0.1/knows".into(),
triples: 2000, distinct_subjects: 1000, distinct_objects: 1800 })
.class(ClassPartition { class: "http://xmlns.com/foaf/0.1/Person".into(), entities: 1000 })
.build();
// Or parse the served descriptor (the /.well-known/void N-Triples form, with scs: sets):
let parsed = SourceDescriptor::from_void_nt(SourceId::new("https://b.example/sparql"), nt)?;
A descriptor parsed from VoID partitions is authority-incomplete (it sees only
predicate/class authorities, never subject/object instance authorities), so
subject/object authority-pruning is disabled for it — recall-safe by construction. To
enable authority pruning, build via .builder(..) and call .authorities_complete()
only when you truly enumerate every authority the source mints (a HiBISCuS-style
capability set / void:uriSpace declaration).
A descriptor may also carry an optional GenAI retrieval capability
(RetrievalCapability — declared vector/text retrieval endpoints + a per-request
cardinality hint), served under the sret: vocab (http://sparq.dev/ns/retrieval#,
RETRIEVAL_NS) next to the VoID partitions and round-tripped via
RetrievalCapability::to_void_nt / from_void_nt. It defaults to absent
(descriptor.retrieval() == None) and is advisory planner metadata only:
select_sources consumes it as a STATIC candidate-ordering hint (sources declaring a
cardinalityHint order first, ascending by hint; undeclared keep index order after
them; source index tie-breaks — so with no hints the ordering is the historical
ascending-index order), but it can never change which triples answer a BGP: the
retained-source SET and every estimate are hint-independent (differential-tested; the
same answer-safe discipline as the cardinality estimators). Set it with
.retrieval(..) on the builder. [FABLE-5] sq-3uijg
Select sources for a BGP (recall-safe)
use sparq_fedplan::{Bgp, TriplePattern, Term, Var, select_sources};
let bgp = Bgp::new(vec![
TriplePattern::new(Term::Var(Var::new("s")),
Term::Iri("http://xmlns.com/foaf/0.1/knows".into()), Term::Var(Var::new("o"))),
]);
let sources = [src];
let selection = select_sources(&bgp, &sources);
// selection[i].candidates: the sources retained for pattern i, with estimated_cardinality.
Recall-safety invariant: a source is pruned for a pattern only when the descriptor proves it holds no matching triple. A bound predicate absent from the source's (complete) predicate-partition set prunes; a bound class absent from a declared class section prunes; a bound subject/object whose authority is absent prunes only when the authority set is complete. On any uncertainty — open predicate, incomplete authority set, absent class section — the source is kept. The cardinality estimate never prunes (a source with a tiny or zero estimate is still retained). This is HiBISCuS's design goal: maximise pruning subject to never losing a result.
Optional live pattern probes in sparq-fedclient
sparq-fedplan itself remains pure and never contacts the network. When served VoID statistics
are missing, the separate federation client can refine its PatternSources through the
default-OFF sparq-fedclient/pattern_probe feature (which implies fedclient):
[dependencies]
sparq-fedclient = { path = "crates/sparq-fedclient", features = ["pattern_probe"] }
Create one PatternProbeSession per query, using the same SSRF-policy-controlled Fetcher as
capability discovery, then pass endpoint/optional-descriptor pairs in planner source-index order:
use sparq_fedclient::{
select_sources_with_pattern_probes, PatternProbeConfig, PatternProbeSession, ProbeSource,
};
let fetcher = sparq_fedclient::discovery::HttpFetcher::new();
let mut session = PatternProbeSession::new(&fetcher, PatternProbeConfig::default());
let selection = select_sources_with_pattern_probes(
&bgp,
&[ProbeSource {
endpoint: "https://example.org/sparql",
descriptor: discovered_descriptor.as_ref(),
}],
&mut session,
);
The request budget is per query and counts every ASK/SELECT HTTP request; repeated
source-pattern pairs are cached. Served VoID cardinalities issue no probe. Recall safety is
load-bearing: only an exact ASK false removes a source. Timeout, HTTP/parse error,
inconsistent responses, or budget exhaustion retain it with the uniform fallback. A successful
capped SELECT row count replaces that fallback and can change ranking/join order, never the
answer multiset. [GPT-5.6] sq-fx5id.
Plan the join (bind vs hash)
use sparq_fedplan::{plan_bgp, PlanOptions, JoinAlgo, JoinNode};
let plan = plan_bgp(&bgp, &selection, &sources, &PlanOptions::default()).unwrap();
let order: Vec<usize> = plan.join_order(); // patterns in join order (left-deep)
let cost: f64 = plan.total_cost;
Each binary join is a bind join (cost ≈ L·(req + fan_out) — probe the right with the
left's bindings; cheap when the left is small and the right selective) or a hash /
symmetric join (cost ≈ R + L — scan both sides once; cheap when the left is large or
the right unselective). The decision flips as the left intermediate grows past the point
where per-row requests overtake a full scan; tune the round-trip penalty with
PlanOptions::request_cost. Star arms (?s p₁ ?a . ?s p₂ ?b) use characteristic-set
cardinality (Σ_{C⊇Q} count(C)·Π avg_mult) for intermediate sizes, capturing the
predicate correlation an independence product loses.
Predicate-selectivity-aware non-star cardinality (opt-in, use_predicate_selectivity, sq-jsuzr)
By default a connected non-star join (e.g. a chain ?s :p ?o . ?o :q ?z) falls back to a
coarse independence estimate out = |L|·|R| / max(|L|, |R|) that approximates the join-key
distinct-value count by the larger leaf cardinality. Setting
PlanOptions::use_predicate_selectivity = true instead folds the candidate's actual
per-predicate distinct counts — distinct_subjects when the join variable sits in the
candidate's subject position, distinct_objects when in its object position, summed (union)
over the candidate's retained sources — into the ndv, giving a skew-aware estimate
out = |L|·|R| / Σ distinct(join-key). These are the same VoID stats every SourceDescriptor
already carries. When the relevant distinct count is absent (VoID leaves it 0 when unknown), or
the join key is in neither position, it falls back to the identical max-leaf estimate, so it
never fabricates an ndv. Cost-estimate-only: this changes the planner's cardinality (hence
its join order and bind-vs-hash choice), but never the result multiset — BGP join is
commutative/associative, so any order over the same patterns answers identically. The flag is
OFF by default so the prior estimate is preserved byte-for-byte for A/B comparison; star
arms keep using the characteristic-set estimate regardless. Whether the tighter estimate yields
faster plans is a measurable hypothesis to confirm on the canonical perf host (sq-0g6g), not a
claim made here.
Non-blocking streaming join + spill (StreamJoin, sq-vf7q)
The planner's JoinAlgo::Streaming choice corresponds to an execution-side operator:
StreamJoin, an ANAPSID/XJoin-style symmetric hash join over two
incrementally-arriving tuple streams. Feed tuples from either side as federated sub-results
arrive; each push returns the results that arrival newly completes — it never blocks on
either input finishing.
use sparq_fedplan::{StreamJoin, StreamJoinOptions, SpillStore, Tuple, Var, blocking_hash_join};
let opts = StreamJoinOptions { mem_budget_tuples: 100_000, spill_store: SpillStore::TempFile };
let mut join = StreamJoin::new([Var::new("s")], opts);
let _ = join.push_left(Tuple::new([(Var::new("s"), "a".into()), (Var::new("o"), "1".into())]));
let out = join.push_right(Tuple::new([(Var::new("s"), "a".into()), (Var::new("n"), "x".into())]));
assert_eq!(out.len(), 1); // emitted the moment both sides hold key s=a — non-blocking.
Bounded spill. Memory is capped at mem_budget_tuples; when an insert would exceed it,
the largest in-memory join-key partition is spilled to a backing run (a temp file under
std::env::temp_dir by default — std only, no new dependency; SpillStore::Memory is an
in-process simulation for tests). Spilling only relocates tuples; the probe consults both
the live bucket and every spilled run for the key.
Correctness invariant (load-bearing). The streamed + spilled result is multiset-equal
to blocking_hash_join(left, right, join_vars) — same tuples, no loss, no duplication — for
any stream interleaving and any budget (including one so low every partition spills).
Each matching pair (l, r) is emitted exactly once, when the second of the two arrives and
probes the other side (found in memory or a spill run). Proven by the streamed_equals_*,
spill_path_equals_*, duplicate_keys_*, and emits_before_inputs_exhausted tests.
The planner picks Streaming over plain Hash when a hash-class join's combined estimated
inputs L + R exceed PlanOptions::stream_threshold (default 100 000 rows; set to
f64::INFINITY to always use plain hash) — large joins run non-blocking + spillable rather
than materialising a side up front.
Live adaptive re-planning (opt-in adaptive-replan feature, sq-7s4z)
Behind the off-by-default adaptive-replan cargo feature (which implies fedplan), the
crate adds the reactive half of ANAPSID adaptivity: mid-execution plan switching. A build
that does not enable the feature compiles zero adaptive code (#[cfg]-gated out), so the
lean default build and the fedplan-only build are byte-unchanged.
AdaptiveExecutor models execution as a sequence of stages (the left-deep join order)
and holds, at all times, the patterns already joined (the prefix) and the patterns still
to join (the suffix).
- Capture —
RuntimeStatsrecords the observed per-pattern leaf cardinality (real row counts the sources returned) and per-source latency, fed in as each stage completes. Latency is EWMA-smoothed per source (below) so the cost model + trigger track the trend, not the last raw sample. - Trigger — at each stage boundary,
maybe_replan(&stats)checks whether a not-yet-executed pattern's observed cardinalityodiverges from its estimateepastReplanPolicy::divergence_factorkeither way (o > k·eore > k·o; defaultk = 4), or (sq-b51o) a not-yet-executed pattern's slowest source is observed at more thank×the latency baseline. If so it re-invokes the cost model on the remaining patterns with the observed cardinalities substituted in (corrected_selection) and the join costs latency-weighted (below). Source membership is never re-pruned — only the order changes — so HiBISCuS recall-safety is preserved. - Per-source latency weighting (sq-b51o) — a HEURISTIC, not optimal. A source can be
slow (contended / far / rate-limited) even at exactly its predicted cardinality, so the
re-planner also folds observed latency into the cost. Each candidate join's cost is scaled by
factor = clamp(1 + latency_weight·(s − 1), latency_floor, latency_cap)wheres = slowest_ewma_source_latency / latency_baselineover the pattern's retained sources. A source at baseline — or with no observation — yieldsfactor = 1.0, so a re-plan with no latency data is byte-identical to the cardinality-only planner; a 2×-slow source costs 1.5× at the defaultlatency_weight = 0.5, withlatency_cap(4.0) bounding any one outlier. The constants (latency_weight 0.5,latency_baseline 100,latency_floor 0.5,latency_cap 4.0) are hand-tuned, not derived — a deliberately gentle bias toward faster sources / deferring a slow one, not a claim to compute the latency-optimal plan. Latency enters only the cost term (and the suffix-selection score), never the output cardinality, so results are unchanged.latency_weight = 0disables it. - Per-source vs slowest-arm aggregation (sq-s5kd) — opt-in, default unchanged. How a
multi-source pattern's per-source latencies fold into its single cost factor is now a policy
knob,
ReplanPolicy::latency_aggregation:LatencyAggregation::SlowestArm(default — the sq-b51o bottleneck model above, bit-identical for existing callers) orLatencyAggregation::CardinalityWeighted— each retained source's latency runs through the samefactorformula individually and the pattern is charged the cardinality-weighted mean of the per-source factors (an expected-work model: a tiny slow arm no longer dominates a huge fast arm's pattern; plain mean when every retained cardinality is 0). An unobserved source still contributes1.0, so with no observations both modes are exactly neutral, and single-source patterns are mode-independent. The re-plan trigger stays slowest-arm under both modes (its job is to detect a pathologically slow arm; the weighted cost + hysteresis then decide adoption). Which model wins is workload-dependent (parallel-fetch unions really are bottlenecked; sequential/bind-join work is proportional) — the MECHANISM ships un-tuned for the federation bench harness to compare. - Latency EWMA smoothing (sq-b51o follow-up) — a HEURISTIC α, not optimal. The cost factor
and the trigger read a per-source exponentially-weighted moving average, not the single
last sample:
record_source_latencyfolds each sample in asewma = α·observed + (1−α)·prev(first sample seeds it), with α =RuntimeStats::latency_alpha, defaultDEFAULT_LATENCY_ALPHA = 0.3— a hand-picked factor (latest 30% / history 70%), not workload-derived. This is the cleaner anti-thrash than a bare last-sample-plus-clamp: a single transient spike does not move the average over the trigger band, but a sustained shift converges past it in a few samples. Thelatency_floor/latency_capclamp is kept as a final guard.RuntimeStats::with_latency_alpha(α)overrides (α = 1.0 ⇒ un-smoothed last-sample behaviour); higher α = faster-tracking/twitchier, lower α = calmer/laggier. - EWMA refinements (sq-3xkz) — per-source α, time-aware decay, eviction — all opt-in,
default-off. Three knobs sharpen the EWMA when a
RuntimeStatsis reused across queries; each defaults to off so the default path is byte-identical to the single-global-α EWMA above.- Per-source adaptive α.
RuntimeStats::set_source_alpha(source, α)/with_source_alphagives a source its own smoothing rate;latency_alphais the fallback andeffective_alpha(source)resolves the override-else-global α. An empty override map ⇒ every source uses the global α ⇒ the prior behaviour reproduced. This ships the MECHANISM- a sensible default; choosing a good per-source α needs a real federated workload, so that tuning is deferred — no α value is claimed optimal.
- Time-aware decay. The plain EWMA equal-weights samples regardless of the gap between
them;
record_source_latency_after(source, latency, elapsed)with a half-life set (set_decay_half_life/with_decay_half_life) inflates the effective α toward1.0as the elapsed gap grows past the half-life (α_eff = α₀ + (1−α₀)·(1 − 0.5^(Δt/half_life))), so a fresh sample after a long idle gap is trusted more and stale history decays toward the prior. The elapsed gap is passed in (the logical clock is injectable, never read internally), so the decay is deterministic + testable. No half-life ⇒ folds at the plain α (back-compat). - Staleness / eviction.
evict_stale(max_age)drops every source whosesource_age(clock − last_seen, on the same injectable logical clock) exceeds the threshold (strictly greater; equal is kept), returning the count evicted — so a stale latency stops biasing a long-lived store.advance_clock(elapsed)ages entries without recording a sample.
- Per-source adaptive α.
- Hysteresis — the re-planned suffix is adopted only if its estimated remaining cost
(cardinality- and latency-weighted) beats the current suffix's by more than
ReplanPolicy::improvement_margin(default 10%), with a hardmax_replansbudget (default 8). Stable-but-noisy stats — including jittery latency — never thrash;maybe_replanreturnsReplanOutcome::{NoDivergence, KeptWithinHysteresis, Switched, BudgetExhausted}.
Soundness boundary (load-bearing). Re-planning reorders only the not-yet-started
suffix — it is NOT a mid-operator swap (an in-flight join is never torn down). A BGP
answer is the natural join of the per-pattern solution multisets, which is commutative and
associative: any order over the same patterns yields the same result multiset, and the
already-produced prefix is carried across the switch unchanged (no binding lost or
duplicated). The latency weighting does not move this boundary — it touches only
cost/ordering, never the output cardinality or the pattern set, so a latency-driven reorder is
the same kind of pure suffix permutation; the EWMA smoothing changes only when the latency
path fires, never this boundary — re-planning stays a sub-query / stage-boundary reorder,
never mid-operator. Proven by adaptive::tests::replan_result_equals_static
(cardinality-driven), latency_replan_result_equals_static (latency-driven) and
ewma_replan_result_equals_static (EWMA-smoothed-latency-driven) — each genuinely flips the
order yet yields the identical multiset to the static plan — plus an exhaustive
all-permutations order-independence test.
The federation CLIENT that consumes this planner (sparq-fedclient, sq-dnko)
sparq-fedplan is the planning brain with no consumer — it plans, but nothing
fetches descriptors or issues a query. The consumer is a separate opt-in crate,
sparq-fedclient (epic sq-dnko / sq-3183, architecture research/federation-client-design.md):
the streaming federation client that discovers each remote source's capability, lowers
a query BGP into this crate's Bgp, calls select_sources + plan_bgp, interprets the
resulting JoinTree into physical operators (Bind → VALUES bind-join, Hash/Streaming →
the StreamJoin above, Local → sparq-engine eval), and streams results back. It REUSES
this planner and the engine's service SRJ transport + SSRF guard; the dependency arrow
points one-way into the engine.
sparq-fedclient started as the Phase-0 skeleton (sq-s1uy) — the public module layout
(source / discovery / planner / pushdown / operators / stream) behind a
default-OFF fedclient feature, plus the load-bearing dependency-boundary proof
(sparq-core/sparq-engine have no edge to it, enforced by
scripts/fedclient-boundary-guard.sh + crates/sparq-fedclient/tests/boundary.rs). Landed
since:
- Phase 1 discovery (
sq-nfxl) — Service-Description parser + VoID/scs:reuse + an SSRF-guarded fetch seam + ASK-probe fallback → aCapability(+ optionalSourceDescriptorfor this planner). TheCapabilityalso reads the SPARQL 1.2 SDsd:supportedVersion <sparql:version-*>advertisement (sq-ym6kf, consuming the server sidesq-2msbemits) intosparql_versions: Vec<SparqlLanguageVersion>, so a 1.2-aware client can detect full-1.2 (triple-term /dir-lang) support viaCapability::advertises_full_sparql_1_2()without probing. Honest boundary: an empty set means the source published nosd:supportedVersion⇒ version posture UNKNOWN (older endpoints predate the term), not unsupported — distinguish the two withCapability::advertises_sparql_versions(). - Phase 2 source abstraction (
sq-rsxf) — theEndpointadapter over the engine's transport seam behind a default-deny SSRF guard.- Port-scoped allowlist entries (
sq-vbnyc, follow-up to the engine'ssq-a7jw4):source::EgressGuard's host allowlist now accepts a port-scoped entry (127.0.0.1:8053,[::1]:8080,.example.org:443) that re-opens a private host on THAT exact port only — strictly narrower than a bare host-level entry, which still re-opens every port (backward compatible). The dialled port (the authority's:portor the scheme default) is the port vetted, so ahost:portentry never widens. The fedclient guard delegates the per-entry decision to the engine's sharedsparq_engine::allowlist_entry_permits(andallowlist_entry_host_matchesfor the host-level "is this host on the list at all" query), so the fedclient guard and the engine SERVICE guard decide every host:port case identically — one source of truth, no divergent copy of the parsing (port-0/overflow/IPv6-bracket/ trailing-colon all fail-CLOSED). The same port-scoping flows throughEgressGuard::check_addr/check_endpointand all three native ureq SSRF resolvers (HttpTransport/HttpFragmentTransport/ discoveryHttpFetcher). There is no wildcard port and no global bypass; default-deny stays default-deny. UseEgressGuard::is_allowed_port(host, port)for the port-precise check.
- Port-scoped allowlist entries (
- Phase 3 planner bridge + single-source interpreter (
sq-j27p) — the consumer of THIS planner'sJoinTree. The plan speaks pattern/source indices only (no endpoint-URL mapping — the Phase-0 finding);sparq_fedclient::SourceResolveris the index → adapter resolution layer that maps a planpattern: usize→TriplePatternand asource: usize→ a source adapter (the resolver requires thedescriptors/adaptersslices to be in the same order, and range-checks every lookup).lower_leaflowers one BGP pattern to a single-patternSELECT;materialize_single_sourcewalks theJoinTree, fetches each leaf's SRJ through the Phase-2 adapter, parses it, and natural-joins in the plan's join order. The load-bearing property — the materialised federated result equals localsparq-engineevaluation of the same query (solutions_equalbag comparison) — is driven end-to-end intests/planner_result_equals_local_eval.rs(in-memoryTransportdouble) and, over a REAL in-processsparq-serverloopback on127.0.0.1:0, intests/endpoint_loopback_result_equals_local.rs(sq-my8wd.2): the latter closes the honest gap that the equivalence tests otherwise only exercise the in-memory seam (the SRJ HTTP round-trip + the on-the-wire SSRF guard are never touched), and asserts the load-bearing egress invariant non-vacuously — a sibling default-denyEndpointagainst the same live loopback host isFedError::EgressRefusedwhile the per-endpoint-allowlisted one reaches the server, so the refusal cannot be a "server is down" artifact;discoveryis driven the same way (allowlistedHttpFetcherreaches it via the ASK-probe fallback, a default-deny one is refused). The fedclient guard is owned per-endpoint (no process-global to widen), so the allowlist granted to one endpoint never leaks to another.materialize_single_sourceis single-source + blocking (its streaming counterpart is Phase 5 below); a leaf the planner retained >1 source for fails closed withInterpError::MultiSource. To fan such a leaf out as a per-source UNION usematerialize_multi_source/stream_multi_source(beadsq-7yf0, below) — the opt-in multi-source entry points that never returnMultiSource. - Phase 4 capability-aware pushdown (
sq-7byx) — thepushdownmodule decides the most precise sub-query each source is asked.exclusive_groups(selection, bgp)derives the FedX exclusive groups (maximal connected sub-patterns whose only retained source is one member — exactly-one-source, same-source, share-a-variable, via union-find); a cross-source or zero-source pattern is excluded.push_group(...)builds the maximal sub-algebra per group: projection trimmed to the join + output vars, the FILTER conjuncts the source'sFilterClasscovers AND that pass the common-variable check,ORDER/LIMITwhen the capability allows — a full endpoint gets the whole group as one multi-patternSELECT, a fragment source answers one pattern only (no collapse, no filter pushed — honest about a fragment server's access unit).common_variable_check(filter, group_vars)is the exact check Comunica omits (#834/#609): push a conjunct only when every variable it references is bound by the group.render_values_block/bind_block_sizeare the cross-group bind-join block primitive (VALUES for endpoints —DEFAULT_BIND_BLOCK;maxMpRfor brTPF; none for plain TPF), mirroringsparq-engine'spub(crate)service.rshelpers. Pushdown only ever narrows a source's result, so it is correctness-preserving; the FILTER model is light (the parsed-query FILTER algebra wiring is Phase 5). - Phase 5 streaming operators (
sq-vtba) — the streaming counterpart of the Phase-3 interpreter, built ON THIS crate's non-blockingStreamJoin.sparq_fedclient::stream'sSolutionStreamis a bounded, backpressuredIteratorover astd::sync::mpsc::sync_channel(the channel bound IS the backpressure);operators::ScatterPoolis a bounded blocking thread-pool over the blocking transport — the ASYNC/RUNTIME decision (no async runtime is pulled in; all concurrency isstd-only and confined to the opt-in crate).StreamingJoindrives THIS crate'sStreamJoinover twoSolutionStreams, bridgingoxrdf::Termrows into theTuplemodel losslessly via the term's canonical N-Triples form (Term::Display↔Term::from_str).stream_single_sourcewalks the sameJoinTree, fans each leaf's blocking fetch onto the pool, and chains the leaves through streaming joins so results EMIT before the inputs are exhausted. The load-bearing invariant — the streamed multiset is multiset-equal to the Phase-3 materialised result for any source-arrival interleaving (and both equal local eval) — is driven on the real engine path under injected per-leaf delays + a forced spill intests/streaming_result_equals_phase3.rs. The pushed-down bind-join (VALUES/maxMpR) remains deferred; a bind-classified join runs as the same streaming symmetric hash join (identical result multiset). - Multi-source UNION-per-leaf fan-out (
sq-7yf0) —materialize_multi_source/stream_multi_sourcelift the single-sourceInterpError::MultiSourceguard: a leaf the planner retained >1 source for is answered as the bag-union of every retained source's solutions for that pattern (SPARQL UNION's multiset semantics — concatenation, no de-dup, multiplicity preserved), resolving each candidatesourceindex to its adapter through theSourceResolver. The streaming path fans each retained source's fetch onto the sameScatterPool, all feeding ONE per-leafSolutionStream(a clonedSolutionSinkper source job). Both fold the per-leaf unions through the unchanged left-deepnatural_join/StreamingJoin.tests/multi_source_union_result_equals_local.rsproves the materialised AND streamed multi-source result equals localsparq-engineevaluation over the union of every source's graph. The single-source entry points keep the fail-closedMultiSourcecontract — multi-source is the opt-in entry point. - Phase 6 the brTPF + TPF fragment adapters (
sq-2qze):source::TpfSource(plain TPF — materialise a fragment to exhaustion, no bind-join) andsource::BrTpfSource(bindings-restricted — pushmaxMpR-bounded binding blocks per request, the standardised brTPF bind-join), both over theFragmentTransportseam, with count-metadata (hydra:totalItems) cardinality surfaced as a one-patternSourceDescriptorfor this planner. The fragment adapters answer one triple pattern completely and return typedFragBindings viasolutions(...)(a fragment server speaks triples, not SPARQL-Results-JSON, so theirFederatedSource::executeis a deliberateUnsupportedthat points atsolutions).- Native HTTP
FragmentTransport+ interpreter wiring (sq-yzca):source::HttpFragmentTransport(native-only — ureq behind the SAME default-deny SSRF resolver as the SRJHttpTransport) is the production seam. It serialises aFragPatterninto the Hydra TPF query string (?subject=&predicate=&object=, percent-encoded N-Triples terms), attaches a brTPF binding block as thevaluesparameter (the server's text wire), follows the opaquehydra:nextpage URL to exhaustion, and parses the Turtle/TriG body (oxttlTriGParser, a Turtle superset) — splitting Hydra/VoID control triples (hydra:totalItems/void:triples→ count;hydra:next→ next link) from data triples (kept only when they match the requested pattern). Theoperatorsinterpreter is wired:fetch_leaf_relationdispatches onsource_type(), routing an endpoint/local leaf to the SRJexecutepath and a TPF/brTPF leaf to the typedsolutionspath (lowered viaplanner::lower_leaf_fragment), convertingFragBindingrows back tooxrdf::Termso a fragment leaf equi-joins with an endpoint leaf. A brTPF leaf currently runs as a complete unbound scan the interpreter hash-joins locally (the same discipline the Phase-3 interpreter applies toJoinAlgo::Bind); the streamed per-block bind-join feeder is a later phase. - brTPF binding-block wire codec (the
wiremodule,sq-6ihg, follow-up to the server'ssq-dxhb): the brTPF bind-join attaches a SET of upstream solution mappings (a&[FragBinding]block, at mostmaxMpR) to each fragment request, and that block is re-sent on every request of a bind nested-loop join. The sparq server parses it from a line-oriented TEXT wire — one mapping per line, space-separatedposition=termpairs, each term fully N-Triples-decorated — which is readable but verbose: it repeats thes=/p=/o=key and the<…>/"…"/^^<…>framing on every term. Thewiremodule adds the compact, self-describing BINARY mapping wire the bead asks for (encode_bindings/decode_bindings) the client can emit instead, plus the text-wire writer (encode_bindings_text) so a client can speak EITHER form over the sameFragBindingmodel (the server already parses the text one). The binary form's twofold compactness win: a 1-byte per-mapping header bitmask records which ofs/p/othe mapping binds, so a position term carries no name bytes (the header bit IS the name), and a 1-byte kind tag distinguishes IRI / blank / literal so the bare lexical bytes follow length-prefixed with no<>/""framing. A binding over an arbitrary (non-position) variable name still round-trips losslessly via an overflow EXTRA section, and the binary wire carries literals with embedded=, whitespace, or newlines that the one-mapping- per-line text wire cannot represent (the text writer drops a non-position variable — it has no brTPF slot). The container leads with a 4-byte magic (BINARY_MAGIC, ASCIIbTPF)- a 1-byte
BINARY_VERSIONso a future revision is detectable, anddecode_bindingsvalidates every length against the remaining input, so a truncated / bad-magic / bad- version / bad-kind / varint-overflow buffer is a cleanWireError, never a panic or OOB read (the crate isforbid(unsafe_code)). Position keys decode in a deterministic canonicals→p→oorder, and the empty mapping μ₀ is skipped on encode (it does not restrict a fragment) exactly as the server'sparse_bindingsskips an all-blank line. HONEST scope — a codec only: it converts&[FragBinding]↔ bytes /String; it issues no request. The native HTTPFragmentTransport(HttpFragmentTransport,sq-yzca, above) attaches the text form on thevaluesquery parameter (the carrier the server reads); this binary wire is the compact alternative a body-carrying transport emits.
- a 1-byte
- Native HTTP
- Phase 7 adaptive re-planning (
sq-ij5x, the FINAL phase) — the client-side ANAPSID feedback loop, behind the extra default-OFFfedclient-adaptivefeature (which pulls this planner'sadaptive-replan).adaptive::execute_adaptive_single_sourceruns the plan as a leaf-scan phase (fetch each leaf once through the real adapter, record its REAL observed row count intoRuntimeStats) followed by an adaptive join-ordering phase that drives this crate'sAdaptiveExecutor: at each operator boundary it re-invokes the planner on the unjoined remainder when an observation diverges pastdivergence_factor, adopting the cheaper suffix only when it clears the hysteresis margin — at most once per boundary, no thrash. The re-plan DECISION engine is this planner'sAdaptiveExecutor(the client does not re-write it); the client supplies real observed cardinalities and joins the re-ordered suffix with the SAME materialisednatural_join. Re-planning changes the plan, never the answer:tests/adaptive_result_equals_static.rsasserts the adaptive result equals both the static interpreter and ground-truth local engine eval across a genuine large-divergence switch.- Multi-source (union-arm) adaptive loop (
sq-xw8zz):adaptive::execute_adaptive_multi_sourcelifts the adaptive path'sMultiSourceguard exactly assq-7yf0did for the static/streaming interpreters — a leaf's retained arms are fetched per source (bag-union re-keyed onto the pattern header; a failed arm fails the leaf CLOSED, never a silent arm-drop), each successful arm's wall-clock fetch latency is recorded under its SOURCE index viaRuntimeStats::record_source_latency(a failed arm records nothing — a transport error's duration would make a fast-failing source look attractively fast), and the observed leaf cardinality is the UNION count. That makes the latency-aware cost bias above — slowest-arm default AND the opt-inCardinalityWeighted(sq-s5kd) — consumable from a LIVE run, not just planner-level tests, and is the live seam the deferred source-failover work needs. The finalRuntimeStatsis exposed onAdaptiveOutcome::stats(carry observations across queries / assert what the re-planner saw).tests/adaptive_multi_source_result_equals_local.rsholds the loop to the same merged-graph engine oracle assq-7yf0, with two distinct live per-arm latencies.
- Multi-source (union-arm) adaptive loop (
With Phase 7 the 8-phase streaming federation client is feature-complete (Phases 0–7 all
landed; epic sq-dnko closed). Multi-source UNION-per-leaf fan-out has since landed under
epic sq-3183 (sq-7yf0, above; the ADAPTIVE loop's union-arm counterpart is sq-xw8zz,
above). Still ahead as future beads under epic sq-3183: the pushed-down streaming bind-join,
and the ANAPSID "adaptive operator" refinement (estimate a leaf's cardinality from a prefix
of its rows while still streaming it). See crates/sparq-fedclient/README.md.
Test-quality note — the mutation ratchet is measured features-ON (sq-3dyje.6). The whole
sparq-fedclient surface (and every test file) is #[cfg(feature = "fedclient")]-gated, so
cargo-mutants MUST run it with --features fedclient,fedclient-adaptive — a features-off run
builds an EMPTY crate and reports every mutant as a spurious survivor (the same per-crate quirk
.github/workflows/ci.yml applies to sparq-canon's rdf12-triple-terms and sparq-prov's
reason). The feature-on suite pins EXACT observable values — rendered pushdown sub-queries,
error-variant Display strings, SRJ/Service-Description parse outputs, both SSRF
is_forbidden_ip boundary tables, and native-transport observables driven over a raw in-process
loopback TCP server (a configured timeout actually bounds a stalled request; a >3 MiB body
round-trips under the real byte cap) — so a mutated return value is caught, not merely
executed. A small residue of genuinely-equivalent mutants remains and is documented rather
than papered over: in wire.rs the |↔^ bit-op mutations are all equivalent — the header /
read_varint flag-set |=→^= (each distinct flag bit is set at most once from a zero start /
LEB128 groups occupy non-overlapping shift windows) and the write_varint continuation-bit
byte | 0x80→byte ^ 0x80 (byte is masked to & 0x7f, so bit 7 is provably clear), so
XOR ≡ OR on every reachable input; EgressGuard::deny_private→Default::default() is
equivalent because the struct derives Default and deny_private constructs exactly the empty
allowlist the default does (its own doc says "Equivalent to EgressGuard::default"); the
exclusive_groups union-find inner-loop bound (i + 1)..n→(i * 1)..n is equivalent (the extra
j == i iteration does an idempotent self-union — same source, self-shares-var, union(i,i) is a
no-op — so the group set is unchanged); and the push_group SubQuery-project-FIELD
&&→|| (line ~486) is equivalent because both branches yield an empty Vec in the only case
they differ (output_vars empty ⇒ project empty). The proj_clause && (line ~449) is NOT
equivalent — it is killed by push_group_projection_clause_by_output_var_membership. Finally the
ScatterPool Drop::drop→() is equivalent: after any drop body Rust drops the struct's fields,
and the tx: Option<SyncSender> field-drop closes the channel exactly as the explicit
self.tx.take() does, so detached workers still exit (the un-joined JoinHandles make drop order
unobservable). (ScatterPool::join→() is a DIFFERENT method and is NOT equivalent — its
blocking drain is killed by scatter_pool_join_blocks_until_all_jobs_complete.) These
equivalents are noted, not asserted on — a test that "kills" an equivalent mutant would be vacuous.
Test-quality note — the mutation ratchet for sparq-fedplan is measured features-ON (sq-3dyje.7). The entire sparq-fedplan surface (and every #[cfg(test)] block) is #[cfg(feature = "fedplan")]-gated, so cargo-mutants MUST run with --features fedplan,adaptive-replan — a features-off run builds an empty crate and every mutant trivially survives (the committed 534-surviving / 0-caught baseline was this feature-OFF artefact, mirroring the sparq-fedclient / sparq-canon / sparq-prov quirk). With features ON the ratchet drops to 52 surviving / 415 caught (89 %). Six externally-authored tests in crates/sparq-fedplan/tests/mutation_kill_assertions.rs pin additional observable decisions: plan_bgp refuses an empty BGP with a non-empty selection slice (pattern.rs:114); diverges() requires STRICTLY greater than k × estimate in both directions — o == k*e and e == k*o.max(1) do NOT trigger (two >= mutants killed via AdaptiveExecutor::maybe_replan on a 3-arm star BGP after one advance(), ensuring remaining.len() >= 2 so the short-circuit guard is not hit); evict_stale(0.0) evicts sources with age > 0 without short-circuiting (< 0.0 guard fires only for negative max_age, killed by <= 0.0 mutant); and iri_eq correctly excludes non-scs:CharacteristicSet rdf:type triples from the char-set classification (descriptor.rs:431, killed by → true mutant). A genuinely-equivalent residue of 46 survivors is documented below — these are NOT papered over with assertions. Key categories: (1) spill/budget bookkeeping — StreamJoin::spill_key -=→+=//= and spilled()-path mutations are equivalent because the spill key is only used as a de-duplication marker and any consistent alteration per spill run behaves identically under the existing test load; (2) Tuple key encoding — > 7→>= 7/== 7/< 7 mutations are equivalent because the test data either stays in or out of the short encoding regime under all variants; (3) run_streaming early-exit comparisons — < capacity→>= capacity/> capacity/== capacity mutations survive because the bounded-result test queries always exhaust results before hitting the capacity edge; (4) cost-formula — plan.rs:263/:262/:259/:258 delete-arm / operator mutations survive because the test plans use symmetric estimates that produce the same greedy choice under both forms; (5) plan_suffix_greedy — several <→==/>/<= and delete-arm mutations in adaptive.rs:902–908 are equivalent under the star-BGP test fixture's symmetric or dominated cost ratios; (6) descriptor — char_set builder < → ==/<= boundary mutations survive because the test fixtures use counts that avoid the exact boundary; (7) is_subset +=→*= is equivalent when accumulator values are 0 or 1 (the test uses binary indicator sums); (8) suffix_cost_greedy *→+ in the cost product is equivalent when all cost components are 1.0 (unit estimates). The record_source_latency_after contains_key → true mutant is equivalent because fold_latency's Entry::Vacant arm seeds unconditionally (no alpha applied), so the guard's value on the first call for a fresh source is unobservable. These equivalents are noted, not asserted on. [SONNET-4.6]
Deferred (NOT here)
Mid-operator adaptivity (tearing down a join while it is producing output and resuming its half-built hash tables under a new algorithm) and live source failover (switching to a replica mid-stage when a source goes dark — observed latency now biases the join order toward faster sources, but hard failover needs the live multi-source execution layer this pure crate does not own) are out of scope. Filed as roadmap beads under epic sq-3183.
[OPUS-4.8] sq-a35t / sq-vf7q / sq-7s4z / sq-b51o / sq-7byx — flag for Fable re-review.