Skip to content

fix(expr): preserve compatible unresolved targets - #300

Merged
mwiebe merged 6 commits into
OpenJobDescription:mainfrom
mwiebe:fix/pr297-followup-1-unresolved-targets
Aug 12, 2026
Merged

fix(expr): preserve compatible unresolved targets#300
mwiebe merged 6 commits into
OpenJobDescription:mainfrom
mwiebe:fix/pr297-followup-1-unresolved-targets

Conversation

@mwiebe

@mwiebe mwiebe commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Follow-up to review comments on #297

What was the problem/requirement? (What/Why)

OpenJD validates expressions before every runtime value is necessarily
available. For example, validation may know that Param.Frame will be an
integer even though the actual frame number is supplied later. The expression
evaluator represents this as an unresolved value: a typed placeholder that
means "the value is not known yet, but its type is."

Expressions can also be evaluated with a target type, which describes the type
the surrounding template field expects. If an unresolved integer reached an
integer target, coercion incorrectly rejected it because there was no concrete
number to convert. This caused valid templates to fail validation even though
the known type already met the requirement.

What was the solution? (How)

Treat coercion as already satisfied when the placeholder's known type matches
the target, and return the unresolved value unchanged. There is no concrete
payload to convert yet, and none is needed to prove that the types are
compatible.

If the known type does not match, coercion still fails. For example, an
unresolved list[int] cannot satisfy an int target, so genuine type errors
remain visible during validation.

What is the impact of this change?

Valid templates no longer fail merely because a runtime value has not been
supplied yet. Validation still catches incompatible types as early as before.

How was this change tested?

  • Yes. Added direct coercion and evaluator regressions.
  • Ran cargo test -p openjd-expr --test integration unresolved.

Was this change documented?

  • Yes. Updated the unresolved coercion rules in specs/expr/values.md and source documentation.

Is this a breaking change?

No.

Does this change impact security?

No.


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:13
Comment thread crates/openjd-expr/src/value.rs
Comment thread crates/openjd-expr/src/value.rs Outdated
@mwiebe
mwiebe force-pushed the fix/pr297-followup-1-unresolved-targets branch from 0c287d1 to 8643642 Compare August 8, 2026 00:32
Comment thread crates/openjd-expr/src/value.rs Outdated
@leongdl

leongdl commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review summary

What was reviewed: Both commits at tip 8643642 — the type-level coercion table (unresolved_coercion_result) that lets unresolved[T] coerce by type and defer payload checks to runtime. This is a follow-up review to my comments on #297.

Angles worked:

  • Differential testing — a 483-pair (source type → target type) coercion matrix, run on base (c780846) vs this tip vs tip-plus-candidate-fix. This found the blocker below; diff reading did not.
  • Reachability — findings driven through the public API (with_target_type, FormatString::resolve_with) using the exact symbol types openjd-model seeds (WrappedAction.Timeout = int? via add_wrapped_action_scope), not just direct coerce calls.
  • Mutation testing — 17 mutants with checksum-verified restore: 7 caught, 10 survived, all 10 survivors verified observable (coverage gaps, not dead code).
  • Test falsification — kept the new tests, reverted the source: 8 of 9 fail. The survivor (coerce_unresolved_does_not_broaden_for_union_target) passes against unfixed base because base's match-first union rule already returned the value unchanged — useful characterization, but not regression coverage.
  • Spec + reference cross-check — RFC 0005 read directly; confirmed no independent reference implementation exists for this path (openjd-model-for-python 0.11.2 re-exports this crate), so base behavior served as the comparison.
  • Verification at tip — full workspace suite 6,968 passed / 0 failed; clippy -D warnings clean; fmt clean; conformance suite 1,158 passed / 0 failed; CI 19/19 green.

Finding (blocker): The union-source branch requires every union member to coerce to the target (collect::<Option<Vec<_>>>()?), while the union-target branch 25 lines below and the concrete evaluator are both first-match-wins. Result: 19 of 483 pairs regress from accept (base) to reject (tip) — e.g. {{ WrappedAction.Timeout }} (int?) against target int | string now fails validation for a template that runs fine at runtime. Whether int | nulltype holds null or 42 is a payload property — the exact class this PR's own docs say it defers. The optimistic variant already suggested in the review comment on value.rs:880 (keep members that coerce, error only when none does) measures 0 regressions / 129 fixes vs the tip's 19 / 77, with the full suite staying green. No test pins this behavior in either direction, and nothing in this repo passes a target_type, so the regression is invisible to CI — exposure is external hosts via the published API and the Python bindings (target_type=).

