Skip to content

Conversation

@mraszyk
Copy link
Contributor

@mraszyk mraszyk commented Sep 22, 2025

This PR fixes coercion of values into nested optional types.

Before the fix from this PR, 5 : nat64 would coerce to opt 5 : opt na64 (as expected) and null : opt opt nat64 (unexpected). This PR fixes the unexpected result to be opt opt 5 : opt opt nat64 (as expected).

Before the fix from this PR, 5 : int would coerce to null : opt opt nat. This PR fixes the result to be opt null : opt opt nat. The motivation for this change is to enable using optional types as follows:

  • outer option characterizes if a value (of any type) is provided;
  • inner option expresses to optimistically deserialize the actual value into the given type (without failing to deserialize and without losing the information if a value of any type is provided).

This PR also adds multiple test cases covering the change to coercion (including coercion to nested optional types over null and reserved).

@mraszyk mraszyk requested a review from a team as a code owner September 22, 2025 06:52
@github-actions
Copy link

github-actions bot commented Sep 22, 2025

Name Max Mem (Kb) Encode Decode
blob 4_224 4_207_740 2_122_616
btreemap 75_456 4_701_848_877 15_692_380_061
nns 128 2_022_530 5_644_704 ($\textcolor{green}{-0.07\%}$)
nns_list_proposal 1_088 6_883_839 ($\textcolor{green}{-0.09\%}$) 68_466_700 ($\textcolor{green}{-0.00\%}$)
option_list 128 8_027_371 27_126_568
text 6_336 4_204_277 7_877_882
variant_list 128 8_224_338 25_454_961
vec_int16 16_704 123_695_930 1_017_143_118
  • Parser cost: 17_831_723 ($\textcolor{red}{0.00\%}$)
  • Extra args: 3_333_990
Click to see raw report
---------------------------------------------------

Benchmark: blob
  total:
    instructions: 6.33 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.21 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 2.12 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: text
  total:
    instructions: 12.08 M (no change)
    heap_increase: 99 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.20 M (no change)
    heap_increase: 66 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 7.88 M (no change)
    heap_increase: 33 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: vec_int16
  total:
    instructions: 1.14 B (no change)
    heap_increase: 261 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 123.70 M (no change)
    heap_increase: 261 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 1.02 B (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: btreemap
  total:
    instructions: 20.39 B (no change)
    heap_increase: 1179 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 4.70 B (no change)
    heap_increase: 159 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 15.69 B (no change)
    heap_increase: 1020 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: option_list
  total:
    instructions: 35.16 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 8.03 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 27.13 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: variant_list
  total:
    instructions: 33.68 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 8.22 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 25.45 M (no change)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: nns
  total:
    instructions: 26.45 M (-0.01%) (change within noise threshold)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  0. Parsing (scope):
    calls: 1 (no change)
    instructions: 17.83 M (0.00%) (change within noise threshold)
    heap_increase: 2 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 2.02 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 5.64 M (-0.07%) (change within noise threshold)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: nns_list_proposal
  total:
    instructions: 75.35 M (-0.01%) (change within noise threshold)
    heap_increase: 17 pages (no change)
    stable_memory_increase: 0 pages (no change)

  1. Encoding (scope):
    calls: 1 (no change)
    instructions: 6.88 M (-0.09%) (change within noise threshold)
    heap_increase: 3 pages (no change)
    stable_memory_increase: 0 pages (no change)

  2. Decoding (scope):
    calls: 1 (no change)
    instructions: 68.47 M (-0.00%) (change within noise threshold)
    heap_increase: 14 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Benchmark: extra_args
  total:
    instructions: 3.33 M (no change)
    heap_increase: 0 pages (no change)
    stable_memory_increase: 0 pages (no change)

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min -6.84K]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min -0.01%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 9 | regressed 0 | improved 0 | new 0 | unchanged 9]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
Successfully persisted results to canbench_results.yml

assert blob "DIDL\01\6e\7f\01\00\00" == "(null)" : (opt opt null) "opt: parsing (null : opt null) at opt opt null gives null, not opt null";
assert blob "DIDL\00\01\7e\01" == "(null)" : (opt opt bool) "opt: parsing true : opt opt bool gives null";
assert blob "DIDL\00\01\7e\01" == "(null)" : (Opt) "opt: parsing (true : bool) at \"fix opt\" gives null";
assert blob "DIDL\00\01\7e\01" == "(opt opt true)" : (opt opt bool) "opt: parsing true : opt opt bool gives null";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's the change by this PR: true : bool coerces to opt opt true : opt opt bool.

