Skip to content

fix: clarify Call target-type propagation and dispatch resolution in RFC 0005 - #168

Merged
mwiebe merged 2 commits into
OpenJobDescription:mainlinefrom
mwiebe:clarify-call-dispatch-return-types
Aug 12, 2026
Merged

fix: clarify Call target-type propagation and dispatch resolution in RFC 0005 #168
mwiebe merged 2 commits into
OpenJobDescription:mainlinefrom
mwiebe:clarify-call-dispatch-return-types

Conversation

@mwiebe

@mwiebe mwiebe commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This PR resolves two ambiguities in RFC 0005's expression-evaluation
pseudo-code that an implementation tripped over. It pins
both down in the direction the RFC's own design rationale points, and adds
matching language to the wiki's Expression Language page.

Background

When a host evaluates an expression from a template, it passes a target
type
— "whatever this expression computes, I need an int at the end."
RFC 0005's guiding rule for this is stated up front: target types guide
coercion, but must not constrain how sub-expressions evaluate. The target
describes the result, so a valid expression should compute the same thing
with or without one; the target only affects the type of what comes back.

The RFC's propagation table applies that rule node by node — operator
operands evaluate unconstrained, a ternary's branches inherit the target,
and so on. For function calls it says arguments get types "computed from
candidate signatures," backed by pseudo-code. That pseudo-code is where
the two ambiguities live. They were found while fixing target-type
propagation bugs in openjd-rs
(OpenJobDescription/openjd-rs#291), when code
review pushed on what the correct call-argument behavior actually is and
the pseudo-code turned out not to fully answer the question.

Ambiguity 1: do generic signatures bind the target into their arguments?

Take sorted(list: list[T1]) -> list[T1] evaluated as sorted([10, 2])
with target list[string]. Two readings:

  • Bind through the return type: unify list[string] with the return
    type list[T1], conclude T1 = string, and evaluate the argument
    toward list[string]. The list becomes ["10", "2"] before sorting,
    and the sort is lexicographic: ["10", "2"].
  • Parameter types as written: the argument's constraint is the
    literal list[T1] — any list type — so there is nothing to coerce
    toward. The sort is numeric, and the result coerces: ["2", "10"].

The pseudo-code computes arg_ts = {sig.param_types[i] for sig in candidates} — parameter types as written, no unification step — but never
says so explicitly, and it doesn't say what a type-variable constraint
means for the argument. The first reading is tempting (the current Python
implementation exhibits it, though via a target-propagation bug rather
than by design), but it violates the RFC's own rule: the target changes
what the expression computes (which order the list is in), not just the
type of the result.

This PR makes the second reading explicit: argument typesets come from
parameter types as written, and the caller's target is never unified with
a return type to bind type variables into the parameters. A type-variable
parameter leaves its position unconstrained; the "any list" part is still
enforced during signature resolution, where sorted("abc") fails with or
without a target and a range_expr argument is still coerced to
list[int].

Ambiguity 2: does the target participate in overload resolution?

resolve_and_call is declared with a candidates parameter, and the
method-call arm passes one — but the function-call arm invokes it as
resolve_and_call(func, arg_values, ...), with no candidates argument.
So the pseudo-code doesn't actually say whether final overload resolution
is restricted to the target-filtered candidate list or considers the
function's full signature set.

The related question with observable consequences: the function-call arm
raises "No matching signature" when the target filter leaves zero
candidates, even though the arguments might evaluate fine and the call
succeed — with only the final result failing to coerce. For min([1, 2])
with target list[string], that's the difference between a generic
"No matching signature for min" and the precise "Cannot coerce int to
list[string]".

This PR pins both down: overload resolution matches argument types alone
against every arity-matching signature — return types and the caller's
target play no part in selecting the implementation — and an empty
candidate set falls back to evaluating arguments unconstrained rather
than raising, so target mismatches surface as precise result-coercion
errors. The empty-candidates change is the one place this PR amends
behavior rather than only clarifying it; flagging it for reviewers who
read the old raise as intentional.

Changes

  • rfcs/0005-expression-language.md
    • Propagation table: Call (function) row states the
      parameter-types-as-written rule, that a type-variable parameter type
      contributes no argument constraint (its concrete structure being
      enforced during resolution instead), and that the target filters
      candidates but never binds into parameters. Call (method) row
      corrected to
      "receiver: None, other args: None" — the old "computed from
      signatures" wording contradicted the RFC's own pseudo-code, which
      evaluates method arguments unconstrained.
    • Rationale: extends the existing Param.Count - 1 example with the
      sorted case, and adds a "Signature resolution is independent of the
      target type" paragraph covering the dispatch and diagnostics rules.
    • Pseudo-code: the function-call arm no longer raises on empty
      candidates; symbolic/empty positions produce None (unconstrained)
      typesets via a new is_symbolic helper; resolve_and_call is
      invoked with an explicit all-arity-matching signature list, matching
      its declared parameters.
  • wiki/2026-02-Expression-Language.md — §1.3.1 gains a paragraph
    stating the user-facing principle: the target describes the result,
    never the computation, with the sorted example.
  • conformance-tests/2023-09/EXPR/jobs/expr1.3.1--target-describes-result-not-computation.test.yaml
    — new execution fixture pinning the clarified Call semantics through
    the one template context where a list[string] target is observable:
    bare "{{expr}}" args items (target string? | list[string] per
    §1.3.2). sorted(Param.Ints) on a LIST[INT] parameter must sort
    numerically and coerce/flatten the result (2,10), with the
    lexicographic order (10,2) asserted as forbidden output; the
    method-call form pins the receiver-unconstrained rule, and
    min(Param.Ints) pins that a call whose signatures' return types may
    not survive the target filter still resolves and executes normally.
    Complements the existing expr1.3.1--target-type-propagation fixture,
    which covers operator/ternary propagation but not calls.

No schema or template changes. openjd-rs implements
these semantics (and passes the new fixture) as of
OpenJobDescription/openjd-rs@c780846, the
merged fix for OpenJobDescription/openjd-rs#291; the
Python implementation currently diverges on both points via the same
target-propagation bug tracked there.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@mwiebe
mwiebe requested a review from a team as a code owner August 8, 2026 00:18
…RFC 0005

Pins down two ambiguities in RFC 0005's expression-evaluation
pseudo-code: argument typesets come from parameter types as written
(the caller's target is never unified with a signature's return type to
bind type variables into the parameters), and overload resolution
matches argument types alone against every arity-matching signature,
with an empty target-filtered candidate set falling back to
unconstrained argument evaluation instead of raising. Adds matching
user-facing language to the wiki's Expression Language page and an EXPR
conformance fixture making the semantics observable through the
bare-args-item list[string] target context.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Ports three points from openjd-rs specs/expr/values.md that landed after
the Call-dispatch clarification:

- Sharpen range_expr -> list[int]: it is the only list type a range_expr
  implicitly coerces to. Any other element type is an error; implicit
  rules do not chain, so the materialized list[int] is never widened
  element-wise toward the target. Templates that want the widened list
  chain the explicit conversion list(value: range_expr) -> list[int]
  from RFC 0006.
- State explicitly that a type-variable target (T, T1, T2, T3) has no
  coercion rule for concrete or unresolved values: type variables are
  resolved by signature matching before coercion, so reaching coercion
  with one unbound is always an error.
- Document coercion of unresolved values: the same conversion table
  applies at the type level, union constraints coerce existentially
  (at least one member must succeed; failing possibilities are
  discarded), payload-dependent checks defer to resolution, and the
  two paths are asymmetric in exactly one direction - type-level
  coercion may accept what the concrete value later rejects, but must
  never reject what the concrete value would accept.

Matching user-facing language added to the wiki's Expression Language
page (Implicit Type Coercion and Static Type Checking sections).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the clarify-call-dispatch-return-types branch from 1a5deb4 to a3520e3 Compare August 11, 2026 00:34
- SEP
- "{{ min(Param.Ints) }}"
expected:
output:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

easy and good example

@seant-aws seant-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall spec makes sense, don't have any outlying comments. Will review the implementation details now

@mwiebe mwiebe changed the title fix: clarify Call target-type propagation and dispatch resolution in RFC 0005 fix: clarify Call target-type propagation and dispatch resolution in RFC 0005 Aug 12, 2026
@mwiebe
mwiebe merged commit e432ebe into OpenJobDescription:mainline Aug 12, 2026
2 of 7 checks passed
@mwiebe
mwiebe deleted the clarify-call-dispatch-return-types branch August 12, 2026 16:36
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.

3 participants