---
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.
