# integrated-disturbance — one alert from four independent forest-disturbance systems.
#
# GOVERNED (folia/0.1, ADR-0078) as of v0.2.0. It was written in the legacy dialect with an
# ALERTS §2.2 declaration block under a top-level `params:`, and that combination is broken:
# `${params.min_agreement}` substitutes the whole DECLARATION mapping
# (`{type: number, default: 2, description: ...}`) where `consensus` declares `type: integer`,
# so the recipe did not plan. registry/recipes/_plan_skips.yaml carried it as a known gap and
# listed three ways out; this is option 3 — adopt the governed dialect, whose binder already
# resolves a declared parameter to its default. Four sibling alert recipes (firms-fire,
# goes-fdc-fire, hazard-detector, cams-aerosol-anomaly) still carry the legacy shape.
#
#   folia plan registry/recipes/integrated-disturbance          # offline; green
#   folia run  registry/recipes/integrated-disturbance          # NOT YET — see "WHAT DOES NOT
#                                                               # RUN YET" below
#
# WHAT DOES NOT RUN YET (stated up front so a green plan is not read as a green run):
#   1. `consensus` has NO dispatch. folia/domains/geo/operations/analysis/consensus.yaml
#      declares `backends: {}` — audited empty, not merely unwritten. The deterministic
#      agreement core is real (products/sdk/folia-engine/src/consensus.rs, its own tests), but
#      no `"consensus" =>` arm exists in folia-engine `dispatch_op`, so a run fails with
#      `op 'consensus' not implemented`. Wiring it (H3 front-end + Arrow table codec) is
#      .tickets/impl/alerts-v1/04-op-consensus, this recipe's declared dependency. `folia plan`
#      prints the same fact as a warning on every consensus layer.
#   2. None of the four sources has a registry record yet, so `folia plan` warns "no registry
#      record for dataset @..." four times and `--resolve` cannot run. The endpoints are
#      pinned in prose on ticket 07 (T001a-e: GLAD-L/GLAD-S2 = GEE only; OPERA = LP DAAC CMR
#      granule API, GEE-free, live-validated; RADD = T001d, still open). Turning each into a
#      registry/datasets/<owner>/<project>/<name>/_dataset.yaml needs live catalog probes and
#      is therefore separate work, not something to guess at offline.
#
# So: this is an inspectable, plan-checked SPEC of the fusion, with both gaps named and
# ticketed. It is not yet a runnable product.

