Skip to main content

g2g_core/runtime/
solver.rs

1//! M16 step 3 (DESIGN.md §4.13.2): linear-pipeline caps solver.
2//!
3//! Takes the ordered constraint list for a source → transform* → sink
4//! chain and returns one fixated `Caps` per link, or a structured
5//! failure describing which pair couldn't agree.
6//!
7//! The algorithm is arc consistency on a chain: forward pass narrows
8//! every link by each constraint's contribution, backward pass
9//! propagates new narrowing back upstream, repeat to fixed point.
10//! `DerivedOutput` is consulted once its input link has fixated, so
11//! decoders that read dims from SPS slot in naturally. Fixed-point
12//! convergence is guaranteed because every iteration either shrinks at
13//! least one link's candidate set or terminates.
14
15use alloc::boxed::Box;
16use alloc::string::{String, ToString};
17use alloc::vec::Vec;
18
19use crate::caps::{Caps, CapsSet, PassthroughFields};
20use crate::format_element::{CapsConstraint, CapsPreferences};
21use crate::graph::{NodeId, NodeKind, ValidatedGraph};
22use crate::log::{self, LogLevel, Target, CAPS_CATEGORY};
23#[cfg(feature = "std")]
24use crate::runtime::passthrough::project_passthrough_derived;
25use crate::runtime::passthrough::{
26    couple_passthrough_derived, discover_passthrough, project_passthrough,
27};
28
29/// Per-link assignment produced by the solver: one fixated `Caps` per
30/// link between adjacent elements. For an `N`-element pipeline this is
31/// length `N - 1`. The runner calls `configure_link` on element `i`
32/// with `input = links[i - 1]` and `output = links[i]` (sources receive
33/// `None` on input; sinks receive `None` on output).
34pub type LinkSolution = Vec<Caps>;
35
36/// The two candidate sets an [`NegotiationFailure::EmptyLink`] failed on: what
37/// each end of the link still allowed when their intersection came out empty,
38/// so tooling can report *what* disagreed and not just which nodes.
39#[derive(Debug, Clone, PartialEq)]
40pub struct CapsConflict {
41    /// What the upstream end offered.
42    pub upstream: CapsSet,
43    /// What the downstream end demanded.
44    pub downstream: CapsSet,
45}
46
47/// Structured solver failure (DESIGN.md §4.13.2).
48#[derive(Debug, Clone, PartialEq)]
49pub enum NegotiationFailure {
50    /// Adjacent elements have no overlap on the link between them, or a
51    /// constraint update emptied that link.
52    EmptyLink {
53        upstream: usize,
54        downstream: usize,
55        /// The two sides' surviving sets, when the solver had both in hand
56        /// (boxed: only the cold failure path carries it).
57        conflict: Option<Box<CapsConflict>>,
58    },
59    /// The constraint list has fewer than two elements; nothing to
60    /// negotiate.
61    Degenerate,
62    /// First element does not produce (or last does not accept), so the
63    /// chain has no source or no sink.
64    EndpointShapeMismatch { index: usize },
65    /// A link's candidate set survived narrowing but cannot be reduced
66    /// to a single `Caps` (every alternative still has `Any` fields).
67    Unfixable { upstream: usize, downstream: usize },
68    /// Reserved for the non-linear solver. The linear solver never
69    /// returns this variant.
70    Cyclic,
71    /// Arc consistency left a non-empty domain on every edge, but no single
72    /// assignment of one fixated `Caps` per edge satisfies every node at once:
73    /// an over-constrained diamond (a tee whose branches re-converge at a fan-in
74    /// with no jointly-valid choice). Only the DAG solver's backtracking
75    /// fixation returns this.
76    NoConsistentFixation,
77    /// Transitional: the chain mixes `Legacy*` variants with the
78    /// native `Accepts` / `Produces` / `Identity` / `Mapping` /
79    /// `DerivedOutput` variants. Mixed handling is added once step 5
80    /// starts migrating individual elements off the legacy bridge.
81    MixedLegacyAndNative,
82}
83
84impl NegotiationFailure {
85    /// `EmptyLink` naming the two nodes only, for sites that never held both
86    /// sides' sets.
87    pub fn empty_link(upstream: usize, downstream: usize) -> Self {
88        NegotiationFailure::EmptyLink {
89            upstream,
90            downstream,
91            conflict: None,
92        }
93    }
94
95    /// `EmptyLink` carrying the sets each end still allowed.
96    pub fn empty_link_conflict(
97        upstream: usize,
98        downstream: usize,
99        up_set: CapsSet,
100        down_set: CapsSet,
101    ) -> Self {
102        NegotiationFailure::EmptyLink {
103            upstream,
104            downstream,
105            conflict: Some(Box::new(CapsConflict {
106                upstream: up_set,
107                downstream: down_set,
108            })),
109        }
110    }
111
112    /// The two sides' sets, when this failure captured them.
113    pub fn conflict(&self) -> Option<&CapsConflict> {
114        match self {
115            NegotiationFailure::EmptyLink { conflict, .. } => conflict.as_deref(),
116            _ => None,
117        }
118    }
119}
120
121/// Solve a linear chain of caps constraints. See module docs.
122///
123/// `constraints[0]` must be `Produces`; `constraints[N-1]` must be
124/// `Accepts`. Interior elements may be `Identity`, `Mapping`, or
125/// `DerivedOutput`.
126pub fn solve_linear<'a>(
127    constraints: &[&CapsConstraint<'a>],
128) -> Result<LinkSolution, NegotiationFailure> {
129    solve_linear_preferred(constraints, &[])
130}
131
132/// [`solve_linear`] with per-element [`CapsPreferences`], indexed the same way
133/// as `constraints` (a shorter slice, or a `None` entry, means that element
134/// declared none). When some element declares costs the chain fixates to the
135/// least-total-cost consistent assignment instead of each link's own first
136/// choice; when none does, this is [`solve_linear`] exactly.
137pub fn solve_linear_preferred<'a>(
138    constraints: &[&CapsConstraint<'a>],
139    preferences: &[Option<CapsPreferences>],
140) -> Result<LinkSolution, NegotiationFailure> {
141    if constraints.len() < 2 {
142        return Err(NegotiationFailure::Degenerate);
143    }
144
145    // Dispatch: chains made entirely of legacy bridge variants take a
146    // simple forward cascade that mirrors today's runner cascade.
147    // Chains made entirely of native variants take the arc-consistency
148    // path below. Mixed chains aren't handled yet (step 5 starts the
149    // migration).
150    let any_legacy = constraints.iter().any(|c| is_legacy(c));
151    let any_native = constraints.iter().any(|c| !is_legacy(c));
152    if any_legacy && any_native {
153        return solve_mixed_cascade(constraints);
154    }
155    if any_legacy {
156        return solve_legacy_cascade(constraints);
157    }
158
159    let n = constraints.len();
160    let n_links = n - 1;
161
162    // Endpoint shape check.
163    match constraints[0] {
164        CapsConstraint::Produces(_) => {}
165        _ => return Err(NegotiationFailure::EndpointShapeMismatch { index: 0 }),
166    }
167    match constraints[n - 1] {
168        CapsConstraint::Accepts(_) | CapsConstraint::AcceptsAny => {}
169        _ => return Err(NegotiationFailure::EndpointShapeMismatch { index: n - 1 }),
170    }
171
172    // Seed each link with the broadest set we can derive: source's
173    // `Produces` set on link 0, sink's `Accepts` set on link n-2,
174    // everything else starts empty and gets filled by the first sweep.
175    // To allow Identity / Mapping to refine before we know endpoints,
176    // we use a sentinel "unconstrained" representation: an empty set
177    // means "not yet constrained" *only* on the first iteration; after
178    // the first forward pass empty truly means failure.
179    let mut links: Vec<Option<CapsSet>> = alloc::vec![None; n_links];
180
181    // Pre-seed endpoints.
182    if let CapsConstraint::Produces(s) = constraints[0] {
183        links[0] = Some(s.clone());
184    }
185    if let CapsConstraint::Accepts(s) = constraints[n - 1] {
186        let li = n_links - 1;
187        links[li] = match links[li].take() {
188            Some(cur) => Some(cur.intersect(s)),
189            None => Some(s.clone()),
190        };
191    }
192
193    // Arc-consistency loop. Bounded by n_links * max_alternatives,
194    // but practically converges in 1-2 sweeps for chains.
195    let max_iters = 8 * n_links + 4;
196    for _ in 0..max_iters {
197        let snapshot = links.clone();
198
199        // Forward sweep.
200        for (i, c) in constraints.iter().enumerate() {
201            apply_constraint(i, c, &mut links, n_links)?;
202        }
203        // Backward sweep — same logic in reverse promotes downstream
204        // narrowing back upstream (relevant for Identity and Mapping).
205        for (i, c) in constraints.iter().enumerate().rev() {
206            apply_constraint(i, c, &mut links, n_links)?;
207        }
208
209        if links == snapshot {
210            break;
211        }
212    }
213
214    // Validate and fixate.
215    let mut domains = Vec::with_capacity(n_links);
216    for (li, slot) in links.iter().enumerate() {
217        let set = slot
218            .as_ref()
219            .ok_or(NegotiationFailure::empty_link(li, li + 1))?;
220        if set.is_empty() {
221            return Err(NegotiationFailure::empty_link(li, li + 1));
222        }
223        let candidates = fixated_candidates(set);
224        if candidates.is_empty() {
225            return Err(NegotiationFailure::Unfixable {
226                upstream: li,
227                downstream: li + 1,
228            });
229        }
230        domains.push(candidates);
231    }
232
233    // Each link's first candidate is what `CapsSet::fixate` would have picked,
234    // so a chain where nobody declared costs keeps its per-link choice. With
235    // costs declared, the chain-wide minimum can prefer a later candidate.
236    let chain: Vec<ChainNode<'_, '_>> = constraints
237        .iter()
238        .enumerate()
239        .map(|(i, c)| ChainNode::new(c, preference_at(preferences, i)))
240        .collect();
241    if let Some(pick) = min_cost_chain(&chain, &domains) {
242        return Ok(pick
243            .iter()
244            .zip(&domains)
245            .map(|(&j, d)| d[j].clone())
246            .collect());
247    }
248    Ok(domains.into_iter().map(|mut d| d.remove(0)).collect())
249}
250
251fn is_legacy(c: &CapsConstraint<'_>) -> bool {
252    matches!(
253        c,
254        CapsConstraint::LegacySource(_)
255            | CapsConstraint::LegacyTransform { .. }
256            | CapsConstraint::LegacySink(_)
257    )
258}
259
260/// Forward cascade for chains of legacy bridge variants. Mirrors
261/// today's runner: source's `intercept_caps()` seeds a `Caps`; each
262/// transform's `intercept_caps(upstream)` narrows; non-boundary
263/// transforms forward the narrowed input as their output, boundary
264/// transforms call `propose_output_caps`. The sink's `intercept_caps`
265/// produces the final fixated `Caps`. Phase 2 fixate runs once at the
266/// end. Mid-stream `ReFixate` retry stays in the runner.
267fn solve_legacy_cascade(
268    constraints: &[&CapsConstraint<'_>],
269) -> Result<LinkSolution, NegotiationFailure> {
270    let n = constraints.len();
271    let n_links = n - 1;
272
273    // Endpoints must be source/sink shape.
274    let mut current = match constraints[0] {
275        CapsConstraint::LegacySource(caps) => caps.clone(),
276        _ => return Err(NegotiationFailure::EndpointShapeMismatch { index: 0 }),
277    };
278    match constraints[n - 1] {
279        CapsConstraint::LegacySink(_) => {}
280        _ => return Err(NegotiationFailure::EndpointShapeMismatch { index: n - 1 }),
281    }
282
283    let mut links: Vec<Caps> = Vec::with_capacity(n_links);
284    // Interior elements: bit-compatible with the pre-M16 inline
285    // cascade. Each transform contributes ONLY `intercept(upstream)`;
286    // `propose_output_caps` is intentionally NOT called here. In the
287    // legacy single-fixated-caps model, the same `Caps` flows through
288    // every element and the decoder's output-side caps don't appear
289    // until the mid-stream `CapsChanged` lands. The mixed/native
290    // cascade paths use `propose_output_caps` to derive per-link caps;
291    // the legacy cascade leaves that out so chains containing
292    // workaround #2 sinks (waylandsink/kmssink with their
293    // pass-through-then-defer pattern) keep working unchanged.
294    for (i, c) in constraints
295        .iter()
296        .enumerate()
297        .skip(1)
298        .take(n.saturating_sub(2))
299    {
300        match c {
301            CapsConstraint::LegacyTransform {
302                intercept,
303                propose_output: _,
304            } => {
305                current =
306                    intercept(&current).map_err(|_| NegotiationFailure::empty_link(i - 1, i))?;
307                links.push(current.clone());
308            }
309            CapsConstraint::LegacySource(_) | CapsConstraint::LegacySink(_) => {
310                return Err(NegotiationFailure::EndpointShapeMismatch { index: i });
311            }
312            _ => return Err(NegotiationFailure::MixedLegacyAndNative),
313        }
314    }
315    // Sink: cascade through its intercept, then fixate the result.
316    if let CapsConstraint::LegacySink(intercept) = constraints[n - 1] {
317        current = intercept(&current).map_err(|_| NegotiationFailure::empty_link(n - 2, n - 1))?;
318        links.push(current);
319    }
320
321    // Phase 2 fixate the final value, then propagate it to every link
322    // slot. The pre-M16 cascade fed one `fixated` Caps to every
323    // `configure_pipeline` call; honoring that exactly means upstream
324    // slots carry the final fixated caps too. Format-changing
325    // boundaries that need per-link semantics must migrate to the
326    // native solver path (one endpoint at a time) — that's where the
327    // mixed cascade kicks in and the runner gets real per-link caps.
328    let fixed_last = links
329        .last()
330        .ok_or(NegotiationFailure::Degenerate)?
331        .fixate()
332        .map_err(|_| NegotiationFailure::Unfixable {
333            upstream: n - 2,
334            downstream: n - 1,
335        })?;
336    for slot in links.iter_mut() {
337        *slot = fixed_last.clone();
338    }
339
340    Ok(links)
341}
342
343/// Unified forward cascade for chains that mix `Legacy*` and native
344/// (`Produces` / `Accepts` / `Identity` / `Mapping` / `DerivedOutput`)
345/// variants. Handles the migration window where elements move from
346/// the legacy bridge to native constraints one at a time.
347///
348/// Single forward pass: each element computes its output `CapsSet`
349/// from the upstream link's `CapsSet`. Legacy variants and
350/// `DerivedOutput` require the upstream to fixate to a single
351/// concrete `Caps` (which the typical migration chain — single-source
352/// upstream — satisfies). No backward pass: arc-consistency benefits
353/// (Identity / Mapping filtering against downstream sinks) are not
354/// applied in the mixed path. Once a chain is fully native, dispatch
355/// routes it back to the arc-consistency solver, which restores
356/// backward propagation.
357fn solve_mixed_cascade(
358    constraints: &[&CapsConstraint<'_>],
359) -> Result<LinkSolution, NegotiationFailure> {
360    let n = constraints.len();
361    let n_links = n - 1;
362
363    let starts_with_source = matches!(
364        constraints[0],
365        CapsConstraint::Produces(_) | CapsConstraint::LegacySource(_)
366    );
367    let ends_with_sink = matches!(
368        constraints[n - 1],
369        CapsConstraint::Accepts(_) | CapsConstraint::LegacySink(_) | CapsConstraint::AcceptsAny
370    );
371    if !starts_with_source {
372        return Err(NegotiationFailure::EndpointShapeMismatch { index: 0 });
373    }
374    if !ends_with_sink {
375        return Err(NegotiationFailure::EndpointShapeMismatch { index: n - 1 });
376    }
377
378    // link_sets[i] is the CapsSet on the link between element i and i+1.
379    let mut link_sets: Vec<CapsSet> = Vec::with_capacity(n_links);
380
381    // Seed link 0 from the source.
382    let seed = match constraints[0] {
383        CapsConstraint::Produces(s) => s.clone(),
384        CapsConstraint::LegacySource(c) => CapsSet::one(c.clone()),
385        _ => unreachable!("checked above"),
386    };
387    link_sets.push(seed);
388
389    // Forward-propagate through every middle element.
390    for i in 1..(n - 1) {
391        let upstream = link_sets[i - 1].clone();
392        let downstream = forward_propagate(constraints[i], &upstream, i)?;
393        link_sets.push(downstream);
394    }
395
396    // Narrow the final link against the sink endpoint.
397    let final_idx = n_links - 1;
398    let upstream = link_sets[final_idx].clone();
399    let narrowed = match constraints[n - 1] {
400        CapsConstraint::Accepts(s) => upstream.intersect(s),
401        CapsConstraint::AcceptsAny => upstream,
402        CapsConstraint::LegacySink(intercept) => {
403            let fixed = upstream.fixate().ok_or(NegotiationFailure::Unfixable {
404                upstream: n - 2,
405                downstream: n - 1,
406            })?;
407            let c = intercept(&fixed).map_err(|_| NegotiationFailure::empty_link(n - 2, n - 1))?;
408            CapsSet::one(c)
409        }
410        _ => unreachable!("checked above"),
411    };
412    if narrowed.is_empty() {
413        return Err(NegotiationFailure::empty_link(n - 2, n - 1));
414    }
415    link_sets[final_idx] = narrowed;
416
417    // Fixate every link.
418    let mut out = Vec::with_capacity(n_links);
419    for (li, s) in link_sets.iter().enumerate() {
420        let fixed = s.fixate().ok_or(NegotiationFailure::Unfixable {
421            upstream: li,
422            downstream: li + 1,
423        })?;
424        out.push(fixed);
425    }
426    Ok(out)
427}
428
429fn forward_propagate(
430    c: &CapsConstraint<'_>,
431    upstream: &CapsSet,
432    i: usize,
433) -> Result<CapsSet, NegotiationFailure> {
434    match c {
435        CapsConstraint::Identity(s) => {
436            let r = upstream.intersect(s);
437            if r.is_empty() {
438                return Err(NegotiationFailure::empty_link(i - 1, i));
439            }
440            Ok(r)
441        }
442        CapsConstraint::Mapping(pairs) => {
443            let mut out = CapsSet::from_alternatives(Vec::new());
444            for (in_set, out_set) in pairs {
445                let in_match = upstream.intersect(in_set);
446                if !in_match.is_empty() {
447                    out = out.union(out_set);
448                }
449            }
450            if out.is_empty() {
451                // No input alternative matched any mapping row: the input link
452                // (between elements i-1 and i) is the conflict, same as Identity.
453                return Err(NegotiationFailure::empty_link(i - 1, i));
454            }
455            Ok(out)
456        }
457        CapsConstraint::DerivedOutput(f) => derived_forward(f.as_ref(), upstream, i),
458        CapsConstraint::DerivedFields(t) => derived_forward(&|c: &Caps| t.derive(c), upstream, i),
459        CapsConstraint::LegacyTransform {
460            intercept,
461            propose_output,
462        } => {
463            let fixed = upstream.fixate().ok_or(NegotiationFailure::Unfixable {
464                upstream: i - 1,
465                downstream: i,
466            })?;
467            let input = intercept(&fixed).map_err(|_| NegotiationFailure::empty_link(i - 1, i))?;
468            Ok(CapsSet::one(propose_output(&input)))
469        }
470        CapsConstraint::IdentityAny => {
471            // Wildcard transform: pass upstream through unchanged.
472            Ok(upstream.clone())
473        }
474        CapsConstraint::Produces(_)
475        | CapsConstraint::Accepts(_)
476        | CapsConstraint::AcceptsAny
477        | CapsConstraint::LegacySource(_)
478        | CapsConstraint::LegacySink(_) => {
479            Err(NegotiationFailure::EndpointShapeMismatch { index: i })
480        }
481    }
482}
483
484/// One forward hop of the single-caps cascade for a derived transform
485/// (`DerivedOutput` closure or `DerivedFields` declaration): the output is a
486/// function of one concrete input, so the upstream link must fixate first.
487fn derived_forward(
488    f: &dyn Fn(&Caps) -> CapsSet,
489    upstream: &CapsSet,
490    i: usize,
491) -> Result<CapsSet, NegotiationFailure> {
492    let fixed = upstream.fixate().ok_or(NegotiationFailure::Unfixable {
493        upstream: i - 1,
494        downstream: i,
495    })?;
496    let r = f(&fixed);
497    if r.is_empty() {
498        return Err(NegotiationFailure::empty_link(i, i + 1));
499    }
500    Ok(r)
501}
502
503/// Caps-α mid-stream re-fixation outcome for one interior element
504/// (DESIGN.md §4.13.4). The runner derives the element's
505/// forwarded output from its declared constraint, steered by the downstream
506/// feasibility snapshot, instead of letting the element fixate greedily.
507#[derive(Debug, Clone, PartialEq)]
508pub(crate) enum ForwardResolve {
509    /// Runner-derived, downstream-aware output caps to forward.
510    Fixed(Caps),
511    /// The output can't be derived/fixated from the constraint, or there is
512    /// no concrete downstream set to steer against. Fall back to the status
513    /// quo: forward the incoming caps and let the element's own `process`
514    /// derive its output (covers `DerivedOutput` / legacy / ranged outputs).
515    Defer,
516    /// The element's possible outputs positively can't satisfy what the
517    /// downstream subgraph accepts. Loud: the caller drives a reverse
518    /// reconfigure and posts the structured failure.
519    Infeasible(NegotiationFailure),
520}
521
522/// Backward feasibility sweep: per output link, the set it can carry such
523/// that the elements *downstream* of that link can still fixate to the sink,
524/// ignoring the (mid-stream-changing) upstream. `None` on a link means
525/// "downstream imposes no expressible constraint here" (an `AcceptsAny`
526/// sink, or a non-invertible `DerivedOutput` / legacy element below it).
527/// Computed once at startup and snapshotted per interior arm so the
528/// mid-stream re-solve can steer an element's output without reaching the
529/// downstream elements at runtime (DESIGN.md §4.13.4).
530///
531/// Test-only since `run_linear_chain` became a thin builder over `run_graph`
532/// (which uses the edge-indexed [`graph_downstream_feasibility`]); the test
533/// still pins the per-link reverse-sweep behavior.
534#[cfg(all(test, feature = "std"))]
535pub(crate) fn downstream_feasibility(constraints: &[&CapsConstraint<'_>]) -> Vec<Option<CapsSet>> {
536    let n = constraints.len();
537    if n < 2 {
538        return Vec::new();
539    }
540    let n_links = n - 1;
541    let mut feas: Vec<Option<CapsSet>> = alloc::vec![None; n_links];
542    // Seed the sink link from the sink's accept set; a wildcard or legacy
543    // sink leaves it unconstrained.
544    feas[n_links - 1] = match constraints[n - 1] {
545        CapsConstraint::Accepts(s) => Some(s.clone()),
546        _ => None,
547    };
548    // Propagate upstream through each interior transform: the element at
549    // position k+1 sits between link k and link k+1. No startup input sample is
550    // threaded here (this test-only sweep pins the Identity / Mapping / coupled
551    // hops); the real graph path supplies it from the solved edge sets.
552    for k in (0..n_links - 1).rev() {
553        feas[k] = backward_feasible(constraints[k + 1], feas[k + 1].as_ref(), None);
554    }
555    feas
556}
557
558/// One reverse hop of [`downstream_feasibility`]: given the feasible set on
559/// an element's output link, the set its input link can carry. `in_sample` is
560/// a representative input alternative the element fixated to at startup,
561/// available for the closure-probing constraints (`DerivedOutput`) that need a
562/// concrete input to discover their invertible fields; `None` for the others.
563#[cfg(feature = "std")]
564fn backward_feasible(
565    c: &CapsConstraint<'_>,
566    down: Option<&CapsSet>,
567    in_sample: Option<&Caps>,
568) -> Option<CapsSet> {
569    match c {
570        CapsConstraint::Identity(s) => Some(match down {
571            Some(d) => s.intersect(d),
572            None => s.clone(),
573        }),
574        CapsConstraint::IdentityAny => down.cloned(),
575        CapsConstraint::Mapping(pairs) => {
576            let mut acc = CapsSet::from_alternatives(Vec::new());
577            for (in_set, out_set) in pairs {
578                let out_ok = match down {
579                    Some(d) => !out_set.intersect(d).is_empty(),
580                    None => true,
581                };
582                if out_ok {
583                    acc = acc.union(in_set);
584                }
585            }
586            Some(acc)
587        }
588        // A `DerivedFields` transform inverts on its passthrough fields: the
589        // input feasibility is the downstream set with retargeted fields widened
590        // to anything the transform accepts (Dim/Rate -> Any, sample_rate ->
591        // ANY). `project_passthrough` returns `None` for a retargeted scalar with
592        // no wildcard (e.g. videoconvert's format), in which case the input
593        // feasibility isn't expressible as a single `Caps` and we impose none.
594        CapsConstraint::DerivedFields(t) => {
595            let d = down?;
596            let mask = t.passthrough();
597            let mut alts = Vec::with_capacity(d.alternatives().len());
598            for o in d.alternatives() {
599                alts.push(project_passthrough(o, mask)?);
600            }
601            Some(CapsSet::from_alternatives(alts))
602        }
603        // A plain `DerivedOutput` (decoder / rescaler) declares no passthrough
604        // mask, but M257's `discover_passthrough` recovers its invertible fields
605        // by probing the closure on a concrete input. Mid-stream the snapshot has
606        // only the output set; the startup-fixated `in_sample` supplies that probe
607        // (and the input variant / scalar identity, which the output alone can't
608        // give across a decoder's variant change). With a non-empty mask the input
609        // feasibility is the downstream set's passthrough fields projected back onto
610        // the sample's variant, with every non-passthrough (re-derived) field
611        // *widened to `Any`* (`project_passthrough_derived`): the transform
612        // re-derives that field from whatever input it gets mid-stream, so the input
613        // edge stays unconstrained on it. Freezing it to the startup value (M258 v1)
614        // made the snapshot reject a legitimately re-derived mid-stream geometry
615        // (the Caps-β forward gap). An empty mask or no sample imposes none.
616        CapsConstraint::DerivedOutput(f) => {
617            let (d, sample) = (down?, in_sample?);
618            let mask = discover_passthrough(f, sample);
619            if mask == PassthroughFields::NONE {
620                return None;
621            }
622            let mut alts = Vec::with_capacity(d.alternatives().len());
623            for o in d.alternatives() {
624                if let Some(c) = project_passthrough_derived(sample, o, mask) {
625                    if !alts.contains(&c) {
626                        alts.push(c);
627                    }
628                }
629            }
630            (!alts.is_empty()).then(|| CapsSet::from_alternatives(alts))
631        }
632        // Non-invertible (legacy) or non-transform shape: impose no constraint
633        // on the input link.
634        _ => None,
635    }
636}
637
638/// Caps-α: derive the forwarded output for an interior element on a
639/// mid-stream caps change (DESIGN.md §4.13.4). `input` is
640/// the new fixated caps the element receives; `downstream_feasible` is its
641/// output link's snapshot from [`downstream_feasibility`]; `prev_output` is
642/// the output caps the element last produced (startup-solved, then tracked by
643/// the arm). Steers when a concrete downstream set exists; with no downstream
644/// snapshot it still forwards the element's output when that output is
645/// *unambiguous* (the constraint maps the input to exactly one fixated caps).
646/// An ambiguous producible set (a caps-driven converter) keeps the shape it
647/// already produces when that shape is still producible from the new input:
648/// its scalar identity (format / codec / channels) carries over and only the
649/// re-derived geometry / rate changes, so a downstream the snapshot could not
650/// see (feasibility is inexpressible through a retargeting converter) is not
651/// handed the element's *input* caps. The kept shape is forwarded only when
652/// its re-derived fields **equal the input's** (a converter's copied
653/// geometry / rate, unfixed values included); a value the input does not
654/// carry (a nominal rate baked into a decoder's produce set) must not be
655/// forwarded as if produced, so that case, and a previous shape no longer
656/// producible, defer to the element's own `process`.
657pub(crate) fn resolve_forward_output(
658    constraint: &CapsConstraint<'_>,
659    input: &Caps,
660    downstream_feasible: Option<&CapsSet>,
661    prev_output: Option<&Caps>,
662) -> ForwardResolve {
663    // Index 1 is a placeholder: the link position is meaningful only inside
664    // a full-chain solve. Mid-stream the failure is link-local to this arm.
665    // `forward_propagate` fixates the input first (the startup contract), which
666    // rejects a mid-stream caps carrying a field the element never learned (a
667    // Matroska track with no DefaultDuration flows framerate `Any`); the
668    // Derived closures take the caps as-is, so probe them directly then.
669    let candidates = forward_propagate(constraint, &CapsSet::one(input.clone()), 1)
670        .ok()
671        .or_else(|| match constraint {
672            CapsConstraint::DerivedOutput(f) => Some(f(input)),
673            CapsConstraint::DerivedFields(t) => Some(t.derive(input)),
674            _ => None,
675        })
676        .filter(|c| !c.is_empty());
677    let Some(candidates) = candidates else {
678        return ForwardResolve::Defer;
679    };
680    // The previous output's scalar identity with geometry / rate widened: the
681    // shape to prefer among ambiguous candidates. `project_passthrough` keeps
682    // the masked scalars and widens the rest to `Any`; a variant it cannot
683    // express (Text / Bytestream) yields no preference.
684    let keep_shape = prev_output.and_then(|p| {
685        project_passthrough(p, PassthroughFields::NONE.with_format().with_channels())
686    });
687    // A kept-shape survivor is forwardable only when its re-derived fields
688    // EQUAL the new input's (a converter's copied geometry / rate, unfixed
689    // values included: a decoder emitting no framerate stays that way through
690    // the converter). A nominal fixate-fallback alternative in a decoder's
691    // produce set (vorbisdec's 48 kHz against a 44.1 kHz input) is not
692    // input-derived, and forwarding it would announce a value the element
693    // never produces.
694    fn tracks_input(survivor: &Caps, input: &Caps) -> bool {
695        match (survivor.dims(), input.dims()) {
696            (Some(s), Some(i)) => s == i,
697            _ => match (survivor, input) {
698                (
699                    Caps::Audio {
700                        channels: s_ch,
701                        sample_rate: s_rate,
702                        ..
703                    },
704                    Caps::Audio {
705                        channels: i_ch,
706                        sample_rate: i_rate,
707                        ..
708                    },
709                ) => s_rate == i_rate && s_ch == i_ch,
710                _ => false,
711            },
712        }
713    }
714    let fixate_kept_shape = |set: &CapsSet| -> Option<Caps> {
715        let shape = keep_shape.as_ref()?;
716        set.intersect(&CapsSet::one(shape.clone()))
717            .alternatives()
718            .iter()
719            .find(|c| tracks_input(c, input))
720            .cloned()
721    };
722    // a Derived closure computed the candidate from this input, so the element
723    // really does produce it, changed geometry included
724    let derives_from_input = matches!(
725        constraint,
726        CapsConstraint::DerivedOutput(_) | CapsConstraint::DerivedFields(_)
727    );
728    let Some(d) = downstream_feasible else {
729        return match candidates.alternatives() {
730            // Unambiguous: forward the one output, fixated, or as-is when an
731            // unlearned input field (Any rate) blocks fixation but the output is
732            // input-derived or otherwise tracks the input.
733            [one] => match candidates.fixate() {
734                Some(c) => ForwardResolve::Fixed(c),
735                None if derives_from_input || tracks_input(one, input) => {
736                    ForwardResolve::Fixed(one.clone())
737                }
738                None => ForwardResolve::Defer,
739            },
740            // Ambiguous with nothing to steer by: keep the previous output
741            // shape if still producible, else the status-quo Defer (never
742            // pick an arbitrary alternative here).
743            _ => match fixate_kept_shape(&candidates) {
744                Some(c) => ForwardResolve::Fixed(c),
745                None => ForwardResolve::Defer,
746            },
747        };
748    };
749    let narrowed = candidates.intersect(d);
750    if narrowed.is_empty() {
751        return ForwardResolve::Infeasible(NegotiationFailure::empty_link(0, 1));
752    }
753    match fixate_kept_shape(&narrowed)
754        .or_else(|| narrowed.fixate())
755        .or_else(|| match narrowed.alternatives() {
756            // Unambiguous but unfixatable: an input field the element never
757            // learned (a decoder announcing its geometry before it knows the
758            // framerate) leaves the derived output partly unfixed. Deferring
759            // here would forward the element's INPUT, which misreports what a
760            // geometry-changing element produces; downstream has already
761            // accepted this shape, so send the element's own output on.
762            [one] => Some(one.clone()),
763            _ => None,
764        }) {
765        Some(c) => ForwardResolve::Fixed(c),
766        None => ForwardResolve::Defer,
767    }
768}
769
770fn apply_constraint(
771    i: usize,
772    c: &CapsConstraint<'_>,
773    links: &mut [Option<CapsSet>],
774    n_links: usize,
775) -> Result<(), NegotiationFailure> {
776    let in_idx = if i == 0 { None } else { Some(i - 1) };
777    let out_idx = if i == n_links { None } else { Some(i) };
778
779    match c {
780        CapsConstraint::Produces(s) => {
781            if let Some(idx) = out_idx {
782                narrow(links, idx, s, i, i + 1)?;
783            }
784        }
785        CapsConstraint::Accepts(s) => {
786            if let Some(idx) = in_idx {
787                narrow(links, idx, s, i - 1, i)?;
788            }
789        }
790        CapsConstraint::Identity(s) => {
791            // Input link and output link both narrowed by S, and must
792            // equal each other (pass-through).
793            if let Some(idx) = in_idx {
794                narrow(links, idx, s, i - 1, i)?;
795            }
796            if let Some(idx) = out_idx {
797                narrow(links, idx, s, i, i + 1)?;
798            }
799            // Couple the two sides: each side ∩= the other.
800            if let (Some(ii), Some(oi)) = (in_idx, out_idx) {
801                let (a, b) = (links[ii].clone(), links[oi].clone());
802                if let (Some(a), Some(b)) = (a, b) {
803                    let coupled = a.intersect(&b);
804                    if coupled.is_empty() {
805                        return Err(NegotiationFailure::empty_link(i - 1, i + 1));
806                    }
807                    links[ii] = Some(coupled.clone());
808                    links[oi] = Some(coupled);
809                }
810            }
811        }
812        CapsConstraint::Mapping(pairs) => {
813            let (Some(ii), Some(oi)) = (in_idx, out_idx) else {
814                return Err(NegotiationFailure::EndpointShapeMismatch { index: i });
815            };
816            // Filter pairs to those still consistent on both sides.
817            let mut new_in = CapsSet::from_alternatives(Vec::new());
818            let mut new_out = CapsSet::from_alternatives(Vec::new());
819            for (in_set, out_set) in pairs {
820                let in_match = match &links[ii] {
821                    Some(cur) => cur.intersect(in_set),
822                    None => in_set.clone(),
823                };
824                let out_match = match &links[oi] {
825                    Some(cur) => cur.intersect(out_set),
826                    None => out_set.clone(),
827                };
828                if !in_match.is_empty() && !out_match.is_empty() {
829                    new_in = new_in.union(&in_match);
830                    new_out = new_out.union(&out_match);
831                }
832            }
833            if new_in.is_empty() || new_out.is_empty() {
834                return Err(NegotiationFailure::empty_link(i - 1, i + 1));
835            }
836            links[ii] = Some(new_in);
837            links[oi] = Some(new_out);
838        }
839        CapsConstraint::DerivedOutput(f) => {
840            let (Some(ii), Some(oi)) = (in_idx, out_idx) else {
841                return Err(NegotiationFailure::EndpointShapeMismatch { index: i });
842            };
843            // Forward (M188): narrow the output by the union of `f` over every
844            // input alternative. For a single fixated input this is just
845            // `f(input)` (the M185/M186 single-transform behaviour); for a still
846            // ambiguous input (a stacked auto transform whose upstream hasn't
847            // fixated) it still produces an output to narrow, so the second
848            // transform's output link no longer stalls at `None`.
849            if let Some(input_set) = &links[ii] {
850                let derived = forward_derived_union(f.as_ref(), input_set);
851                if derived.is_empty() {
852                    return Err(NegotiationFailure::empty_link(i, i + 1));
853                }
854                narrow(links, oi, &derived, i, i + 1)?;
855            }
856            // Backward (M188 + invertible-field coupling): probe the closure for
857            // passthrough fields and narrow the input field-by-field on them
858            // (a downstream geometry / framerate pin couples back through a
859            // decoder); otherwise drop input alternatives that can't reach the
860            // output, so stacked auto transforms resolve.
861            if let (Some(in_set), Some(out_set)) = (links[ii].clone(), links[oi].clone()) {
862                match derived_backward(f.as_ref(), &in_set, &out_set) {
863                    Ok(Some(narrowed)) => links[ii] = Some(narrowed),
864                    Ok(None) => {}
865                    Err(()) => return Err(NegotiationFailure::empty_link(i - 1, i)),
866                }
867            }
868        }
869        CapsConstraint::DerivedFields(t) => {
870            let (Some(ii), Some(oi)) = (in_idx, out_idx) else {
871                return Err(NegotiationFailure::EndpointShapeMismatch { index: i });
872            };
873            let derive = |c: &Caps| t.derive(c);
874            // Forward: identical to `DerivedOutput` (the declaration is the source
875            // of truth for forward derivation).
876            if let Some(input_set) = &links[ii] {
877                let derived = forward_derived_union(&derive, input_set);
878                if derived.is_empty() {
879                    return Err(NegotiationFailure::empty_link(i, i + 1));
880                }
881                narrow(links, oi, &derived, i, i + 1)?;
882            }
883            // Backward: field-level coupling, narrowing passthrough fields *within*
884            // an alternative (the unblock over `DerivedOutput`'s alternative-drop).
885            if let (Some(in_set), Some(out_set)) = (links[ii].clone(), links[oi].clone()) {
886                match backward_field_narrow(&derive, t.passthrough(), &in_set, &out_set) {
887                    Ok(Some(narrowed)) => links[ii] = Some(narrowed),
888                    Ok(None) => {}
889                    Err(()) => return Err(NegotiationFailure::empty_link(i - 1, i)),
890                }
891            }
892        }
893        CapsConstraint::AcceptsAny => {
894            // Wildcard sink: no narrowing. The link feeding this sink
895            // takes whatever shape upstream produces. The endpoint
896            // check enforces that this only appears at the chain's
897            // tail, so no further work is needed here.
898        }
899        CapsConstraint::IdentityAny => {
900            // Wildcard transform: don't narrow either side by a set
901            // (there is no set), just couple input and output to be
902            // equal. Either side's current value determines both.
903            if let (Some(ii), Some(oi)) = (in_idx, out_idx) {
904                let (a, b) = (links[ii].clone(), links[oi].clone());
905                match (a, b) {
906                    (Some(a), Some(b)) => {
907                        let coupled = a.intersect(&b);
908                        if coupled.is_empty() {
909                            return Err(NegotiationFailure::empty_link(i - 1, i + 1));
910                        }
911                        links[ii] = Some(coupled.clone());
912                        links[oi] = Some(coupled);
913                    }
914                    (Some(a), None) => links[oi] = Some(a),
915                    (None, Some(b)) => links[ii] = Some(b),
916                    (None, None) => {}
917                }
918            }
919        }
920        CapsConstraint::LegacySource(_)
921        | CapsConstraint::LegacyTransform { .. }
922        | CapsConstraint::LegacySink(_) => {
923            // Dispatch in `solve_linear` routes all-legacy chains to
924            // `solve_legacy_cascade` and rejects mixed chains, so the
925            // arc-consistency path never sees a legacy variant.
926            return Err(NegotiationFailure::MixedLegacyAndNative);
927        }
928    }
929    Ok(())
930}
931
932fn narrow(
933    links: &mut [Option<CapsSet>],
934    idx: usize,
935    contrib: &CapsSet,
936    upstream: usize,
937    downstream: usize,
938) -> Result<(), NegotiationFailure> {
939    let next = match &links[idx] {
940        Some(cur) => cur.intersect(contrib),
941        None => contrib.clone(),
942    };
943    if next.is_empty() {
944        return Err(NegotiationFailure::empty_link(upstream, downstream));
945    }
946    links[idx] = Some(next);
947    Ok(())
948}
949
950/// Per-node constraint for [`solve_graph`]. Source/transform/sink carry a
951/// single [`CapsConstraint`]; a fan-in muxer carries a per-input-pad constraint
952/// plus its output constraint. A tee is structural, so its slot is ignored
953/// (pass any `Element`).
954#[derive(Debug)]
955pub enum NodeConstraint<'a> {
956    /// Source (`Produces`), transform (`Identity` / `Mapping` /
957    /// `DerivedOutput` / `IdentityAny`), or sink (`Accepts` / `AcceptsAny`).
958    Element(CapsConstraint<'a>),
959    /// Fan-in muxer: `inputs[i]` is input pad `i`'s constraint (`Accepts` to
960    /// narrow that pad, `AcceptsAny` for a wildcard pad that forwards
961    /// per-frame caps), and `output` is the single output pad's `Produces`
962    /// constraint. This is the per-pad shape real muxer elements expose
963    /// (`MultiInputElement::caps_constraint_as_input` / `_for_output`), so the
964    /// DAG runner builds it straight from the element.
965    /// `follows` is `Some(pad)` for an identity-passthrough mux (an overlay /
966    /// watermark) whose output caps are that input pad's negotiated caps: the
967    /// solver derives the output edge from that input edge and `output` is unused
968    /// (a placeholder). `None` is the usual independent output declared by
969    /// `output` (a container interleave, a fixed compositor).
970    Muxer {
971        inputs: Vec<CapsConstraint<'a>>,
972        output: CapsConstraint<'a>,
973        follows: Option<usize>,
974    },
975    /// Fan-out demux (M380): `input` is the byte-stream input constraint (the
976    /// container the demux consumes), and `ports[i]` is output port `i`'s
977    /// `Produces` constraint (its distinct elementary stream). Unlike a broadcast
978    /// tee (which couples in == every out), the ports are *decoupled*, so each
979    /// branch negotiates against its own caps and a downstream decoder configures
980    /// against its codec at startup. Built from a demux element that declares
981    /// per-port caps (`MultiOutputElement::port_output_caps`); a broadcast fan-out
982    /// (declaring none) stays a plain tee.
983    Demux {
984        input: CapsConstraint<'a>,
985        ports: Vec<CapsConstraint<'a>>,
986    },
987}
988
989/// Solve caps for an arbitrary DAG (DESIGN_TODO "DAG runner" D2). Generalizes
990/// [`solve_linear`]'s arc-consistency sweep to topological order over a
991/// [`ValidatedGraph`]: each edge is a link variable, narrowed by the
992/// constraints of the nodes at both ends, swept forward in topo order and
993/// backward in reverse to a fixed point, then fixated. Returns one fixated
994/// `Caps` per edge, indexed by edge id.
995///
996/// `constraints` is indexed by node id (length must equal the node count). A
997/// tee fans its input caps out to every output unchanged (its slot is
998/// ignored); a muxer narrows each input edge by its pad's accept set and its
999/// single output edge by the produce set.
1000pub fn solve_graph<E>(
1001    graph: &ValidatedGraph<E>,
1002    constraints: &[NodeConstraint<'_>],
1003) -> Result<Vec<Caps>, NegotiationFailure> {
1004    // Default node labels for the caps explainer: `n{id}:{kind}`. A caller with
1005    // element names (the runner) uses `solve_graph_labeled` for prettier output.
1006    solve_graph_labeled(graph, constraints, &|n| node_label_default(graph, n))
1007}
1008
1009/// [`solve_graph`] with caller-supplied node labels for the caps-negotiation
1010/// explainer (DESIGN.md 4.20a). The runner passes each node's element category
1011/// (e.g. `h264parse`) so the `G2G_CAPS_TRACE` narration reads in element names
1012/// rather than node ids; direct callers use [`solve_graph`]'s `n{id}:{kind}`
1013/// default. The solve itself is identical; `label` only affects the log text,
1014/// and all formatting is skipped unless the [`CAPS_CATEGORY`] is enabled.
1015pub fn solve_graph_labeled<E>(
1016    graph: &ValidatedGraph<E>,
1017    constraints: &[NodeConstraint<'_>],
1018    label: &dyn Fn(NodeId) -> String,
1019) -> Result<Vec<Caps>, NegotiationFailure> {
1020    solve_graph_preferred(graph, constraints, &[], label)
1021}
1022
1023/// [`solve_graph_labeled`] with each node's declared [`CapsPreferences`],
1024/// indexed by node id (a shorter slice, or a `None` entry, means that element
1025/// declared none). On a linear chain, and only when some element declares
1026/// costs, fixation picks the consistent assignment of least total cost rather
1027/// than the first the greedy backtrack finds; every other graph, and every
1028/// chain with nothing declared, fixates exactly as [`solve_graph_labeled`].
1029pub fn solve_graph_preferred<E>(
1030    graph: &ValidatedGraph<E>,
1031    constraints: &[NodeConstraint<'_>],
1032    preferences: &[Option<CapsPreferences>],
1033    label: &dyn Fn(NodeId) -> String,
1034) -> Result<Vec<Caps>, NegotiationFailure> {
1035    let n = graph.node_count();
1036    if n < 2 || constraints.len() != n {
1037        return Err(NegotiationFailure::Degenerate);
1038    }
1039    let ne = graph.edge_count();
1040    let mut edges: Vec<Option<CapsSet>> = alloc::vec![None; ne];
1041
1042    let t = Target::category(CAPS_CATEGORY);
1043    let trace = log::enabled(CAPS_CATEGORY, LogLevel::Debug);
1044    if trace {
1045        crate::g2g_debug!(t, "negotiating {n} nodes, {ne} edges:");
1046        for (i, c) in constraints.iter().enumerate() {
1047            crate::g2g_debug!(t, "  {} {}", label(NodeId(i as u32)), fmt_constraint(c));
1048        }
1049    }
1050
1051    // Narrate a structured failure once, on the way out: name the conflicting
1052    // nodes and dump the current set on every edge incident to them, so a
1053    // `CapsMismatch` reads as "these two can't agree, here's what each wanted".
1054    // Emitted at error level (visible in any run with a sink, not just a trace),
1055    // but only formatted on the rare failure path.
1056    let report = |f: &NegotiationFailure, edges: &[Option<CapsSet>]| match f {
1057        NegotiationFailure::EmptyLink {
1058            upstream,
1059            downstream,
1060            ..
1061        } => {
1062            let (up, down) = (NodeId(*upstream as u32), NodeId(*downstream as u32));
1063            crate::g2g_error!(
1064                t,
1065                "no caps overlap between {} and {}",
1066                label(up),
1067                label(down)
1068            );
1069            for (id, slot) in edges.iter().enumerate() {
1070                let e = graph.edge(id);
1071                if [e.src.node, e.dst.node]
1072                    .iter()
1073                    .any(|&x| x == up || x == down)
1074                {
1075                    crate::g2g_error!(
1076                        t,
1077                        "  {} -> {}: {}",
1078                        label(e.src.node),
1079                        label(e.dst.node),
1080                        fmt_set_opt(slot)
1081                    );
1082                }
1083            }
1084        }
1085        other => crate::g2g_error!(t, "negotiation failed: {other:?}"),
1086    };
1087
1088    // Same convergence bound as the linear solver, generalized to edges.
1089    let max_iters = 8 * ne + 4;
1090    for _ in 0..max_iters {
1091        let snapshot = edges.clone();
1092        for &node in graph.topo() {
1093            if let Err(f) = apply_node(graph, node, constraints, &mut edges) {
1094                report(&f, &edges);
1095                return Err(f);
1096            }
1097        }
1098        for &node in graph.topo().iter().rev() {
1099            if let Err(f) = apply_node(graph, node, constraints, &mut edges) {
1100                report(&f, &edges);
1101                return Err(f);
1102            }
1103        }
1104        if edges == snapshot {
1105            break;
1106        }
1107    }
1108
1109    // Build a per-edge candidate domain (each surviving alternative, fixated, in
1110    // the set's own preference order so the first candidate is exactly what the
1111    // old per-edge `fixate()` would have picked). Then assign one candidate per
1112    // edge by backtracking search so the chosen combination is *globally*
1113    // consistent. Arc consistency above narrows each edge against its neighbours
1114    // pairwise, but a diamond (a tee whose branches re-converge at a fan-in)
1115    // couples branch choices in a way pairwise narrowing cannot see, so per-edge
1116    // greedy fixation can pick a locally-valid yet jointly-impossible combination
1117    // (e.g. two branches that map the shared tee value to different outputs whose
1118    // alternative orders disagree). The search tries the greedy choice first, so a
1119    // chain or an independent fan-out fixates byte-for-byte as before and only a
1120    // genuinely coupled diamond ever explores alternatives.
1121    let mut domains: Vec<Vec<Caps>> = Vec::with_capacity(ne);
1122    for (id, slot) in edges.iter().enumerate() {
1123        let (up, down) = edge_endpoints(graph, id);
1124        let set = match slot.as_ref() {
1125            Some(s) if !s.is_empty() => s,
1126            _ => {
1127                let f = NegotiationFailure::empty_link(up, down);
1128                report(&f, &edges);
1129                return Err(f);
1130            }
1131        };
1132        let doms = fixated_candidates(set);
1133        if doms.is_empty() {
1134            crate::g2g_error!(
1135                t,
1136                "{} -> {}: {} ✗ cannot fixate (still ambiguous after narrowing)",
1137                label(graph.edge(id).src.node),
1138                label(graph.edge(id).dst.node),
1139                fmt_set(set)
1140            );
1141            return Err(NegotiationFailure::Unfixable {
1142                upstream: up,
1143                downstream: down,
1144            });
1145        }
1146        domains.push(doms);
1147    }
1148
1149    let mut assign: Vec<Option<Caps>> = alloc::vec![None; ne];
1150    if let Some(chosen) = preferred_chain_assignment(graph, constraints, preferences, &domains) {
1151        assign = chosen;
1152    } else if !fixate_backtrack(graph, constraints, &domains, &mut assign, 0) {
1153        let f = NegotiationFailure::NoConsistentFixation;
1154        report(&f, &edges);
1155        return Err(f);
1156    }
1157    let out: Vec<Caps> = assign
1158        .into_iter()
1159        .map(|a| a.expect("every edge assigned"))
1160        .collect();
1161    if trace {
1162        for (id, c) in out.iter().enumerate() {
1163            let e = graph.edge(id);
1164            crate::g2g_debug!(
1165                t,
1166                "{} -> {}: {} ✓ -> {}",
1167                label(e.src.node),
1168                label(e.dst.node),
1169                fmt_set(edges[id].as_ref().expect("edge set present")),
1170                c.to_gst_string()
1171            );
1172        }
1173    }
1174    Ok(out)
1175}
1176
1177/// Every alternative of `set` that collapses to a concrete `Caps`, deduped and
1178/// in the set's own preference order, so the first entry is exactly what
1179/// [`CapsSet::fixate`] would return and an empty result means the same as its
1180/// `None`.
1181fn fixated_candidates(set: &CapsSet) -> Vec<Caps> {
1182    let mut out: Vec<Caps> = Vec::new();
1183    for alt in set.alternatives() {
1184        if let Ok(c) = alt.fixate() {
1185            if !out.contains(&c) {
1186                out.push(c);
1187            }
1188        }
1189    }
1190    out
1191}
1192
1193/// `preferences[i]`, tolerating a slice shorter than the chain.
1194fn preference_at(
1195    preferences: &[Option<CapsPreferences>],
1196    index: usize,
1197) -> Option<&CapsPreferences> {
1198    preferences.get(index).and_then(Option::as_ref)
1199}
1200
1201/// One element of a linear chain, paired with what it is willing to pay for
1202/// each of its advertised alternatives.
1203struct ChainNode<'c, 'a> {
1204    constraint: &'c CapsConstraint<'a>,
1205    preferences: Option<&'c CapsPreferences>,
1206}
1207
1208impl<'c, 'a> ChainNode<'c, 'a> {
1209    fn new(constraint: &'c CapsConstraint<'a>, preferences: Option<&'c CapsPreferences>) -> Self {
1210        let preferences = preferences.filter(|p| !p.is_empty());
1211        Self {
1212            constraint,
1213            preferences,
1214        }
1215    }
1216
1217    /// What this element pays for carrying `input` on its input link and
1218    /// `output` on its output link. A constraint with no alternative list to
1219    /// index (a wildcard, a derived transform, a legacy bridge) is free, so it
1220    /// neither steers the chain nor blocks a neighbour that does.
1221    fn cost(&self, input: Option<&Caps>, output: Option<&Caps>) -> u64 {
1222        match alternative_index(self.constraint, input, output) {
1223            Some(i) => self.preferences.map_or(i as u64, |p| p.cost(i) as u64),
1224            None => 0,
1225        }
1226    }
1227}
1228
1229/// Which advertised alternative of `c` the pair `(input, output)` selects: the
1230/// first one compatible with the chosen caps. `None` when the constraint
1231/// advertises no indexable list.
1232fn alternative_index(
1233    c: &CapsConstraint<'_>,
1234    input: Option<&Caps>,
1235    output: Option<&Caps>,
1236) -> Option<usize> {
1237    let position = |set: &CapsSet, caps: &Caps| {
1238        set.alternatives()
1239            .iter()
1240            .position(|a| a.intersect(caps).is_ok())
1241    };
1242    match c {
1243        CapsConstraint::Produces(set) => position(set, output?),
1244        CapsConstraint::Accepts(set) => position(set, input?),
1245        CapsConstraint::Identity(set) => position(set, input.or(output)?),
1246        CapsConstraint::Mapping(pairs) => {
1247            let (input, output) = (input?, output?);
1248            pairs
1249                .iter()
1250                .position(|(i, o)| i.accepts(input) && o.accepts(output))
1251        }
1252        _ => None,
1253    }
1254}
1255
1256/// Least-total-cost consistent assignment for a linear chain: one candidate
1257/// index per link. `nodes` are the chain's elements in order and `domains[i]`
1258/// the fixated candidates on the link between node `i` and node `i + 1`, in
1259/// that link's own preference order.
1260///
1261/// Dynamic programming over adjacent pairs: the state is the candidate chosen
1262/// on one link, the transition is the element between two links (its
1263/// input/output relation must hold, and it charges its cost for that pair).
1264/// Ties break toward the lexicographically first candidate sequence, which is
1265/// the greedy first-fixable pick, so an all-equal-cost chain resolves exactly
1266/// as it does with no preferences at all.
1267///
1268/// `None` when no element declared costs (the caller keeps its existing
1269/// fixation untouched) or when no assignment satisfies every element.
1270fn min_cost_chain(nodes: &[ChainNode<'_, '_>], domains: &[Vec<Caps>]) -> Option<Vec<usize>> {
1271    if domains.is_empty() || nodes.len() != domains.len() + 1 {
1272        return None;
1273    }
1274    if !nodes.iter().any(|n| n.preferences.is_some()) {
1275        return None;
1276    }
1277
1278    // best[j]: the cheapest prefix ending with `domains[link][j]` on the link
1279    // under consideration, and the candidate indices that reached it.
1280    let mut best: Vec<Option<(u64, Vec<usize>)>> = domains[0]
1281        .iter()
1282        .enumerate()
1283        .map(|(j, caps)| Some((nodes[0].cost(None, Some(caps)), alloc::vec![j])))
1284        .collect();
1285
1286    for link in 1..domains.len() {
1287        let middle = &nodes[link];
1288        let mut next: Vec<Option<(u64, Vec<usize>)>> = alloc::vec![None; domains[link].len()];
1289        for (b, output) in domains[link].iter().enumerate() {
1290            for (a, input) in domains[link - 1].iter().enumerate() {
1291                let Some((so_far, path)) = best[a].as_ref() else {
1292                    continue;
1293                };
1294                if !transform_pair_consistent(middle.constraint, input, output) {
1295                    continue;
1296                }
1297                let total = so_far.saturating_add(middle.cost(Some(input), Some(output)));
1298                let mut candidate = path.clone();
1299                candidate.push(b);
1300                if is_better(next[b].as_ref(), total, &candidate) {
1301                    next[b] = Some((total, candidate));
1302                }
1303            }
1304        }
1305        best = next;
1306    }
1307
1308    let last = nodes.last()?;
1309    let mut winner: Option<(u64, Vec<usize>)> = None;
1310    for (j, caps) in domains[domains.len() - 1].iter().enumerate() {
1311        let Some((so_far, path)) = best[j].as_ref() else {
1312            continue;
1313        };
1314        let total = so_far.saturating_add(last.cost(Some(caps), None));
1315        if is_better(winner.as_ref(), total, path) {
1316            winner = Some((total, path.clone()));
1317        }
1318    }
1319    winner.map(|(_, path)| path)
1320}
1321
1322/// Whether `(cost, path)` beats the incumbent: cheaper, or equally cheap with a
1323/// lexicographically earlier candidate sequence.
1324fn is_better(current: Option<&(u64, Vec<usize>)>, cost: u64, path: &[usize]) -> bool {
1325    match current {
1326        None => true,
1327        Some((c, p)) => (cost, path) < (*c, p.as_slice()),
1328    }
1329}
1330
1331/// Preference-driven fixation for a graph that is a plain linear chain: the
1332/// same DP as [`min_cost_chain`], mapped onto edge ids. `None` (so the caller
1333/// keeps its greedy backtracking) when the graph is not a chain, when no
1334/// element declared costs, or when the DP finds no consistent assignment.
1335fn preferred_chain_assignment<E>(
1336    graph: &ValidatedGraph<E>,
1337    constraints: &[NodeConstraint<'_>],
1338    preferences: &[Option<CapsPreferences>],
1339    domains: &[Vec<Caps>],
1340) -> Option<Vec<Option<Caps>>> {
1341    if !preferences.iter().any(Option::is_some) {
1342        return None;
1343    }
1344    let (nodes, edges) = chain_order(graph, constraints)?;
1345    let chain: Vec<ChainNode<'_, '_>> = nodes
1346        .iter()
1347        .map(|&node| {
1348            let index = node.0 as usize;
1349            let NodeConstraint::Element(c) = &constraints[index] else {
1350                unreachable!("chain_order admits Element nodes only")
1351            };
1352            ChainNode::new(c, preference_at(preferences, index))
1353        })
1354        .collect();
1355    let chain_domains: Vec<Vec<Caps>> = edges.iter().map(|&e| domains[e].clone()).collect();
1356    let pick = min_cost_chain(&chain, &chain_domains)?;
1357
1358    let mut assign: Vec<Option<Caps>> = alloc::vec![None; domains.len()];
1359    for ((&edge, candidates), &j) in edges.iter().zip(&chain_domains).zip(&pick) {
1360        assign[edge] = Some(candidates[j].clone());
1361    }
1362    Some(assign)
1363}
1364
1365/// The graph as a linear chain: its nodes in source-to-sink order and the edge
1366/// ids between them. `None` unless every node has at most one input and one
1367/// output edge, the edges form a single path over all of them, and every node
1368/// carries a plain [`NodeConstraint::Element`] (a demux or muxer node couples
1369/// edges the chain DP does not model).
1370fn chain_order<E>(
1371    graph: &ValidatedGraph<E>,
1372    constraints: &[NodeConstraint<'_>],
1373) -> Option<(Vec<NodeId>, Vec<usize>)> {
1374    let n = graph.node_count();
1375    if n < 2 || graph.edge_count() + 1 != n {
1376        return None;
1377    }
1378    if !constraints
1379        .iter()
1380        .all(|c| matches!(c, NodeConstraint::Element(_)))
1381    {
1382        return None;
1383    }
1384    let mut head = None;
1385    for i in 0..n {
1386        let node = NodeId(i as u32);
1387        if graph.in_edges(node).len() > 1 || graph.out_edges(node).len() > 1 {
1388            return None;
1389        }
1390        if graph.in_edges(node).is_empty() {
1391            if head.is_some() {
1392                return None;
1393            }
1394            head = Some(node);
1395        }
1396    }
1397
1398    let mut nodes = Vec::with_capacity(n);
1399    let mut edges = Vec::with_capacity(n - 1);
1400    let mut current = head?;
1401    loop {
1402        nodes.push(current);
1403        match graph.out_edges(current).first() {
1404            Some(&edge) => {
1405                edges.push(edge);
1406                current = graph.edge(edge).dst.node;
1407            }
1408            None => break,
1409        }
1410    }
1411    (nodes.len() == n).then_some((nodes, edges))
1412}
1413
1414/// Backtracking search for a globally-consistent edge assignment, run after arc
1415/// consistency has narrowed each edge's domain. Assigns edges in id order, trying
1416/// each candidate (greedy choice first) and pruning the moment a node whose edges
1417/// are all assigned violates its relation. Returns `true` with `assign` filled on
1418/// success. Recursion depth is the edge count and domains are tiny in practice
1419/// (almost always one candidate), so the worst-case product is never approached
1420/// for real graphs.
1421fn fixate_backtrack<E>(
1422    graph: &ValidatedGraph<E>,
1423    constraints: &[NodeConstraint<'_>],
1424    domains: &[Vec<Caps>],
1425    assign: &mut [Option<Caps>],
1426    edge: usize,
1427) -> bool {
1428    if edge == domains.len() {
1429        return true;
1430    }
1431    let e = graph.edge(edge);
1432    for cand in &domains[edge] {
1433        assign[edge] = Some(cand.clone());
1434        if node_consistent(graph, constraints, assign, e.src.node)
1435            && node_consistent(graph, constraints, assign, e.dst.node)
1436            && fixate_backtrack(graph, constraints, domains, assign, edge + 1)
1437        {
1438            return true;
1439        }
1440    }
1441    assign[edge] = None;
1442    false
1443}
1444
1445/// Whether `node`'s relation holds for the currently-assigned values of its
1446/// incident edges. Returns `true` while any incident edge is still unassigned
1447/// (the relation cannot be violated yet), so the caller can check a node the
1448/// moment its last edge is assigned. Per-edge membership (a source's produce set,
1449/// a sink's / muxer pad's accept set) is already guaranteed by arc consistency;
1450/// the load-bearing checks here are the cross-edge ones a diamond needs: a tee's
1451/// branches all carrying its input, an `Identity`'s in == out, a `Mapping`'s
1452/// (in, out) being one declared pair, and a derived transform's out in f(in).
1453fn node_consistent<E>(
1454    graph: &ValidatedGraph<E>,
1455    constraints: &[NodeConstraint<'_>],
1456    assign: &[Option<Caps>],
1457    node: NodeId,
1458) -> bool {
1459    let in_e = graph.in_edges(node);
1460    let out_e = graph.out_edges(node);
1461    if in_e
1462        .iter()
1463        .chain(out_e.iter())
1464        .any(|&e| assign[e].is_none())
1465    {
1466        return true;
1467    }
1468    let get = |e: usize| assign[e].as_ref().expect("checked all assigned");
1469    match graph.kind(node) {
1470        // Membership is already ensured by arc consistency; nothing cross-edge.
1471        NodeKind::Source
1472        | NodeKind::Sink
1473        | NodeKind::Muxer(_)
1474        | NodeKind::FaninSink(_)
1475        | NodeKind::FanoutSrc(_) => true,
1476        NodeKind::Tee(_) => {
1477            // A demux decouples its ports (each its own produce set, ensured by arc
1478            // consistency), so there is no cross-edge equality; a broadcast tee
1479            // requires every branch to carry its input.
1480            if matches!(&constraints[node.0 as usize], NodeConstraint::Demux { .. }) {
1481                true
1482            } else {
1483                let inp = get(in_e[0]);
1484                out_e.iter().all(|&o| get(o) == inp)
1485            }
1486        }
1487        NodeKind::Transform => match &constraints[node.0 as usize] {
1488            NodeConstraint::Element(c) => transform_pair_consistent(c, get(in_e[0]), get(out_e[0])),
1489            _ => true,
1490        },
1491    }
1492}
1493
1494/// The cross-edge relation a transform's `(input, output)` pair must satisfy, for
1495/// [`node_consistent`]'s backtracking check.
1496fn transform_pair_consistent(c: &CapsConstraint<'_>, inp: &Caps, outp: &Caps) -> bool {
1497    match c {
1498        CapsConstraint::Identity(_) | CapsConstraint::IdentityAny => inp == outp,
1499        CapsConstraint::Mapping(pairs) => {
1500            pairs.iter().any(|(i, o)| i.accepts(inp) && o.accepts(outp))
1501        }
1502        CapsConstraint::DerivedOutput(f) => f(inp).accepts(outp),
1503        CapsConstraint::DerivedFields(t) => t.derive(inp).accepts(outp),
1504        // Produce / accept shapes on a transform slot, or legacy bridges: not a
1505        // cross-edge relation re-checked here (arc consistency handled the forward
1506        // cascade; the legacy bridge stays permissive through the migration).
1507        _ => true,
1508    }
1509}
1510
1511/// Default node label for the caps explainer: `n{id}:{kind}` (e.g. `n2:xform`),
1512/// used when the caller supplies no element names.
1513fn node_label_default<E>(graph: &ValidatedGraph<E>, node: NodeId) -> String {
1514    alloc::format!("n{}:{}", node.0, kind_short(graph.kind(node)))
1515}
1516
1517fn kind_short(kind: NodeKind) -> &'static str {
1518    match kind {
1519        NodeKind::Source => "src",
1520        NodeKind::Transform => "xform",
1521        NodeKind::Sink => "sink",
1522        NodeKind::Tee(_) => "tee",
1523        NodeKind::Muxer(_) => "mux",
1524        NodeKind::FaninSink(_) => "fanin-sink",
1525        NodeKind::FanoutSrc(_) => "fanout-src",
1526    }
1527}
1528
1529/// Render a `CapsSet` for the explainer: its alternatives joined by ` | `, with
1530/// `∅` for empty and a `(+N more)` elision past four so a wide set stays one
1531/// readable line.
1532fn fmt_set(set: &CapsSet) -> String {
1533    let alts = set.alternatives();
1534    if alts.is_empty() {
1535        return String::from("∅");
1536    }
1537    let mut parts: Vec<String> = Vec::new();
1538    for (i, a) in alts.iter().enumerate() {
1539        if i == 4 {
1540            parts.push(alloc::format!("(+{} more)", alts.len() - 4));
1541            break;
1542        }
1543        parts.push(a.to_gst_string());
1544    }
1545    parts.join(" | ")
1546}
1547
1548fn fmt_set_opt(slot: &Option<CapsSet>) -> String {
1549    match slot {
1550        Some(s) => fmt_set(s),
1551        None => String::from("(unconstrained)"),
1552    }
1553}
1554
1555/// One-line summary of a node's constraint for the explainer's setup dump.
1556fn fmt_constraint(nc: &NodeConstraint<'_>) -> String {
1557    match nc {
1558        NodeConstraint::Element(c) => fmt_caps_constraint(c),
1559        NodeConstraint::Muxer {
1560            inputs,
1561            output,
1562            follows,
1563        } => match follows {
1564            Some(pad) => alloc::format!("mux {} inputs -> follows input {pad}", inputs.len()),
1565            None => alloc::format!(
1566                "mux {} inputs -> {}",
1567                inputs.len(),
1568                fmt_caps_constraint(output)
1569            ),
1570        },
1571        NodeConstraint::Demux { ports, .. } => alloc::format!("demux -> {} ports", ports.len()),
1572    }
1573}
1574
1575fn fmt_caps_constraint(c: &CapsConstraint<'_>) -> String {
1576    match c {
1577        CapsConstraint::Produces(s) => alloc::format!("produces {}", fmt_set(s)),
1578        CapsConstraint::Accepts(s) => alloc::format!("accepts {}", fmt_set(s)),
1579        CapsConstraint::AcceptsAny => "accepts ANY".to_string(),
1580        CapsConstraint::Identity(s) => alloc::format!("identity {}", fmt_set(s)),
1581        CapsConstraint::IdentityAny => "identity ANY".to_string(),
1582        CapsConstraint::Mapping(pairs) => alloc::format!("maps {} pair(s)", pairs.len()),
1583        CapsConstraint::DerivedOutput(_) => "derives output".to_string(),
1584        CapsConstraint::DerivedFields(_) => "derives output (coupled)".to_string(),
1585        CapsConstraint::LegacySource(c) => alloc::format!("legacy source {}", c.to_gst_string()),
1586        CapsConstraint::LegacyTransform { .. } => "legacy transform".to_string(),
1587        CapsConstraint::LegacySink(_) => "legacy sink".to_string(),
1588    }
1589}
1590
1591/// Per-edge downstream feasibility for the DAG runner's mid-stream re-solve
1592/// (D4), the graph generalization of [`downstream_feasibility`]. For each edge
1593/// it returns the set the edge can carry such that every node *downstream* of
1594/// it can still fixate, ignoring the (mid-stream-changing) upstream. `None`
1595/// means "downstream imposes no expressible constraint here". Indexed by edge
1596/// id; snapshotted into each arm so a mid-stream `CapsChanged` can steer an
1597/// element's output without reaching its peers at runtime.
1598///
1599/// Generalizes the linear reverse sweep to a reverse-topo fold: a transform
1600/// passes its output feasibility back through [`backward_feasible`]; a tee's
1601/// input feasibility is the intersection over its branch feasibilities (the
1602/// input must satisfy every branch); a muxer's input pads take their pad accept
1603/// sets independently (the output does not feed back to the inputs).
1604#[cfg(feature = "std")]
1605pub(crate) fn graph_downstream_feasibility<E>(
1606    graph: &ValidatedGraph<E>,
1607    constraints: &[NodeConstraint<'_>],
1608    solution: &[Caps],
1609) -> Vec<Option<CapsSet>> {
1610    let ne = graph.edge_count();
1611    let mut feas: Vec<Option<CapsSet>> = alloc::vec![None; ne];
1612    // Reverse topo: a node's output edges are written by its downstream
1613    // consumers, visited earlier in this order, before we read them here.
1614    for &node in graph.topo().iter().rev() {
1615        let idx = node.0 as usize;
1616        match graph.kind(node) {
1617            // Terminal ends of the DAG: no in-edges to write feasibility for.
1618            NodeKind::Source | NodeKind::FanoutSrc(_) => {}
1619            NodeKind::Sink => {
1620                let ie = graph.in_edges(node)[0];
1621                feas[ie] = match &constraints[idx] {
1622                    NodeConstraint::Element(CapsConstraint::Accepts(s)) => Some(s.clone()),
1623                    _ => None,
1624                };
1625            }
1626            NodeKind::Transform => {
1627                let ie = graph.in_edges(node)[0];
1628                let oe = graph.out_edges(node)[0];
1629                if let NodeConstraint::Element(c) = &constraints[idx] {
1630                    // The element's startup-fixated input, for the closure probe a
1631                    // `DerivedOutput` backward hop needs (see `backward_feasible`).
1632                    feas[ie] = backward_feasible(c, feas[oe].as_ref(), solution.get(ie));
1633                }
1634            }
1635            NodeKind::Tee(_) => {
1636                // A demux decouples its ports, so its input feasibility is its own
1637                // `input` accept (not the branches'); a broadcast tee's input must
1638                // satisfy every branch, so it is their intersection.
1639                if let NodeConstraint::Demux { input, .. } = &constraints[idx] {
1640                    feas[graph.in_edges(node)[0]] = match input {
1641                        CapsConstraint::Accepts(s) => Some(s.clone()),
1642                        _ => None,
1643                    };
1644                } else {
1645                    let mut acc: Option<CapsSet> = None;
1646                    for &oe in graph.out_edges(node) {
1647                        if let Some(s) = feas[oe].as_ref() {
1648                            acc = Some(match acc {
1649                                Some(a) => a.intersect(s),
1650                                None => s.clone(),
1651                            });
1652                        }
1653                    }
1654                    feas[graph.in_edges(node)[0]] = acc;
1655                }
1656            }
1657            // A muxer and a terminal fan-in both take their input pads' accept
1658            // sets independently (a muxer's output does not feed back to its
1659            // inputs; a terminal fan-in has no output at all).
1660            NodeKind::Muxer(_) | NodeKind::FaninSink(_) => {
1661                if let NodeConstraint::Muxer { inputs, .. } = &constraints[idx] {
1662                    for &ie in graph.in_edges(node) {
1663                        let pad = graph.edge(ie).dst.index as usize;
1664                        feas[ie] = match inputs.get(pad) {
1665                            Some(CapsConstraint::Accepts(s)) => Some(s.clone()),
1666                            _ => None,
1667                        };
1668                    }
1669                }
1670            }
1671        }
1672    }
1673    feas
1674}
1675
1676/// The (upstream node, downstream node) ids an edge connects, for failures.
1677fn edge_endpoints<E>(graph: &ValidatedGraph<E>, edge_id: usize) -> (usize, usize) {
1678    let e = graph.edge(edge_id);
1679    (e.src.node.0 as usize, e.dst.node.0 as usize)
1680}
1681
1682fn apply_node<E>(
1683    graph: &ValidatedGraph<E>,
1684    node: NodeId,
1685    constraints: &[NodeConstraint<'_>],
1686    edges: &mut [Option<CapsSet>],
1687) -> Result<(), NegotiationFailure> {
1688    let kind = graph.kind(node);
1689    let in_e = graph.in_edges(node);
1690    let out_e = graph.out_edges(node);
1691    let idx = node.0 as usize;
1692    let nc = &constraints[idx];
1693    let shape_err = NegotiationFailure::EndpointShapeMismatch { index: idx };
1694    match kind {
1695        NodeKind::Source => match nc {
1696            NodeConstraint::Element(CapsConstraint::Produces(s)) => {
1697                narrow_edge(graph, edges, out_e[0], s, node)
1698            }
1699            // Legacy bridge: a `LegacySource` carries one fixated caps, the same
1700            // as `Produces(one(caps))`.
1701            NodeConstraint::Element(CapsConstraint::LegacySource(caps)) => {
1702                narrow_edge(graph, edges, out_e[0], &CapsSet::one(caps.clone()), node)
1703            }
1704            _ => Err(shape_err),
1705        },
1706        NodeKind::Sink => match nc {
1707            NodeConstraint::Element(CapsConstraint::Accepts(s)) => {
1708                narrow_edge(graph, edges, in_e[0], s, node)
1709            }
1710            // `AcceptsAny` and a `LegacySink` both leave the input edge to carry
1711            // whatever the upstream fixates: the legacy sink's `intercept` is the
1712            // terminal accept (the runner configures it with the upstream caps,
1713            // as `run_muxer_sink` did), so it imposes no solver narrowing.
1714            NodeConstraint::Element(CapsConstraint::AcceptsAny)
1715            | NodeConstraint::Element(CapsConstraint::LegacySink(_)) => Ok(()),
1716            _ => Err(shape_err),
1717        },
1718        NodeKind::Transform => match nc {
1719            NodeConstraint::Element(c) => {
1720                apply_transform_node(graph, c, in_e[0], out_e[0], edges, node)
1721            }
1722            _ => Err(shape_err),
1723        },
1724        NodeKind::Tee(_) => match nc {
1725            // A demux decouples its ports (each its own elementary stream); a
1726            // plain (broadcast) tee couples in == every out.
1727            NodeConstraint::Demux { input, ports } => {
1728                apply_demux_node(graph, node, in_e[0], out_e, input, ports, edges)
1729            }
1730            _ => apply_tee_node(graph, in_e[0], out_e, edges),
1731        },
1732        NodeKind::Muxer(_) => match nc {
1733            NodeConstraint::Muxer {
1734                inputs,
1735                output,
1736                follows,
1737            } => apply_muxer_node(graph, node, inputs, output, *follows, edges),
1738            _ => Err(shape_err),
1739        },
1740        // A terminal fan-in narrows only its input pads (no output edge to
1741        // couple); it reuses the muxer constraint shape with `output` / `follows`
1742        // unused.
1743        NodeKind::FaninSink(_) => match nc {
1744            NodeConstraint::Muxer { inputs, .. } => narrow_muxer_inputs(graph, node, inputs, edges),
1745            _ => Err(shape_err),
1746        },
1747        // A terminal fan-out source narrows each output edge by its port's
1748        // produce set (the demux constraint shape with the input half unused:
1749        // there is no input edge).
1750        NodeKind::FanoutSrc(_) => match nc {
1751            NodeConstraint::Demux { ports, .. } => {
1752                for &oe in out_e {
1753                    let port = graph.edge(oe).src.index as usize;
1754                    match ports.get(port) {
1755                        Some(CapsConstraint::Produces(set)) => {
1756                            narrow_edge(graph, edges, oe, set, node)?
1757                        }
1758                        Some(CapsConstraint::LegacySource(caps)) => {
1759                            narrow_edge(graph, edges, oe, &CapsSet::one(caps.clone()), node)?
1760                        }
1761                        _ => return Err(shape_err),
1762                    }
1763                }
1764                Ok(())
1765            }
1766            _ => Err(shape_err),
1767        },
1768    }
1769}
1770
1771/// Narrow one edge by `contrib`, the set node `by` imposes on it. `by` is what
1772/// orients an `EmptyLink`: the contribution belongs to that end of the link, and
1773/// the set already on the edge stands for the other end.
1774fn narrow_edge<E>(
1775    graph: &ValidatedGraph<E>,
1776    edges: &mut [Option<CapsSet>],
1777    edge_id: usize,
1778    contrib: &CapsSet,
1779    by: NodeId,
1780) -> Result<(), NegotiationFailure> {
1781    let next = match &edges[edge_id] {
1782        Some(cur) => cur.intersect(contrib),
1783        None => contrib.clone(),
1784    };
1785    if next.is_empty() {
1786        let (up, down) = edge_endpoints(graph, edge_id);
1787        let other = edges[edge_id].clone().unwrap_or_else(empty_set);
1788        return Err(if graph.edge(edge_id).src.node == by {
1789            NegotiationFailure::empty_link_conflict(up, down, contrib.clone(), other)
1790        } else {
1791            NegotiationFailure::empty_link_conflict(up, down, other, contrib.clone())
1792        });
1793    }
1794    edges[edge_id] = Some(next);
1795    Ok(())
1796}
1797
1798fn empty_set() -> CapsSet {
1799    CapsSet::from_alternatives(Vec::new())
1800}
1801
1802/// Couple two edges to carry equal caps (each intersected with the other),
1803/// the pass-through relation a transform's input and output share.
1804fn couple_edges<E>(
1805    graph: &ValidatedGraph<E>,
1806    edges: &mut [Option<CapsSet>],
1807    a: usize,
1808    b: usize,
1809) -> Result<(), NegotiationFailure> {
1810    match (edges[a].clone(), edges[b].clone()) {
1811        (Some(sa), Some(sb)) => {
1812            let coupled = sa.intersect(&sb);
1813            if coupled.is_empty() {
1814                let up = edge_endpoints(graph, a).0;
1815                let down = edge_endpoints(graph, b).1;
1816                return Err(NegotiationFailure::empty_link_conflict(up, down, sa, sb));
1817            }
1818            edges[a] = Some(coupled.clone());
1819            edges[b] = Some(coupled);
1820        }
1821        (Some(sa), None) => edges[b] = Some(sa),
1822        (None, Some(sb)) => edges[a] = Some(sb),
1823        (None, None) => {}
1824    }
1825    Ok(())
1826}
1827
1828/// Transform node narrowing: the edge-indexed analog of the linear solver's
1829/// `apply_constraint` transform arms.
1830fn apply_transform_node<E>(
1831    graph: &ValidatedGraph<E>,
1832    c: &CapsConstraint<'_>,
1833    in_e: usize,
1834    out_e: usize,
1835    edges: &mut [Option<CapsSet>],
1836    node: NodeId,
1837) -> Result<(), NegotiationFailure> {
1838    match c {
1839        CapsConstraint::Identity(s) => {
1840            narrow_edge(graph, edges, in_e, s, node)?;
1841            narrow_edge(graph, edges, out_e, s, node)?;
1842            couple_edges(graph, edges, in_e, out_e)
1843        }
1844        CapsConstraint::IdentityAny => couple_edges(graph, edges, in_e, out_e),
1845        CapsConstraint::Mapping(pairs) => {
1846            let mut new_in = CapsSet::from_alternatives(Vec::new());
1847            let mut new_out = CapsSet::from_alternatives(Vec::new());
1848            for (in_set, out_set) in pairs {
1849                let in_match = match &edges[in_e] {
1850                    Some(cur) => cur.intersect(in_set),
1851                    None => in_set.clone(),
1852                };
1853                let out_match = match &edges[out_e] {
1854                    Some(cur) => cur.intersect(out_set),
1855                    None => out_set.clone(),
1856                };
1857                if !in_match.is_empty() && !out_match.is_empty() {
1858                    new_in = new_in.union(&in_match);
1859                    new_out = new_out.union(&out_match);
1860                }
1861            }
1862            if new_in.is_empty() || new_out.is_empty() {
1863                let up = edge_endpoints(graph, in_e).0;
1864                let down = edge_endpoints(graph, out_e).1;
1865                return Err(NegotiationFailure::empty_link_conflict(
1866                    up,
1867                    down,
1868                    edges[in_e].clone().unwrap_or_else(empty_set),
1869                    edges[out_e].clone().unwrap_or_else(empty_set),
1870                ));
1871            }
1872            edges[in_e] = Some(new_in);
1873            edges[out_e] = Some(new_out);
1874            Ok(())
1875        }
1876        CapsConstraint::DerivedOutput(f) => {
1877            // Forward (M188): narrow the output edge by the union of `f` over
1878            // every input alternative, mirroring the linear solver. A single
1879            // fixated input gives `f(input)`; a still ambiguous input (a stacked
1880            // auto transform) still yields an output to narrow instead of leaving
1881            // the output edge at `None`.
1882            if let Some(in_set) = edges[in_e].clone() {
1883                let derived = forward_derived_union(f.as_ref(), &in_set);
1884                if derived.is_empty() {
1885                    let (up, down) = edge_endpoints(graph, out_e);
1886                    return Err(NegotiationFailure::empty_link(up, down));
1887                }
1888                narrow_edge(graph, edges, out_e, &derived, node)?;
1889            }
1890            // Backward (M188 + invertible-field coupling): field-level narrow on
1891            // the closure's probed passthrough fields, else alternative-drop.
1892            if let (Some(in_set), Some(out_set)) = (edges[in_e].clone(), edges[out_e].clone()) {
1893                match derived_backward(f.as_ref(), &in_set, &out_set) {
1894                    Ok(Some(narrowed)) => edges[in_e] = Some(narrowed),
1895                    Ok(None) => {}
1896                    Err(()) => {
1897                        let (up, down) = edge_endpoints(graph, in_e);
1898                        return Err(NegotiationFailure::empty_link(up, down));
1899                    }
1900                }
1901            }
1902            Ok(())
1903        }
1904        CapsConstraint::DerivedFields(t) => {
1905            // Mirror of the linear `apply_constraint` arm on graph edges:
1906            // forward via the declaration, backward via field-level coupling.
1907            let derive = |c: &Caps| t.derive(c);
1908            if let Some(in_set) = edges[in_e].clone() {
1909                let derived = forward_derived_union(&derive, &in_set);
1910                if derived.is_empty() {
1911                    let (up, down) = edge_endpoints(graph, out_e);
1912                    return Err(NegotiationFailure::empty_link(up, down));
1913                }
1914                narrow_edge(graph, edges, out_e, &derived, node)?;
1915            }
1916            if let (Some(in_set), Some(out_set)) = (edges[in_e].clone(), edges[out_e].clone()) {
1917                match backward_field_narrow(&derive, t.passthrough(), &in_set, &out_set) {
1918                    Ok(Some(narrowed)) => edges[in_e] = Some(narrowed),
1919                    Ok(None) => {}
1920                    Err(()) => {
1921                        let (up, down) = edge_endpoints(graph, in_e);
1922                        return Err(NegotiationFailure::empty_link(up, down));
1923                    }
1924                }
1925            }
1926            Ok(())
1927        }
1928        // Legacy bridge: forward `intercept(input)` to the output once the input
1929        // fixates, the same single-caps forward cascade `solve_legacy_cascade`
1930        // runs (no backward coupling, like the mixed-cascade path).
1931        CapsConstraint::LegacyTransform { intercept, .. } => {
1932            if let Some(fixed_input) = edges[in_e].as_ref().and_then(fixed_single) {
1933                let out = intercept(&fixed_input).map_err(|_| {
1934                    let (up, down) = edge_endpoints(graph, out_e);
1935                    NegotiationFailure::empty_link(up, down)
1936                })?;
1937                return narrow_edge(graph, edges, out_e, &CapsSet::one(out), node);
1938            }
1939            Ok(())
1940        }
1941        _ => Err(NegotiationFailure::EndpointShapeMismatch {
1942            index: node.0 as usize,
1943        }),
1944    }
1945}
1946
1947/// The single concrete caps an edge has fixated to, or `None` if it still has
1948/// multiple alternatives or ranged (`Any`) fields. Used by the forward-cascade
1949/// constraints (`DerivedOutput`, `LegacyTransform`) that need a concrete input.
1950fn fixed_single(set: &CapsSet) -> Option<Caps> {
1951    let fixed = set.fixate()?;
1952    (set.alternatives().len() == 1 && set.alternatives()[0] == fixed).then_some(fixed)
1953}
1954
1955/// Forward image of a `DerivedOutput` transform over its (possibly ambiguous)
1956/// input set: the union of `f` over the input alternatives (M188). For a single
1957/// fixated input this is just `f(input)`; for a multi-alternative input it lets a
1958/// downstream auto transform still receive an output to narrow, instead of
1959/// stalling until the input fixates (which it can't, with no downstream pin).
1960fn forward_derived_union(f: &dyn Fn(&Caps) -> CapsSet, in_set: &CapsSet) -> CapsSet {
1961    in_set
1962        .alternatives()
1963        .iter()
1964        .fold(CapsSet::from_alternatives(Vec::new()), |acc, a| {
1965            acc.union(&f(a))
1966        })
1967}
1968
1969/// M188 backward narrowing for a `DerivedOutput` transform: given the (already
1970/// constrained) output set, drop input alternatives whose forward image `f(a)`
1971/// can no longer satisfy it. `f` is not analytically invertible, but it is
1972/// evaluable per candidate, so a downstream pin propagates back through a
1973/// not-yet-fixated transform, letting stacked auto transforms
1974/// (`videoconvert ! videoscale ! caps`) resolve.
1975///
1976/// Only narrows when the input is still ambiguous (more than one alternative),
1977/// so single-input transforms (decoders, the single-transform pipelines of
1978/// M185/M186) are untouched. Returns `Some(narrowed)` when it removed
1979/// alternatives, `None` when unchanged, `Err(())` when nothing survives.
1980fn backward_filter_derived(
1981    f: &dyn Fn(&Caps) -> CapsSet,
1982    in_set: &CapsSet,
1983    out_set: &CapsSet,
1984) -> Result<Option<CapsSet>, ()> {
1985    if in_set.alternatives().len() <= 1 {
1986        return Ok(None);
1987    }
1988    let kept: Vec<Caps> = in_set
1989        .alternatives()
1990        .iter()
1991        .filter(|a| !f(a).intersect(out_set).is_empty())
1992        .cloned()
1993        .collect();
1994    if kept.is_empty() {
1995        return Err(());
1996    }
1997    if kept.len() == in_set.alternatives().len() {
1998        return Ok(None);
1999    }
2000    Ok(Some(CapsSet::from_alternatives(kept)))
2001}
2002
2003/// Backward narrowing for a `DerivedOutput` transform. The closure is not
2004/// declared with a passthrough mask, so [`discover_passthrough`] probes it for
2005/// its invertible fields; when any is found the input is narrowed field-by-field
2006/// exactly as a declared `DerivedFields` mask would
2007/// ([`backward_field_narrow`]), so a downstream geometry / framerate pin couples
2008/// back through a decoder or a rescaling convert instead of failing loud. With no
2009/// passthrough field discovered it falls back to the alternative-drop walk
2010/// ([`backward_filter_derived`]), the prior behavior, so a genuinely
2011/// non-invertible closure is untouched.
2012fn derived_backward(
2013    f: &dyn Fn(&Caps) -> CapsSet,
2014    in_set: &CapsSet,
2015    out_set: &CapsSet,
2016) -> Result<Option<CapsSet>, ()> {
2017    let mask = in_set
2018        .alternatives()
2019        .first()
2020        .map(|sample| discover_passthrough(f, sample))
2021        .unwrap_or(PassthroughFields::NONE);
2022    if mask == PassthroughFields::NONE {
2023        backward_filter_derived(f, in_set, out_set)
2024    } else {
2025        backward_field_narrow(f, mask, in_set, out_set)
2026    }
2027}
2028
2029/// Backward field-coupling for a `DerivedFields` transform: the primitive the
2030/// alternative-dropping [`backward_filter_derived`] cannot express. For each
2031/// input alternative, intersect its forward image `derive(a)` with the
2032/// constrained output `out_set`; drop the alternative when nothing survives (the
2033/// same as the alternative-drop walk), otherwise narrow the alternative's
2034/// *passthrough* fields by intersecting each reachable output's passthrough
2035/// fields back in (`couple_passthrough`), e.g. a `Range(1..MAX)` width meeting a
2036/// `Fixed(160)` downstream pin collapses to `Fixed(160)`.
2037///
2038/// Unlike `backward_filter_derived` it runs for a single-alternative input too:
2039/// narrowing a `Range` field *within* that one alternative is the whole point.
2040/// Every step is an intersection (monotone shrink), so the arc-consistency loop
2041/// still converges. Returns `Some(narrowed)` when it changed the set, `None`
2042/// when unchanged, `Err(())` when nothing survives.
2043fn backward_field_narrow(
2044    derive: &dyn Fn(&Caps) -> CapsSet,
2045    passthrough: PassthroughFields,
2046    in_set: &CapsSet,
2047    out_set: &CapsSet,
2048) -> Result<Option<CapsSet>, ()> {
2049    let mut kept: Vec<Caps> = Vec::new();
2050    let mut changed = false;
2051    for a in in_set.alternatives() {
2052        let reach = derive(a).intersect(out_set);
2053        if reach.is_empty() {
2054            changed = true; // this input alternative can't reach the output: drop it
2055            continue;
2056        }
2057        // Couple each reachable output's passthrough fields back into `a`. Uses
2058        // the variant-tolerant coupling so a `DerivedOutput` decoder / encoder
2059        // (which changes variant) couples its shared geometry / rate fields;
2060        // a same-variant `DerivedFields` transform gets the exact coupling.
2061        let mut any = false;
2062        for out_alt in reach.alternatives() {
2063            if let Some(c) = couple_passthrough_derived(a, out_alt, passthrough) {
2064                if &c != a {
2065                    changed = true;
2066                }
2067                if !kept.contains(&c) {
2068                    kept.push(c);
2069                }
2070                any = true;
2071            }
2072        }
2073        if !any {
2074            // Reachable output exists but a passthrough field conflicts: drop.
2075            changed = true;
2076        }
2077    }
2078    if kept.is_empty() {
2079        return Err(());
2080    }
2081    if !changed {
2082        return Ok(None);
2083    }
2084    Ok(Some(CapsSet::from_alternatives(kept)))
2085}
2086
2087/// A tee fans its input caps out to every output unchanged: couple the input
2088/// edge and all output edges to one shared set (their intersection).
2089fn apply_tee_node<E>(
2090    graph: &ValidatedGraph<E>,
2091    in_e: usize,
2092    out_e: &[usize],
2093    edges: &mut [Option<CapsSet>],
2094) -> Result<(), NegotiationFailure> {
2095    let mut acc: Option<CapsSet> = edges[in_e].clone();
2096    for &oe in out_e {
2097        if let Some(s) = edges[oe].clone() {
2098            acc = Some(match acc {
2099                Some(a) => a.intersect(&s),
2100                None => s,
2101            });
2102        }
2103    }
2104    if let Some(coupled) = acc {
2105        if coupled.is_empty() {
2106            let (up, down) = edge_endpoints(graph, in_e);
2107            return Err(NegotiationFailure::empty_link(up, down));
2108        }
2109        edges[in_e] = Some(coupled.clone());
2110        for &oe in out_e {
2111            edges[oe] = Some(coupled.clone());
2112        }
2113    }
2114    Ok(())
2115}
2116
2117/// Apply a fan-out **demux** node (M380): its ports are decoupled, so the input
2118/// edge narrows by the demux's `input` accept (the container it consumes) and each
2119/// output edge narrows by its port's `Produces` caps (its elementary stream),
2120/// independent of one another. The port for an output edge is its source pad
2121/// index, so the order of `out_e` does not matter.
2122fn apply_demux_node<E>(
2123    graph: &ValidatedGraph<E>,
2124    node: NodeId,
2125    in_e: usize,
2126    out_e: &[usize],
2127    input: &CapsConstraint<'_>,
2128    ports: &[CapsConstraint<'_>],
2129    edges: &mut [Option<CapsSet>],
2130) -> Result<(), NegotiationFailure> {
2131    // The byte-stream input: an `Accepts` narrows it; `AcceptsAny` / `LegacySink`
2132    // leave it to whatever the source fixates (the common case, the demux's
2133    // `intercept` being the terminal accept).
2134    if let CapsConstraint::Accepts(s) = input {
2135        narrow_edge(graph, edges, in_e, s, node)?;
2136    }
2137    for &oe in out_e {
2138        let port = graph.edge(oe).src.index as usize;
2139        if let Some(CapsConstraint::Produces(s)) = ports.get(port) {
2140            narrow_edge(graph, edges, oe, s, node)?;
2141        }
2142    }
2143    Ok(())
2144}
2145
2146/// Narrow each of a fan-in node's input edges by its pad's accept set, shared by
2147/// the muxer (which also couples an output) and the terminal fan-in (which does
2148/// not). `Accepts` narrows; an `AcceptsAny` / legacy pad forwards per-frame caps
2149/// without narrowing. `inputs[i]` applies to input pad `i`; D1 validation
2150/// guarantees each input pad index appears exactly once.
2151fn narrow_muxer_inputs<E>(
2152    graph: &ValidatedGraph<E>,
2153    node: NodeId,
2154    inputs: &[CapsConstraint<'_>],
2155    edges: &mut [Option<CapsSet>],
2156) -> Result<(), NegotiationFailure> {
2157    let shape_err = NegotiationFailure::EndpointShapeMismatch {
2158        index: node.0 as usize,
2159    };
2160    for &eid in graph.in_edges(node) {
2161        let pad = graph.edge(eid).dst.index as usize;
2162        match inputs.get(pad) {
2163            Some(CapsConstraint::Accepts(set)) => narrow_edge(graph, edges, eid, set, node)?,
2164            Some(CapsConstraint::AcceptsAny) | Some(CapsConstraint::LegacySink(_)) => {}
2165            _ => return Err(shape_err),
2166        }
2167    }
2168    Ok(())
2169}
2170
2171/// A muxer fans in: apply each input pad's constraint to its edge, and the
2172/// single output edge by the `Produces` set (or couple it to the followed
2173/// input pad).
2174fn apply_muxer_node<E>(
2175    graph: &ValidatedGraph<E>,
2176    node: NodeId,
2177    inputs: &[CapsConstraint<'_>],
2178    output: &CapsConstraint<'_>,
2179    follows: Option<usize>,
2180    edges: &mut [Option<CapsSet>],
2181) -> Result<(), NegotiationFailure> {
2182    let idx = node.0 as usize;
2183    let shape_err = NegotiationFailure::EndpointShapeMismatch { index: idx };
2184    narrow_muxer_inputs(graph, node, inputs, edges)?;
2185    let out_edge = graph.out_edges(node)[0];
2186    // Identity-passthrough mux: the output edge is the followed input pad's caps.
2187    // The solver iterates to a fixpoint, so if that input edge is not yet solved
2188    // this pass narrows nothing and a later pass (once the source has cascaded
2189    // forward) couples them; coupling keeps the two edges equal thereafter.
2190    if let Some(pad) = follows {
2191        let in_edge = graph
2192            .in_edges(node)
2193            .iter()
2194            .copied()
2195            .find(|&e| graph.edge(e).dst.index as usize == pad)
2196            .ok_or(shape_err)?;
2197        return couple_edges(graph, edges, in_edge, out_edge);
2198    }
2199    match output {
2200        CapsConstraint::Produces(set) => narrow_edge(graph, edges, out_edge, set, node),
2201        // A legacy muxer output carries one fixated merged caps.
2202        CapsConstraint::LegacySource(caps) => {
2203            narrow_edge(graph, edges, out_edge, &CapsSet::one(caps.clone()), node)
2204        }
2205        _ => Err(shape_err),
2206    }
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212    use crate::caps::{Dim, Rate, RawVideoFormat, VideoCodec};
2213    use crate::caps_transform::{CapsTransform, FieldTransform, RawVideoShape};
2214    use crate::runtime::passthrough::couple_passthrough;
2215    use alloc::boxed::Box;
2216    use alloc::vec;
2217
2218    fn video(fmt: RawVideoFormat, w: Dim, h: Dim, r: Rate) -> Caps {
2219        Caps::RawVideo {
2220            format: fmt,
2221            width: w,
2222            height: h,
2223            framerate: r,
2224            interlace: crate::Interlace::Any,
2225        }
2226    }
2227
2228    // A multi-hop tensor chain `Produces(f32) -> quantize(f32->u8) ->
2229    // infer(u8->[1,N]) -> AcceptsAny` must negotiate: tensor caps have no
2230    // wildcard fields, so the DerivedOutput closure is the only source of truth
2231    // for the output, and the solver must seed the output edge from it (M451).
2232    #[test]
2233    fn solve_linear_tensor_dtype_change_chain() {
2234        use crate::caps::{TensorDType, TensorLayout, TensorShape};
2235        let t = |d: TensorDType, s: TensorShape| Caps::Tensor {
2236            dtype: d,
2237            shape: s,
2238            layout: TensorLayout::Nchw,
2239        };
2240        let f32_in = t(TensorDType::F32, TensorShape::new([1, 3, 4, 4]));
2241        let u8_mid = t(TensorDType::U8, TensorShape::new([1, 3, 4, 4]));
2242        let logits = t(TensorDType::F32, TensorShape::new([1, 10]));
2243
2244        let src = CapsConstraint::Produces(CapsSet::one(f32_in.clone()));
2245        // quantize: f32 -> u8, shape/layout passthrough (the TensorConvert shape).
2246        let quant = CapsConstraint::DerivedOutput(Box::new(|inp: &Caps| match inp {
2247            Caps::Tensor {
2248                dtype: TensorDType::F32,
2249                shape,
2250                layout,
2251            } => CapsSet::one(Caps::Tensor {
2252                dtype: TensorDType::U8,
2253                shape: *shape,
2254                layout: *layout,
2255            }),
2256            _ => CapsSet::from_alternatives(Vec::new()),
2257        }));
2258        // infer: u8 [1,3,4,4] -> f32 [1,10] (the OrtInference shape).
2259        let logits_c = logits.clone();
2260        let infer = CapsConstraint::DerivedOutput(Box::new(move |inp: &Caps| match inp {
2261            Caps::Tensor {
2262                dtype: TensorDType::U8,
2263                ..
2264            } => CapsSet::one(logits_c.clone()),
2265            _ => CapsSet::from_alternatives(Vec::new()),
2266        }));
2267        let sink = CapsConstraint::AcceptsAny;
2268
2269        let links = solve_linear(&[&src, &quant, &infer, &sink]).expect("tensor chain negotiates");
2270        assert_eq!(links[0], f32_in, "source link f32");
2271        assert_eq!(
2272            links[1], u8_mid,
2273            "quantize output is u8, not the source f32"
2274        );
2275        assert_eq!(links[2], logits, "inference output [1,10]");
2276    }
2277
2278    // The DAG solver (the path `run_linear_chain` -> `run_graph` takes) must
2279    // negotiate the same tensor dtype-change chain as the linear solver.
2280    #[test]
2281    fn solve_graph_tensor_dtype_change_chain() {
2282        use crate::caps::{TensorDType, TensorLayout, TensorShape};
2283        use crate::graph::Graph;
2284        let t = |d: TensorDType, s: TensorShape| Caps::Tensor {
2285            dtype: d,
2286            shape: s,
2287            layout: TensorLayout::Nchw,
2288        };
2289        let f32_in = t(TensorDType::F32, TensorShape::new([1, 3, 4, 4]));
2290        let u8_mid = t(TensorDType::U8, TensorShape::new([1, 3, 4, 4]));
2291        let logits = t(TensorDType::F32, TensorShape::new([1, 10]));
2292        let logits_c = logits.clone();
2293        let cs: Vec<NodeConstraint> = vec![
2294            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(f32_in.clone()))),
2295            NodeConstraint::Element(CapsConstraint::DerivedOutput(Box::new(
2296                |inp: &Caps| match inp {
2297                    Caps::Tensor {
2298                        dtype: TensorDType::F32,
2299                        shape,
2300                        layout,
2301                    } => CapsSet::one(Caps::Tensor {
2302                        dtype: TensorDType::U8,
2303                        shape: *shape,
2304                        layout: *layout,
2305                    }),
2306                    _ => CapsSet::from_alternatives(Vec::new()),
2307                },
2308            ))),
2309            NodeConstraint::Element(CapsConstraint::DerivedOutput(Box::new(
2310                move |inp: &Caps| match inp {
2311                    Caps::Tensor {
2312                        dtype: TensorDType::U8,
2313                        ..
2314                    } => CapsSet::one(logits_c.clone()),
2315                    _ => CapsSet::from_alternatives(Vec::new()),
2316                },
2317            ))),
2318            NodeConstraint::Element(CapsConstraint::AcceptsAny),
2319        ];
2320        let mut g: Graph<()> = Graph::new();
2321        let src = g.add_source(());
2322        let q = g.add_transform(());
2323        let inf = g.add_transform(());
2324        let sink = g.add_sink(());
2325        g.link(src, q).unwrap();
2326        g.link(q, inf).unwrap();
2327        g.link(inf, sink).unwrap();
2328        let v = g.finish().unwrap();
2329        let dag = solve_graph(&v, &cs).expect("tensor chain solves as a graph");
2330        assert_eq!(dag, vec![f32_in, u8_mid, logits]);
2331    }
2332
2333    fn fixed_video(fmt: RawVideoFormat, w: u32, h: u32, fps: u32) -> Caps {
2334        video(fmt, Dim::Fixed(w), Dim::Fixed(h), Rate::Fixed(fps << 16))
2335    }
2336
2337    fn compressed(codec: VideoCodec, w: Dim, h: Dim, r: Rate) -> Caps {
2338        Caps::CompressedVideo {
2339            codec,
2340            width: w,
2341            height: h,
2342            framerate: r,
2343        }
2344    }
2345
2346    fn fixed_compressed(codec: VideoCodec, w: u32, h: u32, fps: u32) -> Caps {
2347        compressed(codec, Dim::Fixed(w), Dim::Fixed(h), Rate::Fixed(fps << 16))
2348    }
2349
2350    #[test]
2351    fn solves_source_sink_minimal_chain() {
2352        let src = CapsConstraint::Produces(CapsSet::one(fixed_video(
2353            RawVideoFormat::Nv12,
2354            1280,
2355            720,
2356            30,
2357        )));
2358        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2359            RawVideoFormat::Nv12,
2360            Dim::Any,
2361            Dim::Any,
2362            Rate::Any,
2363        )));
2364        let links = solve_linear(&[&src, &sink]).unwrap();
2365        assert_eq!(
2366            links,
2367            vec![fixed_video(RawVideoFormat::Nv12, 1280, 720, 30)]
2368        );
2369    }
2370
2371    #[test]
2372    fn empty_link_when_formats_disjoint() {
2373        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2374            VideoCodec::H264,
2375            1280,
2376            720,
2377            30,
2378        )));
2379        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2380            RawVideoFormat::Nv12,
2381            Dim::Any,
2382            Dim::Any,
2383            Rate::Any,
2384        )));
2385        assert_eq!(
2386            solve_linear(&[&src, &sink]),
2387            Err(NegotiationFailure::empty_link(0, 1))
2388        );
2389    }
2390
2391    #[test]
2392    fn degenerate_when_fewer_than_two_elements() {
2393        let src =
2394            CapsConstraint::Produces(CapsSet::one(fixed_video(RawVideoFormat::Nv12, 1, 1, 1)));
2395        assert_eq!(solve_linear(&[&src]), Err(NegotiationFailure::Degenerate));
2396        assert_eq!(solve_linear(&[]), Err(NegotiationFailure::Degenerate));
2397    }
2398
2399    #[test]
2400    fn endpoint_shape_mismatch_rejected() {
2401        let id = CapsConstraint::Identity(CapsSet::one(fixed_video(RawVideoFormat::Nv12, 1, 1, 1)));
2402        let sink =
2403            CapsConstraint::Accepts(CapsSet::one(fixed_video(RawVideoFormat::Nv12, 1, 1, 1)));
2404        assert_eq!(
2405            solve_linear(&[&id, &sink]),
2406            Err(NegotiationFailure::EndpointShapeMismatch { index: 0 })
2407        );
2408    }
2409
2410    #[test]
2411    fn preference_tie_break_picks_self_first_alt() {
2412        // Source prefers Rgba8 then H264 (both fully fixed at the same
2413        // dims); sink accepts both with reversed preference.
2414        let rgba = fixed_video(RawVideoFormat::Rgba8, 640, 480, 30);
2415        let h264 = fixed_compressed(VideoCodec::H264, 640, 480, 30);
2416        let src =
2417            CapsConstraint::Produces(CapsSet::from_alternatives(vec![rgba.clone(), h264.clone()]));
2418        let sink =
2419            CapsConstraint::Accepts(CapsSet::from_alternatives(vec![h264.clone(), rgba.clone()]));
2420        let links = solve_linear(&[&src, &sink]).unwrap();
2421        // Source's outer preference wins because Produces is applied
2422        // first and CapsSet::intersect preserves self's order.
2423        assert_eq!(links, vec![rgba]);
2424    }
2425
2426    #[test]
2427    fn identity_couples_input_and_output() {
2428        let src = CapsConstraint::Produces(CapsSet::one(fixed_video(
2429            RawVideoFormat::Nv12,
2430            1280,
2431            720,
2432            30,
2433        )));
2434        let id = CapsConstraint::Identity(CapsSet::one(video(
2435            RawVideoFormat::Nv12,
2436            Dim::Any,
2437            Dim::Any,
2438            Rate::Any,
2439        )));
2440        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2441            RawVideoFormat::Nv12,
2442            Dim::Any,
2443            Dim::Any,
2444            Rate::Any,
2445        )));
2446        let links = solve_linear(&[&src, &id, &sink]).unwrap();
2447        assert_eq!(
2448            links,
2449            vec![
2450                fixed_video(RawVideoFormat::Nv12, 1280, 720, 30),
2451                fixed_video(RawVideoFormat::Nv12, 1280, 720, 30),
2452            ]
2453        );
2454    }
2455
2456    #[test]
2457    fn identity_format_mismatch_returns_empty_link() {
2458        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2459            VideoCodec::H264,
2460            1280,
2461            720,
2462            30,
2463        )));
2464        let id = CapsConstraint::Identity(CapsSet::one(video(
2465            RawVideoFormat::Nv12,
2466            Dim::Any,
2467            Dim::Any,
2468            Rate::Any,
2469        )));
2470        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2471            RawVideoFormat::Nv12,
2472            Dim::Any,
2473            Dim::Any,
2474            Rate::Any,
2475        )));
2476        assert!(matches!(
2477            solve_linear(&[&src, &id, &sink]),
2478            Err(NegotiationFailure::EmptyLink { .. })
2479        ));
2480    }
2481
2482    #[test]
2483    fn derived_output_evaluated_after_input_fixates() {
2484        // Decoder: H264 input → Nv12 output at the same dims.
2485        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2486            VideoCodec::H264,
2487            1920,
2488            1080,
2489            60,
2490        )));
2491        let dec = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
2492            Caps::CompressedVideo {
2493                width,
2494                height,
2495                framerate,
2496                ..
2497            } => CapsSet::one(Caps::RawVideo {
2498                format: RawVideoFormat::Nv12,
2499                width: width.clone(),
2500                height: height.clone(),
2501                framerate: framerate.clone(),
2502                interlace: crate::Interlace::Any,
2503            }),
2504            _ => CapsSet::from_alternatives(Vec::new()),
2505        }));
2506        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2507            RawVideoFormat::Nv12,
2508            Dim::Any,
2509            Dim::Any,
2510            Rate::Any,
2511        )));
2512        let links = solve_linear(&[&src, &dec, &sink]).unwrap();
2513        assert_eq!(
2514            links,
2515            vec![
2516                fixed_compressed(VideoCodec::H264, 1920, 1080, 60),
2517                fixed_video(RawVideoFormat::Nv12, 1920, 1080, 60),
2518            ]
2519        );
2520    }
2521
2522    #[test]
2523    fn derived_output_couples_downstream_geometry_pin_backward() {
2524        // The decoder leaves the source's geometry open and the *sink* pins it
2525        // (1280x720). Before invertible-field discovery the open H264 input link
2526        // could not fixate (`backward_filter_derived` only drops whole
2527        // alternatives, never narrows a single one's geometry), so this failed
2528        // loud. Now the closure is probed: width/height/framerate are passthrough,
2529        // so the sink's pin couples back and the input fixates to H264 1280x720.
2530        let src = CapsConstraint::Produces(CapsSet::one(compressed(
2531            VideoCodec::H264,
2532            Dim::Any,
2533            Dim::Any,
2534            Rate::Fixed(30 << 16),
2535        )));
2536        let dec = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
2537            Caps::CompressedVideo {
2538                width,
2539                height,
2540                framerate,
2541                ..
2542            } => CapsSet::one(Caps::RawVideo {
2543                format: RawVideoFormat::Nv12,
2544                width: width.clone(),
2545                height: height.clone(),
2546                framerate: framerate.clone(),
2547                interlace: crate::Interlace::Any,
2548            }),
2549            _ => CapsSet::from_alternatives(Vec::new()),
2550        }));
2551        let sink = CapsConstraint::Accepts(CapsSet::one(fixed_video(
2552            RawVideoFormat::Nv12,
2553            1280,
2554            720,
2555            30,
2556        )));
2557        let links = solve_linear(&[&src, &dec, &sink]).unwrap();
2558        assert_eq!(
2559            links,
2560            vec![
2561                fixed_compressed(VideoCodec::H264, 1280, 720, 30),
2562                fixed_video(RawVideoFormat::Nv12, 1280, 720, 30),
2563            ]
2564        );
2565    }
2566
2567    #[test]
2568    fn derived_output_fixed_output_imposes_no_backward_narrowing() {
2569        // A decoder whose output is fixed regardless of input (no passthrough
2570        // field) must not gain spurious backward coupling: discovery finds NONE,
2571        // so the input keeps its produced caps. The source pins its own geometry.
2572        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2573            VideoCodec::H264,
2574            1920,
2575            1080,
2576            30,
2577        )));
2578        let dec = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
2579            Caps::CompressedVideo { .. } => {
2580                CapsSet::one(fixed_video(RawVideoFormat::Nv12, 640, 480, 30))
2581            }
2582            _ => CapsSet::from_alternatives(Vec::new()),
2583        }));
2584        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2585            RawVideoFormat::Nv12,
2586            Dim::Any,
2587            Dim::Any,
2588            Rate::Any,
2589        )));
2590        let links = solve_linear(&[&src, &dec, &sink]).unwrap();
2591        assert_eq!(
2592            links,
2593            vec![
2594                fixed_compressed(VideoCodec::H264, 1920, 1080, 30),
2595                fixed_video(RawVideoFormat::Nv12, 640, 480, 30),
2596            ]
2597        );
2598    }
2599
2600    #[test]
2601    fn mapping_picks_compatible_pair() {
2602        // Codec converter declaring two pre-enumerated (in, out) pairs.
2603        // Source is H265 at 1280x720; the H264 pair gets filtered out.
2604        // Output dims come from the matching pair (mapping doesn't
2605        // propagate dims between paired sides — that's `DerivedOutput`'s
2606        // job).
2607        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2608            VideoCodec::H265,
2609            1280,
2610            720,
2611            30,
2612        )));
2613        let map = CapsConstraint::Mapping(vec![
2614            (
2615                CapsSet::one(compressed(VideoCodec::H264, Dim::Any, Dim::Any, Rate::Any)),
2616                CapsSet::one(fixed_video(RawVideoFormat::Nv12, 640, 480, 30)),
2617            ),
2618            (
2619                CapsSet::one(compressed(VideoCodec::H265, Dim::Any, Dim::Any, Rate::Any)),
2620                CapsSet::one(fixed_video(RawVideoFormat::Nv12, 1280, 720, 30)),
2621            ),
2622        ]);
2623        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2624            RawVideoFormat::Nv12,
2625            Dim::Any,
2626            Dim::Any,
2627            Rate::Any,
2628        )));
2629        let links = solve_linear(&[&src, &map, &sink]).unwrap();
2630        assert_eq!(links[0], fixed_compressed(VideoCodec::H265, 1280, 720, 30));
2631        assert_eq!(links[1], fixed_video(RawVideoFormat::Nv12, 1280, 720, 30));
2632    }
2633
2634    #[test]
2635    fn legacy_cascade_source_to_sink() {
2636        // Source produces 720p NV12; sink accepts anything (returns
2637        // upstream unchanged from its intercept_caps).
2638        let src_caps = fixed_video(RawVideoFormat::Nv12, 1280, 720, 30);
2639        let src = CapsConstraint::LegacySource(src_caps.clone());
2640        let sink = CapsConstraint::LegacySink(Box::new(|upstream: &Caps| Ok(upstream.clone())));
2641        let links = solve_linear(&[&src, &sink]).unwrap();
2642        assert_eq!(links, vec![src_caps]);
2643    }
2644
2645    #[test]
2646    fn legacy_cascade_with_pass_through_transform() {
2647        let src_caps = fixed_video(RawVideoFormat::Nv12, 1920, 1080, 60);
2648        let src = CapsConstraint::LegacySource(src_caps.clone());
2649        let id = CapsConstraint::LegacyTransform {
2650            intercept: Box::new(|c: &Caps| Ok(c.clone())),
2651            propose_output: Box::new(|c: &Caps| c.clone()),
2652        };
2653        let sink = CapsConstraint::LegacySink(Box::new(|c: &Caps| Ok(c.clone())));
2654        let links = solve_linear(&[&src, &id, &sink]).unwrap();
2655        assert_eq!(links, vec![src_caps.clone(), src_caps]);
2656    }
2657
2658    #[test]
2659    fn legacy_cascade_with_boundary_transform() {
2660        // Decoder: input H264, output NV12 at matching dims.
2661        let src_caps = fixed_compressed(VideoCodec::H264, 1280, 720, 30);
2662        let src = CapsConstraint::LegacySource(src_caps.clone());
2663        let dec = CapsConstraint::LegacyTransform {
2664            intercept: Box::new(|c: &Caps| Ok(c.clone())),
2665            propose_output: Box::new(|c: &Caps| match c {
2666                Caps::CompressedVideo {
2667                    width,
2668                    height,
2669                    framerate,
2670                    ..
2671                } => Caps::RawVideo {
2672                    format: RawVideoFormat::Nv12,
2673                    width: width.clone(),
2674                    height: height.clone(),
2675                    framerate: framerate.clone(),
2676                    interlace: crate::Interlace::Any,
2677                },
2678                other => other.clone(),
2679            }),
2680        };
2681        let sink = CapsConstraint::LegacySink(Box::new(|c: &Caps| Ok(c.clone())));
2682        let links = solve_linear(&[&src, &dec, &sink]).unwrap();
2683        // M16 step 5e: the legacy cascade is intercept-only, mirroring
2684        // the pre-M16 single-fixated-caps model exactly. The decoder's
2685        // `propose_output_caps` is ignored on this path because legacy
2686        // sinks (e.g. waylandsink with workaround #2) depend on
2687        // receiving the upstream-side caps at `configure_pipeline`
2688        // and learning the real output dims later via mid-stream
2689        // `CapsChanged`. Both link slots carry the same fixated caps.
2690        // Format-changing semantics arrive when an element migrates to
2691        // a native variant and the chain becomes mixed.
2692        let h264 = fixed_compressed(VideoCodec::H264, 1280, 720, 30);
2693        assert_eq!(links, vec![h264.clone(), h264]);
2694    }
2695
2696    #[test]
2697    fn legacy_cascade_intercept_failure_returns_empty_link() {
2698        let src = CapsConstraint::LegacySource(fixed_compressed(VideoCodec::H264, 1280, 720, 30));
2699        let sink = CapsConstraint::LegacySink(Box::new(|_: &Caps| {
2700            Err(crate::error::G2gError::CapsMismatch)
2701        }));
2702        assert!(matches!(
2703            solve_linear(&[&src, &sink]),
2704            Err(NegotiationFailure::EmptyLink {
2705                upstream: 0,
2706                downstream: 1,
2707                ..
2708            })
2709        ));
2710    }
2711
2712    #[test]
2713    fn mixed_legacy_source_native_sink() {
2714        // Migration shape: source still on legacy bridge, sink moved to
2715        // native Accepts. The mixed cascade fixates link from the
2716        // source and narrows against the sink's CapsSet.
2717        let caps = fixed_video(RawVideoFormat::Nv12, 1280, 720, 30);
2718        let src = CapsConstraint::LegacySource(caps.clone());
2719        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2720            RawVideoFormat::Nv12,
2721            Dim::Any,
2722            Dim::Any,
2723            Rate::Any,
2724        )));
2725        let links = solve_linear(&[&src, &sink]).unwrap();
2726        assert_eq!(links, vec![caps]);
2727    }
2728
2729    #[test]
2730    fn mixed_native_source_legacy_sink() {
2731        // Reverse migration shape: native source, legacy sink.
2732        let caps = fixed_video(RawVideoFormat::Nv12, 640, 480, 30);
2733        let src = CapsConstraint::Produces(CapsSet::one(caps.clone()));
2734        let sink = CapsConstraint::LegacySink(Box::new(|c: &Caps| Ok(c.clone())));
2735        let links = solve_linear(&[&src, &sink]).unwrap();
2736        assert_eq!(links, vec![caps]);
2737    }
2738
2739    #[test]
2740    fn mixed_native_source_legacy_transform_native_sink() {
2741        // Source migrated to native, decoder still on legacy bridge,
2742        // sink migrated. Exercises forward cascade through a legacy
2743        // boundary transform between two native endpoints.
2744        let h264 = fixed_compressed(VideoCodec::H264, 1920, 1080, 60);
2745        let nv12 = fixed_video(RawVideoFormat::Nv12, 1920, 1080, 60);
2746        let src = CapsConstraint::Produces(CapsSet::one(h264));
2747        let dec = CapsConstraint::LegacyTransform {
2748            intercept: Box::new(|c: &Caps| Ok(c.clone())),
2749            propose_output: Box::new(|c: &Caps| match c {
2750                Caps::CompressedVideo {
2751                    width,
2752                    height,
2753                    framerate,
2754                    ..
2755                } => Caps::RawVideo {
2756                    format: RawVideoFormat::Nv12,
2757                    width: width.clone(),
2758                    height: height.clone(),
2759                    framerate: framerate.clone(),
2760                    interlace: crate::Interlace::Any,
2761                },
2762                other => other.clone(),
2763            }),
2764        };
2765        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2766            RawVideoFormat::Nv12,
2767            Dim::Any,
2768            Dim::Any,
2769            Rate::Any,
2770        )));
2771        let links = solve_linear(&[&src, &dec, &sink]).unwrap();
2772        assert_eq!(
2773            links,
2774            vec![fixed_compressed(VideoCodec::H264, 1920, 1080, 60), nv12,]
2775        );
2776    }
2777
2778    #[test]
2779    fn mixed_chain_empty_link_when_sink_rejects() {
2780        let src = CapsConstraint::LegacySource(fixed_compressed(VideoCodec::H264, 1280, 720, 30));
2781        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2782            RawVideoFormat::Nv12,
2783            Dim::Any,
2784            Dim::Any,
2785            Rate::Any,
2786        )));
2787        assert!(matches!(
2788            solve_linear(&[&src, &sink]),
2789            Err(NegotiationFailure::EmptyLink { .. })
2790        ));
2791    }
2792
2793    #[test]
2794    fn accepts_any_native_chain_passes_source_caps_through() {
2795        let caps = fixed_video(RawVideoFormat::Nv12, 1280, 720, 30);
2796        let src = CapsConstraint::Produces(CapsSet::one(caps.clone()));
2797        let sink = CapsConstraint::AcceptsAny;
2798        let links = solve_linear(&[&src, &sink]).unwrap();
2799        assert_eq!(links, vec![caps]);
2800    }
2801
2802    #[test]
2803    fn accepts_any_mixed_chain_passes_legacy_source_through() {
2804        // Migration shape: legacy source still on the bridge, sink
2805        // migrated to AcceptsAny.
2806        let caps = fixed_compressed(VideoCodec::H264, 1920, 1080, 60);
2807        let src = CapsConstraint::LegacySource(caps.clone());
2808        let sink = CapsConstraint::AcceptsAny;
2809        let links = solve_linear(&[&src, &sink]).unwrap();
2810        assert_eq!(links, vec![caps]);
2811    }
2812
2813    #[test]
2814    fn accepts_any_in_middle_position_is_silently_a_no_op() {
2815        // `AcceptsAny` in the middle of a native chain neither narrows
2816        // its input link nor its output link. The surrounding source
2817        // and sink fully determine the link assignments — the middle
2818        // element is invisible to the solver. Forward-cascade paths
2819        // (mixed/legacy) do reject this via `forward_propagate` because
2820        // they need an explicit output rule.
2821        let caps = fixed_video(RawVideoFormat::Nv12, 1, 1, 1);
2822        let src = CapsConstraint::Produces(CapsSet::one(caps.clone()));
2823        let mid = CapsConstraint::AcceptsAny;
2824        let sink = CapsConstraint::Accepts(CapsSet::one(caps.clone()));
2825        let links = solve_linear(&[&src, &mid, &sink]).unwrap();
2826        assert_eq!(links, vec![caps.clone(), caps]);
2827    }
2828
2829    #[test]
2830    fn identity_any_couples_native_links() {
2831        // Fully-native: Produces → IdentityAny → AcceptsAny.
2832        // The wildcard transform doesn't constrain by any set; it just
2833        // forces input = output, so both links carry the source's
2834        // produced caps.
2835        let caps = fixed_video(RawVideoFormat::Nv12, 1280, 720, 30);
2836        let src = CapsConstraint::Produces(CapsSet::one(caps.clone()));
2837        let mid = CapsConstraint::IdentityAny;
2838        let sink = CapsConstraint::AcceptsAny;
2839        let links = solve_linear(&[&src, &mid, &sink]).unwrap();
2840        assert_eq!(links, vec![caps.clone(), caps]);
2841    }
2842
2843    #[test]
2844    fn identity_any_in_mixed_chain_passes_legacy_source_through() {
2845        let caps = fixed_compressed(VideoCodec::H264, 1920, 1080, 60);
2846        let src = CapsConstraint::LegacySource(caps.clone());
2847        let mid = CapsConstraint::IdentityAny;
2848        let sink = CapsConstraint::AcceptsAny;
2849        let links = solve_linear(&[&src, &mid, &sink]).unwrap();
2850        assert_eq!(links, vec![caps.clone(), caps]);
2851    }
2852
2853    #[test]
2854    fn identity_any_endpoint_position_rejected_in_mixed() {
2855        // IdentityAny is interior-only; using it as a source or sink
2856        // should fail the endpoint shape check.
2857        let caps = fixed_video(RawVideoFormat::Nv12, 1, 1, 1);
2858        let bad_src = CapsConstraint::IdentityAny;
2859        let sink = CapsConstraint::Accepts(CapsSet::one(caps));
2860        assert!(matches!(
2861            solve_linear(&[&bad_src, &sink]),
2862            Err(NegotiationFailure::EndpointShapeMismatch { index: 0 })
2863        ));
2864    }
2865
2866    #[test]
2867    fn all_native_produces_to_accepts_any_passes_through() {
2868        // 5f-style chain: native source (Produces) → AcceptsAny.
2869        // Confirms the all-native arc-consistency path passes Produces's
2870        // caps through and the chain returns the source's fixed caps.
2871        let caps = fixed_video(RawVideoFormat::Rgba8, 1280, 720, 30);
2872        let src = CapsConstraint::Produces(CapsSet::one(caps.clone()));
2873        let sink = CapsConstraint::AcceptsAny;
2874        let links = solve_linear(&[&src, &sink]).unwrap();
2875        assert_eq!(links, vec![caps]);
2876    }
2877
2878    #[test]
2879    fn mapping_no_surviving_pair_returns_empty_link() {
2880        let src = CapsConstraint::Produces(CapsSet::one(fixed_compressed(
2881            VideoCodec::Av1,
2882            1280,
2883            720,
2884            30,
2885        )));
2886        let map = CapsConstraint::Mapping(vec![(
2887            CapsSet::one(compressed(VideoCodec::H264, Dim::Any, Dim::Any, Rate::Any)),
2888            CapsSet::one(video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any)),
2889        )]);
2890        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2891            RawVideoFormat::Nv12,
2892            Dim::Any,
2893            Dim::Any,
2894            Rate::Any,
2895        )));
2896        assert!(matches!(
2897            solve_linear(&[&src, &map, &sink]),
2898            Err(NegotiationFailure::EmptyLink { .. })
2899        ));
2900    }
2901
2902    /// Caps-α downstream feasibility ignores the source: link 0's set is the
2903    /// pass-through transform's own set narrowed by the sink, independent of
2904    /// what the source happens to produce, so a mid-stream source change can
2905    /// be re-fixated against the real downstream capability.
2906    #[cfg(feature = "std")]
2907    #[test]
2908    fn downstream_feasibility_is_source_independent() {
2909        let src =
2910            CapsConstraint::Produces(CapsSet::one(fixed_video(RawVideoFormat::Rgba8, 64, 64, 30)));
2911        let id = CapsConstraint::IdentityAny;
2912        let sink = CapsConstraint::Accepts(CapsSet::one(video(
2913            RawVideoFormat::Nv12,
2914            Dim::Any,
2915            Dim::Any,
2916            Rate::Any,
2917        )));
2918        let feas = downstream_feasibility(&[&src, &id, &sink]);
2919        // Two links. Both carry the sink's NV12 set (IdentityAny couples them);
2920        // neither is narrowed to the source's RGBA.
2921        assert_eq!(feas.len(), 2);
2922        assert!(feas[1].as_ref().unwrap().accepts(&video(
2923            RawVideoFormat::Nv12,
2924            Dim::Any,
2925            Dim::Any,
2926            Rate::Any,
2927        )));
2928        assert!(feas[0].as_ref().unwrap().accepts(&video(
2929            RawVideoFormat::Nv12,
2930            Dim::Any,
2931            Dim::Any,
2932            Rate::Any,
2933        )));
2934        assert!(!feas[0].as_ref().unwrap().accepts(&video(
2935            RawVideoFormat::Rgba8,
2936            Dim::Any,
2937            Dim::Any,
2938            Rate::Any,
2939        )));
2940    }
2941
2942    /// `resolve_forward_output` steers a format converter toward the one
2943    /// output its downstream accepts, defers when there is no concrete
2944    /// downstream set, and rejects loud when no output can satisfy it. A
2945    /// format-only converter is a `DerivedOutput` so it carries the input's
2946    /// concrete geometry into its output (a static `Mapping` with `Any` dims
2947    /// can't fixate and would `Defer`).
2948    #[test]
2949    fn resolve_forward_output_steers_defers_and_rejects() {
2950        // Converter: any raw input -> {same format, NV12} at the input's dims.
2951        let conv = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| {
2952            let Caps::RawVideo {
2953                format,
2954                width,
2955                height,
2956                framerate,
2957                interlace: _,
2958            } = input
2959            else {
2960                return CapsSet::from_alternatives(vec![]);
2961            };
2962            CapsSet::from_alternatives(vec![
2963                video(*format, width.clone(), height.clone(), framerate.clone()),
2964                video(
2965                    RawVideoFormat::Nv12,
2966                    width.clone(),
2967                    height.clone(),
2968                    framerate.clone(),
2969                ),
2970            ])
2971        }));
2972        let i420 = fixed_video(RawVideoFormat::I420, 64, 64, 30);
2973        let nv12_set = CapsSet::one(video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any));
2974
2975        // Steered: downstream accepts only NV12, so the runner picks NV12.
2976        match resolve_forward_output(&conv, &i420, Some(&nv12_set), None) {
2977            ForwardResolve::Fixed(c) => {
2978                assert_eq!(
2979                    c,
2980                    video(
2981                        RawVideoFormat::Nv12,
2982                        Dim::Fixed(64),
2983                        Dim::Fixed(64),
2984                        Rate::Fixed(30 << 16)
2985                    )
2986                );
2987            }
2988            other => panic!("expected Fixed(NV12), got {other:?}"),
2989        }
2990
2991        // No concrete downstream set, but the output is ambiguous ({same, NV12}):
2992        // defer to the element's own process.
2993        assert_eq!(
2994            resolve_forward_output(&conv, &i420, None, None),
2995            ForwardResolve::Defer
2996        );
2997
2998        // No downstream snapshot but an UNAMBIGUOUS output: a property-driven
2999        // converter forwards its single output (RGBA8) rather than leaking the
3000        // input format. This is what lets a strict downstream (a textoverlay
3001        // after `mp4src ! avdec ! videoconvert`) see the converted caps on a
3002        // mid-stream change instead of the decoder's NV12.
3003        let to_rgba = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
3004            Caps::RawVideo {
3005                width,
3006                height,
3007                framerate,
3008                ..
3009            } => CapsSet::one(video(
3010                RawVideoFormat::Rgba8,
3011                width.clone(),
3012                height.clone(),
3013                framerate.clone(),
3014            )),
3015            _ => CapsSet::from_alternatives(vec![]),
3016        }));
3017        let nv12_in = fixed_video(RawVideoFormat::Nv12, 64, 64, 30);
3018        match resolve_forward_output(&to_rgba, &nv12_in, None, None) {
3019            ForwardResolve::Fixed(c) => assert_eq!(
3020                c,
3021                video(
3022                    RawVideoFormat::Rgba8,
3023                    Dim::Fixed(64),
3024                    Dim::Fixed(64),
3025                    Rate::Fixed(30 << 16)
3026                )
3027            ),
3028            other => panic!("expected Fixed(RGBA8), got {other:?}"),
3029        }
3030
3031        // Downstream accepts only Bgra8, which the converter cannot emit: loud.
3032        let bgra_set = CapsSet::one(video(RawVideoFormat::Bgra8, Dim::Any, Dim::Any, Rate::Any));
3033        assert!(matches!(
3034            resolve_forward_output(&conv, &i420, Some(&bgra_set), None),
3035            ForwardResolve::Infeasible(NegotiationFailure::EmptyLink { .. })
3036        ));
3037    }
3038
3039    /// A rescaler's single output is forwarded even when an input field it never
3040    /// learned leaves that output unfixatable. The `filesrc ! qtdemux ! h264parse
3041    /// ! ffmpegdec ! videoscale ! video/x-raw,width=640,height=640` regression:
3042    /// the decoder announces its geometry before it knows the framerate, and
3043    /// deferring on that first event forwards the scaler's 1280x720 INPUT, which
3044    /// the capsfilter then rightly rejects.
3045    #[test]
3046    fn resolve_forward_output_forwards_an_unfixatable_single_output() {
3047        // Rescaler: any raw input -> the same format at a fixed 640x640, the
3048        // input's framerate carried through (unfixed included).
3049        let scale = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
3050            Caps::RawVideo {
3051                format, framerate, ..
3052            } => CapsSet::one(video(
3053                *format,
3054                Dim::Fixed(640),
3055                Dim::Fixed(640),
3056                framerate.clone(),
3057            )),
3058            _ => CapsSet::from_alternatives(vec![]),
3059        }));
3060        // The decoder's first mid-stream caps: real geometry, framerate not yet
3061        // known.
3062        let no_rate = video(
3063            RawVideoFormat::I420,
3064            Dim::Fixed(1280),
3065            Dim::Fixed(720),
3066            Rate::Any,
3067        );
3068        let downstream = CapsSet::one(video(
3069            RawVideoFormat::I420,
3070            Dim::Fixed(640),
3071            Dim::Fixed(640),
3072            Rate::Any,
3073        ));
3074        match resolve_forward_output(&scale, &no_rate, Some(&downstream), None) {
3075            ForwardResolve::Fixed(c) => assert_eq!(
3076                c,
3077                video(
3078                    RawVideoFormat::I420,
3079                    Dim::Fixed(640),
3080                    Dim::Fixed(640),
3081                    Rate::Any
3082                ),
3083                "the scaler's own output geometry, not its input's"
3084            ),
3085            other => panic!("expected Fixed(640x640), got {other:?}"),
3086        }
3087    }
3088
3089    /// The same unfixatable single output with no downstream snapshot, which a
3090    /// wildcard sink below a chain of `DerivedOutput` elements leaves empty.
3091    /// Deferring forwards a letterboxing `videobox`'s INPUT, which the
3092    /// fixed-size consumer below it then rejects.
3093    #[test]
3094    fn resolve_forward_output_forwards_a_derived_single_output_without_a_snapshot() {
3095        // Letterbox: any raw input -> same format, 24 rows taller, input's rate.
3096        let letterbox = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| match input {
3097            Caps::RawVideo {
3098                format,
3099                width,
3100                height: Dim::Fixed(h),
3101                framerate,
3102                ..
3103            } => CapsSet::one(video(
3104                *format,
3105                width.clone(),
3106                Dim::Fixed(h + 24),
3107                framerate.clone(),
3108            )),
3109            _ => CapsSet::from_alternatives(vec![]),
3110        }));
3111        let no_rate = video(
3112            RawVideoFormat::Rgba8,
3113            Dim::Fixed(640),
3114            Dim::Fixed(360),
3115            Rate::Any,
3116        );
3117        match resolve_forward_output(&letterbox, &no_rate, None, None) {
3118            ForwardResolve::Fixed(c) => assert_eq!(
3119                c,
3120                video(
3121                    RawVideoFormat::Rgba8,
3122                    Dim::Fixed(640),
3123                    Dim::Fixed(384),
3124                    Rate::Any
3125                ),
3126                "the boxer's own output geometry, not its input's"
3127            ),
3128            other => panic!("expected Fixed(640x384), got {other:?}"),
3129        }
3130    }
3131
3132    /// An ambiguous producible set with no downstream snapshot keeps the shape
3133    /// the element already produces (its previous output with re-derived
3134    /// geometry), and defers only when that shape is no longer producible. The
3135    /// `filesrc ! decodebin ! videoconvert ! textoverlay` regression: the
3136    /// converter's mid-stream re-solve must forward RGBA (its negotiated
3137    /// output) at the new geometry, not its I420 input.
3138    #[test]
3139    fn resolve_forward_output_keeps_previous_shape() {
3140        // Converter: any raw input -> {same format, NV12} at the input's dims.
3141        let conv = CapsConstraint::DerivedOutput(Box::new(|input: &Caps| {
3142            let Caps::RawVideo {
3143                format,
3144                width,
3145                height,
3146                framerate,
3147                interlace: _,
3148            } = input
3149            else {
3150                return CapsSet::from_alternatives(vec![]);
3151            };
3152            CapsSet::from_alternatives(vec![
3153                video(*format, width.clone(), height.clone(), framerate.clone()),
3154                video(
3155                    RawVideoFormat::Nv12,
3156                    width.clone(),
3157                    height.clone(),
3158                    framerate.clone(),
3159                ),
3160            ])
3161        }));
3162        let i420_big = fixed_video(RawVideoFormat::I420, 1920, 1080, 30);
3163
3164        // Startup produced NV12 at the placeholder geometry: the re-solve keeps
3165        // NV12 and takes the refined dims.
3166        let prev = fixed_video(RawVideoFormat::Nv12, 16, 16, 1);
3167        match resolve_forward_output(&conv, &i420_big, None, Some(&prev)) {
3168            ForwardResolve::Fixed(c) => assert_eq!(
3169                c,
3170                video(
3171                    RawVideoFormat::Nv12,
3172                    Dim::Fixed(1920),
3173                    Dim::Fixed(1080),
3174                    Rate::Fixed(30 << 16)
3175                )
3176            ),
3177            other => panic!("expected Fixed(NV12 at new dims), got {other:?}"),
3178        }
3179
3180        // The previous shape is no longer producible: defer to the element.
3181        let prev_gone = fixed_video(RawVideoFormat::Bgra8, 16, 16, 1);
3182        assert_eq!(
3183            resolve_forward_output(&conv, &i420_big, None, Some(&prev_gone)),
3184            ForwardResolve::Defer
3185        );
3186
3187        // A field the candidates leave open must not be invented: a decoder
3188        // whose produce set carries no framerate (the vorbisdec regression, an
3189        // unpinned sample rate steered to a default 48000) defers so the
3190        // element's own `process` emits the real value.
3191        let open_rate = CapsConstraint::DerivedOutput(Box::new(|_input: &Caps| {
3192            CapsSet::from_alternatives(vec![
3193                video(
3194                    RawVideoFormat::Nv12,
3195                    Dim::Fixed(1920),
3196                    Dim::Fixed(1080),
3197                    Rate::Any,
3198                ),
3199                video(
3200                    RawVideoFormat::I420,
3201                    Dim::Fixed(1920),
3202                    Dim::Fixed(1080),
3203                    Rate::Any,
3204                ),
3205            ])
3206        }));
3207        assert_eq!(
3208            resolve_forward_output(&open_rate, &i420_big, None, Some(&prev)),
3209            ForwardResolve::Defer
3210        );
3211
3212        // With a downstream snapshot admitting both, the previous shape still
3213        // orders the fixation.
3214        let both = CapsSet::from_alternatives(vec![
3215            video(RawVideoFormat::I420, Dim::Any, Dim::Any, Rate::Any),
3216            video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any),
3217        ]);
3218        match resolve_forward_output(&conv, &i420_big, Some(&both), Some(&prev)) {
3219            ForwardResolve::Fixed(Caps::RawVideo { format, .. }) => {
3220                assert_eq!(format, RawVideoFormat::Nv12);
3221            }
3222            other => panic!("expected Fixed(NV12), got {other:?}"),
3223        }
3224    }
3225
3226    use crate::graph::Graph;
3227
3228    #[test]
3229    fn solve_graph_matches_solve_linear_on_a_chain() {
3230        // source Produces fixed RGBA -> DerivedOutput RGBA->NV12 -> Accepts NV12.
3231        let rgba = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
3232        let nv12 = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3233        let lin: Vec<CapsConstraint> = vec![
3234            CapsConstraint::Produces(CapsSet::one(rgba.clone())),
3235            CapsConstraint::DerivedOutput(Box::new({
3236                let nv12 = nv12.clone();
3237                move |_input: &Caps| CapsSet::one(nv12.clone())
3238            })),
3239            CapsConstraint::Accepts(CapsSet::one(nv12.clone())),
3240        ];
3241        let refs: Vec<&CapsConstraint> = lin.iter().collect();
3242        let linear = solve_linear(&refs).expect("linear chain solves");
3243
3244        // the same chain expressed as a graph (constraints rebuilt identically).
3245        let dag_cs: Vec<NodeConstraint> = vec![
3246            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(rgba.clone()))),
3247            NodeConstraint::Element(CapsConstraint::DerivedOutput(Box::new({
3248                let nv12 = nv12.clone();
3249                move |_input: &Caps| CapsSet::one(nv12.clone())
3250            }))),
3251            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12.clone()))),
3252        ];
3253        let mut g: Graph<()> = Graph::new();
3254        let src = g.add_source(());
3255        let tx = g.add_transform(());
3256        let sink = g.add_sink(());
3257        g.link(src, tx).unwrap();
3258        g.link(tx, sink).unwrap();
3259        let v = g.finish().unwrap();
3260        let dag = solve_graph(&v, &dag_cs).expect("same chain as a graph solves");
3261
3262        assert_eq!(
3263            dag, linear,
3264            "DAG solver matches the linear solver byte-for-byte"
3265        );
3266        assert_eq!(dag, vec![rgba, nv12]);
3267    }
3268
3269    #[test]
3270    fn solve_graph_empty_link_carries_both_sides_sets() {
3271        // source produces RGBA only, sink accepts NV12 only: the failure must
3272        // name the two nodes *and* what each of them still allowed.
3273        let rgba = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
3274        let nv12 = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3275        let cs: Vec<NodeConstraint> = vec![
3276            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(rgba.clone()))),
3277            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12.clone()))),
3278        ];
3279        let mut g: Graph<()> = Graph::new();
3280        let src = g.add_source(());
3281        let sink = g.add_sink(());
3282        g.link(src, sink).unwrap();
3283        let v = g.finish().unwrap();
3284
3285        let err = solve_graph(&v, &cs).expect_err("RGBA source vs NV12 sink cannot solve");
3286        assert!(matches!(
3287            err,
3288            NegotiationFailure::EmptyLink {
3289                upstream: 0,
3290                downstream: 1,
3291                ..
3292            }
3293        ));
3294        let c = err.conflict().expect("both candidate sets captured");
3295        assert_eq!(c.upstream, CapsSet::one(rgba));
3296        assert_eq!(c.downstream, CapsSet::one(nv12));
3297    }
3298
3299    #[test]
3300    fn solve_graph_tee_fanout_couples_branches() {
3301        let nv12_fixed = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3302        let nv12_any = video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any);
3303        // source (node 0) -> tee (1) -> two NV12 sinks (2, 3).
3304        let cs: Vec<NodeConstraint> = vec![
3305            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(nv12_fixed.clone()))),
3306            NodeConstraint::Element(CapsConstraint::IdentityAny), // tee slot, ignored
3307            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12_any.clone()))),
3308            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12_any))),
3309        ];
3310        let mut g: Graph<()> = Graph::new();
3311        let src = g.add_source(());
3312        let tee = g.add_tee(2);
3313        let a = g.add_sink(());
3314        let b = g.add_sink(());
3315        g.link(src, tee.input()).unwrap();
3316        g.link(tee.out(0), a).unwrap();
3317        g.link(tee.out(1), b).unwrap();
3318        let v = g.finish().unwrap();
3319
3320        let sol = solve_graph(&v, &cs).expect("tee fan-out solves");
3321        assert_eq!(sol.len(), 3, "three edges");
3322        assert!(
3323            sol.iter().all(|c| *c == nv12_fixed),
3324            "every branch carries the source caps"
3325        );
3326    }
3327
3328    #[test]
3329    fn solve_graph_rejects_incompatible_branch() {
3330        let nv12 = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3331        let nv12_any = video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any);
3332        let rgba_any = video(RawVideoFormat::Rgba8, Dim::Any, Dim::Any, Rate::Any);
3333        // one branch accepts NV12, the other only RGBA: strict whole-graph fail.
3334        let cs: Vec<NodeConstraint> = vec![
3335            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(nv12))),
3336            NodeConstraint::Element(CapsConstraint::IdentityAny),
3337            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12_any))),
3338            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(rgba_any))),
3339        ];
3340        let mut g: Graph<()> = Graph::new();
3341        let src = g.add_source(());
3342        let tee = g.add_tee(2);
3343        let a = g.add_sink(());
3344        let b = g.add_sink(());
3345        g.link(src, tee.input()).unwrap();
3346        g.link(tee.out(0), a).unwrap();
3347        g.link(tee.out(1), b).unwrap();
3348        let v = g.finish().unwrap();
3349
3350        assert!(
3351            matches!(
3352                solve_graph(&v, &cs),
3353                Err(NegotiationFailure::EmptyLink { .. })
3354            ),
3355            "an incompatible branch fails the whole solve"
3356        );
3357    }
3358
3359    #[test]
3360    fn solve_graph_diamond_fixates_globally_consistent() {
3361        // True diamond: source {V,W} -> tee -> two Mapping branches -> muxer.
3362        // Branch 1 maps V->A, W->C; branch 2 maps W->B, V->D (orders misaligned).
3363        // The muxer accepts {A,C} on pad 0 and {B,D} on pad 1. Valid solutions
3364        // exist (source=V => b1=A, b2=D; or source=W => b1=C, b2=B), but greedy
3365        // per-edge fixation picks source=V, b1=A (first of {A,C}), b2=B (first of
3366        // {B,D}) -- and (V, B) is not a branch-2 mapping pair. Arc consistency
3367        // can't catch this; the backtracking fixation must.
3368        let v = fixed_compressed(VideoCodec::H264, 64, 48, 30);
3369        let w = fixed_compressed(VideoCodec::H265, 64, 48, 30);
3370        let a = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3371        let c = fixed_video(RawVideoFormat::I420, 64, 48, 30);
3372        let b = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
3373        let d = fixed_video(RawVideoFormat::I422, 64, 48, 30);
3374        let muxed = fixed_video(RawVideoFormat::I444, 64, 48, 30);
3375
3376        let cs: Vec<NodeConstraint> = vec![
3377            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::from_alternatives(vec![
3378                v.clone(),
3379                w.clone(),
3380            ]))),
3381            NodeConstraint::Element(CapsConstraint::IdentityAny), // tee
3382            NodeConstraint::Element(CapsConstraint::Mapping(vec![
3383                (CapsSet::one(v.clone()), CapsSet::one(a.clone())),
3384                (CapsSet::one(w.clone()), CapsSet::one(c.clone())),
3385            ])),
3386            NodeConstraint::Element(CapsConstraint::Mapping(vec![
3387                (CapsSet::one(w.clone()), CapsSet::one(b.clone())),
3388                (CapsSet::one(v.clone()), CapsSet::one(d.clone())),
3389            ])),
3390            NodeConstraint::Muxer {
3391                inputs: vec![
3392                    CapsConstraint::Accepts(CapsSet::from_alternatives(vec![a.clone(), c.clone()])),
3393                    CapsConstraint::Accepts(CapsSet::from_alternatives(vec![b.clone(), d.clone()])),
3394                ],
3395                output: CapsConstraint::Produces(CapsSet::one(muxed)),
3396                follows: None,
3397            },
3398            NodeConstraint::Element(CapsConstraint::AcceptsAny),
3399        ];
3400        let mut g: Graph<()> = Graph::new();
3401        let src = g.add_source(());
3402        let tee = g.add_tee(2);
3403        let b1 = g.add_transform(());
3404        let b2 = g.add_transform(());
3405        let mux = g.add_muxer((), 2);
3406        let sink = g.add_sink(());
3407        g.link(src, tee.input()).unwrap();
3408        g.link(tee.out(0), b1).unwrap();
3409        g.link(tee.out(1), b2).unwrap();
3410        g.link(b1, mux.input(0)).unwrap();
3411        g.link(b2, mux.input(1)).unwrap();
3412        g.link(mux.output(), sink).unwrap();
3413        let vg = g.finish().unwrap();
3414
3415        let sol = solve_graph(&vg, &cs).expect("diamond has a satisfying assignment");
3416        // Edges: 0 src->tee, 1 tee->b1, 2 tee->b2, 3 b1->mux, 4 b2->mux, 5 mux->sink.
3417        // The tee broadcasts one value, so both branch inputs equal the source.
3418        assert_eq!(sol[1], sol[0], "tee broadcasts to branch 1");
3419        assert_eq!(sol[2], sol[0], "tee broadcasts to branch 2");
3420        // Each branch's (in, out) must be one of its declared mapping pairs.
3421        let b1_pair = (sol[1].clone(), sol[3].clone());
3422        assert!(
3423            b1_pair == (v.clone(), a.clone()) || b1_pair == (w.clone(), c.clone()),
3424            "branch 1 fixated to a real mapping pair, got {b1_pair:?}"
3425        );
3426        let b2_pair = (sol[2].clone(), sol[4].clone());
3427        assert!(
3428            b2_pair == (w.clone(), b.clone()) || b2_pair == (v.clone(), d.clone()),
3429            "branch 2 fixated to a real mapping pair, got {b2_pair:?}"
3430        );
3431    }
3432
3433    #[test]
3434    fn solve_graph_muxer_fan_in_narrows_each_input() {
3435        // two video sources combine at a muxer: input pad 0 accepts H264,
3436        // pad 1 accepts H265, the output produces a (token) muxed stream.
3437        let h264 = compressed(
3438            VideoCodec::H264,
3439            Dim::Fixed(64),
3440            Dim::Fixed(48),
3441            Rate::Fixed(30 << 16),
3442        );
3443        let h265 = compressed(
3444            VideoCodec::H265,
3445            Dim::Fixed(64),
3446            Dim::Fixed(48),
3447            Rate::Fixed(30 << 16),
3448        );
3449        let h264_any = compressed(VideoCodec::H264, Dim::Any, Dim::Any, Rate::Any);
3450        let h265_any = compressed(VideoCodec::H265, Dim::Any, Dim::Any, Rate::Any);
3451        let muxed = compressed(
3452            VideoCodec::H264,
3453            Dim::Fixed(64),
3454            Dim::Fixed(48),
3455            Rate::Fixed(30 << 16),
3456        );
3457
3458        let cs: Vec<NodeConstraint> = vec![
3459            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(h264.clone()))),
3460            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(h265.clone()))),
3461            NodeConstraint::Muxer {
3462                inputs: vec![
3463                    CapsConstraint::Accepts(CapsSet::one(h264_any)),
3464                    CapsConstraint::Accepts(CapsSet::one(h265_any)),
3465                ],
3466                output: CapsConstraint::Produces(CapsSet::one(muxed.clone())),
3467                follows: None,
3468            },
3469            NodeConstraint::Element(CapsConstraint::AcceptsAny),
3470        ];
3471        let mut g: Graph<()> = Graph::new();
3472        let s0 = g.add_source(());
3473        let s1 = g.add_source(());
3474        let mux = g.add_muxer((), 2);
3475        let sink = g.add_sink(());
3476        g.link(s0, mux.input(0)).unwrap();
3477        g.link(s1, mux.input(1)).unwrap();
3478        g.link(mux.output(), sink).unwrap();
3479        let v = g.finish().unwrap();
3480
3481        let sol = solve_graph(&v, &cs).expect("muxer fan-in solves");
3482        // edges in id order: s0->in0, s1->in1, mux.out->sink.
3483        assert_eq!(
3484            sol,
3485            vec![h264, h265, muxed],
3486            "each input narrowed by its pad, output by produce"
3487        );
3488    }
3489
3490    #[test]
3491    fn solve_graph_muxer_follows_input_derives_output() {
3492        // An identity-passthrough mux (overlay): a video pad 0, a sidecar pad 1,
3493        // output follows pad 0. The output edge must equal the video source's caps
3494        // even though no output caps were declared (`output` is a placeholder).
3495        let rgba = fixed_video(RawVideoFormat::Rgba8, 320, 240, 30);
3496        let rgba_any = video(RawVideoFormat::Rgba8, Dim::Any, Dim::Any, Rate::Any);
3497        let text = Caps::Text {
3498            format: crate::caps::TextFormat::Utf8,
3499        };
3500
3501        let cs: Vec<NodeConstraint> = vec![
3502            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(rgba.clone()))),
3503            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(text.clone()))),
3504            NodeConstraint::Muxer {
3505                inputs: vec![
3506                    CapsConstraint::Accepts(CapsSet::one(rgba_any)),
3507                    CapsConstraint::Accepts(CapsSet::one(text.clone())),
3508                ],
3509                // Placeholder: ignored because `follows` is set.
3510                output: CapsConstraint::AcceptsAny,
3511                follows: Some(0),
3512            },
3513            NodeConstraint::Element(CapsConstraint::AcceptsAny),
3514        ];
3515        let mut g: Graph<()> = Graph::new();
3516        let video_src = g.add_source(());
3517        let text_src = g.add_source(());
3518        let mux = g.add_muxer((), 2);
3519        let sink = g.add_sink(());
3520        g.link(video_src, mux.input(0)).unwrap();
3521        g.link(text_src, mux.input(1)).unwrap();
3522        g.link(mux.output(), sink).unwrap();
3523        let v = g.finish().unwrap();
3524
3525        let sol = solve_graph(&v, &cs).expect("follows-input muxer solves");
3526        // edges: 0 video->in0, 1 text->in1, 2 mux.out->sink.
3527        assert_eq!(
3528            sol[2], rgba,
3529            "output edge follows the video pad's negotiated caps"
3530        );
3531        assert_eq!(sol[0], rgba, "video pad edge unchanged");
3532        assert_eq!(sol[1], text, "text pad edge unchanged");
3533    }
3534
3535    #[test]
3536    fn solve_graph_muxer_wildcard_inputs_forward_source_caps() {
3537        // The `InterleaveMux` shape: every input pad is `AcceptsAny` (frames
3538        // carry their own caps), the output `Produces` a fixed merged caps. The
3539        // wildcard inputs impose no narrowing, so each input edge keeps its
3540        // source's caps and the output edge takes the produced caps.
3541        let h264 = compressed(
3542            VideoCodec::H264,
3543            Dim::Fixed(64),
3544            Dim::Fixed(48),
3545            Rate::Fixed(30 << 16),
3546        );
3547        let aac = Caps::Audio {
3548            format: crate::caps::AudioFormat::Aac,
3549            channels: 2,
3550            sample_rate: 48_000,
3551        };
3552        let merged = compressed(
3553            VideoCodec::H264,
3554            Dim::Fixed(64),
3555            Dim::Fixed(48),
3556            Rate::Fixed(30 << 16),
3557        );
3558
3559        let cs: Vec<NodeConstraint> = vec![
3560            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(h264.clone()))),
3561            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(aac.clone()))),
3562            NodeConstraint::Muxer {
3563                inputs: vec![CapsConstraint::AcceptsAny, CapsConstraint::AcceptsAny],
3564                output: CapsConstraint::Produces(CapsSet::one(merged.clone())),
3565                follows: None,
3566            },
3567            NodeConstraint::Element(CapsConstraint::AcceptsAny),
3568        ];
3569        let mut g: Graph<()> = Graph::new();
3570        let s0 = g.add_source(());
3571        let s1 = g.add_source(());
3572        let mux = g.add_muxer((), 2);
3573        let sink = g.add_sink(());
3574        g.link(s0, mux.input(0)).unwrap();
3575        g.link(s1, mux.input(1)).unwrap();
3576        g.link(mux.output(), sink).unwrap();
3577        let v = g.finish().unwrap();
3578
3579        let sol = solve_graph(&v, &cs).expect("wildcard muxer solves");
3580        // a wildcard input pad leaves each input edge at its own source caps.
3581        assert_eq!(sol, vec![h264, aac, merged]);
3582    }
3583
3584    #[test]
3585    fn solve_graph_accepts_legacy_bridge_constraints() {
3586        // A native source/muxer feeding a `LegacySink` (the default sink bridge,
3587        // e.g. m10's CollectingSink). The legacy sink imposes no narrowing, so
3588        // the merged output flows through unchanged. Previously this hit
3589        // EndpointShapeMismatch; now `run_muxer_sink` can build it as a graph.
3590        let h264 = fixed_compressed(VideoCodec::H264, 64, 48, 30);
3591        let cs: Vec<NodeConstraint> = vec![
3592            NodeConstraint::Element(CapsConstraint::LegacySource(h264.clone())),
3593            NodeConstraint::Element(CapsConstraint::LegacySource(h264.clone())),
3594            NodeConstraint::Muxer {
3595                inputs: vec![CapsConstraint::AcceptsAny, CapsConstraint::AcceptsAny],
3596                output: CapsConstraint::Produces(CapsSet::one(h264.clone())),
3597                follows: None,
3598            },
3599            NodeConstraint::Element(CapsConstraint::LegacySink(Box::new(|c: &Caps| {
3600                Ok(c.clone())
3601            }))),
3602        ];
3603        let mut g: Graph<()> = Graph::new();
3604        let s0 = g.add_source(());
3605        let s1 = g.add_source(());
3606        let mux = g.add_muxer((), 2);
3607        let sink = g.add_sink(());
3608        g.link(s0, mux.input(0)).unwrap();
3609        g.link(s1, mux.input(1)).unwrap();
3610        g.link(mux.output(), sink).unwrap();
3611        let v = g.finish().unwrap();
3612
3613        let sol = solve_graph(&v, &cs).expect("native muxer + legacy sink solves");
3614        assert_eq!(sol, vec![h264.clone(), h264.clone(), h264]);
3615    }
3616
3617    #[test]
3618    fn solve_graph_forwards_legacy_transform() {
3619        // src(LegacySource RGBA) -> LegacyTransform(RGBA->NV12) -> Accepts NV12.
3620        let rgba = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
3621        let nv12 = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3622        let cs: Vec<NodeConstraint> = vec![
3623            NodeConstraint::Element(CapsConstraint::LegacySource(rgba.clone())),
3624            NodeConstraint::Element(CapsConstraint::LegacyTransform {
3625                intercept: Box::new({
3626                    let nv12 = nv12.clone();
3627                    move |_in: &Caps| Ok(nv12.clone())
3628                }),
3629                propose_output: Box::new(|c: &Caps| c.clone()),
3630            }),
3631            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12.clone()))),
3632        ];
3633        let mut g: Graph<()> = Graph::new();
3634        let src = g.add_source(());
3635        let tx = g.add_transform(());
3636        let sink = g.add_sink(());
3637        g.link(src, tx).unwrap();
3638        g.link(tx, sink).unwrap();
3639        let v = g.finish().unwrap();
3640
3641        let sol = solve_graph(&v, &cs).expect("legacy transform forwards");
3642        assert_eq!(sol, vec![rgba, nv12]);
3643    }
3644
3645    #[cfg(feature = "std")]
3646    #[test]
3647    fn graph_feasibility_intersects_tee_branches() {
3648        // src -> tee(2) -> {accepts NV12-any, accepts NV12 64x48}. The tee input
3649        // edge's feasibility is the intersection: the tighter 64x48 set.
3650        let nv12_any = video(RawVideoFormat::Nv12, Dim::Any, Dim::Any, Rate::Any);
3651        let nv12_fixed = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3652        let cs: Vec<NodeConstraint> = vec![
3653            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(nv12_fixed.clone()))),
3654            NodeConstraint::Element(CapsConstraint::IdentityAny),
3655            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12_any))),
3656            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(nv12_fixed.clone()))),
3657        ];
3658        let mut g: Graph<()> = Graph::new();
3659        let src = g.add_source(());
3660        let tee = g.add_tee(2);
3661        let a = g.add_sink(());
3662        let b = g.add_sink(());
3663        g.link(src, tee.input()).unwrap();
3664        g.link(tee.out(0), a).unwrap();
3665        g.link(tee.out(1), b).unwrap();
3666        let v = g.finish().unwrap();
3667
3668        let sol = solve_graph(&v, &cs).expect("tee branches solve");
3669        let feas = graph_downstream_feasibility(&v, &cs, &sol);
3670        // edge 0 = src->tee, edge 1 = tee.out0->a, edge 2 = tee.out1->b.
3671        let tee_in = feas[0].as_ref().expect("tee input has feasibility");
3672        assert!(tee_in
3673            .intersect(&CapsSet::one(nv12_fixed.clone()))
3674            .fixate()
3675            .is_some());
3676        // the tee input cannot carry an off-geometry frame both branches reject.
3677        let off = fixed_video(RawVideoFormat::Nv12, 99, 99, 30);
3678        assert!(
3679            tee_in.intersect(&CapsSet::one(off)).is_empty(),
3680            "branch B pins 64x48"
3681        );
3682    }
3683
3684    #[cfg(feature = "std")]
3685    #[test]
3686    fn graph_feasibility_muxer_inputs_are_per_pad() {
3687        // two sources -> muxer{H264, H265} -> wildcard sink. Each input edge's
3688        // feasibility is its own pad accept set; the output edge is unconstrained
3689        // (the wildcard sink imposes nothing, and the output never feeds inputs).
3690        let h264_any = compressed(VideoCodec::H264, Dim::Any, Dim::Any, Rate::Any);
3691        let h265_any = compressed(VideoCodec::H265, Dim::Any, Dim::Any, Rate::Any);
3692        let cs: Vec<NodeConstraint> = vec![
3693            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(fixed_compressed(
3694                VideoCodec::H264,
3695                64,
3696                48,
3697                30,
3698            )))),
3699            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(fixed_compressed(
3700                VideoCodec::H265,
3701                64,
3702                48,
3703                30,
3704            )))),
3705            NodeConstraint::Muxer {
3706                inputs: vec![
3707                    CapsConstraint::Accepts(CapsSet::one(h264_any.clone())),
3708                    CapsConstraint::Accepts(CapsSet::one(h265_any.clone())),
3709                ],
3710                output: CapsConstraint::Produces(CapsSet::one(fixed_compressed(
3711                    VideoCodec::H264,
3712                    64,
3713                    48,
3714                    30,
3715                ))),
3716                follows: None,
3717            },
3718            NodeConstraint::Element(CapsConstraint::AcceptsAny),
3719        ];
3720        let mut g: Graph<()> = Graph::new();
3721        let s0 = g.add_source(());
3722        let s1 = g.add_source(());
3723        let mux = g.add_muxer((), 2);
3724        let sink = g.add_sink(());
3725        g.link(s0, mux.input(0)).unwrap();
3726        g.link(s1, mux.input(1)).unwrap();
3727        g.link(mux.output(), sink).unwrap();
3728        let v = g.finish().unwrap();
3729
3730        let sol = solve_graph(&v, &cs).expect("muxer graph solves");
3731        let feas = graph_downstream_feasibility(&v, &cs, &sol);
3732        // edges: 0 = s0->in0, 1 = s1->in1, 2 = mux.out->sink.
3733        assert_eq!(
3734            feas[0],
3735            Some(CapsSet::one(h264_any)),
3736            "pad 0 feasibility = its accept set"
3737        );
3738        assert_eq!(
3739            feas[1],
3740            Some(CapsSet::one(h265_any)),
3741            "pad 1 feasibility = its accept set"
3742        );
3743        assert_eq!(
3744            feas[2], None,
3745            "wildcard sink leaves the muxer output unconstrained"
3746        );
3747    }
3748
3749    #[cfg(feature = "std")]
3750    #[test]
3751    fn graph_feasibility_couples_pin_back_through_a_decoder() {
3752        // M258: src(H264, open geometry) -> decoder(DerivedOutput, geometry
3753        // passthrough) -> sink pinned to Nv12 1280x720. The decoder's INPUT edge
3754        // snapshot used to be `None` (a plain `DerivedOutput` had no input to probe
3755        // mid-stream), so a mid-stream re-solve couldn't steer the source back to
3756        // the pinned geometry. With the startup-fixated input threaded in, the
3757        // discovered passthrough fields couple the pin onto the H264 input edge.
3758        let dec_closure = |input: &Caps| match input {
3759            Caps::CompressedVideo {
3760                width,
3761                height,
3762                framerate,
3763                ..
3764            } => CapsSet::one(Caps::RawVideo {
3765                format: RawVideoFormat::Nv12,
3766                width: width.clone(),
3767                height: height.clone(),
3768                framerate: framerate.clone(),
3769                interlace: crate::Interlace::Any,
3770            }),
3771            _ => CapsSet::from_alternatives(Vec::new()),
3772        };
3773        let cs: Vec<NodeConstraint> = vec![
3774            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(compressed(
3775                VideoCodec::H264,
3776                Dim::Any,
3777                Dim::Any,
3778                Rate::Fixed(30 << 16),
3779            )))),
3780            NodeConstraint::Element(CapsConstraint::DerivedOutput(Box::new(dec_closure))),
3781            NodeConstraint::Element(CapsConstraint::Accepts(CapsSet::one(fixed_video(
3782                RawVideoFormat::Nv12,
3783                1280,
3784                720,
3785                30,
3786            )))),
3787        ];
3788        let mut g: Graph<()> = Graph::new();
3789        let src = g.add_source(());
3790        let dec = g.add_transform(());
3791        let sink = g.add_sink(());
3792        g.link(src, dec).unwrap();
3793        g.link(dec, sink).unwrap();
3794        let v = g.finish().unwrap();
3795
3796        let sol = solve_graph(&v, &cs).expect("decoder graph solves");
3797        let feas = graph_downstream_feasibility(&v, &cs, &sol);
3798        // edge 0 = src->dec (the decoder input), edge 1 = dec->sink.
3799        let dec_in = feas[0]
3800            .as_ref()
3801            .expect("decoder input edge is now constrained");
3802        assert!(
3803            dec_in
3804                .intersect(&CapsSet::one(fixed_compressed(
3805                    VideoCodec::H264,
3806                    1280,
3807                    720,
3808                    30
3809                )))
3810                .fixate()
3811                .is_some(),
3812            "pinned 1280x720 couples back onto the H264 input edge",
3813        );
3814        let off = fixed_compressed(VideoCodec::H264, 640, 480, 30);
3815        assert!(
3816            dec_in.intersect(&CapsSet::one(off)).is_empty(),
3817            "off-geometry input is rejected by the snapshot"
3818        );
3819    }
3820
3821    // --- M227 field-level bidirectional caps coupling ---
3822
3823    /// A scale-like `DerivedFields`: passthrough format + framerate, retarget
3824    /// geometry to [passthrough-input, Range 1..32768].
3825    fn scale_like<'a>() -> CapsConstraint<'a> {
3826        let range = Dim::Range { min: 1, max: 32768 };
3827        CapsConstraint::DerivedFields(CapsTransform::RawVideo {
3828            accept: Vec::new(),
3829            produce: Vec::new(),
3830            shapes: vec![
3831                RawVideoShape::PASSTHROUGH,
3832                RawVideoShape::PASSTHROUGH
3833                    .with_width(FieldTransform::Fixed(range.clone()))
3834                    .with_height(FieldTransform::Fixed(range)),
3835            ],
3836        })
3837    }
3838
3839    /// A convert-like `DerivedFields`: passthrough geometry + framerate,
3840    /// retarget format to [Rgba8, Nv12] (Rgba8 preferred).
3841    fn convert_like<'a>() -> CapsConstraint<'a> {
3842        CapsConstraint::DerivedFields(CapsTransform::RawVideo {
3843            accept: Vec::new(),
3844            produce: Vec::new(),
3845            shapes: vec![
3846                RawVideoShape::PASSTHROUGH
3847                    .with_format(FieldTransform::Fixed(RawVideoFormat::Rgba8)),
3848                RawVideoShape::PASSTHROUGH.with_format(FieldTransform::Fixed(RawVideoFormat::Nv12)),
3849            ],
3850        })
3851    }
3852
3853    #[test]
3854    fn couple_passthrough_narrows_a_range_field_within_an_alternative() {
3855        // The primitive the alternative-drop walk can't express: a Range width
3856        // meeting a Fixed pin collapses to Fixed, format (retargeted) untouched.
3857        let mask = PassthroughFields::NONE
3858            .with_width()
3859            .with_height()
3860            .with_framerate();
3861        let input = video(
3862            RawVideoFormat::Rgba8,
3863            Dim::Range { min: 1, max: 32768 },
3864            Dim::Range { min: 1, max: 32768 },
3865            Rate::Fixed(30 << 16),
3866        );
3867        let pin = fixed_video(RawVideoFormat::Nv12, 160, 120, 30);
3868        let coupled = couple_passthrough(&input, &pin, mask).unwrap();
3869        assert_eq!(
3870            coupled,
3871            fixed_video(RawVideoFormat::Rgba8, 160, 120, 30),
3872            "passthrough width/height/framerate pinned, retargeted format kept"
3873        );
3874    }
3875
3876    #[test]
3877    fn couple_passthrough_rejects_conflicting_passthrough_field() {
3878        // A passthrough format that disagrees with the pin kills the alternative.
3879        let mask = PassthroughFields::NONE.with_format();
3880        let input = fixed_video(RawVideoFormat::Rgba8, 160, 120, 30);
3881        let pin = fixed_video(RawVideoFormat::Nv12, 160, 120, 30);
3882        assert_eq!(couple_passthrough(&input, &pin, mask), None);
3883    }
3884
3885    #[test]
3886    fn field_coupling_resolves_scale_then_convert() {
3887        // The M188 KNOWN-LIMIT, now resolved: a 160x120 geometry pin sits behind
3888        // the geometry-passthrough convert; coupling intersects it into the
3889        // scaler's output field instead of dropping whole alternatives.
3890        let src = CapsConstraint::Produces(CapsSet::one(fixed_video(
3891            RawVideoFormat::Rgba8,
3892            320,
3893            240,
3894            30,
3895        )));
3896        let scale = scale_like();
3897        let convert = convert_like();
3898        let sink = CapsConstraint::Accepts(CapsSet::one(fixed_video(
3899            RawVideoFormat::Nv12,
3900            160,
3901            120,
3902            30,
3903        )));
3904        let links = solve_linear(&[&src, &scale, &convert, &sink]).unwrap();
3905        assert_eq!(
3906            links,
3907            vec![
3908                fixed_video(RawVideoFormat::Rgba8, 320, 240, 30),
3909                fixed_video(RawVideoFormat::Rgba8, 160, 120, 30),
3910                fixed_video(RawVideoFormat::Nv12, 160, 120, 30),
3911            ],
3912            "scaler reads 320x240, emits 160x120; convert changes only the format"
3913        );
3914    }
3915
3916    #[test]
3917    fn field_coupling_no_pin_stays_passthrough() {
3918        // No downstream pin (AcceptsAny): both transforms prefer their first
3919        // (passthrough) alternative, and the solve converges (no oscillation).
3920        let src = CapsConstraint::Produces(CapsSet::one(fixed_video(
3921            RawVideoFormat::Rgba8,
3922            320,
3923            240,
3924            30,
3925        )));
3926        let scale = scale_like();
3927        let convert = convert_like();
3928        let sink = CapsConstraint::AcceptsAny;
3929        let links = solve_linear(&[&src, &scale, &convert, &sink]).unwrap();
3930        assert_eq!(
3931            links,
3932            vec![
3933                fixed_video(RawVideoFormat::Rgba8, 320, 240, 30),
3934                fixed_video(RawVideoFormat::Rgba8, 320, 240, 30),
3935                fixed_video(RawVideoFormat::Rgba8, 320, 240, 30),
3936            ],
3937            "passthrough is preferred and stable"
3938        );
3939    }
3940
3941    #[test]
3942    fn field_coupling_unsatisfiable_geometry_fails_loud() {
3943        // No scaler upstream: convert passes geometry through, so a 160x120 pin
3944        // against a fixed 320x240 source has no solution. Loud, never silent.
3945        let src = CapsConstraint::Produces(CapsSet::one(fixed_video(
3946            RawVideoFormat::Rgba8,
3947            320,
3948            240,
3949            30,
3950        )));
3951        let convert = convert_like();
3952        let sink = CapsConstraint::Accepts(CapsSet::one(fixed_video(
3953            RawVideoFormat::Nv12,
3954            160,
3955            120,
3956            30,
3957        )));
3958        assert!(
3959            solve_linear(&[&src, &convert, &sink]).is_err(),
3960            "geometry pin must fail loud"
3961        );
3962    }
3963
3964    // --- caps-negotiation explainer (M280) -------------------------------
3965
3966    #[test]
3967    fn explainer_formats_sets_constraints_and_labels() {
3968        let rgba = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
3969        let nv12 = fixed_video(RawVideoFormat::Nv12, 64, 48, 30);
3970
3971        // A set renders its alternatives joined by " | "; empty is the ∅ glyph.
3972        let set = CapsSet::from_alternatives(vec![rgba.clone(), nv12.clone()]);
3973        let rendered = fmt_set(&set);
3974        assert!(rendered.contains("format=RGBA") && rendered.contains("format=NV12"));
3975        assert!(rendered.contains(" | "));
3976        assert_eq!(fmt_set(&CapsSet::from_alternatives(vec![])), "∅");
3977
3978        // Wide sets elide past four alternatives so the line stays readable.
3979        let wide = CapsSet::from_alternatives(
3980            (1..=6)
3981                .map(|w| fixed_video(RawVideoFormat::Rgba8, w * 16, 48, 30))
3982                .collect(),
3983        );
3984        assert!(fmt_set(&wide).contains("(+2 more)"), "{}", fmt_set(&wide));
3985
3986        // Constraint summaries name the shape.
3987        assert!(
3988            fmt_caps_constraint(&CapsConstraint::Produces(CapsSet::one(rgba.clone())))
3989                .starts_with("produces ")
3990        );
3991        assert_eq!(
3992            fmt_caps_constraint(&CapsConstraint::AcceptsAny),
3993            "accepts ANY"
3994        );
3995        assert_eq!(
3996            fmt_caps_constraint(&CapsConstraint::DerivedOutput(Box::new(move |_: &Caps| {
3997                CapsSet::one(nv12.clone())
3998            }))),
3999            "derives output"
4000        );
4001    }
4002
4003    #[test]
4004    fn solve_graph_labeled_matches_default_and_uses_labels() {
4005        // The labeled solver returns the identical solution; the label closure
4006        // only affects log text, which a label override exercises here.
4007        let rgba = fixed_video(RawVideoFormat::Rgba8, 64, 48, 30);
4008        let cs: Vec<NodeConstraint> = vec![
4009            NodeConstraint::Element(CapsConstraint::Produces(CapsSet::one(rgba.clone()))),
4010            NodeConstraint::Element(CapsConstraint::AcceptsAny),
4011        ];
4012        let mut g: Graph<()> = Graph::new();
4013        let src = g.add_source(());
4014        let sink = g.add_sink(());
4015        g.link(src, sink).unwrap();
4016        let v = g.finish().unwrap();
4017
4018        let default = solve_graph(&v, &cs).expect("solves");
4019        let labeled = solve_graph_labeled(&v, &cs, &|n| alloc::format!("node{}", n.0))
4020            .expect("solves with custom labels");
4021        assert_eq!(default, labeled);
4022        assert_eq!(labeled, vec![rgba]);
4023    }
4024}