Skip to main content

pixelflow_core/
graph.rs

1//! Immutable graph construction and Phase 1 validation.
2
3use crate::{
4    ChromaSubsampling, FilterOptions, FormatDescriptor, FormatFamily, Rational, SampleType,
5};
6
7use semisafe::slice::get as semisafe_get;
8use semisafe::slice::get_mut as semisafe_get_mut;
9
10/// Stable graph node identifier.
11#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct NodeId(usize);
13
14impl NodeId {
15    /// Creates node ID from zero-based storage index.
16    #[must_use]
17    pub const fn new(index: usize) -> Self {
18        Self(index)
19    }
20
21    /// Returns zero-based storage index.
22    #[must_use]
23    pub const fn index(self) -> usize {
24        self.0
25    }
26}
27
28/// Immutable script-facing clip handle.
29#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
30pub struct Clip {
31    node_id: NodeId,
32}
33
34impl Clip {
35    /// Creates clip handle for existing node.
36    #[must_use]
37    pub const fn new(node_id: NodeId) -> Self {
38        Self { node_id }
39    }
40
41    /// Returns backing node ID.
42    #[must_use]
43    pub const fn node_id(self) -> NodeId {
44        self.node_id
45    }
46}
47
48/// Filter output axes that may differ from the first input clip.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct FilterChangeSet {
51    /// Output format may differ from the first input format.
52    pub format: bool,
53    /// Output resolution may differ from the first input resolution.
54    pub resolution: bool,
55    /// Output frame count may differ from the first input frame count.
56    pub frame_count: bool,
57    /// Output frame rate may differ from the first input frame rate.
58    pub frame_rate: bool,
59}
60
61/// Filter media compatibility policy used during validation.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum FilterCompatibility {
64    /// Output and all inputs must have identical media properties.
65    Preserve,
66    /// Output may differ from the first input only on declared axes.
67    AllowChanges(FilterChangeSet),
68    /// Planner owns input/output media compatibility validation for this filter.
69    Custom,
70}
71
72/// Format state known for graph clip.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub enum ClipFormat {
75    /// Clip always uses one concrete format.
76    Fixed(FormatDescriptor),
77    /// Clip can vary format by frame. Unsupported in Phase 1.
78    Variable,
79}
80
81/// Resolution state known for graph clip.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum ClipResolution {
84    /// Clip always uses one concrete resolution.
85    Fixed {
86        /// Width in pixels.
87        width: usize,
88        /// Height in pixels.
89        height: usize,
90    },
91    /// Clip can vary resolution by frame. Unsupported in Phase 1.
92    Variable,
93}
94
95/// Frame-count state known for graph clip.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum FrameCount {
98    /// Known finite number of frames.
99    Finite(usize),
100    /// Unknown or infinite count. Unsupported in Phase 1.
101    Unknown,
102}
103
104/// Frame-rate state known for graph clip.
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum FrameRate {
107    /// Known constant frame rate.
108    Cfr(Rational),
109    /// Unknown or variable frame rate. Unsupported in Phase 1.
110    Unknown,
111}
112
113/// Declared media properties for one clip.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ClipMedia {
116    format: ClipFormat,
117    resolution: ClipResolution,
118    frame_count: FrameCount,
119    frame_rate: FrameRate,
120}
121
122impl ClipMedia {
123    /// Creates fixed Phase 1 media properties.
124    #[must_use]
125    pub const fn fixed(
126        format: FormatDescriptor,
127        width: usize,
128        height: usize,
129        frame_count: usize,
130        frame_rate: Rational,
131    ) -> Self {
132        Self {
133            format: ClipFormat::Fixed(format),
134            resolution: ClipResolution::Fixed { width, height },
135            frame_count: FrameCount::Finite(frame_count),
136            frame_rate: FrameRate::Cfr(frame_rate),
137        }
138    }
139
140    /// Creates media properties from explicit state values.
141    #[must_use]
142    pub const fn new(
143        format: ClipFormat,
144        resolution: ClipResolution,
145        frame_count: FrameCount,
146        frame_rate: FrameRate,
147    ) -> Self {
148        Self {
149            format,
150            resolution,
151            frame_count,
152            frame_rate,
153        }
154    }
155
156    /// Returns format state.
157    #[must_use]
158    pub const fn format(&self) -> &ClipFormat {
159        &self.format
160    }
161
162    /// Returns resolution state.
163    #[must_use]
164    pub const fn resolution(&self) -> &ClipResolution {
165        &self.resolution
166    }
167
168    /// Returns frame-count state.
169    #[must_use]
170    pub const fn frame_count(&self) -> FrameCount {
171        self.frame_count
172    }
173
174    /// Returns frame-rate state.
175    #[must_use]
176    pub const fn frame_rate(&self) -> FrameRate {
177        self.frame_rate
178    }
179}
180
181/// Returns true when final clip format can be written as Phase 1 Y4M.
182#[must_use]
183pub const fn is_y4m_compatible_format(format: &FormatDescriptor) -> bool {
184    let integer_sample = matches!(format.sample_type(), SampleType::U8 | SampleType::U16);
185    let supported_family = match format.family() {
186        FormatFamily::Gray => true,
187        FormatFamily::Yuv => matches!(
188            format.subsampling(),
189            Some(ChromaSubsampling::Cs420 | ChromaSubsampling::Cs422 | ChromaSubsampling::Cs444)
190        ),
191        FormatFamily::PlanarRgb => false,
192    };
193
194    integer_sample && supported_family
195}
196
197/// Stored graph node kind.
198#[derive(Clone, Debug, PartialEq)]
199pub enum NodeKind {
200    /// Source node, indexed only when reachable.
201    Source {
202        /// Stable source diagnostic name.
203        name: String,
204        /// Source request captured during graph construction.
205        request: crate::SourceRequest,
206        /// Source scheduling capabilities.
207        capabilities: crate::SourceCapabilities,
208    },
209    /// Filter node produced by applying filter to input clips.
210    Filter {
211        /// Stable filter diagnostic name.
212        name: String,
213        /// Input clip handles.
214        inputs: Vec<Clip>,
215        /// Generic options captured during graph construction for runtime executor assembly.
216        options: FilterOptions,
217        /// Compatibility policy for this filter call.
218        compatibility: FilterCompatibility,
219        /// Declared upstream frame dependency contract.
220        dependencies: crate::DependencyPattern,
221        /// Declared scheduler concurrency class.
222        concurrency: crate::ConcurrencyClass,
223    },
224}
225
226/// One immutable graph node.
227#[derive(Clone, Debug, PartialEq)]
228pub struct GraphNode {
229    id: NodeId,
230    kind: NodeKind,
231    media: ClipMedia,
232}
233
234impl GraphNode {
235    /// Returns node ID.
236    #[must_use]
237    pub const fn id(&self) -> NodeId {
238        self.id
239    }
240
241    /// Returns node kind.
242    #[must_use]
243    pub const fn kind(&self) -> &NodeKind {
244        &self.kind
245    }
246
247    /// Returns declared media properties.
248    #[must_use]
249    pub const fn media(&self) -> &ClipMedia {
250        &self.media
251    }
252
253    /// Returns stored filter options when this node is a filter.
254    #[must_use]
255    pub const fn filter_options(&self) -> Option<&FilterOptions> {
256        match &self.kind {
257            NodeKind::Filter { options, .. } => Some(options),
258            NodeKind::Source { .. } => None,
259        }
260    }
261}
262
263/// Immutable graph ready for validation and future rendering.
264#[derive(Clone, Debug, PartialEq)]
265pub struct Graph {
266    nodes: Vec<GraphNode>,
267    outputs: Vec<Clip>,
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
271enum VisitState {
272    Visiting,
273    Done,
274}
275
276/// Validated graph plus reachability plan, ready for later render scheduling.
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct ValidatedGraph {
279    plan: ValidationPlan,
280}
281
282impl ValidatedGraph {
283    /// Returns graph validation plan.
284    #[must_use]
285    pub const fn plan(&self) -> &ValidationPlan {
286        &self.plan
287    }
288}
289
290impl Graph {
291    /// Returns graph clone with one source node media description replaced.
292    pub fn with_source_media(mut self, node_id: NodeId, media: ClipMedia) -> crate::Result<Self> {
293        let node = self.nodes.get_mut(node_id.index()).ok_or_else(|| {
294            crate::PixelFlowError::new(
295                crate::ErrorCategory::Graph,
296                crate::ErrorCode::new("graph.invalid_clip"),
297                format!(
298                    "source media update references missing node {}",
299                    node_id.index()
300                ),
301            )
302        })?;
303
304        if !matches!(node.kind, NodeKind::Source { .. }) {
305            return Err(crate::PixelFlowError::new(
306                crate::ErrorCategory::Graph,
307                crate::ErrorCode::new("graph.invalid_clip"),
308                format!("node {} is not a source", node_id.index()),
309            ));
310        }
311
312        node.media = media;
313        self.propagate_filter_media()?;
314        Ok(self)
315    }
316
317    fn propagate_filter_media(&mut self) -> crate::Result<()> {
318        for index in 0..self.nodes.len() {
319            let node = semisafe_get(&self.nodes, index);
320            let (inputs, compatibility, existing_media) = match &node.kind {
321                NodeKind::Filter {
322                    inputs,
323                    compatibility,
324                    ..
325                } => (inputs.clone(), *compatibility, node.media.clone()),
326                NodeKind::Source { .. } => continue,
327            };
328
329            let Some(first_input) = inputs.first().copied() else {
330                return Err(crate::PixelFlowError::new(
331                    crate::ErrorCategory::Graph,
332                    crate::ErrorCode::new("graph.missing_filter_input"),
333                    format!("filter node {index} has no inputs"),
334                ));
335            };
336            let input_media = self.input_media(first_input)?.clone();
337
338            let node = semisafe_get_mut(&mut self.nodes, index);
339            node.media = match compatibility {
340                FilterCompatibility::Preserve => input_media,
341                FilterCompatibility::AllowChanges(changes) => ClipMedia::new(
342                    if changes.format {
343                        existing_media.format().clone()
344                    } else {
345                        input_media.format().clone()
346                    },
347                    if changes.resolution {
348                        existing_media.resolution().clone()
349                    } else {
350                        input_media.resolution().clone()
351                    },
352                    if changes.frame_count {
353                        existing_media.frame_count()
354                    } else {
355                        input_media.frame_count()
356                    },
357                    if changes.frame_rate {
358                        existing_media.frame_rate()
359                    } else {
360                        input_media.frame_rate()
361                    },
362                ),
363                FilterCompatibility::Custom => existing_media,
364            };
365        }
366
367        Ok(())
368    }
369
370    /// Returns node by ID.
371    #[must_use]
372    pub fn node(&self, id: NodeId) -> Option<&GraphNode> {
373        self.nodes.get(id.index()).filter(|node| node.id == id)
374    }
375
376    /// Returns all stored nodes.
377    #[must_use]
378    pub fn nodes(&self) -> &[GraphNode] {
379        &self.nodes
380    }
381
382    /// Returns final output clips declared by graph construction.
383    #[must_use]
384    pub fn outputs(&self) -> &[Clip] {
385        &self.outputs
386    }
387
388    /// Finds reachable nodes and sources from single final output.
389    pub fn validation_plan(&self) -> crate::Result<ValidationPlan> {
390        match self.outputs.as_slice() {
391            [] => Err(crate::PixelFlowError::new(
392                crate::ErrorCategory::Graph,
393                crate::ErrorCode::new("graph.missing_output"),
394                "graph has no final output",
395            )),
396            [output] => {
397                let mut reachable = Vec::new();
398                self.collect_reachable(output.node_id(), &mut reachable)?;
399                let reachable_sources = reachable
400                    .iter()
401                    .copied()
402                    .filter(|id| {
403                        self.node(*id)
404                            .is_some_and(|node| matches!(node.kind(), NodeKind::Source { .. }))
405                    })
406                    .collect();
407
408                Ok(ValidationPlan {
409                    reachable_nodes: reachable,
410                    reachable_sources,
411                })
412            }
413            _ => Err(crate::PixelFlowError::new(
414                crate::ErrorCategory::Graph,
415                crate::ErrorCode::new("graph.multiple_outputs"),
416                "graph has multiple final outputs",
417            )),
418        }
419    }
420
421    /// Validates topology and media constraints for reachable output graph.
422    pub fn validate(&self) -> crate::Result<ValidatedGraph> {
423        let plan = self.validation_plan()?;
424        self.validate_acyclic(&plan)?;
425        self.validate_media(&plan)?;
426        Ok(ValidatedGraph { plan })
427    }
428
429    fn collect_reachable(&self, id: NodeId, reachable: &mut Vec<NodeId>) -> crate::Result<()> {
430        let mut states = vec![None; self.nodes.len()];
431        self.collect_reachable_checked(id, reachable, &mut states)
432    }
433
434    fn collect_reachable_checked(
435        &self,
436        id: NodeId,
437        reachable: &mut Vec<NodeId>,
438        states: &mut [Option<VisitState>],
439    ) -> crate::Result<()> {
440        match states.get(id.index()).copied().flatten() {
441            Some(VisitState::Visiting) => {
442                return Err(crate::PixelFlowError::new(
443                    crate::ErrorCategory::Graph,
444                    crate::ErrorCode::new("graph.cycle"),
445                    format!("graph contains a cycle involving node {}", id.index()),
446                ));
447            }
448            Some(VisitState::Done) => return Ok(()),
449            None => {}
450        }
451
452        let node = self.node(id).ok_or_else(|| {
453            crate::PixelFlowError::new(
454                crate::ErrorCategory::Graph,
455                crate::ErrorCode::new("graph.invalid_clip"),
456                format!("clip references missing node {}", id.index()),
457            )
458        })?;
459
460        *semisafe_get_mut(states, id.index()) = Some(VisitState::Visiting);
461
462        if let NodeKind::Filter { inputs, .. } = node.kind() {
463            for input in inputs {
464                self.collect_reachable_checked(input.node_id(), reachable, states)?;
465            }
466        }
467
468        *semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
469        reachable.push(id);
470        Ok(())
471    }
472
473    fn validate_acyclic(&self, plan: &ValidationPlan) -> crate::Result<()> {
474        let mut states = vec![None; self.nodes.len()];
475        for id in plan.reachable_nodes() {
476            self.visit_for_cycle(*id, &mut states)?;
477        }
478        Ok(())
479    }
480
481    fn visit_for_cycle(&self, id: NodeId, states: &mut [Option<VisitState>]) -> crate::Result<()> {
482        match states.get(id.index()).copied().flatten() {
483            Some(VisitState::Visiting) => {
484                return Err(crate::PixelFlowError::new(
485                    crate::ErrorCategory::Graph,
486                    crate::ErrorCode::new("graph.cycle"),
487                    format!("graph contains a cycle involving node {}", id.index()),
488                ));
489            }
490            Some(VisitState::Done) => return Ok(()),
491            None => {}
492        }
493
494        let state = states.get_mut(id.index()).ok_or_else(|| {
495            crate::PixelFlowError::new(
496                crate::ErrorCategory::Graph,
497                crate::ErrorCode::new("graph.invalid_clip"),
498                format!("clip references missing node {}", id.index()),
499            )
500        })?;
501        *state = Some(VisitState::Visiting);
502
503        let node = self.node(id).ok_or_else(|| {
504            crate::PixelFlowError::new(
505                crate::ErrorCategory::Graph,
506                crate::ErrorCode::new("graph.invalid_clip"),
507                format!("clip references missing node {}", id.index()),
508            )
509        })?;
510
511        if let NodeKind::Filter { inputs, .. } = node.kind() {
512            for input in inputs {
513                self.visit_for_cycle(input.node_id(), states)?;
514            }
515        }
516
517        *semisafe_get_mut(states, id.index()) = Some(VisitState::Done);
518        Ok(())
519    }
520
521    fn validate_media(&self, plan: &ValidationPlan) -> crate::Result<()> {
522        for id in plan.reachable_nodes() {
523            let node = self.node(*id).ok_or_else(|| {
524                crate::PixelFlowError::new(
525                    crate::ErrorCategory::Graph,
526                    crate::ErrorCode::new("graph.invalid_clip"),
527                    format!("clip references missing node {}", id.index()),
528                )
529            })?;
530            validate_phase1_media(node.media(), *id)?;
531            if let NodeKind::Filter {
532                inputs,
533                compatibility,
534                ..
535            } = node.kind()
536            {
537                self.validate_filter_compatibility(node, inputs, *compatibility)?;
538            }
539        }
540
541        let output = semisafe_get(&self.outputs, 0).node_id();
542        let output_node = self.node(output).ok_or_else(|| {
543            crate::PixelFlowError::new(
544                crate::ErrorCategory::Graph,
545                crate::ErrorCode::new("graph.invalid_clip"),
546                format!("output references missing node {}", output.index()),
547            )
548        })?;
549        let ClipFormat::Fixed(format) = output_node.media().format() else {
550            return Err(crate::PixelFlowError::new(
551                crate::ErrorCategory::Graph,
552                crate::ErrorCode::new("graph.variable_format"),
553                format!("node {} has variable format", output.index()),
554            ));
555        };
556        if !is_y4m_compatible_format(format) {
557            return Err(crate::PixelFlowError::new(
558                crate::ErrorCategory::Graph,
559                crate::ErrorCode::new("graph.unsupported_output_format"),
560                format!(
561                    "final output format '{}' is not Y4M-compatible",
562                    format.name()
563                ),
564            ));
565        }
566
567        Ok(())
568    }
569
570    fn validate_filter_compatibility(
571        &self,
572        node: &GraphNode,
573        inputs: &[Clip],
574        compatibility: FilterCompatibility,
575    ) -> crate::Result<()> {
576        let Some(first_input) = inputs.first() else {
577            return Err(crate::PixelFlowError::new(
578                crate::ErrorCategory::Graph,
579                crate::ErrorCode::new("graph.missing_filter_input"),
580                format!("filter node {} has no inputs", node.id().index()),
581            ));
582        };
583        let first_media = self.input_media(*first_input)?;
584
585        if compatibility == FilterCompatibility::Custom {
586            return Ok(());
587        }
588
589        for input in inputs.iter().skip(1) {
590            let input_media = self.input_media(*input)?;
591            require_equal_format(first_media, input_media, input.node_id())?;
592            require_equal_resolution(first_media, input_media, input.node_id())?;
593            require_equal_frame_count(first_media, input_media, input.node_id())?;
594            require_equal_frame_rate(first_media, input_media, input.node_id())?;
595        }
596
597        match compatibility {
598            FilterCompatibility::Preserve => {
599                require_equal_format(first_media, node.media(), node.id())?;
600                require_equal_resolution(first_media, node.media(), node.id())?;
601                require_equal_frame_count(first_media, node.media(), node.id())?;
602                require_equal_frame_rate(first_media, node.media(), node.id())?;
603            }
604            FilterCompatibility::AllowChanges(changes) => {
605                if !changes.format {
606                    require_equal_format(first_media, node.media(), node.id())?;
607                }
608                if !changes.resolution {
609                    require_equal_resolution(first_media, node.media(), node.id())?;
610                }
611                if !changes.frame_count {
612                    require_equal_frame_count(first_media, node.media(), node.id())?;
613                }
614                if !changes.frame_rate {
615                    require_equal_frame_rate(first_media, node.media(), node.id())?;
616                }
617            }
618            FilterCompatibility::Custom => unreachable!("custom compatibility returned early"),
619        }
620
621        Ok(())
622    }
623
624    fn input_media(&self, clip: Clip) -> crate::Result<&ClipMedia> {
625        self.node(clip.node_id())
626            .map(GraphNode::media)
627            .ok_or_else(|| {
628                crate::PixelFlowError::new(
629                    crate::ErrorCategory::Graph,
630                    crate::ErrorCode::new("graph.invalid_clip"),
631                    format!("clip references missing node {}", clip.node_id().index()),
632                )
633            })
634    }
635}
636
637fn validate_phase1_media(media: &ClipMedia, id: NodeId) -> crate::Result<()> {
638    match media.format() {
639        ClipFormat::Fixed(_) => {}
640        ClipFormat::Variable => {
641            return Err(crate::PixelFlowError::new(
642                crate::ErrorCategory::Graph,
643                crate::ErrorCode::new("graph.variable_format"),
644                format!("node {} has variable format", id.index()),
645            ));
646        }
647    }
648
649    match media.resolution() {
650        ClipResolution::Fixed { width, height } if *width > 0 && *height > 0 => {}
651        ClipResolution::Fixed { .. } => {
652            return Err(crate::PixelFlowError::new(
653                crate::ErrorCategory::Graph,
654                crate::ErrorCode::new("graph.invalid_resolution"),
655                format!("node {} has zero resolution dimension", id.index()),
656            ));
657        }
658        ClipResolution::Variable => {
659            return Err(crate::PixelFlowError::new(
660                crate::ErrorCategory::Graph,
661                crate::ErrorCode::new("graph.variable_resolution"),
662                format!("node {} has variable resolution", id.index()),
663            ));
664        }
665    }
666
667    match media.frame_count() {
668        FrameCount::Finite(_) => {}
669        FrameCount::Unknown => {
670            return Err(crate::PixelFlowError::new(
671                crate::ErrorCategory::Graph,
672                crate::ErrorCode::new("graph.unknown_frame_count"),
673                format!("node {} has unknown frame count", id.index()),
674            ));
675        }
676    }
677
678    match media.frame_rate() {
679        FrameRate::Cfr(rate) if rate.denominator > 0 && rate.numerator > 0 => {}
680        FrameRate::Cfr(_) => {
681            return Err(crate::PixelFlowError::new(
682                crate::ErrorCategory::Graph,
683                crate::ErrorCode::new("graph.invalid_frame_rate"),
684                format!("node {} has invalid frame rate", id.index()),
685            ));
686        }
687        FrameRate::Unknown => {
688            return Err(crate::PixelFlowError::new(
689                crate::ErrorCategory::Graph,
690                crate::ErrorCode::new("graph.unknown_frame_rate"),
691                format!("node {} has unknown frame rate", id.index()),
692            ));
693        }
694    }
695
696    Ok(())
697}
698
699fn require_equal_format(expected: &ClipMedia, actual: &ClipMedia, id: NodeId) -> crate::Result<()> {
700    if expected.format() == actual.format() {
701        return Ok(());
702    }
703    Err(crate::PixelFlowError::new(
704        crate::ErrorCategory::Graph,
705        crate::ErrorCode::new("graph.incompatible_format"),
706        format!(
707            "node {} changes format without explicit conversion policy",
708            id.index()
709        ),
710    ))
711}
712
713fn require_equal_resolution(
714    expected: &ClipMedia,
715    actual: &ClipMedia,
716    id: NodeId,
717) -> crate::Result<()> {
718    if expected.resolution() == actual.resolution() {
719        return Ok(());
720    }
721    Err(crate::PixelFlowError::new(
722        crate::ErrorCategory::Graph,
723        crate::ErrorCode::new("graph.incompatible_resolution"),
724        format!(
725            "node {} changes resolution without explicit resize policy",
726            id.index()
727        ),
728    ))
729}
730
731fn require_equal_frame_count(
732    expected: &ClipMedia,
733    actual: &ClipMedia,
734    id: NodeId,
735) -> crate::Result<()> {
736    if expected.frame_count() == actual.frame_count() {
737        return Ok(());
738    }
739    Err(crate::PixelFlowError::new(
740        crate::ErrorCategory::Graph,
741        crate::ErrorCode::new("graph.incompatible_frame_count"),
742        format!(
743            "node {} changes frame count without explicit policy",
744            id.index()
745        ),
746    ))
747}
748
749fn require_equal_frame_rate(
750    expected: &ClipMedia,
751    actual: &ClipMedia,
752    id: NodeId,
753) -> crate::Result<()> {
754    if expected.frame_rate() == actual.frame_rate() {
755        return Ok(());
756    }
757    Err(crate::PixelFlowError::new(
758        crate::ErrorCategory::Graph,
759        crate::ErrorCode::new("graph.incompatible_frame_rate"),
760        format!(
761            "node {} changes frame rate without explicit policy",
762            id.index()
763        ),
764    ))
765}
766
767/// Reachability result used to index only reachable sources before render validation.
768#[derive(Clone, Debug, Eq, PartialEq)]
769pub struct ValidationPlan {
770    reachable_nodes: Vec<NodeId>,
771    reachable_sources: Vec<NodeId>,
772}
773
774impl ValidationPlan {
775    /// Returns reachable nodes in dependency-first order.
776    #[must_use]
777    pub fn reachable_nodes(&self) -> &[NodeId] {
778        &self.reachable_nodes
779    }
780
781    /// Returns reachable source nodes in dependency-first order.
782    #[must_use]
783    pub fn reachable_sources(&self) -> &[NodeId] {
784        &self.reachable_sources
785    }
786}
787
788/// Mutable construction API. `build` freezes nodes into immutable graph.
789#[derive(Clone, Debug, Default, PartialEq)]
790pub struct GraphBuilder {
791    nodes: Vec<GraphNode>,
792    outputs: Vec<Clip>,
793}
794
795impl GraphBuilder {
796    /// Creates empty graph builder.
797    #[must_use]
798    pub const fn new() -> Self {
799        Self {
800            nodes: Vec::new(),
801            outputs: Vec::new(),
802        }
803    }
804
805    /// Adds source node and returns immutable clip handle.
806    pub fn source(&mut self, name: impl Into<String>, media: ClipMedia) -> Clip {
807        let name = name.into();
808        self.source_with_request(crate::SourceRequest::new(name), media)
809    }
810
811    /// Adds source node with explicit request metadata.
812    pub fn source_with_request(&mut self, request: crate::SourceRequest, media: ClipMedia) -> Clip {
813        self.source_with_request_and_capabilities(
814            request,
815            media,
816            crate::SourceCapabilities::random_access(),
817        )
818    }
819
820    /// Adds source node with explicit scheduler capabilities.
821    pub fn source_with_capabilities(
822        &mut self,
823        name: impl Into<String>,
824        media: ClipMedia,
825        capabilities: crate::SourceCapabilities,
826    ) -> Clip {
827        self.source_with_request_and_capabilities(
828            crate::SourceRequest::new(name.into()),
829            media,
830            capabilities,
831        )
832    }
833
834    /// Adds source node with explicit request metadata and scheduler capabilities.
835    pub fn source_with_request_and_capabilities(
836        &mut self,
837        request: crate::SourceRequest,
838        media: ClipMedia,
839        capabilities: crate::SourceCapabilities,
840    ) -> Clip {
841        let id = NodeId::new(self.nodes.len());
842        let clip = Clip::new(id);
843        self.nodes.push(GraphNode {
844            id,
845            kind: NodeKind::Source {
846                name: request.path().to_owned(),
847                request,
848                capabilities,
849            },
850            media,
851        });
852        clip
853    }
854
855    /// Adds filter node and returns new immutable clip handle.
856    pub fn filter(
857        &mut self,
858        name: impl Into<String>,
859        inputs: &[Clip],
860        media: ClipMedia,
861        compatibility: FilterCompatibility,
862    ) -> crate::Result<Clip> {
863        self.filter_with_schedule(
864            name,
865            inputs,
866            media,
867            compatibility,
868            crate::DependencyPattern::same_frame(),
869            crate::ConcurrencyClass::Stateless,
870        )
871    }
872
873    /// Adds filter node with explicit scheduling contract.
874    pub fn filter_with_schedule(
875        &mut self,
876        name: impl Into<String>,
877        inputs: &[Clip],
878        media: ClipMedia,
879        compatibility: FilterCompatibility,
880        dependencies: crate::DependencyPattern,
881        concurrency: crate::ConcurrencyClass,
882    ) -> crate::Result<Clip> {
883        self.filter_with_schedule_and_options(
884            name,
885            inputs,
886            media,
887            compatibility,
888            dependencies,
889            concurrency,
890            FilterOptions::new(),
891        )
892    }
893
894    /// Adds filter node with explicit scheduling contract and stored options.
895    pub fn filter_with_schedule_and_options(
896        &mut self,
897        name: impl Into<String>,
898        inputs: &[Clip],
899        media: ClipMedia,
900        compatibility: FilterCompatibility,
901        dependencies: crate::DependencyPattern,
902        concurrency: crate::ConcurrencyClass,
903        options: FilterOptions,
904    ) -> crate::Result<Clip> {
905        for input in inputs {
906            if self.nodes.get(input.node_id().index()).is_none() {
907                return Err(crate::PixelFlowError::new(
908                    crate::ErrorCategory::Graph,
909                    crate::ErrorCode::new("graph.invalid_clip"),
910                    format!(
911                        "filter input references missing node {}",
912                        input.node_id().index()
913                    ),
914                ));
915            }
916        }
917
918        let id = NodeId::new(self.nodes.len());
919        let clip = Clip::new(id);
920        self.nodes.push(GraphNode {
921            id,
922            kind: NodeKind::Filter {
923                name: name.into(),
924                inputs: inputs.to_vec(),
925                options,
926                compatibility,
927                dependencies,
928                concurrency,
929            },
930            media,
931        });
932        Ok(clip)
933    }
934
935    /// Replaces final output list with one clip.
936    pub fn set_output(&mut self, output: Clip) {
937        self.outputs.clear();
938        self.outputs.push(output);
939    }
940
941    /// Adds final output clip. Phase 1 validation rejects multiple outputs.
942    pub fn add_output(&mut self, output: Clip) {
943        self.outputs.push(output);
944    }
945
946    /// Freezes builder into immutable graph.
947    #[must_use]
948    pub fn build(self) -> Graph {
949        Graph {
950            nodes: self.nodes,
951            outputs: self.outputs,
952        }
953    }
954}
955
956#[cfg(test)]
957impl Graph {
958    fn from_parts_for_tests(nodes: Vec<GraphNode>, outputs: Vec<Clip>) -> Self {
959        Self { nodes, outputs }
960    }
961}
962
963#[cfg(test)]
964impl GraphNode {
965    fn filter_for_tests(
966        id: NodeId,
967        name: impl Into<String>,
968        inputs: &[Clip],
969        media: ClipMedia,
970        compatibility: FilterCompatibility,
971    ) -> Self {
972        Self {
973            id,
974            kind: NodeKind::Filter {
975                name: name.into(),
976                inputs: inputs.to_vec(),
977                options: crate::FilterOptions::new(),
978                compatibility,
979                dependencies: crate::DependencyPattern::same_frame(),
980                concurrency: crate::ConcurrencyClass::Stateless,
981            },
982            media,
983        }
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    #![expect(clippy::panic, reason = "allow in tests")]
990    #![expect(clippy::unwrap_used, reason = "allow in tests")]
991
992    use crate::{ChromaSubsampling, FormatFamily, Rational, SampleType, resolve_format_alias};
993
994    use super::{
995        ClipFormat, ClipMedia, ClipResolution, FilterChangeSet, FilterCompatibility, FrameCount,
996        FrameRate, GraphBuilder, NodeId, NodeKind, is_y4m_compatible_format,
997    };
998
999    fn fixed_media(alias: &str) -> ClipMedia {
1000        ClipMedia::fixed(
1001            resolve_format_alias(alias).expect("format alias should resolve"),
1002            1920,
1003            1080,
1004            24,
1005            Rational {
1006                numerator: 24_000,
1007                denominator: 1_001,
1008            },
1009        )
1010    }
1011
1012    #[test]
1013    fn graph_builder_source_uses_phase1_source_capability_defaults() {
1014        let mut builder = GraphBuilder::new();
1015        let source = builder.source("source", fixed_media("yuv420p10"));
1016        let graph = builder.build();
1017
1018        let node = graph.node(source.node_id()).expect("source exists");
1019        let NodeKind::Source { capabilities, .. } = node.kind() else {
1020            panic!("expected source node");
1021        };
1022
1023        assert!(capabilities.supports_random_access());
1024        assert!(capabilities.indexing_required());
1025        assert!(capabilities.known_frame_count());
1026        assert_eq!(capabilities.concurrency_limit(), Some(1));
1027    }
1028
1029    #[test]
1030    fn graph_builder_source_stores_request_path_and_options() {
1031        let mut builder = GraphBuilder::new();
1032        let request = crate::SourceRequest::new("relative/input.mkv")
1033            .with_option(
1034                "fps",
1035                crate::SourceOptionValue::Rational(Rational {
1036                    numerator: 30_000,
1037                    denominator: 1_001,
1038                }),
1039            )
1040            .with_option(
1041                "vfr",
1042                crate::SourceOptionValue::String("normalize".to_owned()),
1043            );
1044        let clip = builder.source_with_request(request.clone(), fixed_media("yuv420p8"));
1045        let graph = builder.build();
1046
1047        let node = graph.node(clip.node_id()).expect("source exists");
1048        let NodeKind::Source {
1049            request: stored, ..
1050        } = node.kind()
1051        else {
1052            panic!("expected source node");
1053        };
1054
1055        assert_eq!(stored, &request);
1056    }
1057
1058    #[test]
1059    fn graph_with_source_media_replaces_only_source_media() {
1060        let mut builder = GraphBuilder::new();
1061        let source = builder.source(
1062            "input.mkv",
1063            ClipMedia::new(
1064                ClipFormat::Fixed(resolve_format_alias("yuv420p8").unwrap()),
1065                ClipResolution::Fixed {
1066                    width: 1,
1067                    height: 1,
1068                },
1069                FrameCount::Unknown,
1070                FrameRate::Unknown,
1071            ),
1072        );
1073        let graph = builder.build();
1074        let media = fixed_media("yuv420p10");
1075
1076        let graph = graph
1077            .with_source_media(source.node_id(), media.clone())
1078            .expect("source media updates");
1079
1080        assert_eq!(graph.node(source.node_id()).unwrap().media(), &media);
1081    }
1082
1083    #[test]
1084    fn graph_builder_filter_records_dependency_and_concurrency_defaults() {
1085        let mut builder = GraphBuilder::new();
1086        let source = builder.source("source", fixed_media("yuv420p10"));
1087        let filtered = builder
1088            .filter(
1089                "identity",
1090                &[source],
1091                fixed_media("yuv420p10"),
1092                FilterCompatibility::Preserve,
1093            )
1094            .expect("filter should be added");
1095        let graph = builder.build();
1096
1097        let node = graph.node(filtered.node_id()).expect("filter exists");
1098        let NodeKind::Filter {
1099            dependencies,
1100            concurrency,
1101            ..
1102        } = node.kind()
1103        else {
1104            panic!("expected filter node");
1105        };
1106
1107        assert_eq!(dependencies, &crate::DependencyPattern::same_frame());
1108        assert_eq!(*concurrency, crate::ConcurrencyClass::Stateless);
1109    }
1110
1111    #[test]
1112    fn graph_builder_filter_with_schedule_records_explicit_contracts() {
1113        let mut builder = GraphBuilder::new();
1114        let source = builder.source("source", fixed_media("yuv420p10"));
1115        let filtered = builder
1116            .filter_with_schedule(
1117                "temporal",
1118                &[source],
1119                fixed_media("yuv420p10"),
1120                FilterCompatibility::Preserve,
1121                crate::DependencyPattern::window(2, 0),
1122                crate::ConcurrencyClass::OrderedStateful,
1123            )
1124            .expect("filter should be added");
1125        let graph = builder.build();
1126
1127        let node = graph.node(filtered.node_id()).expect("filter exists");
1128        let NodeKind::Filter {
1129            dependencies,
1130            concurrency,
1131            ..
1132        } = node.kind()
1133        else {
1134            panic!("expected filter node");
1135        };
1136
1137        assert_eq!(dependencies, &crate::DependencyPattern::window(2, 0));
1138        assert_eq!(*concurrency, crate::ConcurrencyClass::OrderedStateful);
1139    }
1140
1141    #[test]
1142    fn graph_builder_filter_with_options_preserves_options() {
1143        let media = fixed_media("yuv420p8");
1144        let mut builder = GraphBuilder::new();
1145        let source = builder.source("input", media.clone());
1146        let mut options = crate::FilterOptions::new();
1147        options.insert("width".to_owned(), crate::FilterOptionValue::Int(320));
1148        options.insert("height".to_owned(), crate::FilterOptionValue::Int(180));
1149
1150        let filtered = builder
1151            .filter_with_schedule_and_options(
1152                "resize",
1153                &[source],
1154                media,
1155                FilterCompatibility::Preserve,
1156                crate::DependencyPattern::same_frame(),
1157                crate::ConcurrencyClass::Stateless,
1158                options.clone(),
1159            )
1160            .expect("filter should build");
1161        builder.set_output(filtered);
1162        let graph = builder.build();
1163
1164        let NodeKind::Filter {
1165            options: stored, ..
1166        } = graph
1167            .node(filtered.node_id())
1168            .expect("filter exists")
1169            .kind()
1170        else {
1171            panic!("node should be filter");
1172        };
1173        assert_eq!(stored, &options);
1174    }
1175
1176    #[test]
1177    fn custom_filter_compatibility_allows_planner_validated_heterogeneous_inputs() {
1178        let gray8 = resolve_format_alias("gray8").expect("format should resolve");
1179        let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
1180        let y_media = ClipMedia::fixed(
1181            gray8.clone(),
1182            4,
1183            4,
1184            1,
1185            Rational {
1186                numerator: 24,
1187                denominator: 1,
1188            },
1189        );
1190        let u_media = ClipMedia::fixed(
1191            gray8.clone(),
1192            2,
1193            2,
1194            1,
1195            Rational {
1196                numerator: 24,
1197                denominator: 1,
1198            },
1199        );
1200        let v_media = ClipMedia::fixed(
1201            gray8,
1202            2,
1203            2,
1204            1,
1205            Rational {
1206                numerator: 24,
1207                denominator: 1,
1208            },
1209        );
1210        let output_media = ClipMedia::fixed(
1211            yuv420,
1212            4,
1213            4,
1214            1,
1215            Rational {
1216                numerator: 24,
1217                denominator: 1,
1218            },
1219        );
1220
1221        let mut builder = GraphBuilder::new();
1222        let y = builder.source("y", y_media);
1223        let u = builder.source("u", u_media);
1224        let v = builder.source("v", v_media);
1225        let merged = builder
1226            .filter(
1227                "merge_planes",
1228                &[y, u, v],
1229                output_media,
1230                FilterCompatibility::Custom,
1231            )
1232            .expect("custom filter node should build");
1233        builder.set_output(merged);
1234        let graph = builder.build();
1235
1236        graph
1237            .validate()
1238            .expect("custom compatibility should skip generic input equality");
1239    }
1240
1241    #[test]
1242    fn custom_filter_compatibility_still_rejects_missing_inputs() {
1243        let yuv420 = resolve_format_alias("yuv420p8").expect("format should resolve");
1244        let output_media = ClipMedia::fixed(
1245            yuv420,
1246            4,
1247            4,
1248            1,
1249            Rational {
1250                numerator: 24,
1251                denominator: 1,
1252            },
1253        );
1254        let mut builder = GraphBuilder::new();
1255        let merged = builder
1256            .filter(
1257                "merge_planes",
1258                &[],
1259                output_media,
1260                FilterCompatibility::Custom,
1261            )
1262            .expect("graph builder records empty custom input list");
1263        builder.set_output(merged);
1264
1265        let error = builder
1266            .build()
1267            .validate()
1268            .expect_err("empty custom inputs should fail");
1269        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1270        assert_eq!(
1271            error.code(),
1272            crate::ErrorCode::new("graph.missing_filter_input")
1273        );
1274    }
1275
1276    #[test]
1277    fn y4m_compatibility_accepts_phase1_integer_yuv_and_gray() {
1278        let yuv420 = resolve_format_alias("yuv420p10").expect("format should resolve");
1279        let yuv422 = resolve_format_alias("yuv422p16").expect("format should resolve");
1280        let yuv444 = resolve_format_alias("yuv444p8").expect("format should resolve");
1281        let gray = resolve_format_alias("gray12").expect("format should resolve");
1282
1283        assert!(is_y4m_compatible_format(&yuv420));
1284        assert!(is_y4m_compatible_format(&yuv422));
1285        assert!(is_y4m_compatible_format(&yuv444));
1286        assert!(is_y4m_compatible_format(&gray));
1287    }
1288
1289    #[test]
1290    fn y4m_compatibility_rejects_rgb_and_float_outputs() {
1291        let rgb = resolve_format_alias("rgbp10").expect("format should resolve");
1292        let gray_float = resolve_format_alias("grayf32").expect("format should resolve");
1293        let yuv_float = resolve_format_alias("yuv420pf32").expect("format should resolve");
1294
1295        assert!(!is_y4m_compatible_format(&rgb));
1296        assert!(!is_y4m_compatible_format(&gray_float));
1297        assert!(!is_y4m_compatible_format(&yuv_float));
1298    }
1299
1300    #[test]
1301    fn fixed_media_records_phase1_constraints() {
1302        let media = fixed_media("yuv420p10");
1303
1304        assert!(
1305            matches!(media.format(), ClipFormat::Fixed(format) if format.family() == FormatFamily::Yuv)
1306        );
1307        assert!(
1308            matches!(media.format(), ClipFormat::Fixed(format) if format.subsampling() == Some(ChromaSubsampling::Cs420))
1309        );
1310        assert!(
1311            matches!(media.format(), ClipFormat::Fixed(format) if format.sample_type() == SampleType::U16)
1312        );
1313        assert_eq!(
1314            media.resolution(),
1315            &ClipResolution::Fixed {
1316                width: 1920,
1317                height: 1080,
1318            }
1319        );
1320        assert_eq!(media.frame_count(), FrameCount::Finite(24));
1321        assert_eq!(
1322            media.frame_rate(),
1323            FrameRate::Cfr(Rational {
1324                numerator: 24_000,
1325                denominator: 1_001,
1326            })
1327        );
1328    }
1329
1330    #[test]
1331    fn filter_call_creates_new_clip_without_mutating_input_clip() {
1332        let mut builder = GraphBuilder::new();
1333        let source = builder.source("source", fixed_media("yuv420p10"));
1334        let filtered = builder
1335            .filter(
1336                "identity",
1337                &[source],
1338                fixed_media("yuv420p10"),
1339                FilterCompatibility::Preserve,
1340            )
1341            .expect("filter should be added");
1342
1343        assert_ne!(source.node_id(), filtered.node_id());
1344        assert_eq!(source.node_id().index(), 0);
1345        assert_eq!(filtered.node_id().index(), 1);
1346    }
1347
1348    #[test]
1349    fn missing_final_output_fails_with_structured_diagnostic() {
1350        let mut builder = GraphBuilder::new();
1351        builder.source("source", fixed_media("yuv420p10"));
1352        let graph = builder.build();
1353
1354        let error = graph
1355            .validation_plan()
1356            .expect_err("missing output should fail");
1357
1358        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1359        assert_eq!(error.code(), crate::ErrorCode::new("graph.missing_output"));
1360    }
1361
1362    #[test]
1363    fn multiple_final_outputs_fail_phase1_validation() {
1364        let mut builder = GraphBuilder::new();
1365        let first = builder.source("first", fixed_media("yuv420p10"));
1366        let second = builder.source("second", fixed_media("yuv420p10"));
1367        builder.add_output(first);
1368        builder.add_output(second);
1369        let graph = builder.build();
1370
1371        let error = graph
1372            .validation_plan()
1373            .expect_err("multiple outputs should fail");
1374
1375        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1376        assert_eq!(
1377            error.code(),
1378            crate::ErrorCode::new("graph.multiple_outputs")
1379        );
1380        assert_eq!(error.message(), "graph has multiple final outputs");
1381    }
1382
1383    #[test]
1384    fn unreachable_source_and_filter_nodes_are_skipped_by_validation_plan() {
1385        let mut builder = GraphBuilder::new();
1386        let unused_source = builder.source("unused", fixed_media("yuv420p10"));
1387        let used_source = builder.source("used", fixed_media("yuv420p10"));
1388        let used_filter = builder
1389            .filter(
1390                "identity",
1391                &[used_source],
1392                fixed_media("yuv420p10"),
1393                FilterCompatibility::Preserve,
1394            )
1395            .expect("reachable filter should be added");
1396        let unused_filter = builder
1397            .filter(
1398                "identity",
1399                &[unused_source],
1400                fixed_media("yuv420p10"),
1401                FilterCompatibility::Preserve,
1402            )
1403            .expect("unreachable filter should be added");
1404        builder.set_output(used_filter);
1405        let graph = builder.build();
1406
1407        let plan = graph
1408            .validation_plan()
1409            .expect("graph should have validation plan");
1410
1411        assert_eq!(
1412            plan.reachable_nodes(),
1413            &[used_source.node_id(), used_filter.node_id()]
1414        );
1415        assert_eq!(plan.reachable_sources(), &[used_source.node_id()]);
1416        assert!(!plan.reachable_nodes().contains(&unused_source.node_id()));
1417        assert!(!plan.reachable_nodes().contains(&unused_filter.node_id()));
1418        assert!(matches!(
1419            graph.node(used_source.node_id()).unwrap().kind(),
1420            NodeKind::Source { .. }
1421        ));
1422    }
1423
1424    #[test]
1425    fn cyclic_graph_fails_before_render_validation() {
1426        let media = fixed_media("yuv420p10");
1427        let graph = super::Graph::from_parts_for_tests(
1428            vec![
1429                super::GraphNode::filter_for_tests(
1430                    NodeId::new(0),
1431                    "a",
1432                    &[super::Clip::new(NodeId::new(1))],
1433                    media.clone(),
1434                    FilterCompatibility::Preserve,
1435                ),
1436                super::GraphNode::filter_for_tests(
1437                    NodeId::new(1),
1438                    "b",
1439                    &[super::Clip::new(NodeId::new(0))],
1440                    media,
1441                    FilterCompatibility::Preserve,
1442                ),
1443            ],
1444            vec![super::Clip::new(NodeId::new(0))],
1445        );
1446
1447        let error = graph.validate().expect_err("cycle should fail validation");
1448
1449        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1450        assert_eq!(error.code(), crate::ErrorCode::new("graph.cycle"));
1451    }
1452
1453    fn invalid_media(
1454        format: ClipFormat,
1455        resolution: ClipResolution,
1456        frame_count: FrameCount,
1457        frame_rate: FrameRate,
1458    ) -> ClipMedia {
1459        ClipMedia::new(format, resolution, frame_count, frame_rate)
1460    }
1461
1462    #[test]
1463    fn variable_format_resolution_unknown_count_and_unknown_rate_fail_before_render() {
1464        let cases = [
1465            (
1466                invalid_media(
1467                    ClipFormat::Variable,
1468                    ClipResolution::Fixed {
1469                        width: 1920,
1470                        height: 1080,
1471                    },
1472                    FrameCount::Finite(24),
1473                    FrameRate::Cfr(Rational {
1474                        numerator: 24,
1475                        denominator: 1,
1476                    }),
1477                ),
1478                crate::ErrorCode::new("graph.variable_format"),
1479            ),
1480            (
1481                invalid_media(
1482                    ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1483                    ClipResolution::Variable,
1484                    FrameCount::Finite(24),
1485                    FrameRate::Cfr(Rational {
1486                        numerator: 24,
1487                        denominator: 1,
1488                    }),
1489                ),
1490                crate::ErrorCode::new("graph.variable_resolution"),
1491            ),
1492            (
1493                invalid_media(
1494                    ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1495                    ClipResolution::Fixed {
1496                        width: 1920,
1497                        height: 1080,
1498                    },
1499                    FrameCount::Unknown,
1500                    FrameRate::Cfr(Rational {
1501                        numerator: 24,
1502                        denominator: 1,
1503                    }),
1504                ),
1505                crate::ErrorCode::new("graph.unknown_frame_count"),
1506            ),
1507            (
1508                invalid_media(
1509                    ClipFormat::Fixed(resolve_format_alias("yuv420p10").unwrap()),
1510                    ClipResolution::Fixed {
1511                        width: 1920,
1512                        height: 1080,
1513                    },
1514                    FrameCount::Finite(24),
1515                    FrameRate::Unknown,
1516                ),
1517                crate::ErrorCode::new("graph.unknown_frame_rate"),
1518            ),
1519        ];
1520
1521        for (media, expected_code) in cases {
1522            let mut builder = GraphBuilder::new();
1523            let source = builder.source("source", media);
1524            builder.set_output(source);
1525            let graph = builder.build();
1526
1527            let error = graph.validate().expect_err("invalid media should fail");
1528
1529            assert_eq!(error.category(), crate::ErrorCategory::Graph);
1530            assert_eq!(error.code(), expected_code);
1531        }
1532    }
1533
1534    #[test]
1535    fn unsupported_final_y4m_format_fails_before_render() {
1536        let mut builder = GraphBuilder::new();
1537        let source = builder.source("source", fixed_media("rgbp10"));
1538        builder.set_output(source);
1539        let graph = builder.build();
1540
1541        let error = graph
1542            .validate()
1543            .expect_err("rgb output should fail y4m validation");
1544
1545        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1546        assert_eq!(
1547            error.code(),
1548            crate::ErrorCode::new("graph.unsupported_output_format")
1549        );
1550    }
1551
1552    #[test]
1553    fn preserve_policy_rejects_implicit_format_conversion() {
1554        let mut builder = GraphBuilder::new();
1555        let source = builder.source("source", fixed_media("rgbp10"));
1556        let converted = builder
1557            .filter(
1558                "identity",
1559                &[source],
1560                fixed_media("yuv420p10"),
1561                FilterCompatibility::Preserve,
1562            )
1563            .expect("filter should be added");
1564        builder.set_output(converted);
1565        let graph = builder.build();
1566
1567        let error = graph
1568            .validate()
1569            .expect_err("implicit conversion should fail");
1570
1571        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1572        assert_eq!(
1573            error.code(),
1574            crate::ErrorCode::new("graph.incompatible_format")
1575        );
1576    }
1577
1578    #[test]
1579    fn explicit_conversion_filter_can_change_format_without_implicit_insertion() {
1580        let mut builder = GraphBuilder::new();
1581        let source = builder.source("source", fixed_media("rgbp10"));
1582        let converted = builder
1583            .filter(
1584                "convert_format",
1585                &[source],
1586                fixed_media("yuv420p10"),
1587                FilterCompatibility::AllowChanges(FilterChangeSet {
1588                    format: true,
1589                    resolution: false,
1590                    frame_count: false,
1591                    frame_rate: false,
1592                }),
1593            )
1594            .expect("filter should be added");
1595        builder.set_output(converted);
1596        let graph = builder.build();
1597
1598        let validated = graph.validate().expect("explicit conversion should pass");
1599
1600        assert_eq!(
1601            validated.plan().reachable_nodes(),
1602            &[source.node_id(), converted.node_id()]
1603        );
1604    }
1605
1606    #[test]
1607    fn allow_changes_policy_accepts_format_and_resolution_change() {
1608        let mut builder = GraphBuilder::new();
1609        let source = builder.source("source", fixed_media("yuv420p10"));
1610        let changed_media = ClipMedia::fixed(
1611            resolve_format_alias("yuv444p8").unwrap(),
1612            1280,
1613            720,
1614            24,
1615            Rational {
1616                numerator: 24_000,
1617                denominator: 1_001,
1618            },
1619        );
1620        let filtered = builder
1621            .filter(
1622                "scale_convert",
1623                &[source],
1624                changed_media,
1625                FilterCompatibility::AllowChanges(FilterChangeSet {
1626                    format: true,
1627                    resolution: true,
1628                    frame_count: false,
1629                    frame_rate: false,
1630                }),
1631            )
1632            .expect("filter should be added");
1633        builder.set_output(filtered);
1634
1635        builder
1636            .build()
1637            .validate()
1638            .expect("declared format and resolution change should pass");
1639    }
1640
1641    #[test]
1642    fn allow_changes_policy_rejects_undeclared_frame_count_change() {
1643        let mut builder = GraphBuilder::new();
1644        let source = builder.source("source", fixed_media("yuv420p10"));
1645        let changed_media = ClipMedia::fixed(
1646            resolve_format_alias("yuv444p8").unwrap(),
1647            1280,
1648            720,
1649            12,
1650            Rational {
1651                numerator: 24_000,
1652                denominator: 1_001,
1653            },
1654        );
1655        let filtered = builder
1656            .filter(
1657                "scale_convert",
1658                &[source],
1659                changed_media,
1660                FilterCompatibility::AllowChanges(FilterChangeSet {
1661                    format: true,
1662                    resolution: true,
1663                    frame_count: false,
1664                    frame_rate: false,
1665                }),
1666            )
1667            .expect("filter should be added");
1668        builder.set_output(filtered);
1669
1670        let error = builder
1671            .build()
1672            .validate()
1673            .expect_err("undeclared frame-count change should fail");
1674
1675        assert_eq!(error.category(), crate::ErrorCategory::Graph);
1676        assert_eq!(
1677            error.code(),
1678            crate::ErrorCode::new("graph.incompatible_frame_count")
1679        );
1680    }
1681
1682    #[test]
1683    fn allow_changes_policy_accepts_frame_rate_change_when_declared() {
1684        let mut builder = GraphBuilder::new();
1685        let source = builder.source("source", fixed_media("yuv420p10"));
1686        let changed_media = ClipMedia::fixed(
1687            resolve_format_alias("yuv420p10").unwrap(),
1688            1920,
1689            1080,
1690            24,
1691            Rational {
1692                numerator: 30,
1693                denominator: 1,
1694            },
1695        );
1696        let filtered = builder
1697            .filter(
1698                "retime",
1699                &[source],
1700                changed_media,
1701                FilterCompatibility::AllowChanges(FilterChangeSet {
1702                    format: false,
1703                    resolution: false,
1704                    frame_count: false,
1705                    frame_rate: true,
1706                }),
1707            )
1708            .expect("filter should be added");
1709        builder.set_output(filtered);
1710
1711        builder
1712            .build()
1713            .validate()
1714            .expect("declared frame-rate change should pass");
1715    }
1716
1717    #[test]
1718    fn preserve_policy_rejects_frame_count_and_rate_mismatch() {
1719        let source_media = fixed_media("yuv420p10");
1720        let count_changed = ClipMedia::fixed(
1721            resolve_format_alias("yuv420p10").unwrap(),
1722            1920,
1723            1080,
1724            12,
1725            Rational {
1726                numerator: 24_000,
1727                denominator: 1_001,
1728            },
1729        );
1730        let rate_changed = ClipMedia::fixed(
1731            resolve_format_alias("yuv420p10").unwrap(),
1732            1920,
1733            1080,
1734            24,
1735            Rational {
1736                numerator: 30,
1737                denominator: 1,
1738            },
1739        );
1740
1741        for (media, code) in [
1742            (
1743                count_changed,
1744                crate::ErrorCode::new("graph.incompatible_frame_count"),
1745            ),
1746            (
1747                rate_changed,
1748                crate::ErrorCode::new("graph.incompatible_frame_rate"),
1749            ),
1750        ] {
1751            let mut builder = GraphBuilder::new();
1752            let source = builder.source("source", source_media.clone());
1753            let filtered = builder
1754                .filter("identity", &[source], media, FilterCompatibility::Preserve)
1755                .expect("filter should be added");
1756            builder.set_output(filtered);
1757            let graph = builder.build();
1758
1759            let error = graph.validate().expect_err("preserve mismatch should fail");
1760
1761            assert_eq!(error.category(), crate::ErrorCategory::Graph);
1762            assert_eq!(error.code(), code);
1763        }
1764    }
1765}