Skip to main content

otf_pixels_core/
schedule.rs

1//! The pull-based tile scheduler.
2//!
3//! This is the demand-driven evaluator ADR-0001 commits to, and the thing M1's
4//! whole-image evaluator exists to be checked against: for any pipeline the two
5//! must agree byte for byte (ROADMAP M2).
6//!
7//! # How a run proceeds
8//!
9//! 1. [`Plan`] analyses the graph once: tile shapes (ADR-0003) and
10//!    materialization points (ADR-0009). No pixels move.
11//! 2. Nodes the plan marked are materialized, in forward order.
12//! 3. Output tiles are processed in **batches**. Within a batch, tiles are
13//!    evaluated in parallel on the work-stealing pool; batches themselves run
14//!    in order, and results are handed to the sink in order so an encoder still
15//!    sees rows top to bottom.
16//!
17//! # Where the parallelism comes from
18//!
19//! A linear pipeline has no *intra*-tile parallelism — every node depends on
20//! the one below it. Parallelism therefore comes from having several output
21//! tiles in flight, which is also what bounds memory: peak usage is a batch's
22//! working set, not the image.
23//!
24//! # Why forward-only sources stay correct
25//!
26//! A [`DecodeCapability::Sequential`] producer cannot serve concurrent
27//! out-of-order requests. Source bands for a batch are therefore pulled
28//! **serially and in order** before the batch's parallel phase begins, and held
29//! for its duration. The plan has already guaranteed that order is forward.
30//!
31//! # Why the cache is only ever an optimisation
32//!
33//! Each output tile evaluates into its own working set. The shared
34//! [`TileCache`] is consulted and populated, but never depended on: an eviction
35//! between producing a tile and consuming it can cost recomputation, never
36//! correctness. That is what lets the cache be byte-budgeted without the
37//! scheduler having to pin anything.
38//!
39//! [`DecodeCapability::Sequential`]: crate::DecodeCapability::Sequential
40
41use crate::{
42    Image, Node, NodeId, PixelsError, Plan, PlanOptions, Region, Result, ThreadPool, Tile, TileBuf,
43    TileCache, TileKey,
44};
45use std::collections::HashMap;
46use std::sync::Arc;
47
48/// Tuning for a [`Scheduler`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct SchedulerOptions {
52    /// Worker threads. Zero means [`ThreadPool::default_threads`].
53    pub threads: usize,
54    /// Byte budget for the shared tile cache.
55    pub cache_budget: usize,
56    /// Output tiles evaluated concurrently.
57    ///
58    /// This is the memory/parallelism dial: peak usage scales with it, and so
59    /// does available concurrency. Zero means "one per worker thread".
60    pub batch_tiles: usize,
61    /// Tile shape negotiation options.
62    pub plan: PlanOptions,
63}
64
65impl SchedulerOptions {
66    /// Set the worker thread count; zero means one per core.
67    ///
68    /// `SchedulerOptions` is `#[non_exhaustive]`, so downstream crates cannot
69    /// use a struct literal; these setters are the only way to configure it.
70    #[must_use]
71    pub const fn with_threads(mut self, threads: usize) -> Self {
72        self.threads = threads;
73        self
74    }
75
76    /// Set the tile cache byte budget.
77    #[must_use]
78    pub const fn with_cache_budget(mut self, bytes: usize) -> Self {
79        self.cache_budget = bytes;
80        self
81    }
82
83    /// Set how many output tiles are evaluated concurrently.
84    #[must_use]
85    pub const fn with_batch_tiles(mut self, tiles: usize) -> Self {
86        self.batch_tiles = tiles;
87        self
88    }
89
90    /// Set the tile shape negotiation options.
91    #[must_use]
92    pub const fn with_plan(mut self, plan: PlanOptions) -> Self {
93        self.plan = plan;
94        self
95    }
96}
97
98impl Default for SchedulerOptions {
99    fn default() -> Self {
100        Self {
101            threads: 0,
102            cache_budget: TileCache::DEFAULT_BUDGET,
103            batch_tiles: 0,
104            plan: PlanOptions::default(),
105        }
106    }
107}
108
109/// Counters describing one run, for tests and diagnostics.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111#[non_exhaustive]
112pub struct RunStats {
113    /// Output tiles produced.
114    pub output_tiles: u64,
115    /// Nodes materialized because of ADR-0009.
116    pub materialized_nodes: u64,
117    /// Peak bytes held in working sets and materialized buffers.
118    ///
119    /// This is the number the constant-memory guarantee is about: it must not
120    /// grow with image height for a streaming pipeline.
121    pub peak_bytes: u64,
122    /// The source resolution shrink-on-load lowered, if it did.
123    ///
124    /// `None` means the source decoded at full size — either because it has
125    /// one resolution, or because the pipeline was not eligible (see
126    /// [`shrink_on_load`]). Reported rather than left silent so a pipeline
127    /// that expected the fast path and did not get it can be diagnosed
128    /// instead of merely being slow.
129    ///
130    /// Filled in by whoever applied the rewrite. [`Scheduler::run`] does not
131    /// apply it: the rewrite has to happen *above* the choice of evaluator, or
132    /// the scheduler and the reference evaluator would be handed different
133    /// graphs and stop agreeing — and their agreement is the oracle the whole
134    /// engine is checked against (ROADMAP M2).
135    ///
136    /// [`shrink_on_load`]: crate::shrink_on_load
137    pub reduction: Option<crate::Reduction>,
138}
139
140/// A demand-driven, parallel tile evaluator.
141///
142/// One scheduler can run many pipelines; the pool and cache are reused.
143#[derive(Debug)]
144pub struct Scheduler {
145    pool: ThreadPool,
146    cache: Arc<TileCache>,
147    options: SchedulerOptions,
148}
149
150impl Scheduler {
151    /// Build a scheduler with `options`.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`PixelsError::Io`] if worker threads cannot be spawned.
156    pub fn new(options: SchedulerOptions) -> Result<Self> {
157        let threads = if options.threads == 0 {
158            ThreadPool::default_threads()
159        } else {
160            options.threads
161        };
162        Ok(Self {
163            pool: ThreadPool::new(threads)?,
164            cache: Arc::new(TileCache::new(options.cache_budget)),
165            options,
166        })
167    }
168
169    /// A scheduler with default tuning.
170    ///
171    /// # Errors
172    ///
173    /// As [`Scheduler::new`].
174    pub fn with_defaults() -> Result<Self> {
175        Self::new(SchedulerOptions::default())
176    }
177
178    /// The worker thread count.
179    #[must_use]
180    pub const fn threads(&self) -> usize {
181        self.pool.threads()
182    }
183
184    /// The shared tile cache.
185    #[must_use]
186    pub fn cache(&self) -> &Arc<TileCache> {
187        &self.cache
188    }
189
190    /// Evaluate `image`, handing each output tile to `consume` in order.
191    ///
192    /// `consume` is called on the calling thread, in tile order, so a sink may
193    /// hold non-`Send` state and an encoder still sees rows top to bottom.
194    ///
195    /// # Errors
196    ///
197    /// Propagates any producer, op or consumer error. A failure anywhere fails
198    /// the whole run; the sink is simply not given the remaining tiles
199    /// (ARCHITECTURE §Failure model).
200    pub fn run(
201        &self,
202        image: &Image,
203        mut consume: impl FnMut(Region, &Tile<'_>) -> Result<()>,
204    ) -> Result<RunStats> {
205        let plan = Plan::build(image, self.options.plan)?;
206        let root = Arc::clone(image.node());
207        let mut stats = RunStats::default();
208
209        // Which nodes are worth caching; see `NodePlan::cacheable`.
210        let cacheable: Arc<std::collections::HashSet<NodeId>> = Arc::new(
211            dependency_order(&root)
212                .iter()
213                .filter(|node| plan.node(node.id()).is_some_and(|p| p.cacheable))
214                .map(|node| node.id())
215                .collect(),
216        );
217
218        // Phase 1: realize the nodes ADR-0009 marked, in dependency order.
219        let materialized = self.materialize(&root, &plan, &cacheable, &mut stats)?;
220
221        // Phase 2: stream output tiles in batches.
222        let batch_size = if self.options.batch_tiles == 0 {
223            self.threads()
224        } else {
225            self.options.batch_tiles
226        }
227        .max(1);
228        let tiles = plan.output_tiles().to_vec();
229
230        for batch in tiles.chunks(batch_size) {
231            // Source bands first, serially and in order: a forward-only
232            // producer cannot serve concurrent out-of-order requests.
233            let sources = self.pull_sources(&root, batch, &materialized)?;
234
235            let context = Arc::new(Context {
236                cache: Arc::clone(&self.cache),
237                materialized: Arc::clone(&materialized),
238                sources: Arc::new(sources),
239                cacheable: Arc::clone(&cacheable),
240            });
241
242            // Evaluate the batch's tiles in parallel.
243            let outputs: Arc<Vec<std::sync::Mutex<Option<Arc<TileBuf>>>>> =
244                Arc::new(batch.iter().map(|_| std::sync::Mutex::new(None)).collect());
245            let tasks: Vec<_> = batch
246                .iter()
247                .enumerate()
248                .map(|(index, region)| {
249                    let (root, context, outputs, region) = (
250                        Arc::clone(&root),
251                        Arc::clone(&context),
252                        Arc::clone(&outputs),
253                        *region,
254                    );
255                    move || {
256                        let tile = produce(&root, region, &context)?;
257                        if let Some(slot) = outputs.get(index) {
258                            *slot
259                                .lock()
260                                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(tile);
261                        }
262                        Ok(())
263                    }
264                })
265                .collect();
266            self.pool.run_all(tasks)?;
267
268            // Hand results to the sink in order.
269            let mut batch_bytes = 0_u64;
270            for (index, region) in batch.iter().enumerate() {
271                let slot = outputs
272                    .get(index)
273                    .ok_or_else(|| PixelsError::graph("output slot vanished"))?;
274                let buffer = slot
275                    .lock()
276                    .unwrap_or_else(std::sync::PoisonError::into_inner)
277                    .take()
278                    .ok_or_else(|| {
279                        PixelsError::graph(format!("output tile {region} was never produced"))
280                    })?;
281                batch_bytes += buffer.bytes().len() as u64;
282                consume(*region, &buffer.as_tile()?)?;
283                stats.output_tiles += 1;
284            }
285            stats.peak_bytes = stats
286                .peak_bytes
287                .max(batch_bytes + materialized_bytes(&materialized));
288        }
289        Ok(stats)
290    }
291
292    /// Realize every node the plan marked, in dependency order.
293    fn materialize(
294        &self,
295        root: &Arc<Node>,
296        plan: &Plan,
297        cacheable: &Arc<std::collections::HashSet<NodeId>>,
298        stats: &mut RunStats,
299    ) -> Result<Arc<HashMap<NodeId, Arc<TileBuf>>>> {
300        let mut materialized: HashMap<NodeId, Arc<TileBuf>> = HashMap::new();
301        for node in dependency_order(root) {
302            let should = plan.node(node.id()).is_some_and(|p| p.materialize);
303            if !should {
304                continue;
305            }
306            // Realized in one pass, forward, so a sequential source is read in
307            // the only order it can be read.
308            let context = Context {
309                cache: Arc::clone(&self.cache),
310                materialized: Arc::new(materialized.clone()),
311                sources: Arc::new(HashMap::new()),
312                cacheable: Arc::clone(cacheable),
313            };
314            let whole = node.descriptor().region();
315            let buffer = produce_uncached(&node, whole, &context)?;
316            stats.materialized_nodes += 1;
317            materialized.insert(node.id(), buffer);
318        }
319        Ok(Arc::new(materialized))
320    }
321
322    /// Pull every source band this batch needs, serially and in order.
323    fn pull_sources(
324        &self,
325        root: &Arc<Node>,
326        batch: &[Region],
327        materialized: &HashMap<NodeId, Arc<TileBuf>>,
328    ) -> Result<HashMap<TileKey, Arc<TileBuf>>> {
329        let mut sources = HashMap::new();
330        for region in batch {
331            for (node, needed) in source_demand(root, *region)? {
332                if materialized.contains_key(&node.id()) {
333                    continue;
334                }
335                let key = TileKey::new(node.id(), needed);
336                if sources.contains_key(&key) {
337                    continue;
338                }
339                let Some(producer) = node.producer() else {
340                    continue;
341                };
342                let mut buffer = TileBuf::zeroed(needed, node.descriptor().pixel)?;
343                producer.produce(needed, &mut buffer.as_tile_mut()?)?;
344                sources.insert(key, Arc::new(buffer));
345            }
346        }
347        Ok(sources)
348    }
349}
350
351/// Total bytes held by materialized intermediates.
352///
353/// Accounted separately from the cache budget, per ADR-0009: a full
354/// intermediate is by definition not bounded by a cache budget.
355fn materialized_bytes(materialized: &HashMap<NodeId, Arc<TileBuf>>) -> u64 {
356    materialized.values().map(|b| b.bytes().len() as u64).sum()
357}
358
359/// Everything a task needs to evaluate one output tile.
360#[derive(Debug)]
361struct Context {
362    cache: Arc<TileCache>,
363    materialized: Arc<HashMap<NodeId, Arc<TileBuf>>>,
364    sources: Arc<HashMap<TileKey, Arc<TileBuf>>>,
365    /// Nodes whose tiles are worth retaining; see [`NodePlan::cacheable`].
366    ///
367    /// [`NodePlan::cacheable`]: crate::NodePlan::cacheable
368    cacheable: Arc<std::collections::HashSet<NodeId>>,
369}
370
371/// Produce `region` of `node`, consulting the cache where that can pay off.
372///
373/// Nodes with a single consumer bypass the cache entirely: their tiles are
374/// consumed exactly once, so retaining them would evict tiles that *are*
375/// reused and would make a streaming pipeline's peak memory equal to the cache
376/// budget rather than a few tiles in flight.
377fn produce(node: &Arc<Node>, region: Region, context: &Context) -> Result<Arc<TileBuf>> {
378    if !context.cacheable.contains(&node.id()) {
379        return produce_uncached(node, region, context);
380    }
381    let key = TileKey::new(node.id(), region);
382    if let Some(tile) = context.cache.get(&key) {
383        return Ok(tile);
384    }
385    let buffer = produce_uncached(node, region, context)?;
386    Ok(context.cache.insert(key, buffer))
387}
388
389/// Produce `region` of `node` without consulting the cache for `node` itself.
390fn produce_uncached(node: &Arc<Node>, region: Region, context: &Context) -> Result<Arc<TileBuf>> {
391    // A materialized ancestor short-circuits everything below it.
392    if let Some(whole) = context.materialized.get(&node.id()) {
393        let mut buffer = TileBuf::zeroed(region, node.descriptor().pixel)?;
394        crate::copy_region(&whole.as_tile()?, &mut buffer.as_tile_mut()?, region)?;
395        return Ok(Arc::new(buffer));
396    }
397
398    if let Some(producer) = node.producer() {
399        // Pre-pulled by `pull_sources` when running a batch; pulled directly
400        // when materializing, where the caller is already serial.
401        let key = TileKey::new(node.id(), region);
402        if let Some(band) = context.sources.get(&key) {
403            return Ok(Arc::clone(band));
404        }
405        let mut buffer = TileBuf::zeroed(region, node.descriptor().pixel)?;
406        producer.produce(region, &mut buffer.as_tile_mut()?)?;
407        return Ok(Arc::new(buffer));
408    }
409
410    let op = node.op().ok_or_else(|| {
411        PixelsError::graph(format!("node `{}` is neither op nor source", node.name()))
412    })?;
413    let descriptors: Vec<_> = node.inputs().iter().map(|n| n.descriptor()).collect();
414    let requested = op.input_regions(region, &descriptors)?;
415    if requested.len() != node.inputs().len() {
416        return Err(PixelsError::graph(format!(
417            "op `{}` requested {} input region(s) for {} input(s)",
418            op.name(),
419            requested.len(),
420            node.inputs().len()
421        )));
422    }
423
424    // Depth-first: each input tile is produced, then consumed immediately, so
425    // the working set is the pipeline depth rather than the whole graph.
426    let mut inputs = Vec::with_capacity(requested.len());
427    for (input, needed) in node.inputs().iter().zip(&requested) {
428        inputs.push(produce(input, *needed, context)?);
429    }
430    let mut tiles = Vec::with_capacity(inputs.len());
431    for buffer in &inputs {
432        tiles.push(buffer.as_tile()?);
433    }
434
435    let mut output = TileBuf::zeroed(region, node.descriptor().pixel)?;
436    op.compute(&tiles, &mut output.as_tile_mut()?)?;
437    Ok(Arc::new(output))
438}
439
440/// The source nodes and regions needed to produce `region` of `root`.
441fn source_demand(root: &Arc<Node>, region: Region) -> Result<Vec<(Arc<Node>, Region)>> {
442    let mut found = Vec::new();
443    let mut stack = vec![(Arc::clone(root), region)];
444    while let Some((node, region)) = stack.pop() {
445        let Some(op) = node.op() else {
446            found.push((node, region));
447            continue;
448        };
449        let descriptors: Vec<_> = node.inputs().iter().map(|n| n.descriptor()).collect();
450        let requested = op.input_regions(region, &descriptors)?;
451        for (input, needed) in node.inputs().iter().zip(requested) {
452            stack.push((Arc::clone(input), needed));
453        }
454    }
455    // Sorting by row keeps a batch's pulls forward-ordered even when the walk
456    // visits branches in an arbitrary order.
457    found.sort_by_key(|(_, region)| (region.y, region.x));
458    Ok(found)
459}
460
461/// Nodes in dependency order: inputs before the nodes that consume them.
462fn dependency_order(root: &Arc<Node>) -> Vec<Arc<Node>> {
463    enum Step {
464        Visit(Arc<Node>),
465        Emit(Arc<Node>),
466    }
467    let mut seen = std::collections::HashSet::new();
468    let mut order = Vec::new();
469    let mut stack = vec![Step::Visit(Arc::clone(root))];
470    while let Some(step) = stack.pop() {
471        match step {
472            Step::Visit(node) => {
473                if seen.contains(&node.id()) {
474                    continue;
475                }
476                stack.push(Step::Emit(Arc::clone(&node)));
477                for input in node.inputs() {
478                    stack.push(Step::Visit(Arc::clone(input)));
479                }
480            }
481            Step::Emit(node) => {
482                if seen.insert(node.id()) {
483                    order.push(node);
484                }
485            }
486        }
487    }
488    order
489}
490
491/// Evaluate `image` into one whole-image buffer using the tile scheduler.
492///
493/// Convenience for callers that want the pixels rather than a stream; the
494/// streaming path is [`Scheduler::run`].
495///
496/// # Errors
497///
498/// As [`Scheduler::run`].
499pub fn evaluate_tiled(image: &Image, options: SchedulerOptions) -> Result<TileBuf> {
500    let scheduler = Scheduler::new(options)?;
501    let descriptor = image.descriptor();
502    let mut out = TileBuf::for_image(&descriptor)?;
503    {
504        let mut view = out.as_tile_mut()?;
505        scheduler.run(image, |region, tile| {
506            crate::copy_region(tile, &mut view, region)
507        })?;
508    }
509    Ok(out)
510}
511
512#[cfg(test)]
513#[allow(
514    clippy::unwrap_used,
515    clippy::indexing_slicing,
516    clippy::panic,
517    reason = "tests operate on known-good values and assert shapes directly"
518)]
519mod tests {
520    use super::*;
521    use crate::testing::{ConstantOp, RampProducer, SumOp};
522    use crate::{
523        AccessPattern, BufferSource, DecodedSource, Decoder, Format, ImageDescriptor, Op,
524        PixelFormat, PixelsError, TileMut, evaluate,
525    };
526
527    fn ramp_image(width: u32, height: u32) -> Image {
528        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
529        Image::from_producer(Arc::new(RampProducer::new(descriptor)), Format::Raw)
530    }
531
532    /// A decoder emitting a row-numbered ramp, to drive a sequential source.
533    #[derive(Debug)]
534    struct RampDecoder {
535        descriptor: ImageDescriptor,
536        row: u32,
537    }
538
539    impl Decoder for RampDecoder {
540        fn descriptor(&self) -> ImageDescriptor {
541            self.descriptor
542        }
543        fn read_row(&mut self, out: &mut [u8]) -> Result<()> {
544            if self.row >= self.descriptor.height {
545                return Err(PixelsError::invalid_argument("out", "past the end"));
546            }
547            for (x, cell) in out.iter_mut().enumerate() {
548                *cell = (self.row as usize).wrapping_mul(7).wrapping_add(x) as u8;
549            }
550            self.row += 1;
551            Ok(())
552        }
553    }
554
555    /// A pipeline rooted in a forward-only streaming decoder.
556    fn streamed_image(width: u32, height: u32) -> Image {
557        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
558        let decoder = RampDecoder { descriptor, row: 0 };
559        Image::from_producer(Arc::new(DecodedSource::new(Box::new(decoder))), Format::Raw)
560    }
561
562    /// A pipeline rooted in a random-access memory buffer.
563    fn buffered_image(width: u32, height: u32) -> Image {
564        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
565        let bytes: Vec<u8> = (0..descriptor.byte_len().unwrap())
566            .map(|i| (i % 251) as u8)
567            .collect();
568        let buffer = TileBuf::from_vec(descriptor.region(), descriptor.pixel, bytes).unwrap();
569        let source = BufferSource::new(descriptor, Arc::new(buffer)).unwrap();
570        Image::from_producer(Arc::new(source), Format::Raw)
571    }
572
573    /// A vertical mirror, like `Flip`: correct demand mapping, reversed order.
574    #[derive(Debug)]
575    struct Reverse;
576    impl Op for Reverse {
577        fn name(&self) -> &'static str {
578            "reverse"
579        }
580        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
581            inputs
582                .first()
583                .copied()
584                .ok_or_else(|| PixelsError::graph("no input"))
585        }
586        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
587            let input = inputs
588                .first()
589                .ok_or_else(|| PixelsError::graph("no input"))?;
590            let y = (u64::from(input.height) - out.bottom()) as u32;
591            Ok(vec![Region::new(out.x, y, out.width, out.height)])
592        }
593        fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
594            let input = inputs
595                .first()
596                .ok_or_else(|| PixelsError::graph("no input tile"))?;
597            let (out_region, in_region) = (output.region(), input.region());
598            for offset in 0..out_region.height {
599                let from = input
600                    .row(in_region.y + (in_region.height - 1 - offset))
601                    .ok_or_else(|| PixelsError::graph("missing input row"))?;
602                let into = output
603                    .row_mut(out_region.y + offset)
604                    .ok_or_else(|| PixelsError::graph("missing output row"))?;
605                into.copy_from_slice(from);
606            }
607            Ok(())
608        }
609    }
610
611    /// A 3-row neighbourhood op, to exercise square tiles and overlap.
612    #[derive(Debug)]
613    struct RowBlur;
614    impl Op for RowBlur {
615        fn name(&self) -> &'static str {
616            "row-blur"
617        }
618        fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
619            inputs
620                .first()
621                .copied()
622                .ok_or_else(|| PixelsError::graph("no input"))
623        }
624        fn input_regions(&self, out: Region, inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
625            let input = inputs
626                .first()
627                .ok_or_else(|| PixelsError::graph("no input"))?;
628            let top = out.y.saturating_sub(1);
629            let bottom = (out.bottom() + 1).min(u64::from(input.height));
630            Ok(vec![Region::new(
631                out.x,
632                top,
633                out.width,
634                (bottom - u64::from(top)) as u32,
635            )])
636        }
637        fn access_pattern(&self) -> AccessPattern {
638            AccessPattern::Spatial
639        }
640        fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
641            let input = inputs
642                .first()
643                .ok_or_else(|| PixelsError::graph("no input tile"))?;
644            let out_region = output.region();
645            let in_region = input.region();
646            for y in out_region.y..out_region.y.saturating_add(out_region.height) {
647                // Average this row with its available neighbours.
648                let mut sums = vec![0_u32; out_region.width as usize];
649                let mut count = 0_u32;
650                for neighbour in [y.saturating_sub(1), y, y + 1] {
651                    if neighbour < in_region.y || u64::from(neighbour) >= in_region.bottom() {
652                        continue;
653                    }
654                    let Some(row) = input.row(neighbour) else {
655                        continue;
656                    };
657                    let start = (out_region.x - in_region.x) as usize;
658                    for (i, sum) in sums.iter_mut().enumerate() {
659                        *sum += u32::from(row[start + i]);
660                    }
661                    count += 1;
662                }
663                let into = output
664                    .row_mut(y)
665                    .ok_or_else(|| PixelsError::graph("missing output row"))?;
666                for (cell, sum) in into.iter_mut().zip(&sums) {
667                    *cell = (sum / count.max(1)) as u8;
668                }
669            }
670            Ok(())
671        }
672    }
673
674    fn options(threads: usize, strip_rows: u32) -> SchedulerOptions {
675        SchedulerOptions {
676            threads,
677            plan: PlanOptions {
678                strip_rows,
679                square_size: 8,
680            },
681            ..SchedulerOptions::default()
682        }
683    }
684
685    // --- Agreement with the M1 oracle (the M2 exit criterion) ----------------
686
687    #[test]
688    fn a_bare_source_matches_the_m1_evaluator() {
689        let image = ramp_image(16, 40);
690        let expected = evaluate(&image).unwrap();
691        let actual = evaluate_tiled(&image, options(4, 8)).unwrap();
692        assert_eq!(actual.bytes(), expected.bytes());
693    }
694
695    #[test]
696    fn a_chain_matches_the_m1_evaluator() {
697        let image = buffered_image(23, 37)
698            .apply(Arc::new(ConstantOp::new(9)))
699            .unwrap()
700            .apply(Arc::new(Reverse))
701            .unwrap();
702        let expected = evaluate(&image).unwrap();
703        let actual = evaluate_tiled(&image, options(4, 8)).unwrap();
704        assert_eq!(actual.bytes(), expected.bytes());
705    }
706
707    #[test]
708    fn agreement_holds_across_tile_sizes_and_thread_counts() {
709        // The core M2 exit criterion, swept: whatever the tiling and however
710        // many workers, the answer is the M1 answer.
711        let image = buffered_image(31, 53).apply(Arc::new(Reverse)).unwrap();
712        let expected = evaluate(&image).unwrap();
713        for threads in [1, 2, 4, 8] {
714            for strip_rows in [1, 3, 8, 64, 1000] {
715                let actual = evaluate_tiled(&image, options(threads, strip_rows)).unwrap();
716                assert_eq!(
717                    actual.bytes(),
718                    expected.bytes(),
719                    "threads={threads} strip_rows={strip_rows}"
720                );
721            }
722        }
723    }
724
725    #[test]
726    fn a_spatial_op_matches_the_m1_evaluator() {
727        // Square tiles plus overlapping demand: the case where getting
728        // input_regions or the seam wrong shows up as wrong borders.
729        let image = buffered_image(20, 20).apply(Arc::new(RowBlur)).unwrap();
730        let expected = evaluate(&image).unwrap();
731        for threads in [1, 4] {
732            let actual = evaluate_tiled(&image, options(threads, 4)).unwrap();
733            assert_eq!(actual.bytes(), expected.bytes(), "threads={threads}");
734        }
735    }
736
737    #[test]
738    fn a_branching_graph_matches_the_m1_evaluator() {
739        let base = buffered_image(16, 24);
740        let left = base.apply(Arc::new(ConstantOp::new(3))).unwrap();
741        let right = base.apply(Arc::new(ConstantOp::new(4))).unwrap();
742        let joined = Image::combine(&[left, right], Arc::new(SumOp)).unwrap();
743        let expected = evaluate(&joined).unwrap();
744        let actual = evaluate_tiled(&joined, options(4, 8)).unwrap();
745        assert_eq!(actual.bytes(), expected.bytes());
746        assert_eq!(actual.bytes()[0], 7);
747    }
748
749    #[test]
750    fn a_streaming_source_matches_the_m1_evaluator() {
751        let build = || {
752            streamed_image(12, 40)
753                .apply(Arc::new(ConstantOp::new(2)))
754                .unwrap()
755        };
756        let expected = evaluate(&build()).unwrap();
757        let actual = evaluate_tiled(&build(), options(4, 8)).unwrap();
758        assert_eq!(actual.bytes(), expected.bytes());
759    }
760
761    #[test]
762    fn reversal_over_a_streaming_source_matches_the_m1_evaluator() {
763        // ADR-0009's hard case end to end: the plan materializes, and the
764        // result is still exactly what the oracle produces.
765        let build = || streamed_image(12, 40).apply(Arc::new(Reverse)).unwrap();
766        let expected = evaluate(&build()).unwrap();
767        let actual = evaluate_tiled(&build(), options(4, 8)).unwrap();
768        assert_eq!(actual.bytes(), expected.bytes());
769    }
770
771    // --- Ordering and streaming ---------------------------------------------
772
773    #[test]
774    fn output_tiles_reach_the_sink_in_order() {
775        // Encoders write rows top to bottom, so this is not negotiable.
776        let image = buffered_image(16, 50);
777        let scheduler = Scheduler::new(options(8, 4)).unwrap();
778        let mut seen = Vec::new();
779        scheduler
780            .run(&image, |region, _| {
781                seen.push(region.y);
782                Ok(())
783            })
784            .unwrap();
785        let mut sorted = seen.clone();
786        sorted.sort_unstable();
787        assert_eq!(seen, sorted, "tiles arrived out of order");
788        assert_eq!(seen.first(), Some(&0));
789    }
790
791    #[test]
792    fn a_forward_pipeline_does_not_materialize() {
793        let image = streamed_image(8, 64)
794            .apply(Arc::new(ConstantOp::new(1)))
795            .unwrap();
796        let scheduler = Scheduler::new(options(4, 8)).unwrap();
797        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
798        assert_eq!(stats.materialized_nodes, 0);
799        assert_eq!(stats.output_tiles, 8);
800    }
801
802    #[test]
803    fn reversal_over_a_streaming_source_materializes_exactly_once() {
804        let image = streamed_image(8, 64).apply(Arc::new(Reverse)).unwrap();
805        let scheduler = Scheduler::new(options(4, 8)).unwrap();
806        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
807        assert_eq!(stats.materialized_nodes, 1, "ADR-0009 buffer");
808    }
809
810    #[test]
811    fn reversal_over_a_buffer_source_materializes_nothing() {
812        // The ADR-0009 payoff, observed at runtime rather than in the plan.
813        let image = buffered_image(8, 64).apply(Arc::new(Reverse)).unwrap();
814        let scheduler = Scheduler::new(options(4, 8)).unwrap();
815        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
816        assert_eq!(
817            stats.materialized_nodes, 0,
818            "a memory source needs no buffer"
819        );
820    }
821
822    // --- Memory --------------------------------------------------------------
823
824    #[test]
825    fn peak_memory_does_not_grow_with_image_height() {
826        // SPEC §Guarantees 1. The same pipeline over a 10x taller image must
827        // not cost 10x the memory.
828        let measure = |height: u32| {
829            let image = streamed_image(64, height)
830                .apply(Arc::new(ConstantOp::new(1)))
831                .unwrap();
832            let scheduler = Scheduler::new(options(2, 8)).unwrap();
833            scheduler.run(&image, |_, _| Ok(())).unwrap().peak_bytes
834        };
835        let small = measure(64);
836        let large = measure(4096);
837        assert_eq!(
838            small, large,
839            "peak memory scaled with height: {small} vs {large}"
840        );
841    }
842
843    #[test]
844    fn materializing_pipelines_report_their_buffer() {
845        // The honest accounting ADR-0009 requires: the buffer is visible, not
846        // hidden inside a cache budget it does not respect.
847        let image = streamed_image(64, 256).apply(Arc::new(Reverse)).unwrap();
848        let scheduler = Scheduler::new(options(2, 8)).unwrap();
849        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
850        assert!(
851            stats.peak_bytes >= 64 * 256,
852            "a materialized full image should be accounted: {}",
853            stats.peak_bytes
854        );
855    }
856
857    #[test]
858    fn a_tiny_cache_budget_changes_cost_not_correctness() {
859        // The cache is an optimisation; evicting everything must not alter the
860        // pixels, only how often they are recomputed.
861        let image = buffered_image(16, 32)
862            .apply(Arc::new(ConstantOp::new(5)))
863            .unwrap();
864        let expected = evaluate(&image).unwrap();
865        for budget in [0, 1, 64, 1024, 1 << 20] {
866            let options = SchedulerOptions {
867                threads: 4,
868                cache_budget: budget,
869                plan: PlanOptions {
870                    strip_rows: 4,
871                    square_size: 8,
872                },
873                ..SchedulerOptions::default()
874            };
875            let actual = evaluate_tiled(&image, options).unwrap();
876            assert_eq!(actual.bytes(), expected.bytes(), "cache_budget={budget}");
877        }
878    }
879
880    // --- Failure -------------------------------------------------------------
881
882    #[test]
883    fn a_failing_op_fails_the_run() {
884        let image = buffered_image(16, 32)
885            .apply(Arc::new(crate::testing::FailingOp))
886            .unwrap();
887        let err = evaluate_tiled(&image, options(4, 8)).unwrap_err();
888        assert_eq!(err.code(), crate::ErrorCode::Malformed);
889    }
890
891    #[test]
892    fn a_failing_sink_stops_the_run() {
893        let image = buffered_image(16, 32);
894        let scheduler = Scheduler::new(options(4, 8)).unwrap();
895        let mut seen = 0;
896        let err = scheduler
897            .run(&image, |_, _| {
898                seen += 1;
899                Err(PixelsError::unsupported("sink refused"))
900            })
901            .unwrap_err();
902        assert_eq!(err.code(), crate::ErrorCode::Unsupported);
903        assert_eq!(seen, 1, "the sink should not be called again after failing");
904    }
905
906    #[test]
907    fn failures_are_deterministic_across_runs() {
908        // SPEC §Guarantees 2 extends to errors, not just pixels.
909        let image = buffered_image(16, 64)
910            .apply(Arc::new(crate::testing::FailingOp))
911            .unwrap();
912        let first = evaluate_tiled(&image, options(8, 4))
913            .unwrap_err()
914            .to_string();
915        for _ in 0..10 {
916            assert_eq!(
917                evaluate_tiled(&image, options(8, 4))
918                    .unwrap_err()
919                    .to_string(),
920                first
921            );
922        }
923    }
924
925    // --- Configuration -------------------------------------------------------
926
927    #[test]
928    fn defaults_produce_a_usable_scheduler() {
929        let scheduler = Scheduler::with_defaults().unwrap();
930        assert!(scheduler.threads() >= 1);
931        assert_eq!(scheduler.cache().budget(), TileCache::DEFAULT_BUDGET);
932        let image = buffered_image(8, 8);
933        let stats = scheduler.run(&image, |_, _| Ok(())).unwrap();
934        assert_eq!(stats.output_tiles, 1);
935    }
936
937    #[test]
938    fn one_scheduler_runs_many_pipelines() {
939        let scheduler = Scheduler::new(options(4, 8)).unwrap();
940        for height in [8_u32, 16, 32] {
941            let image = buffered_image(8, height);
942            let expected = evaluate(&image).unwrap();
943            let mut out = TileBuf::for_image(&image.descriptor()).unwrap();
944            {
945                let mut view = out.as_tile_mut().unwrap();
946                scheduler
947                    .run(&image, |region, tile| {
948                        crate::copy_region(tile, &mut view, region)
949                    })
950                    .unwrap();
951            }
952            assert_eq!(out.bytes(), expected.bytes(), "height={height}");
953        }
954    }
955}