Non-blocking, real:

  • The doc sentence "applies the same table at the type level" (also in specs/expr/values.md) is false in three measured ways: (a) the union-source all-must-coerce rule has no concrete analogue and is documented nowhere; (b) range_expr → list[T≠int]: concrete returns Ok (a list[int]), unresolved returns Err; (c) type-variable target: concrete Int(42).coerce(&T) errors, unresolved[int].coerce(&T) succeeds.
  • Two RFC-mandated rules are implemented but unpinned — deleting the list[nulltype] special case (RFC 0005 "list[nulltype]list[T] for any T") or the union-target per-member loop survives all 3,463 openjd-expr tests.
  • Error messages dropped the unresolved[...] wrapper (Cannot coerce int? to int vs base's Cannot coerce unresolved[int?] to int) — probably an improvement, but user-visible and untested except incidentally.

Verdict: do not ship as-is — one branch rewrite away from ship (see the inline comment on the union-source branch).

Corrections to my own prior review: my #297 prescription (a match_type-based guard in coerce) was wrong — match_type tests compatibility, not coercibility, and would have rejected unresolved[int]string. The table approach in this PR is better than what I asked for; the union-source branch is the one part that needs the same optimism the rest of the table already has.

Comment thread crates/openjd-expr/src/value.rs Outdated
@mwiebe
mwiebe force-pushed the fix/pr297-followup-1-unresolved-targets branch 2 times, most recently from bd6c3ec to 1357dd1 Compare August 10, 2026 17:13
mwiebe added 4 commits August 10, 2026 10:49
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Two divergences between the concrete coercion table and the type-level
table used for unresolved placeholders:

The `range_expr` arm matched on a bare `list` target while ignoring the
element type, so it returned a `list[int]` for any `list[T]` target,
breaking the postcondition that a coerced value satisfies the target it
was coerced to. Materialize, then widen element-wise, and reject targets
whose element type `int` cannot reach. The unresolved table now accepts
`range_expr -> list[T]` on the same condition instead of only `list[int]`.

A type-variable target was accepted for unresolved values but has no arm
in the concrete table, so a concrete value always fails against one.
Rejecting both keeps validation from passing an expression that can only
fail once the value is known.

Unresolved coercion may accept a pair the concrete value later rejects,
since the deciding information is a payload the placeholder does not
carry. It must never reject a pair the concrete value would accept: that
fails a template at validation time that would have run correctly. Record
that asymmetry in the docstring and specs/expr/values.md.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the fix/pr297-followup-1-unresolved-targets branch from 1357dd1 to a421788 Compare August 10, 2026 17:50
Comment thread specs/expr/values.md Outdated
The materialize-then-widen behavior introduced in the previous commit
overshot: RFC 0005's implicit coercion table lists list[int] as the only
list type a range_expr coerces to, and implicit rules do not chain. The
widening belongs to the explicit list() conversion (RFC 0006), whose
list[int] result the list[T] -> list[U] rule can then apply to.

Restore the postcondition the right way: reject a list[T] target with
any element type other than int, on both the concrete path and the
unresolved type-level table, instead of widening element-wise toward
the target. The target check now runs before the materialization size
cap so an invalid target reports the type error rather than a size
error. Update specs/expr/values.md and the affected tests to match.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Comment thread crates/openjd-expr/src/value.rs Outdated
Comment thread crates/openjd-expr/src/value.rs Outdated
Two review findings on the coercion tables, both rejections in the
direction the asymmetry rule forbids (failing validation for a value
that would have run correctly):

The range_expr element-type guard compared against int by strict
equality, rejecting a list[any] target even though a list[int] value
already satisfies list[any] — no widening involved. Accept an int or
any element target, on both the concrete path and the type-level table
(which was also internally inconsistent: unresolved[list[int]] coerced
to list[any] but unresolved[range_expr] did not).

The type-level list/list rule required the source element type to reach
the target's, but the concrete path accepts an empty list of any element
type against any list[U] (element-wise over zero elements). Since an
unresolved[list[S]] could resolve to the empty list, accept every
list/list pair and defer the element compatibility check until the
payload is known — consistent with how unresolved[string] -> int defers
the parse check.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
// element target is accepted because a `list[int]` value
// already satisfies `list[any]` — no widening involved.
if let Some(elem) = target.params().first() {
if elem != &ExprType::INT && elem.code() != TypeCode::Any {

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.

The new element guard rejects element types that a list[int] value already satisfies, which contradicts the rationale given two lines above ("An any element target is accepted because a list[int] value already satisfies list[any] — no widening involved").

elem != &ExprType::INT && elem.code() != TypeCode::Any also rejects:

  • list[int | string] — a list[int] satisfies this by union membership
  • list[int?] (i.e. list[int | nulltype]) — same
  • list[T] / list[T1]… — a type-variable element binds to int

For all of these the materialized list[int] would satisfy the target with no widening, so by the stated reasoning they should be accepted. Only genuinely-widening element targets (list[float], list[string], list[bool], list[path]) should be rejected.

The satisfaction test the codebase already has expresses this directly:

if let Some(elem) = target.params().first() {
    if elem.match_type(&ExprType::INT).is_none() {
        return Err(...);
    }
}

match_type covers int, any, int | string, int?, and type variables in one check, and drops the special-case code() == TypeCode::Any branch.

The same condition is duplicated in has_range_list_rule (unresolved_coercion_result, ~line 985), so the type-level table under-accepts identically. That one is the more consequential half: by the invariant this PR documents ("it must never reject one the concrete value would accept"), an unresolved[range_expr] against a list[int | string] target fails validation for a template that would have run correctly once resolved.

@leongdl

leongdl commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Non-blocking nit (single-step accept/reject behavior is correct, so not gating):

In unresolved_coercion_result, the union-target loop returns the first member with a type-level rule:

for member in target.params() {
    ...
    if let Some(result_type) = Self::unresolved_coercion_result(source, member) {
        return Some(result_type);
    }
}

But the semantics this PR adds to specs/expr/values.md say the result constraint is "the union of the result types from all successful possibilities" and only failing possibilities are discarded. First-match discards successful possibilities too, so the constraint can exclude a resolution the concrete path actually produces.

Example (union params sort alphabetically, so member order is deterministic):

  • unresolved[string] coerced to bool | pathunresolved[bool] (the (String, Bool) rule is tried first).
  • A concrete "abc" against bool | path fails the bool parse and coerces to path.

So path is a feasible resolution that the constraint now excludes. If that result is coerced again (e.g. to path), the unresolved path rejects — (Bool, Path) has no rule — while the concrete value would have been accepted, which is the direction the doc comment on coerce() says must never happen ("it must never reject one the concrete value would accept"), just across two coercion steps instead of one. Same shape with unresolved[float]int | stringunresolved[int], dropping the string outcome a non-whole float resolves to.

Suggested fix: collect results from all succeeding members and return ExprType::union(...) of them, mirroring what the source-union branch already does. The source-union tests (coerce_unresolved_unions_successful_result_types) would then pin both directions symmetrically.

(Cross-checked against RFC 0005 §"Implicit Type Coercion" while reviewing — separately, the range_expr → list[int]-only tightening matches the RFC text "when the target types include list[int]", so that part looks right, though it is a concrete-path behavior change relative to main.)

@leongdl

leongdl commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

For other reviewers to understand — a plain-language walkthrough of what this PR does and why:

The setting: templates are validated before all values exist

Templates contain expressions like timeout: "{{ Param.Timeout + 30 }}". Validation happens at submit time, but the actual value of Param.Timeout may not exist until a worker picks up the task. So the validator uses a typed placeholder: it puts unresolved[int] in the symbol table — "I don't know which int this is, but I know it's an int" — and runs the normal evaluator. unresolved[int] + 30unresolved[int]. Something invalid like Param.Timeout.upper() fails right away, at submit time instead of mid-render. One code path does both type checking and evaluation (RFC 0005 §Static Type Checking).

What coercion is

Every field has an expected target type. If an expression produces an int but the field wants a string, the engine applies the implicit non-destructive coercion table (int → float, int → string, path → string, ...) rather than erroring.

The bug this PR fixes

Before this PR, coerce() only handled concrete values. During validation:

value:  unresolved[int]   ("will be an int, don't know which")
target: int               (field expects an int)

Coercion said "I have no actual number to convert" and rejected it — a valid template failed validation just because the value hadn't arrived yet.

The fix

Apply the same coercion table at the type level:

  • unresolved[int] vs target int → types match, pass through unchanged.
  • unresolved[int] vs target string → table has int → string, result is unresolved[string].
  • unresolved[list[int]] vs target int → no rule exists, genuine type error, still caught at validation. ✅

One asymmetry is deliberate: some conversions depend on the payload (string → int works for "42", not "hello"). With no payload, validation optimistically accepts and defers the parse check to runtime. The invariant documented in values.md: validation may accept something runtime later rejects, but must never reject something runtime would accept — a false rejection at validation kills a template that would have run fine, and nothing downstream can recover from that.

(For those who've seen abstract interpretation: unresolved[T] is an abstract value, and soundness here means over-approximating the concrete behaviors — the one direction you're not allowed to be wrong in is rejecting what the concrete value would accept. That framing is also the context for the union-target nit in my earlier comment: first-match under-approximates the possibility set.)

@leongdl

leongdl commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Complexity baseline for the modified call chain

Measured with lizard (CCN = cyclomatic complexity number) at tip 9af43ab vs base 1a89f3a, for reference in this review and future ones.

Call stack reaching the modified code

FormatString::resolve / resolve_value          format_string.rs   CCN 3
  └─ eval_parsed (sets target via EvalBuilder) format_string.rs   CCN 3
      └─ ParsedExpression/EvalBuilder::evaluate  eval/parse.rs    (public API)
          └─ Evaluator::evaluate                 evaluator.rs      CCN 1
              └─ eval_node                       evaluator.rs      CCN 8
                  └─ ExprValue::coerce  ★ MODIFIED   value.rs      CCN 25 (was 20)
                      ├─ unresolved_coercion_result ★ NEW  value.rs  CCN 23 (recursive)
                      ├─ coerce (self-recursion: union members, list elements)
                      └─ from_str_coerce           value.rs        CCN 9

Secondary call site into the same modified function: Evaluator::eval_list (CCN 56) calls coerce per element — untouched by this PR.

Measurements

Function Base CCN PR CCN NLOC Note
coerce 20 25 98 Already above the ~15 restructure threshold before this PR; +5 here
unresolved_coercion_result 23 (new) 94 New function, recursive
eval_list 56 56 136 Pre-existing hot spot, not this PR's doing
eval_node, from_str_coerce 8, 9 8, 9 Within budget

Assessment

The complexity here is of the dispatch-table kind — long flat sequences of early-return type rules mirroring the RFC coercion table, not nested state machines — so each rule is independently readable and the raw CCN overstates the reading difficulty. Caveat: lizard counts match arms, which inflates CCN for large Rust match expressions (eval_list's 56 especially).

The structural observation worth tracking: unresolved_coercion_result is a second, parallel implementation of the coercion table — a type-level twin of the value-level match in coerce. Any future coercion rule now has to land in both tables, or validation and runtime will disagree about what coerces to what. Not a blocker for this PR (the duplication is inherent to deferring payload checks), but a future refactor toward a single table both paths consult would remove the divergence risk.

Comment on lines +917 to +933
if target.code() == TypeCode::Union {
if target.match_type(source).is_some() {
return Some(source.clone());
}
for member in target.params() {
if matches!(
member.code(),
TypeCode::NullType | TypeCode::List | TypeCode::Union
) {
continue;
}
if let Some(result_type) = Self::unresolved_coercion_result(source, member) {
return Some(result_type);
}
}
return None;
}

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.

Ran into this same thing independently — maybe worth a follow-up?

The concrete path's member choice is payload-dependent: from_str_coerce("abc", Bool) fails and it falls through, where the type-level path succeeds on that member. Collecting all viable members instead matches the spec text this PR adds ("the union of the result types from all successful possibilities") and makes this branch symmetric with the source-union branch after the follow-up commits.

if target.code() == TypeCode::Union {
    if target.match_type(source).is_some() {
        return Some(source.clone());
    }
    let result_types: Vec<_> = target
        .params()
        .iter()
        .filter(|m| !matches!(m.code(), TypeCode::NullType | TypeCode::List | TypeCode::Union))
        .filter_map(|member| Self::unresolved_coercion_result(source, member))
        .collect();
    return if result_types.is_empty() {
        None
    } else {
        Some(ExprType::union(result_types))
    };
}

@seant-aws

Copy link
Copy Markdown
Contributor

Ok, went over @leongdl's comments as well. I agree with most things, seems like that one followup is valuable. The rest seems good, I think makes sense. Good catch!

@mwiebe
mwiebe merged commit 65e0b80 into OpenJobDescription:main Aug 12, 2026
22 checks passed
@mwiebe
mwiebe deleted the fix/pr297-followup-1-unresolved-targets branch August 12, 2026 16:35
@github-actions github-actions Bot mentioned this pull request Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants