Version: dev

Multi-Output Discretization Schemes — SUPERSEDED

Status: SUPERSEDED — promoted to normative spec (2026-06-14). The design in this document (Option B, demand-driven provider resolution) has been adopted as normative §7.9 of docs/content/rfcs/discretization.md and encoded in esm-schema.json (kind "multi_output_stencil" + consumer requires field). Open question 2 (provider matching) was resolved 2026-06-13; open question 1 (bounded/staggered extents) and open question 3 (derived outputs) are deferred to follow-on beads.

Do not edit this file. Refer to §7.9 of discretization.md for the authoritative specification. This draft is preserved for design history.

Authored 2026-06-12 alongside the ESD Layer-B activation work (ESD/dsc-a7b2); the per-output lowering stopgap described in §5 below is shipped and covers the numerical-convergence acceptance.

1. Problem

Several authoritative ESD catalog rules describe operators that emit multiple named outputs from one stencil application:

RuleOutputsConsumer
ppm_reconstructionq_left_edge, q_right_edge (+ documented parabola.{a_L,a_R,da,a_6} block)PPM flux assembly
weno5_advection_2dq_left_edge_x, q_right_edge_x, q_left_edge_y, q_right_edge_y2D advective flux
flux_1d_ppmF_{i+1/2} (face-located)flux-difference tendency

The catalog encodes these as a stencil object keyed by output name (vs. §7.1’s flat array) plus an outputs array — a schema extension ahead of this RFC. The §7.2 rewrite contract cannot express them: one rule match replaces one expression occurrence with one expression. Two rules with the same pattern cannot both fire on a single reconstruct(q, dim=x) occurrence, and the outputs are not consumed at the matched expression anyway — they are intermediate fields a downstream rule (flux assembly) reads.

2. Design options considered

