id: temporal_reduce
name: Temporal Reduce
description: >
  Reduce a stack of aligned raster bands to a single band using a
  pixel-wise statistical method (mean, median, min, max, percentile).
  Every method is NaN-aware: NaN samples are skipped, and a pixel with no
  valid samples answers NaN rather than a fabricated zero.
version: 1.0.0
category: analysis
type: raster-to-raster

inputs:
  - name: time_series
    type: raster
    description: >
      Multi-band raster stack (one band per timestep) OR a labeled tensor. The semantics
      are keyed by AXIS NAME (ADR-0086): over an axis-product tensor (a compound leading
      axis, e.g. `[time, band]` from a `bands_mode: preserve` scenes source) the op reduces
      over the axis NAMED `time` and PRESERVES every other leading axis — the output is a
      labeled tensor whose bands are the surviving coordinates (e.g. the declared R/G/B, in
      declared order). Over a plain `[time,y,x]` scene tensor or a bare band-major raster it
      reduces the whole leading axis to one band, byte-identically to before.
    required: true

# Structural: pixel-wise reduction (mean/median/etc.) over any time stack.
# Output carries the source raster's concept; no concept declared here.

outputs:
  - name: reduced
    type: raster
    format: cog
    dtype: float32
    description: Single-band result of the reduction

params:
  method:
    type: enum
    enum: [mean, median, min, max, percentile]
    default: median
    description: Pixel-wise reduction method
    # `std` and `count` were listed here until 2026-08-07 and were implemented by NOTHING —
    # not the Rust engine (`temporal_reduce_op` answered "unknown method"), not the WASM
    # bridge, not the Python runner. That is the `formula:` failure mode one level down: an
    # enum is a promise, and this one was not kept. No recipe under registry/recipes/ used
    # either spelling, so dropping them turns a run-time error into an authoring-time truth.
    # Re-add either only WITH a kernel.
    #
    # `default: mean` was also wrong here: `temporal_reduce_op` and `lock.rs`'s
    # `effective_params` both default an absent `method` to MEDIAN, and the lock's default is
    # what enters the key. The YAML now states what actually happens.
  percentile:
    type: number
    minimum: 0
    maximum: 100
    description: >
      The percentile to keep, 0-100. Read ONLY by `method: percentile`, and REQUIRED by it —
      the op refuses `method: percentile` with no value, and refuses a `percentile:` value
      alongside any other method, rather than silently ignoring it.

# INTERPOLATION CONVENTION for `method: percentile` — this is identity, not a detail.
#
# Over the n VALID (non-NaN) samples at a pixel, sorted ascending, the answer is taken at the
# zero-based rank `(p/100) * (n - 1)`, LINEARLY INTERPOLATED between the two order statistics
# that bracket it. That is the Hyndman-Fan type 7 convention, the same one
# `numpy.percentile(..., method="linear")` uses.
#
# It is stated here because the alternative is equally defensible and disagrees numerically:
# under NEAREST-RANK (`ceil(p/100 * n)`, no interpolation) the 30th percentile of
# [10, 20, 30, 40] is 20.0, where this op answers 19.0. Since results are content-addressed,
# switching conventions later would move every percentile layer's bytes AND its key, so it is
# an op-identity version bump ([[adr:0065]] D4) and never a silent "fix".
#
# Consequences that follow from the definition (asserted in
# `folia-compute/src/temporal_ops.rs` tests): p=0 is exactly `min`, p=100 is exactly `max`,
# p=50 over an odd count is exactly `median`, one valid sample answers itself at every p, and
# an all-NaN pixel answers NaN.
#
# The method and its parameter are ordinary params, so they enter the layer key through
# `effective_params`' generic fold — a percentile layer and a median layer of the same stack
# are different addresses by construction. No version bump was needed to ADD the method: the
# existing methods' keys and bytes are untouched.

# Which methods a SCENE-PARTITIONED chain can serve from small per-run accumulator
# partials instead of reading every scene tensor (spec partition-grain.md
# #v3-mergeable-reduce). "Mergeable" here means EXACTLY associative — the merged result is
# BYTE-identical to the whole-stack reduction at every grain, which is the partition
# invariant, not an approximation of it.
#   min, max  — mergeable. The fold is "first strictly-better valid value in axis order",
#               and concatenation preserves order, so grouping cannot change the answer.
#   mean      — NOT mergeable as specified. It accumulates in an f64 LEFT FOLD, which is
#               not associative, so per-unit partial sums merged later differ in the last
#               ULPs. Making it mergeable requires redefining the reduction as an
#               order-independent (exact) summation, which changes results and therefore
#               takes an op-identity version bump here and in `builtin_op_version`.
#   median    — not mergeable at all (a rank needs the whole population).
#   percentile— same as median, and for the same reason: it IS a rank. A percentile chain
#               therefore always takes the whole-tensor path, which is what
#               `mergeable_kind("percentile") == None` already encodes.
# The engine reads NONE of this: `folia_compute_core::temporal_ops::mergeable_kind` is the
# one authority. This block is the registry's record of why the list is what it is.
partition:
  mergeable_methods: [min, max]

# backends: audited 2026-08-14 (defect 50). A key means a runtime that DISPATCHES this op —
# folia-engine `dispatch_op` (products/sdk/folia-engine/src/lib.rs), a `registerOp`/OP_TABLE
# entry in packages/compute, `_BUILTIN_OP_MAP` in folia/compute.py, or a backend manifest
# (folia/backends/*/backend.yaml).
backends:
  rust-engine:
    op: temporal_reduce
    function: temporal_reduce_op
    dispatch: products/sdk/folia-engine/src/lib.rs:879
  rust-wasm:
    function: temporal_ops::temporal_reduce_flat
    handle_function: h_temporal_reduce
  js:
    function: temporal_reduce

performance:
  reduction_class: reduce                # per plan-lock.md#placement — one band out of an n-band stack
  throughput:                            # measured, never asserted (sdk/007)
    # px = input samples (f32 values), matching plan.rs px = est_input_bytes/4. Release
    # build; 16-band NaN-holey stack, default method (mean) — median runs ~8x slower
    # (88688494 px/s on the same host, see the imagery_composite block).
    native_px_per_s: 744580304
    measured: { on: "darwin-arm64 Apple M1 Max", date: 2026-07-01, via: "folia-compute tests/op_throughput.rs::op_throughput" }