spec_version: folia/0.1
name: integrated-disturbance
version: "0.2.0"
license: Apache-2.0
description: >
  Fuse four independent near-real-time forest-disturbance systems into one alert layer: fire
  where at least `min_agreement` of them flag the same H3 cell. This is Folia's reproduction of
  GFW's Integrated Disturbance Alerts (Reiche et al. 2024, ERL ad2d82) with one difference that
  matters — the fusion RULE is the subscriber's, not the operator's. GFW bakes one confirmation
  rule; here `min_agreement` and each system's confidence gate are declared parameters
  (ALERTS §2.2), so a 2-of-4 subscriber and a stricter 3-of-4 subscriber share this single
  layer definition instead of materializing a variant each.

  Four inputs, each addressable independently and each emitted as its own named output
  (ALERTS §10.3):
    - glad_l     : GLAD-L (30 m Landsat, bagged decision trees, 2-of-4 confirm) — UMD GLAD
    - glad_s2    : GLAD-S2 (10 m Sentinel-2, decision trees) — UMD GLAD
    - radd       : RADD (10 m Sentinel-1 Bayesian; low P>0.85 / high P>0.975) — Wageningen
    - dist_alert : OPERA DIST-ALERT-HLS V1 (30 m HLS, near-daily, VEG-DIST-STATUS
                   baseline-anomaly) — NASA/JPL via LP DAAC

  Two rulesets, both built from the one generic `consensus` op:
    - Ruleset-1 (what GFW runs in production): >= `min_agreement` systems agree at a cell,
      regardless of each one's confidence. One op call. Output `disturbance`.
    - Ruleset-2 (Reiche 2024's conservative rule: 3 systems at low confidence OR 2 at high):
      composed INLINE from three `consensus` calls rather than promoted into the op, so the
      op's vocabulary stays `any_k_agree`. Output `disturbance_conservative`.

  Every firing commits WHICH systems agreed, not just how many: `consensus` emits an `agreed`
  list beside `n_agree`, so a receiver can render "3/4 agreed: glad_l, glad_s2, dist_alert"
  and inspect the reasoning instead of trusting a count.

# ── Subscription parameters (ALERTS §2.2 — a subscription supplies `layer_params`) ───────────
parameters:

  bbox:
    type: bbox
    description: >
      Watched area [w, s, e, n] in EPSG:4326. Defaults to a Brazil/Amazon deforestation front —
      the §10.3 benchmark region, and the region ticket 07's T008 held-out comparison against
      GFW's published product will be drawn from.
    default: [-63.0, -10.0, -60.0, -7.0]

  temporal:
    type: array
    items: string
    min_items: 2
    max_items: 2
    description: >
      Alert window [start, end] as ISO dates. An alert subscription re-runs this over a rolling
      recent window; the dispatcher (alerts-v1/11) is what advances it, not this recipe.
    default: ["2026-01-01", "2026-06-30"]

  min_agreement:
    type: integer
    description: >
      k for Ruleset-1 — how many of the four systems must flag a cell before it fires. A
      stricter k always yields a SUBSET of a looser k's firings over the same inputs, because
      agreement is monotone in k. Supplied per subscription as `layer_params.min_agreement`.
    default: 2
    minimum: 1
    maximum: 4

  thresholds:
    type: object
    description: >
      Per-input confidence gate tau_i. A cell counts as flagged by input i when input i's
      normalized confidence >= tau_i. Defaults follow each source's own published tier, which
      is why they are not uniform: RADD publishes a low tier at P>0.85, the other three carry
      no comparable published low tier and take the op's own 0.5 boolean-safe default. This is
      the LOW gate; Ruleset-2 pairs it with `ruleset2_high`.
    default:
      glad_l: 0.5
      glad_s2: 0.5
      radd: 0.85
      dist_alert: 0.5

  ruleset2_high:
    type: object
    description: >
      HIGH-confidence gate per input, read only by Ruleset-2's arm B. RADD's 0.975 is its
      published high tier; the other three are a placeholder 0.9 pending a per-system
      calibration against GFW's product (ticket 07 T008/T009) — do not read 0.9 as measured.
    default:
      glad_l: 0.9
      glad_s2: 0.9
      radd: 0.975
      dist_alert: 0.9

  ruleset2_low_k:
    type: integer
    description: Ruleset-2 arm A — systems that must clear the LOW gate. Reiche 2024 uses 3.
    default: 3
    minimum: 1
    maximum: 4

  ruleset2_high_k:
    type: integer
    description: Ruleset-2 arm B — systems that must clear the HIGH gate. Reiche 2024 uses 2.
    default: 2
    minimum: 1
    maximum: 4

  h3_resolution:
    type: integer
    description: >
      Common H3 grid the inputs are resampled onto before agreement is counted. 9 (~174 m
      average edge) is matched to the COARSEST input (30 m Landsat/HLS), not the finest: a
      finer grid would let sub-cell misregistration between a 10 m and a 30 m system split one
      detection across neighbouring cells and lose the agreement (consensus.yaml, the
      `co_registration` uncertainty factor).
    default: 9
    minimum: 0
    maximum: 15

layers:

  # ── SOURCES ─────────────────────────────────────────────────────────────────────────────
  #
  # Four independent systems, four separate source layers, so each is addressable on its own
  # (Oracle line 1 / §10.3) and each shows up in `outputs:` below. NONE of these four `@ref`s
  # resolves yet — `folia plan` says so, once per layer. They are written in the canonical
  # registry ref form (`@owner/project/name`) rather than an invented `catalog://` scheme so
  # that the plan's "no registry record for dataset @..." warning is the accurate finding and
  # points at where the record must land: registry/datasets/<owner>/<project>/<name>/.
  #
  # WINDOW REDUCTION — every one of the four says `method: max`, and that is load-bearing.
  # A source layer IS an `imagery_composite` (the plan's `why` column names it), so it reduces
  # every scene in the `temporal` window down to one raster. Its default is MEDIAN
  # (products/sdk/folia-engine/src/composite.rs `DEFAULT_METHOD`) — right for radiance, wrong
  # for these. An alert raster is a SPARSE EVENT: zero almost everywhere, almost always. Over
  # the 6-month default window a cell flagged at 0.95 in one scene and absent in four others
  # medians to 0, so the alert is erased before `consensus` ever counts it and the fusion reads
  # zero agreement from four working systems. `max` asks what the fusion is actually asking:
  # did this system flag this cell AT ANY POINT in the window, and how strongly? This is
  # defect 51's exact shape (an unstated reducer plans green and computes another statistic),
  # which is why `folia plan` prints `(method: median (default — the recipe did not say))` on
  # any source that stays silent. None of these four do.
  #
  # `max` presumes each source's native encoding is MONOTONE in "more disturbed". That holds
  # for RADD's probability and the GLAD confidence tiers. OPERA's VEG-DIST-STATUS is an ordinal
  # CODE, so reducing by max BEFORE normalizing to the [0, 1] the gates compare against is only
  # sound if the code → confidence map is monotone; that map lands with the dataset record
  # (T005/T010) and has to say so out loud.

  source/glad-l:
    type: raster
    uri: "@umd/glad/forest-alerts-landsat"
    description: >
      GLAD-L Landsat forest-loss alerts, 30 m, 2-of-4 confirmation (Hansen et al. 2016).
      Distribution: Google Earth Engine only — ticket 07 T001a surfaced no STAC or S3 mirror,
      so a GEE-free path for this input does not exist today and the dataset record will have
      to say so.
    params:
      bbox: { param: bbox }
      temporal: { param: temporal }
      method: max

  source/glad-s2:
    type: raster
    uri: "@umd/glad/forest-alerts-sentinel2"
    description: >
      GLAD-S2 Sentinel-2 forest-loss alerts, 10 m (UMD GLAD). Google Earth Engine only,
      same caveat as GLAD-L (T001b).
    params:
      bbox: { param: bbox }
      temporal: { param: temporal }
      method: max

  source/radd:
    type: raster
    uri: "@wur/radd/forest-disturbance-alerts"
    description: >
      RADD Sentinel-1 radar disturbance alerts, 10 m, Bayesian (Reiche et al. 2021). Radar is
      why this input carries the fusion: it sees through the cloud that blinds the three
      optical systems. ENDPOINT NOT PINNED — ticket 07 T001d is still open on whether RADD is
      independently addressable as a GEE asset, a Planetary Computer collection, or AWS Open
      Data. Its native probability maps onto the published low (P>0.85) / high (P>0.975) tiers.
    params:
      bbox: { param: bbox }
      temporal: { param: temporal }
      method: max

  source/dist-alert:
    type: raster
    uri: "@nasa/opera/dist-alert-hls"
    description: >
      OPERA DIST-ALERT-HLS V1, 30 m, near-daily (NASA/JPL via LP DAAC). The fastest clock in
      the set. GEE-free access is proven, not assumed: CMR granule API (umm_json) for tokenless
      discovery — CMR-STAC returns 0 for this collection — and COGs read over /vsicurl with
      Earthdata auth (T001e, live-validated; connector at
      research/change-detection/emapr-ensemble/models/opera/).
    params:
      bbox: { param: bbox }
      temporal: { param: temporal }
      method: max   # ordinal status CODE — see the monotonicity caveat under WINDOW REDUCTION
    extensions:
      # The band to read once the dataset record exists. VEG-DIST-STATUS, NOT the VEG-DIST-CONF
      # this ticket originally guessed at: only the confirmed status codes (3/6/7/8) count as a
      # detection under the calibrated eMapR/OPERA connector (T005, normalize.py). It rides
      # `extensions:` because nothing reads it yet — the source read takes CANONICAL band codes
      # from the dataset's own band table, and there is no dataset record to declare one. It was
      # previously spelled `params.status_layer:`, which `folia plan` correctly reported as a
      # param no backend reads.
      folia/asset: VEG-DIST-STATUS

  # ── RULESET-1 — what GFW runs in production ─────────────────────────────────────────────

  alerts/integrated-disturbance:
    type: computed
    description: >
      Fires where >= `min_agreement` of the four systems flag the same H3 cell, at each
      system's own low-confidence gate. Emits one row per cell touched by at least one system:
      h3_cell_id, n_agree, n_inputs, score (n_agree / n_inputs), flag (n_agree >=
      min_agreement) and `agreed` (the names of the systems that agreed). Near-misses are kept
      with flag = false rather than dropped, so a subscriber can watch "one short of firing".
    compute:
      op: consensus
      params:
        rule: any_k_agree
        min_agreement: { param: min_agreement }
        thresholds: { param: thresholds }
        h3_resolution: { param: h3_resolution }
      inputs:
        glad_l: { layer: source/glad-l }
        glad_s2: { layer: source/glad-s2 }
        radd: { layer: source/radd }
        dist_alert: { layer: source/dist-alert }
    extensions:
      folia/cell_system: h3

  # ── RULESET-2 — Reiche 2024's conservative rule, composed inline ────────────────────────
  #
  # "3 systems at low confidence OR 2 at high" is a domain-specific tiebreak, so it stays in
  # the recipe instead of becoming a second `rule:` in the op (consensus.yaml says this
  # outright; the op's enum is `any_k_agree` and stays that way). Three calls of the one rule:
  #
  #     arm A  = any_k_agree(k = 3, gate = thresholds)      over the four systems
  #     arm B  = any_k_agree(k = 2, gate = ruleset2_high)   over the four systems
  #     R2     = any_k_agree(k = 1)                         over {arm A, arm B}   <- the OR
  #
  # The OR is a third `consensus` call at k = 1, not raster arithmetic. An earlier draft spelled
  # it `stack_concat` + `raster_calc max(b1, b2)`, which was wrong twice: `stack_concat` takes
  # NAMED raster layers (the draft passed a list under one `rasters:` key), and both arms are
  # cell-indexed TABLES, not rasters — stacking them as bands is a type error that plan-time
  # shape checks would not have caught. `consensus` consumes cell records and emits cell
  # records, so composing it with itself is closed under its own type, and the R2 row inherits
  # the same `agreed` provenance one level up: `agreed: [arm_3_low]` says which arm fired.

  alerts/_r2-arm-3low:
    type: computed
    description: "Ruleset-2 arm A — >= `ruleset2_low_k` systems clear the LOW gate."
    compute:
      op: consensus
      params:
        rule: any_k_agree
        min_agreement: { param: ruleset2_low_k }
        thresholds: { param: thresholds }
        h3_resolution: { param: h3_resolution }
      inputs:
        glad_l: { layer: source/glad-l }
        glad_s2: { layer: source/glad-s2 }
        radd: { layer: source/radd }
        dist_alert: { layer: source/dist-alert }

  alerts/_r2-arm-2high:
    type: computed
    description: "Ruleset-2 arm B — >= `ruleset2_high_k` systems clear the HIGH gate."
    compute:
      op: consensus
      params:
        rule: any_k_agree
        min_agreement: { param: ruleset2_high_k }
        thresholds: { param: ruleset2_high }
        h3_resolution: { param: h3_resolution }
      inputs:
        glad_l: { layer: source/glad-l }
        glad_s2: { layer: source/glad-s2 }
        radd: { layer: source/radd }
        dist_alert: { layer: source/dist-alert }

  alerts/integrated-disturbance-r2:
    type: computed
    description: >
      Ruleset-2 alert layer. Fires where arm A (3 low) OR arm B (2 high) fires — spelled as
      1-of-2 agreement over the two arms. The op's default `threshold: 0.5` is what makes this
      an OR over the arms' 0/1 `flag`: 0.5 sits between them, so an arm counts iff it fired.
      OPEN, and worth knowing before trusting a number out of this layer: which column of an
      upstream consensus table a downstream consensus call reads (`flag` or `score`) is not
      pinned anywhere — consensus.yaml admits "lists of H3 cell records" as an input without
      naming the value column, and no dispatch exists to settle it by behaviour. Pin it when
      alerts-v1/04 wires the front end.
    compute:
      op: consensus
      params:
        rule: any_k_agree
        min_agreement: 1
        h3_resolution: { param: h3_resolution }
      inputs:
        arm_3_low: { layer: alerts/_r2-arm-3low }
        arm_2_high: { layer: alerts/_r2-arm-2high }
    extensions:
      folia/cell_system: h3

outputs:

  disturbance:
    layer: alerts/integrated-disturbance
    value_kind: table
    description: >
      THE product — the Ruleset-1 alert table, one row per H3 cell any system touched, with
      flag / n_agree / score / agreed. Cell-indexed and thresholdable, so it is usable directly
      as an alert layer (ALERTS §2) and each firing carries the systems that produced it (§4).

  disturbance_conservative:
    layer: alerts/integrated-disturbance-r2
    value_kind: table
    description: >
      The Ruleset-2 variant (3 low OR 2 high). Same shape as `disturbance`. Kept as a separate
      named output rather than a parameter switch because the two rules answer different
      questions and ticket 07's benchmark scores them against each other on the same AOI.

  # Show your work: the four systems, each on its own, exactly as they entered the fusion.
  # A cell fired at 2-of-4 is only inspectable if you can look at all four.
  glad_l:
    layer: source/glad-l
    value_kind: raster
    description: GLAD-L alerts as the fusion saw them (show-your-work).
  glad_s2:
    layer: source/glad-s2
    value_kind: raster
    description: GLAD-S2 alerts as the fusion saw them (show-your-work).
  radd:
    layer: source/radd
    value_kind: raster
    description: RADD alerts as the fusion saw them (show-your-work).
  dist_alert:
    layer: source/dist-alert
    value_kind: raster
    description: OPERA DIST-ALERT-HLS as the fusion saw it (show-your-work).

views:
  - name: Integrated disturbance (Ruleset-1)
    layers: [alerts/integrated-disturbance]
  - name: Integrated disturbance (Ruleset-2, conservative)
    layers: [alerts/integrated-disturbance-r2]

# Non-executable metadata. AuthorRecipe is `deny_unknown_fields`, so the catalog facets, the
# ALERTS §2 subscription defaults, the cache scope and the upstream licences all ride here
# under namespaced keys rather than as top-level blocks (the shape landtrendr-change
# established for `folia/dashboard`).
extensions:
  folia/category: events-hazards
  folia/themes: [forests, change-detection]
  folia/family: integrated-forest-disturbance-alert
  folia/cites_papers: ["reiche-2024"]
  folia/deploy: alert
  folia/alert_ready: false   # was true; `consensus` has no dispatch, so nothing can be deployed

  # ALERTS §2 — the defaults a subscription starts from.
  folia/alert:
    cell_system: h3
    resolution: 9            # tracks the `h3_resolution` parameter default
    value: flag              # thresholdable boolean; n_agree / score / agreed also emitted
    cooldown_default: 24h    # OPERA is near-daily; the other three are 1-16 day revisit
    min_confidence: 0.0      # agreement is deterministic — confidence lives in the per-input gates

  folia/cache:
    scope: [bbox, temporal, min_agreement, thresholds]
    reuse_policy: hash_inputs

  folia/license:
    recipe: Apache-2.0
    output_dataset: CC-BY-4.0
    upstream_data:
      - { source: "@umd/glad/forest-alerts-landsat", license: "CC-BY-4.0 (UMD GLAD)" }
      - { source: "@umd/glad/forest-alerts-sentinel2", license: "CC-BY-4.0 (UMD GLAD)" }
      - { source: "@wur/radd/forest-disturbance-alerts", license: "CC-BY-4.0 (Wageningen)" }
      - { source: "@nasa/opera/dist-alert-hls", license: "public-domain (NASA)" }

  # Four sources, four freshness clocks. The multi-source dispatcher (alerts-v1/11) wakes on ANY
  # input's refresh — OPERA's near-daily clock is the one that will drive most re-runs — re-runs
  # the consensus, and hands the refreshed layer to the gateway evaluator (ALERTS §3.2). Nothing
  # in this recipe changes when that retrofits: the dispatcher reads each distribution's own
  # freshness block, upstream of these source layers.
  folia/freshness_refs:
    - { source: "@nasa/opera/dist-alert-hls", kind: scheduled }
    - { source: "@umd/glad/forest-alerts-landsat", kind: scheduled }
    - { source: "@umd/glad/forest-alerts-sentinel2", kind: scheduled }
    - { source: "@wur/radd/forest-disturbance-alerts", kind: scheduled }
