# embedding-change-two-date — the two-date embedding comparison, governed folia/0.1.
#
# Embed one AOI at two temporal pins with ONE model release, then render per-patch
# distance as a change surface. Spec: docs/spec/EMBEDDINGS_ON_DEMAND.md §6.1
# (#two-date-comparison). Gate: embeddings.two-date-comparison-e2e.
#
# WHY THE LAYERS ARE OPS, NOT SOURCES. Generating an embedding nobody has computed
# yet is work performed against a named model release, so it is `embedding_generate`,
# a governed op, and not an `embed://` URI (DECIDED 2026-08-17, spec §4.2). The
# practical payoff is right here: `model_release` is an op param, op params hash into
# the layer key, so bumping the checkpoint invalidates BOTH dates together instead of
# silently serving one old side against one new side.
#
# WHY BOTH SIDES NAME THE SAME model_release. Embedding spaces are arbitrarily
# rotated relative to each other, so cross-model cosine is not interpretable
# (research/fm-uses/MODELS.md). `embedding_distance` refuses a cross-model pair
# outright rather than returning a plausible number.
#
# AOI SIZE IS DELIBERATE. The default bbox is ~3.5 x 4.4 km and yields 2 patches.
# The local locus is NETWORK-bound, not compute-bound: a cold call at this AOI spent
# 1113.6 s of 1122.7 s in STAC/COG reads against 7.3 s of inference
# (spec §11 finding 4, conformance/aspects/embeddings/receipts/local-embed-contract.json).
# Enlarge the AOI and you buy minutes of network, not seconds of GPU.

spec_version: folia/0.1
name: embedding-change-two-date
version: "0.1.0"
description: >
  Per-patch embedding change between two quarters at one AOI, from one foundation
  model release. Embed the AOI at t0 and at t1 with the same model, then take the
  per-patch cosine distance between the two vector sets and render it as a change
  surface. High distance means the model sees the place differently than it did
  before; it does not say why, and the recipe makes no claim about the cause.

parameters:
  aoi_bbox:
    type: array
    items: number
    description: >
      The AOI bounding box [w, s, e, n] in WGS84 lon/lat. Default is the Donana
      sub-tile the first conformance slice measured, kept small on purpose (see
      header). Hard cap at both loci is 50 x 50 km.
    default: [-6.44, 36.96, -6.40, 37.00]
    min_items: 4
    max_items: 4
  model_release:
    type: string
    description: >
      Release identity of the foundation model, spelled <fm>@<fm_version> and pinned
      in registry/models/<id>.yaml `serving:`. The bare <fm> form resolves to the
      record's current fm_version. This param is what carries checkpoint identity
      into the layer key (spec §3.3).
    default: "terramind@hf-ibm-esa-geospatial-terramind-v1-base"
  t0:
    type: string
    description: The earlier temporal pin. YYYY-Q[1-4] is the only spelling in v0.
    default: "2023-Q2"
  t1:
    type: string
    description: The later temporal pin. Same season as t0 unless you want phenology in your signal.
    default: "2025-Q2"
  metric:
    type: string
    description: Per-patch distance metric. cosine | euclidean.
    default: cosine
  locus:
    type: string
    description: >
      Where the forward pass runs. A placement decision (ADR-0084), not a change of
      contract. `local` is the default for development, tests and gates because it
      costs $0 and needs no GPU vendor account.
    default: local

layers:
  compute/e0:
    type: table
    compute:
      op: embedding_generate
      params:
        model_release: { param: model_release }
        temporal: { param: t0 }
        bbox: { param: aoi_bbox }
        method: composite
        locus: { param: locus }

  compute/e1:
    type: table
    compute:
      op: embedding_generate
      params:
        model_release: { param: model_release }
        temporal: { param: t1 }
        bbox: { param: aoi_bbox }
        method: composite
        locus: { param: locus }

  compute/change:
    type: raster
    compute:
      op: embedding_distance
      inputs:
        a: { layer: compute/e0 }
        b: { layer: compute/e1 }
      params:
        metric: { param: metric }

outputs:
  change_surface:
    layer: compute/change
    value_kind: raster
  embeddings_t0:
    layer: compute/e0
    value_kind: table
  embeddings_t1:
    layer: compute/e1
    value_kind: table

# Shelf facets. A governed recipe carries these under `extensions:`, never at the
# top level: AuthorRecipe is deny_unknown_fields, so a top-level `category:` stops
# the recipe binding at all.
extensions:
  folia/category: change-detection
  folia/themes: [imagery, land-cover]
  folia/family: multiscale-embedding-change-attribution
