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 ...
Scanned 9/12/2026
Install to Claude Code
npx -y skills add sparq-org/sparq --skill federated-planning --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Federated Planning?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/sparq-org-federated-planning)More formats (shields.io, HTML) on the badges page.
---
name: federated-planning
description: "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."
---
# 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:
1. **which sources can contribute** to each triple pattern, and
2. **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
```toml
[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.
```rust
// `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)
```rust
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`):
```toml
[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:
```rust
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)
```rust
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.
```rust
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** — `RuntimeStats` records 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 cardinality `o` diverges from its estimate `e` past
`ReplanPolicy::divergence_factor` `k` either way (`o > k·e` or `e > k·o`; default `k = 4`),
**or** (sq-b51o) a not-yet-executed pattern's slowest source is observed at more than `k×`
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)` where
`s = slowest_ewma_source_latency / latency_baseline` over the pattern's retained sources.
A source at baseline — **or with no observation** — yields `factor = 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 default `latency_weight = 0.5`, with `latency_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 = 0` disables 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) or
`LatencyAggregation::CardinalityWeighted` — each retained source's latency runs through the
same `factor` formula 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 contributes `1.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_latency` folds each sample in as `ewma = α·observed + (1−α)·prev`
(first sample seeds it), with α = `RuntimeStats::latency_alpha`, default
`DEFAULT_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. The `latency_floor`/`latency_cap` clamp 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 `RuntimeStats` is 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_alpha` gives a source its own smoothing rate; `latency_alpha` is the *fallback*
and `effective_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 α toward `1.0` as 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 whose `source_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.
- **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 hard `max_replans` budget
(default 8). Stable-but-noisy stats — **including jittery latency** — never thrash;
`maybe_replan` returns `ReplanOutcome::{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 → a `Capability` (+ optional
`SourceDescriptor` for *this* planner). The `Capability` also reads the SPARQL 1.2 SD
`sd:supportedVersion <sparql:version-*>` advertisement (`sq-ym6kf`, consuming the server side
`sq-2msb` emits) into `sparql_versions: Vec<SparqlLanguageVersion>`, so a 1.2-aware client can
detect full-1.2 (triple-term / `dir`-lang) support via `Capability::advertises_full_sparql_1_2()`
**without probing**. Honest boundary: an empty set means the source published no
`sd:supportedVersion` ⇒ version posture **UNKNOWN** (older endpoints predate the term), not
unsupported — distinguish the two with `Capability::advertises_sparql_versions()`.
- **Phase 2 source abstraction** (`sq-rsxf`) — the `Endpoint` adapter over the engine's
transport seam behind a default-deny SSRF guard.
- **Port-scoped allowlist entries** (`sq-vbnyc`, follow-up to the engine's `sq-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 `:port` or the scheme default) is the port
vetted, so a `host:port` entry never widens. The fedclient guard delegates the per-entry
decision to the engine's shared `sparq_engine::allowlist_entry_permits` (and
`allowlist_entry_host_matches` for 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 through `EgressGuard::check_addr`
/ `check_endpoint` and all three native ureq SSRF resolvers (`HttpTransport` /
`HttpFragmentTransport` / discovery `HttpFetcher`). There is no wildcard port and no global
bypass; default-deny stays default-deny. Use `EgressGuard::is_allowed_port(host, port)` for the
port-precise check.
- **Phase 3 planner bridge + single-source interpreter** (`sq-j27p`) — the consumer of THIS
planner's `JoinTree`. The plan speaks pattern/source **indices** only (no endpoint-URL
mapping — the Phase-0 finding); `sparq_fedclient::SourceResolver` is the **index → adapter
resolution layer** that maps a plan `pattern: usize` → `TriplePattern` and a `source: usize`
→ a source adapter (the resolver requires the `descriptors`/`adapters` slices to be in the
same order, and range-checks every lookup). `lower_leaf` lowers one BGP pattern to a
single-pattern `SELECT`; `materialize_single_source` walks the `JoinTree`, 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** local
`sparq-engine` evaluation of the same query (`solutions_equal` bag comparison) — is driven
end-to-end in `tests/planner_result_equals_local_eval.rs` (in-memory `Transport` double)
**and, over a REAL in-process `sparq-server` loopback on `127.0.0.1:0`, in
`tests/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-deny* `Endpoint` against the *same*
live loopback host is `FedError::EgressRefused` while the per-endpoint-allowlisted one
reaches the server, so the refusal cannot be a "server is down" artifact; `discovery` is
driven the same way (allowlisted `HttpFetcher` reaches 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_source` is
single-source + blocking (its streaming counterpart is Phase 5 below); a leaf the planner
retained >1 source for fails closed with `InterpError::MultiSource`. To fan such a leaf out
as a per-source UNION use `materialize_multi_source` / `stream_multi_source` (bead `sq-7yf0`,
below) — the opt-in multi-source entry points that never return `MultiSource`.
- **Phase 4 capability-aware pushdown** (`sq-7byx`) — the `pushdown` module 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's
`FilterClass` covers AND that pass the common-variable check, `ORDER`/`LIMIT` when the
capability allows — a full endpoint gets the whole group as one multi-pattern `SELECT`, 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_size` are the cross-group bind-join
block primitive (VALUES for endpoints — `DEFAULT_BIND_BLOCK`; `maxMpR` for brTPF; none for
plain TPF), mirroring `sparq-engine`'s `pub(crate)` `service.rs` helpers. 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-blocking `StreamJoin`. `sparq_fedclient::stream`'s
`SolutionStream` is a bounded, backpressured `Iterator` over a `std::sync::mpsc::sync_channel`
(the channel bound IS the backpressure); `operators::ScatterPool` is a **bounded blocking
thread-pool** over the blocking transport — the ASYNC/RUNTIME decision (no async runtime is
pulled in; all concurrency is `std`-only and confined to the opt-in crate). `StreamingJoin`
drives THIS crate's `StreamJoin` over two `SolutionStream`s, bridging `oxrdf::Term` rows into
the `Tuple` model losslessly via the term's canonical N-Triples form (`Term::Display` ↔
`Term::from_str`). `stream_single_source` walks the same `JoinTree`, 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
in `tests/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_source` lift the single-source `InterpError::MultiSource` guard: 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 candidate `source` index to its adapter through the
`SourceResolver`. The streaming path fans each retained source's fetch onto the same
`ScatterPool`, all feeding ONE per-leaf `SolutionStream` (a cloned `SolutionSink` per source
job). Both fold the per-leaf unions through the unchanged left-deep `natural_join` /
`StreamingJoin`. `tests/multi_source_union_result_equals_local.rs` proves the materialised
AND streamed multi-source result equals local `sparq-engine` evaluation over the union of
every source's graph. The single-source entry points keep the fail-closed `MultiSource`
contract — 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) and `source::BrTpfSource`
(bindings-restricted — push `maxMpR`-bounded binding blocks per request, the standardised
brTPF bind-join), both over the `FragmentTransport` seam, with count-metadata
(`hydra:totalItems`) cardinality surfaced as a one-pattern `SourceDescriptor` for this
planner. The fragment adapters answer one triple pattern **completely** and return typed
`FragBinding`s via `solutions(...)` (a fragment server speaks triples, not
SPARQL-Results-JSON, so their `FederatedSource::execute` is a deliberate `Unsupported` that
points at `solutions`).
- **Native HTTP `FragmentTransport` + interpreter wiring** (`sq-yzca`):
`source::HttpFragmentTransport` (native-only — ureq behind the SAME default-deny SSRF
resolver as the SRJ `HttpTransport`) is the production seam. It serialises a `FragPattern`
into the Hydra TPF query string (`?subject=&predicate=&object=`, percent-encoded N-Triples
terms), attaches a brTPF binding block as the `values` parameter (the server's text wire),
follows the opaque `hydra:next` page URL to exhaustion, and parses the Turtle/TriG body
(oxttl `TriGParser`, 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). The `operators` interpreter is wired:
`fetch_leaf_relation` dispatches on `source_type()`, routing an endpoint/local leaf to the
SRJ `execute` path and a TPF/brTPF leaf to the typed `solutions` path (lowered via
`planner::lower_leaf_fragment`), converting `FragBinding` rows back to `oxrdf::Term` so 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 to `JoinAlgo::Bind`); the streamed per-block bind-join feeder is a later phase.
- **brTPF binding-block wire codec** (the `wire` module, `sq-6ihg`, follow-up to the
server's `sq-dxhb`): the brTPF bind-join attaches a SET of upstream solution mappings (a
`&[FragBinding]` block, at most `maxMpR`) 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-separated `position=term` pairs,
each term fully N-Triples-decorated — which is readable but verbose: it repeats the
`s=`/`p=`/`o=` key and the `<…>`/`"…"`/`^^<…>` framing on every term. The `wire` module
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 same
`FragBinding` model (the server already parses the text one). The binary form's twofold
compactness win: a 1-byte per-mapping header bitmask records which of `s`/`p`/`o` the
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`, ASCII `bTPF`)
+ a 1-byte `BINARY_VERSION` so a future revision is detectable, and `decode_bindings`
validates every length against the remaining input, so a truncated / bad-magic / bad-
version / bad-kind / varint-overflow buffer is a clean `WireError`, never a panic or OOB
read (the crate is `forbid(unsafe_code)`). Position keys decode in a deterministic
canonical `s`→`p`→`o` order, and the empty mapping μ₀ is skipped on encode (it does not
restrict a fragment) exactly as the server's `parse_bindings` skips an all-blank line.
**HONEST scope — a codec only:** it converts `&[FragBinding]` ↔ bytes / `String`; it
issues no request. The native HTTP `FragmentTransport` (`HttpFragmentTransport`, `sq-yzca`,
above) attaches the **text** form on the `values` query parameter (the carrier the server
reads); this binary wire is the compact alternative a body-carrying transport emits.
- **Phase 7 adaptive re-planning** (`sq-ij5x`, the FINAL phase) — the client-side ANAPSID
feedback loop, behind the extra default-OFF **`fedclient-adaptive`** feature (which pulls
this planner's `adaptive-replan`). `adaptive::execute_adaptive_single_source` runs the plan
as a leaf-scan phase (fetch each leaf once through the real adapter, record its REAL observed
row count into `RuntimeStats`) followed by an adaptive join-ordering phase that drives this
crate's `AdaptiveExecutor`: at each operator boundary it re-invokes the planner on the
**unjoined remainder** when an observation diverges past `divergence_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's `AdaptiveExecutor` (the client does not
re-write it); the client supplies real observed cardinalities and joins the re-ordered suffix
with the SAME materialised `natural_join`. Re-planning changes the plan, never the answer:
`tests/adaptive_result_equals_static.rs` asserts 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_source` lifts the adaptive path's `MultiSource` guard
exactly as `sq-7yf0` did 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 via `RuntimeStats::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-in `CardinalityWeighted`
(sq-s5kd) — consumable from a LIVE run, not just planner-level tests, and is the live seam
the deferred source-failover work needs. The final `RuntimeStats` is exposed on
`AdaptiveOutcome::stats` (carry observations across queries / assert what the re-planner
saw). `tests/adaptive_multi_source_result_equals_local.rs` holds the loop to the same
merged-graph engine oracle as `sq-7yf0`, with two distinct live per-arm latencies.
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 `JoinHandle`s 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!