A. Output-qualified use: ("use": "ppm_reconstruction#q_left_edge"). Minimal parser change, no AST change — but forces the model author to write one distinct PDE op per output (reconstruct_left(q), reconstruct_right(q)), leaking the discretization’s internal structure into the model document. Rejected as the primary mechanism (though the scheme#output resolution syntax is reused in option B).

B. Scheme-emitted observed fields with demand-driven provider resolution (recommended; resolution of former open question 2 — see §4.2). A new scheme kind: "multi_output_stencil". Two trigger paths:

  1. Consumed directly — a use: rule matches an expression occurrence; the engine emits one observed faq equation per named output into the enclosing model:

    { "lhs": { "op": "faq", "output_idx": ["i"],
               "expr": { "op": "index", "args": ["q_left_edge", "i"] }, ... },
      "rhs": <expansion of stencil["q_left_edge"] at $target>,
      "observed": true, "emitted_by": "ppm_reconstruction" }
    

    and rewrites the matched expression to the output named by the scheme’s primary field (e.g. flux_1d_ppm’s F_{i+1/2}). Output variables are auto-declared with location from the scheme’s emits_location (face outputs live on faces; see open question §4.1) and shape from the operand’s grid.

  2. Demanded by a consumer — provider-only schemes (no occurrence of their applies_to op in any equation; reconstruct is the canonical case) fire when a downstream scheme requires one of their named outputs:

    "ppm_flux": {
      "applies_to": { "op": "flux", "args": ["$q", "$c"], "dim": "$x" },
      "requires": { "q_left_edge":  "ppm_reconstruction#q_left_edge",
                    "q_right_edge": "ppm_reconstruction#q_right_edge" },
      ...
    }
    

    On first reference the engine instantiates the provider with the consumer’s inherited bindings (the same §7.2.1 name-flow protocol that dimensional_split.inner_rule already uses — another instance of sibling-scheme reference, not a new mechanism) and emits the observed equations. Resolution is recursive (limiter → reconstruction → flux chains), with a resolution stack for cycle detection (E_SCHEME_CYCLE). Providers nobody demands never emit.

Downstream rules reference the emitted names as ordinary shaped variables (they are, after emission). This composes with the existing requires_locations / emits_location fields, which are parsed today but unenforced.

Two mechanical sub-decisions that come with demand-driven resolution:

  • Name scoping. Two consumers demanding reconstruction of different operands need distinct emitted-field names. Emitted names are mangled per instantiation — <output>__<operand>__<axis> (e.g. q_left_edge__rho__x) — unless the document author pre-declares the variable, in which case the declared name wins and a second conflicting instantiation is an error (E_PROVIDER_NAME_CLASH).
  • Once-only emission. Provider instantiations are memoized by (scheme name, canonical bindings) so diamond dependencies (two consumers demanding the same reconstruction) emit a single set of observed equations.

C. Tuple/record AST node (a §4 outputs bundle selected by field access). Uniform but heavy: touches expression typing, canonical JSON, and every binding’s evaluator. Rejected for v1.

3. Proposed schema (option B)

{
  "discretizations": {
    "ppm_reconstruction": {
      "kind": "multi_output_stencil",
      "applies_to": { "op": "reconstruct", "args": ["$q"], "dim": "$x" },
      "grid_family": "cartesian",
      "outputs": ["q_left_edge", "q_right_edge"],
      "emits_location": "face",          // per-output override allowed
      "primary": null,                    // provider-only: demanded via requires
      "stencil": {
        "q_left_edge":  [ { "selector": {...}, "coeff": {...} }, ... ],
        "q_right_edge": [ ... ]
      }
    },
    "ppm_flux": {
      "applies_to": { "op": "flux", "args": ["$q", "$c"], "dim": "$x" },
      "requires": { "q_left_edge":  "ppm_reconstruction#q_left_edge",
                    "q_right_edge": "ppm_reconstruction#q_right_edge" },
      "stencil": [ ... ]                  // may reference required names
    }
  }
}

Loader contract: outputs must equal the stencil object’s key set; each entry list validates per §7.1; primary, when non-null, names an output; every requires value resolves to <sibling scheme>#<declared output> statically at load time (requires is declared, not discovered mid-rewrite, so resolvability — though not binding compatibility — is a parse-time check). Bindings round-trip the block losslessly (same contract as §7.5 dimensional_split).

4. Open questions

  1. Staggered output extents. A face-located output on a size-n periodic dimension has n faces, but n+1 on a bounded one. The faq ranges for emitted equations need the §7.4 staggering vocabulary to be answerable; this proposal should land after (or with) staggered-location enforcement. (A deliberately scoped v1 — periodic dimensions only, where face extent equals cell extent n — sidesteps the extent question and covers the catalog’s current periodic conformance fixtures.)
  2. Provider matching — RESOLVED (2026-06-13): demand-driven resolution via consumer requires, as specified in §2 option B. The alternatives were rejected: a model-level discretization_uses block leaks discretization structure into the model document (switching PPM→WENO would edit the model, not the rule set, and provider chains would need author-maintained ordering); phantom reconstruct(...) equations with discarded LHS pollute ODE/DAE classification and the canonical byte contracts. Demand-driven resolution reuses the existing §7.2.1 binding name-flow and the sibling-scheme reference pattern of §7.5/§7.6, keeps model documents scheme-agnostic, composes recursively, and never emits unconsumed providers. A document-level materialization block remains a possible orthogonal later feature for diagnostics-only outputs (an output-selection concern, not a trigger mechanism).
  3. Derived (non-stencil) outputs. CW84’s parabola coefficients (a_6 = 6·(q̄ − (a_L+a_R)/2)) are expressions over other outputs, not stencil rows. A derived: { <name>: <ExpressionNode over outputs/operand> } block would cover them and keep the AST-first authoring policy.

5. Shipped stopgap (per-output lowering)

Until this lands, ESD’s lower_stencil_to_scheme(name, spec; output=...) lowers one named output at a time to an ordinary §7.1 scheme; the ESD Layer-B walker drives each output through discretize → ArrayOp → eval in its own document and verifies convergence (ppm_reconstruction measures O(h⁴) edge interpolation this way). What the stopgap cannot provide is the document-level contract: one model document whose single reconstruct occurrence yields all outputs plus their consumers — that is exactly the Layer-A canonical byte contract the affected fixtures keep applicable:false for.