Skip to content

feat(input): declare horizon semantics on future-known variables - #8

Merged
mabesa merged 1 commit into
mainfrom
feat/fi-002-horizon-semantics
Aug 15, 2026
Merged

feat(input): declare horizon semantics on future-known variables#8
mabesa merged 1 commit into
mainfrom
feat/fi-002-horizon-semantics

Conversation

@mabesa

@mabesa mabesa commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes FI issue 002 — FutureKnownVariable.future_steps cannot express "at most N".

Problem

future_steps had two incompatible readings in the wild and no way to tell them apart:

  • a floor — "I require exactly this many future steps; fewer is an error";
  • a ceiling — "I can use up to this many; fewer yields a correspondingly shorter forecast".

aquacast declares its trained maximum and degrades gracefully below it (_relax_horizon); SAP3 reads the same field as a hard requirement and refuses to invoke a model whose future forcing is short. Both are correct against the contract as written. The result is that a model which would happily produce a 5-day forecast is never called where the NWP feed is 120 h (MeteoSwiss ICON-CH2-EPS), and those stations produce nothing.

Change

FutureKnownVariable now states its own semantics:

class HorizonSemantics(Enum):
    EXACT = "exact"      # future_steps is a floor: fewer is an error
    AT_MOST = "at_most"  # future_steps is a ceiling: fewer yields a shorter forecast
  • horizon_semantics defaults to EXACT, so no existing declaration changes meaning and no provider starts truncating silently after an upgrade.
  • min_future_steps is required when — and only when — AT_MOST, and must satisfy 0 < min_future_steps <= future_steps. An unbounded "fewer is fine" would put the floor judgement back with each provider, which is the coordination failure this change exists to remove.
  • Semantics are per variable, not per model: a model may need one forcing in full while tolerating truncation in another, and aquacast's _relax_horizon refuses to shrink some window geometries, so the same model is AT_MOST for one configuration and EXACT for another.

Two rules the original draft left open are now written into the contract:

  1. A short delivery is a genuinely shorter series, not a full-length one padded with trailing NaN. The undelivered steps are not counted against max_nan, which continues to gate only NaNs within the delivered extent (decision 1.13).
  2. The delivered steps are the contiguous prefix from the first future step — at_most licenses a short tail, never a leading or interior gap.

FutureKnownVariable also gains validate_assignment=True, so the cross-field invariant survives post-construction mutation.

Relation to existing decisions

This does not move horizon ownership. The model still owns the horizon and declares the actual one in metadata.forecast_horizon; future_steps stays forcing extent (1.15). What is added is only whether that extent is a floor or a ceiling — the narrowest form of the horizon-capability field 1.15 deferred as YAGNI, now with a concrete blocking driver. The Q9 availability-lag mechanism is unchanged: a systematically shorter product still declares a smaller future_steps; AT_MOST covers the different case where the delivered extent varies per run.

Recorded as decision 1.16 in docs/open_design_questions.md.

Compatibility

Additive and backward-compatible. Old-field construction and model_validate() of an old payload both succeed and default to EXACT. Two caveats, recorded rather than fixed:

  • model_dump() now emits horizon_semantics and min_future_steps, so equality against a previously serialized dict differs. No JSON/YAML fixture in ForecastInterface, aquacast or SAPPHIRE_flow contains future_steps, so nothing drifts today.
  • The cross-field rule lives in a model validator, so the exported JSON Schema does not encode it. Both consumers import the Python types rather than validating against the schema; expressing it in-schema would mean a discriminated union, a much larger contract change than the problem warrants.

Downstream adoption (not carried by this PR)

  • aquacast — declare AT_MOST + a floor where _relax_horizon applies, keep EXACT elsewhere, drop the explanatory comment in operational/requirement.py, bump its pin from v0.1.19.
  • SAP3 — its adapter currently collapses the requirement to max(future_steps) across variables and gates every future feature on that single maximum, so AT_MOST changes nothing until that path becomes per-variable. It can then retire the interim provider-side opt-in list. Its SUPPORTED_FI_VERSION guard also needs the bump.

Until each side adopts, a stale consumer keeps today's strict behaviour rather than misreading the new one.

Verification

196 tests pass (11 new, covering both defaults, all four semantics/floor combinations, boundary values, assignment, and a serialization round trip); ruff check, ruff format and mypy clean. Cross-checked with codex exec; its findings are either fixed here or recorded above.

🤖 Generated with Claude Code

`future_steps` had two incompatible readings with no way to tell them
apart: a floor ("I require this many; fewer is an error") and a ceiling
("I can use up to this many"). aquacast declares its trained maximum and
degrades gracefully below it; SAP3 reads the same field as a hard
requirement and refuses to invoke a model whose forcing is short. Both
are correct against the contract as written, and the disagreement blocks
stations whose NWP feed is shorter than the trained horizon.

`FutureKnownVariable` now states its own semantics via
`horizon_semantics` (EXACT | AT_MOST, default EXACT, so no existing
declaration changes meaning) plus `min_future_steps`, required when and
only when AT_MOST — an unbounded "fewer is fine" would put the floor
judgement back with each provider.

Fixes FI issue 002.
@mabesa
mabesa merged commit 40b393e into main Aug 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant