Skip to main content

otf_pixels_core/
plan.rs

1//! Static analysis of a graph, done once before any pixel moves.
2//!
3//! A [`Plan`] answers two questions the scheduler needs and neither the ops nor
4//! the codecs can answer alone:
5//!
6//! 1. **What shape are tiles?** Runs of [`AccessPattern::Sequential`] ops move
7//!    full-width strips; segments containing a [`AccessPattern::Spatial`] op
8//!    move square tiles (ADR-0003).
9//! 2. **Where must the pipeline materialize?** Where the region sequence a node
10//!    is asked for is not forward-monotonic, and the pixels below it come from
11//!    a forward-only source (ADR-0009).
12//!
13//! Both are derived from what ops already declare — [`Op::access_pattern`] and
14//! [`Op::input_regions`] — so an op cannot get the analysis wrong separately
15//! from getting its own contract wrong.
16
17use crate::{AccessPattern, DecodeCapability, Image, Node, NodeId, PixelsError, Region, Result};
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20
21/// The shape tiles take through a segment of the graph.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum TileShape {
24    /// Full-width strips of at most `rows` rows.
25    ///
26    /// Matches how codecs produce and consume pixels, so a sequential segment
27    /// moves data with no repacking.
28    Strip {
29        /// Maximum rows per strip; the final strip may be shorter.
30        rows: u32,
31    },
32    /// Square tiles of at most `size` × `size` pixels.
33    ///
34    /// Used where a spatial op needs a neighbourhood, so that border pixels are
35    /// recomputed for four edges rather than for the full image width.
36    Square {
37        /// Maximum edge length; edge tiles may be smaller.
38        size: u32,
39    },
40}
41
42impl TileShape {
43    /// The default strip height, in rows.
44    pub const DEFAULT_STRIP_ROWS: u32 = 64;
45    /// The default square tile edge, in pixels (ADR-0003).
46    pub const DEFAULT_SQUARE_SIZE: u32 = 128;
47
48    /// Split `region` into tiles of this shape, in production order.
49    ///
50    /// Order is top-to-bottom, then left-to-right, so a sink consuming the
51    /// result sees rows in order wherever the shape allows.
52    #[must_use]
53    pub fn tiles(self, region: Region) -> Vec<Region> {
54        if region.is_empty() {
55            return Vec::new();
56        }
57        let (tile_width, tile_height) = match self {
58            Self::Strip { rows } => (region.width, rows.max(1)),
59            Self::Square { size } => (size.max(1), size.max(1)),
60        };
61        let mut tiles = Vec::new();
62        let mut y = region.y;
63        while u64::from(y) < region.bottom() {
64            let height = tile_height.min((region.bottom() - u64::from(y)) as u32);
65            let mut x = region.x;
66            while u64::from(x) < region.right() {
67                let width = tile_width.min((region.right() - u64::from(x)) as u32);
68                tiles.push(Region::new(x, y, width, height));
69                x = x.saturating_add(width);
70            }
71            y = y.saturating_add(height);
72        }
73        tiles
74    }
75}
76
77/// How the scheduler must treat one node.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79#[non_exhaustive]
80pub struct NodePlan {
81    /// The shape tiles of this node's output take.
82    pub shape: TileShape,
83    /// Whether this node's whole output must be realized before its consumers
84    /// pull from it.
85    ///
86    /// Set where demand on this node is not forward-monotonic and the pixels
87    /// below come from a forward-only source (ADR-0009). It is a property of
88    /// this *position in this pipeline*, not of the node's op.
89    pub materialize: bool,
90    /// Whether this node's tiles are worth retaining in the tile cache.
91    ///
92    /// Only nodes whose output is genuinely demanded more than once qualify:
93    /// a shared graph prefix, or a node feeding a spatial op whose tile
94    /// requests overlap. In a linear pipeline every tile is produced once and
95    /// consumed once, so caching it would be pure waste — and, worse, would
96    /// fill the byte budget with garbage and make a streaming pipeline's
97    /// memory look like the cache budget rather than a few tiles.
98    pub cacheable: bool,
99}
100
101/// The analysis result for one pipeline.
102#[derive(Debug, Clone)]
103pub struct Plan {
104    nodes: HashMap<NodeId, NodePlan>,
105    output_tiles: Vec<Region>,
106    root: NodeId,
107}
108
109/// Knobs for [`Plan::build`].
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct PlanOptions {
113    /// Rows per strip in sequential segments.
114    pub strip_rows: u32,
115    /// Edge length of square tiles in spatial segments.
116    pub square_size: u32,
117}
118
119impl PlanOptions {
120    /// Set the rows per strip in sequential segments.
121    ///
122    /// `PlanOptions` is `#[non_exhaustive]`, so downstream crates cannot use a
123    /// struct literal; these setters are the only way to configure it.
124    #[must_use]
125    pub const fn with_strip_rows(mut self, rows: u32) -> Self {
126        self.strip_rows = rows;
127        self
128    }
129
130    /// Set the edge length of square tiles in spatial segments.
131    #[must_use]
132    pub const fn with_square_size(mut self, size: u32) -> Self {
133        self.square_size = size;
134        self
135    }
136}
137
138impl Default for PlanOptions {
139    fn default() -> Self {
140        Self {
141            strip_rows: TileShape::DEFAULT_STRIP_ROWS,
142            square_size: TileShape::DEFAULT_SQUARE_SIZE,
143        }
144    }
145}
146
147impl Plan {
148    /// Analyse `image`'s graph.
149    ///
150    /// # Errors
151    ///
152    /// Propagates [`Op::input_regions`] for any op whose demand mapping
153    /// rejects a region this plan would ask for.
154    ///
155    /// [`Op::input_regions`]: crate::Op::input_regions
156    pub fn build(image: &Image, options: PlanOptions) -> Result<Self> {
157        let root = Arc::clone(image.node());
158        let order = topological_order(&root);
159
160        // 1. Tile shapes. A node's output is square if it or any consumer is
161        //    spatial: a spatial op needs its input as squares, and producing
162        //    squares from a strip producer is what the seam line-cache is for.
163        let spatial_consumers = spatial_consumers(&order);
164        let mut nodes = HashMap::with_capacity(order.len());
165        for node in &order {
166            let is_spatial = node
167                .op()
168                .is_some_and(|op| op.access_pattern() == AccessPattern::Spatial);
169            let feeds_spatial = spatial_consumers.contains(&node.id());
170            let shape = if is_spatial || feeds_spatial {
171                TileShape::Square {
172                    size: options.square_size,
173                }
174            } else {
175                TileShape::Strip {
176                    rows: options.strip_rows,
177                }
178            };
179            nodes.insert(
180                node.id(),
181                NodePlan {
182                    shape,
183                    materialize: false,
184                    // Filled in below, once fan-out is known.
185                    cacheable: false,
186                },
187            );
188        }
189
190        // 2. The output tile sequence, in the order the sink will pull it.
191        let root_shape = nodes
192            .get(&root.id())
193            .ok_or_else(|| PixelsError::graph("root node missing from plan"))?
194            .shape;
195        let output_tiles = root_shape.tiles(root.descriptor().region());
196
197        // 3. Demand order. Replay the whole output sequence through
198        //    `input_regions` and record what each node is asked for, in order.
199        let demand = demand_sequences(&root, &output_tiles)?;
200
201        // 4. Retention. A tile is only worth caching if something will ask
202        //    for it twice: a shared prefix (fan-out above one) or a spatial
203        //    consumer whose tile requests overlap at the borders.
204        let fan_out = fan_out(&order);
205        for node in &order {
206            let shared = fan_out.get(&node.id()).copied().unwrap_or(0) > 1;
207            let overlapping = spatial_consumers.contains(&node.id());
208            if let Some(plan) = nodes.get_mut(&node.id()) {
209                plan.cacheable = shared || overlapping;
210            }
211        }
212
213        // 5. Materialize where non-monotonic demand meets a forward-only
214        //    source (ADR-0009).
215        let forward_only = forward_only_nodes(&order);
216        for node in &order {
217            let Some(regions) = demand.get(&node.id()) else {
218                continue;
219            };
220            if is_forward_monotonic(regions) || !forward_only.contains(&node.id()) {
221                continue;
222            }
223            if let Some(plan) = nodes.get_mut(&node.id()) {
224                plan.materialize = true;
225            }
226        }
227
228        Ok(Self {
229            nodes,
230            output_tiles,
231            root: root.id(),
232        })
233    }
234
235    /// The plan for one node, if it is part of this pipeline.
236    #[must_use]
237    pub fn node(&self, id: NodeId) -> Option<NodePlan> {
238        self.nodes.get(&id).copied()
239    }
240
241    /// The output regions the sink will pull, in order.
242    #[must_use]
243    pub fn output_tiles(&self) -> &[Region] {
244        &self.output_tiles
245    }
246
247    /// The root node this plan was built for.
248    #[must_use]
249    pub const fn root(&self) -> NodeId {
250        self.root
251    }
252
253    /// How many nodes the plan covers.
254    #[must_use]
255    pub fn len(&self) -> usize {
256        self.nodes.len()
257    }
258
259    /// Whether the plan covers no nodes.
260    #[must_use]
261    pub fn is_empty(&self) -> bool {
262        self.nodes.is_empty()
263    }
264
265    /// Whether any node in the pipeline must be materialized.
266    ///
267    /// A pipeline with no materialization points runs in memory bounded by
268    /// tiles in flight; one with them buffers a full intermediate per point
269    /// (SPEC §Guarantees 1).
270    #[must_use]
271    pub fn materializes(&self) -> bool {
272        self.nodes.values().any(|plan| plan.materialize)
273    }
274}
275
276/// Nodes in dependency order: every node appears after its inputs.
277fn topological_order(root: &Arc<Node>) -> Vec<Arc<Node>> {
278    /// One step of the explicit walk, mirroring the evaluator's.
279    enum Step {
280        Visit(Arc<Node>),
281        Emit(Arc<Node>),
282    }
283    let mut seen = HashSet::new();
284    let mut order = Vec::new();
285    let mut stack = vec![Step::Visit(Arc::clone(root))];
286    while let Some(step) = stack.pop() {
287        match step {
288            Step::Visit(node) => {
289                if seen.contains(&node.id()) {
290                    continue;
291                }
292                stack.push(Step::Emit(Arc::clone(&node)));
293                for input in node.inputs() {
294                    stack.push(Step::Visit(Arc::clone(input)));
295                }
296            }
297            Step::Emit(node) => {
298                if seen.insert(node.id()) {
299                    order.push(node);
300                }
301            }
302        }
303    }
304    order
305}
306
307/// How many consumers each node has within this graph.
308fn fan_out(order: &[Arc<Node>]) -> HashMap<NodeId, usize> {
309    let mut counts: HashMap<NodeId, usize> = HashMap::new();
310    for node in order {
311        for input in node.inputs() {
312            *counts.entry(input.id()).or_default() += 1;
313        }
314    }
315    counts
316}
317
318/// Nodes that feed at least one spatial op.
319fn spatial_consumers(order: &[Arc<Node>]) -> HashSet<NodeId> {
320    let mut feeding = HashSet::new();
321    for node in order {
322        let spatial = node
323            .op()
324            .is_some_and(|op| op.access_pattern() == AccessPattern::Spatial);
325        if spatial {
326            for input in node.inputs() {
327                feeding.insert(input.id());
328            }
329        }
330    }
331    feeding
332}
333
334/// Nodes whose pixels ultimately come from a forward-only producer.
335///
336/// A node inherits the constraint from its inputs: if anything below it can
337/// only go forward, it can only be produced going forward.
338fn forward_only_nodes(order: &[Arc<Node>]) -> HashSet<NodeId> {
339    let mut forward_only = HashSet::new();
340    // `order` is topological, so inputs are classified before their consumers.
341    for node in order {
342        let constrained = match node.producer() {
343            Some(producer) => producer.capability() == DecodeCapability::Sequential,
344            None => node
345                .inputs()
346                .iter()
347                .any(|input| forward_only.contains(&input.id())),
348        };
349        if constrained {
350            forward_only.insert(node.id());
351        }
352    }
353    forward_only
354}
355
356/// Replay the output tile sequence, recording what each node is asked for.
357fn demand_sequences(
358    root: &Arc<Node>,
359    output_tiles: &[Region],
360) -> Result<HashMap<NodeId, Vec<Region>>> {
361    let mut sequences: HashMap<NodeId, Vec<Region>> = HashMap::new();
362    for tile in output_tiles {
363        let mut stack = vec![(Arc::clone(root), *tile)];
364        while let Some((node, region)) = stack.pop() {
365            sequences.entry(node.id()).or_default().push(region);
366            let Some(op) = node.op() else { continue };
367            let descriptors: Vec<_> = node.inputs().iter().map(|n| n.descriptor()).collect();
368            let requested = op.input_regions(region, &descriptors)?;
369            if requested.len() != node.inputs().len() {
370                return Err(PixelsError::graph(format!(
371                    "op `{}` requested {} input region(s) for {} input(s)",
372                    op.name(),
373                    requested.len(),
374                    node.inputs().len()
375                )));
376            }
377            for (input, region) in node.inputs().iter().zip(requested) {
378                stack.push((Arc::clone(input), region));
379            }
380        }
381    }
382    Ok(sequences)
383}
384
385/// Whether a demand sequence only ever moves forward through the image.
386///
387/// "Forward" means each request starts no earlier than the previous one
388/// started — the exact condition a forward-only source can satisfy. Repeats and
389/// overlaps are fine (a rolling window covers them); going backwards is not.
390fn is_forward_monotonic(regions: &[Region]) -> bool {
391    regions.windows(2).all(|pair| {
392        let (Some(previous), Some(next)) = (pair.first(), pair.get(1)) else {
393            return true;
394        };
395        next.y >= previous.y
396    })
397}
398
399#[cfg(test)]
400#[allow(
401    clippy::unwrap_used,
402    clippy::indexing_slicing,
403    clippy::panic,
404    reason = "tests operate on known-good values and assert shapes directly"
405)]
406mod tests {
407    use super::*;
408    use crate::testing::{ConstantOp, CountingProducer};
409    use crate::{
410        BufferSource, Format, ImageDescriptor, Op, PixelFormat, Producer, Tile, TileBuf, TileMut,
411    };
412
413    /// A source with a chosen capability, to drive ADR-0009's analysis.
414    #[derive(Debug)]
415    struct Source {
416        descriptor: ImageDescriptor,
417        capability: DecodeCapability,
418    }
419
420    impl Producer for Source {
421        fn name(&self) -> &'static str {
422            "test-source"
423        }
424        fn descriptor(&self) -> ImageDescriptor {
425            self.descriptor
426        }
427        fn capability(&self) -> DecodeCapability {
428            self.capability
429        }
430        fn produce(&self, _: Region, _: &mut TileMut<'_>) -> Result<()> {
431            Ok(())
432        }
433    }
434
435    fn image(width: u32, height: u32, capability: DecodeCapability) -> Image {
436        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
437        Image::from_producer(
438            Arc::new(Source {
439                descriptor,
440                capability,
441            }),
442            Format::Raw,
443        )
444    }
445
446    /// An op that mirrors vertically, like `Flip`: demand runs backwards.
447    #[derive(Debug)]
448    struct Reverse;
449    impl Op for Reverse {
450        fn name(&self) -> &'static str {
451            "reverse"
452        }
453        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
454            inputs
455                .first()
456                .copied()
457                .ok_or_else(|| PixelsError::graph("no input"))
458        }
459        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
460            let input = inputs
461                .first()
462                .ok_or_else(|| PixelsError::graph("no input"))?;
463            let y = (u64::from(input.height) - out.bottom()) as u32;
464            Ok(vec![Region::new(out.x, y, out.width, out.height)])
465        }
466        fn compute(&self, _: &[Tile<'_>], _: &mut TileMut<'_>) -> Result<()> {
467            Ok(())
468        }
469    }
470
471    /// A neighbourhood op, to exercise square-tile negotiation.
472    #[derive(Debug)]
473    struct Blur;
474    impl Op for Blur {
475        fn name(&self) -> &'static str {
476            "blur"
477        }
478        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
479            inputs
480                .first()
481                .copied()
482                .ok_or_else(|| PixelsError::graph("no input"))
483        }
484        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
485            let input = inputs
486                .first()
487                .ok_or_else(|| PixelsError::graph("no input"))?;
488            // Grown by one pixel, clamped to the image.
489            Ok(vec![Region::new(
490                out.x.saturating_sub(1),
491                out.y.saturating_sub(1),
492                (out.width + 2).min(input.width),
493                (out.height + 2).min(input.height),
494            )])
495        }
496        fn access_pattern(&self) -> AccessPattern {
497            AccessPattern::Spatial
498        }
499        fn compute(&self, _: &[Tile<'_>], _: &mut TileMut<'_>) -> Result<()> {
500            Ok(())
501        }
502    }
503
504    // --- Tile shape splitting ------------------------------------------------
505
506    #[test]
507    fn strips_cover_the_region_exactly_once() {
508        let region = Region::from_size(10, 25);
509        let tiles = TileShape::Strip { rows: 10 }.tiles(region);
510        assert_eq!(tiles.len(), 3);
511        assert_eq!(tiles[0], Region::new(0, 0, 10, 10));
512        assert_eq!(tiles[1], Region::new(0, 10, 10, 10));
513        assert_eq!(tiles[2], Region::new(0, 20, 10, 5), "final strip is short");
514        assert_eq!(
515            tiles.iter().map(|t| t.pixel_count()).sum::<u64>(),
516            region.pixel_count()
517        );
518    }
519
520    #[test]
521    fn squares_cover_the_region_exactly_once() {
522        let region = Region::from_size(10, 10);
523        let tiles = TileShape::Square { size: 4 }.tiles(region);
524        assert_eq!(tiles.len(), 9, "3x3 grid of tiles over a 10x10 image");
525        assert_eq!(tiles[0], Region::new(0, 0, 4, 4));
526        assert_eq!(tiles[2], Region::new(8, 0, 2, 4), "right edge is narrow");
527        assert_eq!(tiles[8], Region::new(8, 8, 2, 2), "corner is small");
528        assert_eq!(
529            tiles.iter().map(|t| t.pixel_count()).sum::<u64>(),
530            region.pixel_count()
531        );
532    }
533
534    #[test]
535    fn tiles_are_produced_top_to_bottom() {
536        let tiles = TileShape::Square { size: 4 }.tiles(Region::from_size(8, 8));
537        let ys: Vec<u32> = tiles.iter().map(|t| t.y).collect();
538        assert!(
539            ys.windows(2).all(|w| w[0] <= w[1]),
540            "rows must not go backwards"
541        );
542    }
543
544    #[test]
545    fn splitting_handles_degenerate_inputs() {
546        assert!(TileShape::Strip { rows: 4 }.tiles(Region::EMPTY).is_empty());
547        // A zero tile size is clamped rather than looping forever.
548        assert_eq!(
549            TileShape::Strip { rows: 0 }
550                .tiles(Region::from_size(2, 2))
551                .len(),
552            2
553        );
554        assert_eq!(
555            TileShape::Square { size: 0 }
556                .tiles(Region::from_size(2, 2))
557                .len(),
558            4
559        );
560        // A tile larger than the image yields exactly one tile.
561        assert_eq!(
562            TileShape::Strip { rows: 999 }
563                .tiles(Region::from_size(4, 4))
564                .len(),
565            1
566        );
567    }
568
569    // --- Tile shape negotiation (ADR-0003) -----------------------------------
570
571    #[test]
572    fn a_sequential_pipeline_moves_strips() {
573        let image = image(64, 64, DecodeCapability::Regions)
574            .apply(Arc::new(ConstantOp::new(1)))
575            .unwrap();
576        let plan = Plan::build(&image, PlanOptions::default()).unwrap();
577        for node in [image.node().id(), image.node().inputs()[0].id()] {
578            assert!(
579                matches!(plan.node(node).unwrap().shape, TileShape::Strip { .. }),
580                "sequential nodes should move strips"
581            );
582        }
583    }
584
585    #[test]
586    fn a_spatial_op_switches_its_segment_to_squares() {
587        // ADR-0003: the spatial op and the node feeding it both go square.
588        let source = image(64, 64, DecodeCapability::Regions);
589        let blurred = source.apply(Arc::new(Blur)).unwrap();
590        let plan = Plan::build(&blurred, PlanOptions::default()).unwrap();
591        assert!(matches!(
592            plan.node(blurred.node().id()).unwrap().shape,
593            TileShape::Square { .. }
594        ));
595        assert!(
596            matches!(
597                plan.node(source.node().id()).unwrap().shape,
598                TileShape::Square { .. }
599            ),
600            "the node feeding a spatial op must deliver squares"
601        );
602    }
603
604    #[test]
605    fn sequential_nodes_above_a_spatial_op_stay_strips() {
606        // Only the segment around the spatial op changes shape.
607        let source = image(64, 64, DecodeCapability::Regions);
608        let blurred = source.apply(Arc::new(Blur)).unwrap();
609        let after = blurred.apply(Arc::new(ConstantOp::new(1))).unwrap();
610        let plan = Plan::build(&after, PlanOptions::default()).unwrap();
611        assert!(
612            matches!(
613                plan.node(after.node().id()).unwrap().shape,
614                TileShape::Strip { .. }
615            ),
616            "a sequential consumer keeps strips"
617        );
618    }
619
620    #[test]
621    fn plan_options_choose_the_tile_dimensions() {
622        let image = image(100, 100, DecodeCapability::Regions);
623        let options = PlanOptions {
624            strip_rows: 25,
625            square_size: 10,
626        };
627        let plan = Plan::build(&image, options).unwrap();
628        assert_eq!(
629            plan.node(image.node().id()).unwrap().shape,
630            TileShape::Strip { rows: 25 }
631        );
632        assert_eq!(plan.output_tiles().len(), 4);
633    }
634
635    // --- Demand order and materialization (ADR-0009) -------------------------
636
637    #[test]
638    fn a_forward_pipeline_never_materializes() {
639        let image = image(256, 256, DecodeCapability::Sequential)
640            .apply(Arc::new(ConstantOp::new(1)))
641            .unwrap();
642        let plan = Plan::build(&image, PlanOptions::default()).unwrap();
643        assert!(!plan.materializes(), "forward demand needs no buffer");
644    }
645
646    #[test]
647    fn reversal_over_a_sequential_source_materializes() {
648        // The ADR-0009 case: non-forward demand meeting a forward-only source.
649        let source = image(256, 256, DecodeCapability::Sequential);
650        let reversed = source.apply(Arc::new(Reverse)).unwrap();
651        let plan = Plan::build(&reversed, PlanOptions::default()).unwrap();
652        assert!(
653            plan.materializes(),
654            "reversal over a forward-only source must buffer"
655        );
656        assert!(
657            plan.node(source.node().id()).unwrap().materialize,
658            "the buffer belongs at the source being read backwards"
659        );
660        assert!(
661            !plan.node(reversed.node().id()).unwrap().materialize,
662            "the reversing node itself is pulled in output order"
663        );
664    }
665
666    #[test]
667    fn reversal_over_a_random_access_source_streams() {
668        // The point of ADR-0009: the good case is not sacrificed to the bad
669        // one. Same pipeline, different upstream, no buffer.
670        let source = image(256, 256, DecodeCapability::Regions);
671        let reversed = source.apply(Arc::new(Reverse)).unwrap();
672        let plan = Plan::build(&reversed, PlanOptions::default()).unwrap();
673        assert!(
674            !plan.materializes(),
675            "a random-access source serves bands in any order"
676        );
677    }
678
679    #[test]
680    fn a_memory_buffer_source_streams_under_reversal() {
681        // The same property, through the real BufferSource rather than a stub.
682        let descriptor = ImageDescriptor::new(32, 32, PixelFormat::Gray8).unwrap();
683        let buffer = Arc::new(TileBuf::for_image(&descriptor).unwrap());
684        let source = BufferSource::new(descriptor, buffer).unwrap();
685        let image = Image::from_producer(Arc::new(source), Format::Raw)
686            .apply(Arc::new(Reverse))
687            .unwrap();
688        let plan = Plan::build(&image, PlanOptions::default()).unwrap();
689        assert!(!plan.materializes());
690    }
691
692    #[test]
693    fn the_constraint_propagates_down_a_chain() {
694        // A sequential source seen through several ops still forces a buffer.
695        let source = image(128, 128, DecodeCapability::Sequential);
696        let image = source
697            .apply(Arc::new(ConstantOp::new(1)))
698            .unwrap()
699            .apply(Arc::new(ConstantOp::new(2)))
700            .unwrap()
701            .apply(Arc::new(Reverse))
702            .unwrap();
703        let plan = Plan::build(&image, PlanOptions::default()).unwrap();
704        assert!(plan.materializes());
705    }
706
707    #[test]
708    fn a_single_tile_pipeline_is_trivially_forward() {
709        // One output tile means one demand per node: nothing can go backwards.
710        let source = image(8, 8, DecodeCapability::Sequential);
711        let reversed = source.apply(Arc::new(Reverse)).unwrap();
712        let options = PlanOptions {
713            strip_rows: 64,
714            square_size: 128,
715        };
716        let plan = Plan::build(&reversed, options).unwrap();
717        assert_eq!(plan.output_tiles().len(), 1);
718        assert!(
719            !plan.materializes(),
720            "a single whole-image tile never reverses"
721        );
722    }
723
724    #[test]
725    fn monotonicity_allows_repeats_and_overlap() {
726        // A rolling window covers overlap; only going backwards is fatal.
727        assert!(is_forward_monotonic(&[]));
728        assert!(is_forward_monotonic(&[Region::new(0, 0, 4, 4)]));
729        assert!(is_forward_monotonic(&[
730            Region::new(0, 0, 4, 4),
731            Region::new(0, 4, 4, 4)
732        ]));
733        assert!(is_forward_monotonic(&[
734            Region::new(0, 0, 4, 4),
735            Region::new(0, 0, 4, 4)
736        ]));
737        assert!(is_forward_monotonic(&[
738            Region::new(0, 0, 4, 8),
739            Region::new(0, 4, 4, 8)
740        ]));
741        assert!(!is_forward_monotonic(&[
742            Region::new(0, 4, 4, 4),
743            Region::new(0, 0, 4, 4)
744        ]));
745    }
746
747    // --- Plan shape ----------------------------------------------------------
748
749    #[test]
750    fn a_linear_pipeline_caches_nothing() {
751        // Every tile in a chain is produced once and consumed once, so
752        // retaining any of them is pure waste — and would make a streaming
753        // pipeline's peak memory equal the cache budget.
754        let source = image(64, 64, DecodeCapability::Regions);
755        let a = source.apply(Arc::new(ConstantOp::new(1))).unwrap();
756        let b = a.apply(Arc::new(ConstantOp::new(2))).unwrap();
757        let plan = Plan::build(&b, PlanOptions::default()).unwrap();
758        for node in [source.node().id(), a.node().id(), b.node().id()] {
759            assert!(
760                !plan.node(node).unwrap().cacheable,
761                "a linear chain cached a tile"
762            );
763        }
764    }
765
766    #[test]
767    fn a_shared_prefix_is_cacheable() {
768        // Fan-out above one is the case the cache exists for: without it the
769        // shared prefix is recomputed once per branch.
770        let source = image(32, 32, DecodeCapability::Regions);
771        let base = source.apply(Arc::new(ConstantOp::new(1))).unwrap();
772        let left = base.apply(Arc::new(ConstantOp::new(2))).unwrap();
773        let right = base.apply(Arc::new(ConstantOp::new(3))).unwrap();
774        let joined =
775            Image::combine(&[left.clone(), right], Arc::new(crate::testing::SumOp)).unwrap();
776        let plan = Plan::build(&joined, PlanOptions::default()).unwrap();
777        assert!(
778            plan.node(base.node().id()).unwrap().cacheable,
779            "shared prefix not cached"
780        );
781        assert!(
782            !plan.node(left.node().id()).unwrap().cacheable,
783            "single consumer cached"
784        );
785        assert!(
786            !plan.node(joined.node().id()).unwrap().cacheable,
787            "the root has no consumer"
788        );
789    }
790
791    #[test]
792    fn a_node_feeding_a_spatial_op_is_cacheable() {
793        // Neighbourhood demand overlaps at tile borders, so those tiles are
794        // genuinely asked for more than once.
795        let source = image(64, 64, DecodeCapability::Regions);
796        let blurred = source.apply(Arc::new(Blur)).unwrap();
797        let plan = Plan::build(&blurred, PlanOptions::default()).unwrap();
798        assert!(plan.node(source.node().id()).unwrap().cacheable);
799    }
800
801    #[test]
802    fn the_plan_covers_every_node_once() {
803        let source = image(32, 32, DecodeCapability::Regions);
804        let a = source.apply(Arc::new(ConstantOp::new(1))).unwrap();
805        let b = a.apply(Arc::new(ConstantOp::new(2))).unwrap();
806        let plan = Plan::build(&b, PlanOptions::default()).unwrap();
807        assert_eq!(plan.len(), 3);
808        assert!(!plan.is_empty());
809        assert_eq!(plan.root(), b.node().id());
810        assert!(plan.node(source.node().id()).is_some());
811    }
812
813    #[test]
814    fn a_shared_prefix_is_planned_once() {
815        let source = image(32, 32, DecodeCapability::Regions);
816        let base = source.apply(Arc::new(ConstantOp::new(1))).unwrap();
817        let left = base.apply(Arc::new(ConstantOp::new(2))).unwrap();
818        let right = base.apply(Arc::new(ConstantOp::new(3))).unwrap();
819        let joined = Image::combine(&[left, right], Arc::new(crate::testing::SumOp)).unwrap();
820        let plan = Plan::build(&joined, PlanOptions::default()).unwrap();
821        // source + base + left + right + join = 5 distinct nodes.
822        assert_eq!(plan.len(), 5);
823    }
824
825    #[test]
826    fn output_tiles_tile_the_whole_image() {
827        let image = image(100, 70, DecodeCapability::Regions);
828        let plan = Plan::build(
829            &image,
830            PlanOptions {
831                strip_rows: 16,
832                square_size: 128,
833            },
834        )
835        .unwrap();
836        let covered: u64 = plan.output_tiles().iter().map(|t| t.pixel_count()).sum();
837        assert_eq!(covered, 100 * 70);
838        assert_eq!(plan.output_tiles().len(), 5, "70 rows in strips of 16");
839    }
840
841    #[test]
842    fn planning_touches_no_pixels() {
843        let descriptor = ImageDescriptor::new(64, 64, PixelFormat::Gray8).unwrap();
844        let producer = Arc::new(CountingProducer::new(descriptor));
845        let image = Image::from_producer(Arc::clone(&producer) as Arc<dyn Producer>, Format::Raw)
846            .apply(Arc::new(ConstantOp::new(1)))
847            .unwrap();
848        let _plan = Plan::build(&image, PlanOptions::default()).unwrap();
849        assert_eq!(producer.produce_calls(), 0, "planning is pure analysis");
850    }
851
852    #[test]
853    fn an_op_rejecting_a_planned_region_fails_the_plan() {
854        /// An op whose demand mapping refuses everything.
855        #[derive(Debug)]
856        struct Refuses;
857        impl Op for Refuses {
858            fn name(&self) -> &'static str {
859                "refuses"
860            }
861            fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
862                inputs
863                    .first()
864                    .copied()
865                    .ok_or_else(|| PixelsError::graph("no input"))
866            }
867            fn input_regions(&self, _: Region, _: &[ImageDescriptor]) -> Result<Vec<Region>> {
868                Err(PixelsError::invalid_argument("output", "never satisfiable"))
869            }
870            fn compute(&self, _: &[Tile<'_>], _: &mut TileMut<'_>) -> Result<()> {
871                Ok(())
872            }
873        }
874        let image = image(32, 32, DecodeCapability::Regions)
875            .apply(Arc::new(Refuses))
876            .unwrap();
877        let err = Plan::build(&image, PlanOptions::default()).unwrap_err();
878        assert_eq!(err.code(), crate::ErrorCode::InvalidArgument);
879    }
880}