assert blob "DIDL\00\01\7e\01" == "(null)" : (opt opt bool) "opt: parsing true : opt opt bool gives null";
assert blob "DIDL\00\01\7e\01" == "(null)" : (Opt) "opt: parsing (true : bool) at \"fix opt\" gives null";
assert blob "DIDL\00\01\7e\01" == "(opt opt true)" : (opt opt bool) "opt: parsing true : opt opt bool gives null";
assert blob "DIDL\00\01\7e\01" !: (Opt) "opt: parsing (true : bool) at \"fix opt\" fails";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

true : bool cannot coerce into an infinitely nested optional type

Copy link
Contributor

@crusso crusso left a comment

Choose a reason for hiding this comment

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

Looks fine to me (also ported the changes to Motoko with little problem)

@mraszyk mraszyk merged commit e5505bd into master Oct 7, 2025
11 checks passed
@mraszyk mraszyk deleted the mraszyk/opt-null-in-coercion branch October 7, 2025 09:21
mraszyk added a commit that referenced this pull request Oct 7, 2025
This PR fixes the specification of subtyping and coercion rules for
optional types.

The changes to the specification are implemented in a separate
[PR](#679).

# Subtyping rules

## Subtyping fix 1

The specification claims that the rules allow "*any* type to be regarded
as a subtype of an option".

Hence, the pre-condition of the rule
```
not (<datatype> <: opt <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>
```
is always false and this rule can be dropped.

## Subtyping fix 2

The existing subtyping rule
```
not (null <: <datatype'>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>
```
should be fixed to
```
not (null <: <datatype>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>
```
according to the specification "It can also be specialised to its
constituent type, unless that type is itself optional". Also the fact
that `nat <: opt opt nat` cannot be derived otherwise.

## Subtyping fix 3

The pre-condition `not (null <: <datatype'>)` in the existing
subtyping rule

```
not (null <: <datatype'>)

not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>
```
effectively only forbids `null` as `<datatype'>` because every other
`<datatype'>` such that `null <: <datatype'>` would also satisfy
`<datatype> <: <datatype'>` and thus violate the other
pre-condition. Since the corresponding coercion rule coerces to `null`
in
this case anyway, we should enable `null` as `<datatype'>` in this rule
(as otherwise a separate rule to cover this case is needed with the same
coercion rule). Hence, the updated rule should read
```
not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>
```

## Subtyping fix 4

Analogously to the existing rule
```

----------------------

null <: opt <datatype>
```
we need to add a new rule
```

--------------------------

reserved <: opt <datatype>
```
otherwise the type `reserved` is not a subtype of an option (contrary to
the claim that every type is a subtype of an option).

## Subtyping fix 5

The specification claims that the rules allow "any type to be regarded
as a subtype of an option". Hence, we need to add the rule
```
not (null <: <datatype>)

not (<datatype> <: <datatype'>)

-----------------------------

<datatype> <: opt <datatype'>
```
otherwise the type `nat` is not a subtype of `opt nat64`.

## Fixed subtyping rules

The final set of subtyping rules for optional types is
```
----------------------

null <: opt <datatype>
```

```

--------------------------

reserved <: opt <datatype>
```

```
<datatype> <: <datatype'>

---------------------------------

opt <datatype> <: opt <datatype'>
```

```
not (<datatype> <: <datatype'>)

---------------------------------

opt <datatype> <: opt <datatype'>
```

```
not (null <: <datatype>)

<datatype> <: <datatype'>

-----------------------------

<datatype> <: opt <datatype'>
```

```
not (null <: <datatype>)

not (<datatype> <: <datatype'>)

-----------------------------

<datatype> <: opt <datatype'>
```
and it is easy to see that these rules are non-overlapping and
correspond to coercion rules.

# Coercion rules

## Coercion fix 1

The rule
```

---------------------------------

<v> : reserved ~> null : opt <t'>
```
is overlapping with
```
null <: <t>

-----------------------------

null : <t> ~> null : opt <t'>
```
and thus the former should be fixed to
```
not (<v> = null)

-----------------------------

<v> : reserved ~> null : opt <t'>
```

## Coercion fix 2

The coercion rule
```
not (null <: <t>)

not (null <: <t'>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>
```
has a pre-condition `not (null <: <t'>)` that does not correspond to
any pre-condition in the corresponding subtyping rule (the fixed rule in
the section "Subtyping fix 2") and prevents coercion of `nat` into `opt
opt nat`
(for instance). Hence, we drop the pre-condition and the updated
rule is
```
not (null <: <t>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>
```

## Coercion fix 3

The rule
```
not (null <: <t'>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>
```
allows `opt 5 : opt nat` to coerce to `null : opt nat` which does not
make
sense as it loses data (`opt 5`) that could be preserved. This rule
should
be extended with an additional pre-condition `not (null <: <t>)` since
there are separate coercion rules for such cases.

## Coercion fix 4

The rule
```
null <: <t'>

not (null <: <t>)

----------------------------

<v> : <t> ~> null : opt <t'>
```
allows `5 : nat` to coerce to `null : opt opt nat` which does not make
sense. This rule should be extended with an additional pre-condition
```
not (exists <w>. <v> : <t> ~> <w> : <t'>)
```
to only coerce to `null : opt <t'>` if coercion to `<t'>` failed.

## Coercion fix 5

But now we easily see that the two rules
```
not (null <: <t>)

not (null <: <t'>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>
```
and
```
not (null <: <t>)

null <: <t'>

not (exists <w>. <v> : <t> ~> <w> : <t'>)

----------------------------

<v> : <t> ~> null : opt <t'>
```
can be merged into a single rule
```
not (null <: <t>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>
```

## Fixed coercion rules

The final set of coercion rules is
```
not (<v> = null)

---------------------------------

<v> : reserved ~> null : opt <t'>
```

```
null <: <t>

-----------------------------

null : <t> ~> null : opt <t'>
```

```
<v> : <t> ~> <v'> : <t'>

----------------------------------------

opt <v> : opt <t> ~> opt <v'> : opt <t'>
```

```
not (exists <w>. <v> : <t> ~> <w> : <t'>)

----------------------------------------

opt <v> : opt <t> ~> null : opt <t'>
```

```
not (null <: <t>)

<v> : <t> ~> <v'> : <t'>

--------------------------------

<v> : <t> ~> opt <v'> : opt <t'>
```

```
not (null <: <t>)

not (exists <w>. <v> : <t> ~> <w> : <t'>)

--------------------------------

<v> : <t> ~> null : opt <t'>
```
and it is easy to see that these rules are non-overlapping and
correspond to subtyping rules.
ggreif added a commit to dfinity/motoko that referenced this pull request Nov 6, 2025
## Implement Martin's revised deserialization for Candid options

> This PR fixes coercion of values into nested optional types.

>Before the fix from this PR, `5 : nat64` would coerce to `opt 5 : opt
na64` (as expected) and `null : opt opt nat64` (unexpected). >This PR
fixes the unexpected result to be `opt opt 5 : opt opt nat64` (as
expected).

>Before the fix from this PR, `5 : int` would coerce to `null : opt opt
nat`. This PR fixes the result to be `opt null : opt opt nat`. The
>motivation for this change is to enable using optional types as
follows:
>- outer option characterizes if a value (of any type) is provided;
>- inner option expresses to optimistically deserialize the actual value
into the given type (without failing to deserialize and without losing
the information if a value of any type is provided).

>This PR also adds multiple test cases covering the change to coercion
(including coercion to nested optional types over `null` and
`reserved`).

c.f.
*  Rust fix dfinity/candid#679
* Candid spec [PR](dfinity/candid#678), in
particular this commit

dfinity/candid@90af305

## Furthermore (from #5619) fixes #5543.

The spec says that every Candid value is a subtype of `opt T`, and gives
`opt t` when `t <: T`. But the `null` type has no subtypes. I.e. we only
get out a `null : Null` when the binary Candid has a `0x7F` there. In
all other cases the coercion must fail, either trapping or indicating a
coercion failure to the caller (when recoverable). However `null : ?T`
can always be materialised when an argument is missing, similarly `null
: Null`. So we have to treat missing arguments and coercion failures
differently when the requested type is `Null` (or `null` respectively,
in Candid).

This PR corrects the reading of (e.g. `\x70`, `<reserved>`) in the
non-recoverable case and makes it a coercion failure.

Also adds improvements to the `candid-tests` runner to
- not ignore the case when a field type can be inferred, but not in the
record type (eliminating `M0164`, ignored test)
- adds `null` values to top-level argument tuples where the type
requests them
- adds `null` fields to record values (in argument sequences) where the
type requests them

------------------
TODO Tasks:
- [x] bump candid dependency to pick up revised test suite
- [x] It would be cool if the generated Motoko would be output on
`stderr` when `--diag` is passed to `candid-tests`
- [ ] potential simplifications suggested in #5619

---------

Co-authored-by: Gabor Greif <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: pr-automation-bot-public[bot] <189003650+pr-automation-bot-public[bot]@users.noreply.github.com>
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