Skip to content

feat(quotations): compute the parts of a quotation that read no variable - #59

Open
xperiandri wants to merge 5 commits into
mainfrom
feat/quotations-evaluator
Open

xperiandri wants to merge 5 commits into
mainfrom
feat/quotations-evaluator

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

Depends on #58. The branch is built on the branch of #58, so until that is merged this pull request also shows its three commits; its own are the last two. It targets main because the build workflow runs only for pull requests into main.

The second step of phase 1 of ADR 0001 (#34). A query has parts that do not depend on the document:

<@ fun p -> p.Created > DateTime.UtcNow.AddDays -30.0 && p.Age > limit + 1 && p.Tenant = settings.Tenant @>
//                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^           ^^^^^^^^^              ^^^^^^^^^^^^^^^

Their values are known before the query runs, so they become its parameters. This pull request adds what computes such a value: Evaluator.tryEvaluate : Expr -> Result<objnull, TranslationError>. Which parts of a query are computed, and under which parameter names, is the next step.

How. By reflection over a quotation in the normal form of #58: properties and fields are read, methods and constructors called, tuples, records, unions and arrays built and taken apart, types and union cases tested, a captured function applied. Nothing is compiled: turning a quotation into a delegate costs hundreds of microseconds (#56 measured 98 to 705 µs for two small expressions).

What needed care

  • A choice computes one branch. &&, || and if are computed the way compiled code computes them, so nick.IsSome && nick.Value.Length > 0 over a captured None is false and raises nothing.
  • 37 operators of FSharp.Core cannot be called by reflection: -, /, %, the unary minus and plus, the bitwise operators, the shifts, the conversions between number types (float, int64, byte, …) and the checked forms. They are compiled into the code that uses them, and their own methods raise NotSupportedException, "Dynamic invocation of op_Subtraction is not supported". +, * and the comparisons can be called, which makes the gap easy to miss: limit + 1 works and limit - 1 does not. For every one of the 37, FSharp.Core has a function that does the same for operands of any types (LanguagePrimitives.SubtractionDynamic and its siblings). The compiler passes that function to the operator as a hidden argument, and the evaluator takes the pairs from there instead of from a hand-written list.
  • The checked operators are paired by hand. The compiler passes Checked.byte the unchecked conversion function; with it Checked.byte 300 would give 44 instead of raising OverflowException. The evaluator pairs the checked operators with the checked functions, so they raise as they do in compiled code.
  • Enumeration values. Those functions fail for an enumeration type, also behind int day, whose own method can be called for other types. The evaluator calls them for the number type under the enumeration and makes a result that is an enumeration value one again, so BindingFlags.Public ||| BindingFlags.Static is a BindingFlags value and not the number 24: a parameter of a query is written by the type its value has.
  • A receiver that is null raises NullReferenceException, as compiled code does, not the TargetException of reflection. So does a function that is null when it is applied. Compiled code finds the null after it has computed the arguments, so an argument that raises comes first here as well. (Found by the review: the null was reported before the arguments were computed.)
  • A coercion is a cast. The compiler writes one only for :> and for an argument that is passed as a base type, where the cast cannot fail. A quotation built through the API can hold one that fails; its value is then refused with the InvalidCastException of the cast and not passed on as a value of a type it does not have. A cast converts nothing: a boxed int is no byte, in compiled code either. (Found by the review.)
  • A node is computed once. The normalizer writes a value that a let bound at every place it is used, as the very same node, and the code computes a bound value once: let made = obj () in (made, made) is a pair of one object. The evaluator keeps the values of the nodes it computed, by their identity, so it is one object here too. tryEvaluate keeps them for one part; tryEvaluateOnce takes them from its caller, who computes several parts of one quotation (feat(quotations): tell the values of a quotation from its shape #60). (Found by the review.)
  • The default value of a struct is its zeroed value, for which no constructor runs. A struct of another language can declare a constructor without parameters, and Activator.CreateInstance runs it: for a struct whose constructor sets a field to 7 it gives 7 where the default value is 0. So the value comes from RuntimeHelpers.GetUninitializedObject; a nullable without a value stays null as an object, because that function gives a boxed value of the type under it for a nullable. (Found by the review.)
  • A member of a nullable is computed by a function that takes the nullable as an argument. Reflection has nothing to call such a member on: a nullable is boxed as its value or, when it has none, as null, never as itself. Called on the box, settings.Limit.HasValue of a nullable without a value raised instead of giving false. As an argument, null is taken for the nullable without a value. (Found by the review.)

Errors. An exception of the code that is run becomes a TranslationError with the new code ClientEvaluationFailed and the exception itself in the new field Cause, taken out of the TargetInvocationException that reflection wraps it in. A part that holds a function written in the quotation, a variable or another quotation has no value and is Unsupported.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

The assembly is not packed yet (#58); the packages are unchanged.

Checklist

  • Build and tests pass locally
  • I have added tests that prove my fix is effective or that my feature works (if appropriate)
  • I have added necessary documentation (if appropriate)

Further comments

  • Tests: 148 in EvaluatorTests (FSharp.Azure.Cosmos.Quotations.Tests has 450 now).
    • 129 computations, each a piece of code that reads no variable. The compiler passes the code together with its quotation (a parameter marked [<ReflectedDefinition(true)>], as in feat(quotations): bring a quotation into its normal form #58), so the expected value is what the code gives when it runs. There is one for every kind of node, for every one of the 37 operators, for every member of a nullable with a value and without one, and for enumeration values. One is a call of a function with a reflected definition that calls itself, which the normalizer leaves a call when it reads nothing of the document (feat(quotations): bring a quotation into its normal form #58).
    • 11 failures: code that raises, checked for the error it becomes — the code, the exception as the cause, its name in the message, the place in the source.
    • One test builds coercions through the API: a value of the target type is given, any other is the exception of the failed cast.
    • One test makes a struct with a constructor without parameters at run time, which F# does not declare, and checks that its default value is the zeroed one.
    • Two tests count how often the code of a bound value runs: once for two places of one part, and once for two parts when the caller keeps what was computed.
    • One test asks FSharp.Core which operators it marks as not callable (NoDynamicInvocationAttribute) and fails when one of them has no function to stand in for it. A new version of FSharp.Core cannot add such an operator unnoticed.
  • Can the tests fail? 26 one-line defects were planted in the evaluator, one at a time, and reverted: both branches of a choice computed, a null receiver passed on, the wrong item of a tuple, an inverted case test, a checked operator computed unchecked, an enumeration type passed as it is, a member of a nullable called on its box, a function that is null passed on, a coercion that gives any value, the wrapper of reflection reported as the cause, and others. Every one of them fails at least one test. The first run found two that none failed; both are closed (a struct made without arguments had no test, and the type of a computed enumeration value was not checked).
  • Cost, measured in a Release build on a machine under other load, so orders of magnitude: a captured value 0.1 µs, a field of a captured record 0.3 µs, limit + 1 0.5 µs, DateTime.UtcNow.AddDays -30.0 0.4 µs, a record or tuple that is built 0.3 µs. Keeping the values of the nodes adds about 0.1 µs to a part that runs code. The functions of FSharp.Reflection that are called for every value (MakeRecord, MakeTuple, GetTupleField) cost 3 to 9 µs each; the evaluator asks for their precomputed forms once per type and keeps them for the process.
  • Found on the way: the compiler refuses to read a member of a captured local of a value type in a quotation (FS3155): date.Year for a captured DateTime. The ADR has this; the tests keep their date in a field of a captured record.
  • Not in this step: which parts of a query are computed and what the policy for that is (EvaluationPolicy of the ADR) — the evaluator computes what it is given.
  • Verification: dotnet build FSharp.Azure.Cosmos.slnx in Debug and Release with the warnings main has and none in the two Quotations projects, also with -p:WarnOn=3390; the tests pass in both configurations; Fantomas clean; the strict documentation build passes; every cref of the changed library files names a member that a documentation file declares; both commits build, and the second passes the tests.
  • No changelog entry: nothing is packed.

🤖 Generated with Claude Code

FSharp.Azure.Cosmos.Quotations is going to translate F# quotations into
Azure Cosmos DB queries (ADR 0001). The translator is built stage by
stage, so no package is made of the project until the stages produce a
query end to end.

A stage that cannot translate a quotation says why with a
TranslationError: a code for the calling program, a message and hints
for the person who reads it, and the offending part of the quotation as
text. When the code was compiled with --quotations-debug, the error also
carries the place in the source that the compiler recorded for that
part; a part that a rewrite built has no place of its own, so the place
of the first part inside it is taken.

The error has no equality: its hints are an ImmutableArray, which equals
another one only when both wrap the same array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 11, 2026 15:56
xperiandri added a commit that referenced this pull request Oct 11, 2026
#59 computes the parts of a quotation that read no variable. The record
states what the step found: 37 operators of FSharp.Core that reflection
cannot call and the functions that stand in for them, the checked
operators whose witnesses are unchecked, how enumeration values are
computed, why the precomputed forms of the FSharp.Reflection functions
are used, and how an exception of the caller's code is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

Nullable receivers, null function application, and unreachable constant branches currently produce incorrect behavior.

4 open findings
What changed in this PR

Adds the staged quotation translator foundation and reflection-based evaluation of document-independent expressions.

Changes:

  • Adds quotation normalization and client-side evaluation.
  • Extends translation errors with evaluation failures and causes.
  • Adds comprehensive unit tests and solution integration.
File Description
.github/​copilot-instructions.md Documents the new projects.
FSharp.Azure.Cosmos.slnf Adds projects to the solution filter.
FSharp.Azure.Cosmos.slnx Adds library and test projects.
src/​Cosmos.Quotations/​AssemblyInfo.fs Defines assembly metadata.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates variable-independent quotation parts.
src/​Cosmos.Quotations/​FSharp.Azure.Cosmos.Quotations.fsproj Configures the new library.
src/​Cosmos.Quotations/​InternalsVisibleTo.fs Exposes internals to tests.
src/​Cosmos.Quotations/​Normalizer.fs Normalizes quotation expressions.
src/​Cosmos.Quotations/​TranslationError.fs Defines translation error contracts.
tests/​Cosmos.Quotations.Tests/​EvaluatorTests.fs Tests evaluator behavior and failures.
tests/​Cosmos.Quotations.Tests/​FSharp.Azure.Cosmos.Quotations.Tests.fsproj Configures the test project.
tests/​Cosmos.Quotations.Tests/​Model.fs Supplies quotation test models.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization errors.
tests/​Cosmos.Quotations.Tests/​NormalizerSpellingTests.fs Tests equivalent quotation forms.
tests/​Cosmos.Quotations.Tests/​NormalizerTests.fs Tests normalization invariants.
tests/​Cosmos.Quotations.Tests/​QuotationAssert.fs Adds quotation test helpers.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Defines test categories.
tests/​Cosmos.Quotations.Tests/​testconfig.json Enables parallel test execution.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated
Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated
Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated
Comment thread src/Cosmos.Quotations/Normalizer.fs

Copilot AI 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.

Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated
xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable and
raises NullReferenceException for a function that is null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-evaluator branch from a1cc504 to 64fc9c7 Compare October 11, 2026 17:32
xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 17:34
xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🔵 Needs a closer look

The extensive reflection-based evaluator and stacked normalizer changes require final human validation despite comprehensive tests.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-evaluator branch from 64fc9c7 to bb2d4ee Compare October 11, 2026 19:39
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 19:40
xperiandri added a commit that referenced this pull request Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

Evaluation currently recomputes normalized let-bound expressions used at multiple locations, changing observable results.

1 open finding

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated

Copilot AI 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.

🔵 Needs a closer look

The large reflection-heavy normalization and evaluation engine warrants final human validation despite extensive tests.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

Copilot AI 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.

🔵 Needs a closer look

The combined dependency and extensive reflection-heavy normalization and evaluation semantics warrant final human validation.

0 open findings

🧠 Review effort: Balanced

Copilot AI 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.

🟡 Changes recommended

Null targets are rejected before their arguments are evaluated, diverging from compiled call semantics.

1 open finding

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated

Copilot AI 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.

🔵 Needs a closer look

The extensive reflection and quotation-rewriting logic warrants final human review despite broad test coverage.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

xperiandri added a commit that referenced this pull request Oct 12, 2026
A receiver that is null is reported after the arguments of the call
are computed (#59). A record whose converter an attribute of the
application makes is not described, and a read-only member with a rule
that code has set is not taken to be left out (#61). One node at two
places stays one value and one slot: the record says why, and what
would change it.

Found while the next step was built, and fixed in #61: a read-only
member is a collection, which the serializer writes all the same, by
the converter that writes it, and that can be one the member names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri and others added 2 commits October 12, 2026 03:12
One condition can be written in many ways: with pipes, composition,
let bindings, functions marked with ReflectedDefinition and spliced
quotations. The stages after this one should not have to know every
spelling, so the normalizer rewrites them away. What is left holds
operators, members of the document and calls whose code cannot be read.

Rewritten away:

- |>, <|, ||>, |||>, <||, <|||, >> and <<, recognised by the methods of
  FSharp.Core and never by name;
- a function that is written and applied in place, and a let whose
  variable is never assigned to. That includes what the compiler binds
  itself: the value a match looks at, the parts of a tuple pattern and
  the copy of a struct, which is a mutable variable when the compiler
  cannot tell that the called member leaves the struct alone;
- a call of a function, method, property getter or extension member
  with a reflected definition, also an inline one that takes its
  operators as witnesses, a generic one and a member of a base class.
  A member that a derived class can override stays a call, and so does
  a module-level value, which is computed once;
- the reading of an item, field or case of a tuple, record, anonymous
  record or union that is built in the same place, and fst, snd and
  Item1..Item7, which become the reading of an item;
- a comparison of a boolean with true or false, a choice between true
  and false, a condition that is a constant and a double negation. The
  branch that a constant condition does not choose is not read at all:
  code that never runs needs no normal form. A captured boolean is not
  a constant: it is a parameter of the query;
- the witnesses of a call, the address of a struct anonymous record and
  the compiled value that an auto-quoted argument carries.

Reported as a TranslationError instead:

- a call of a function with a reflected definition that calls itself,
  directly or through others, when the call reads a value of the
  document;
- a quotation whose rewriting does not end, after 10 000 steps or when
  the stack of the thread runs low, whichever comes first;
- a compiled function that is applied to a value of the document. An
  application to captured values only is left for the stage that
  computes values outside the query;
- a mutable variable that is assigned to, an assignment, the address of
  a value, a sequence of statements, a loop, exception handling and a
  recursive function.

Of the constructs that stay in the normal form, the outermost one is
reported. An assignment, a sequence of statements, a loop, exception
handling and a recursive function are errors whatever is inside them,
so the outermost of these is reported also when the quotation inside
it has no normal form at all.

Only what stays is an error. A call of a function that calls itself is
not replaced by its definition, which would never end: it stays a
call, and so does the call that led to it. What becomes of such a call
is decided when the whole quotation is normalized. One that reads a
value of the document is the error. One that reads none is a value to
compute outside the query, as a call of any compiled function is. And
one in code that never runs is gone by then, so the branch that a
constant does not choose may hold one however the constant gets to the
condition: bound by a let, passed to a function written in place or to
a function with a definition.

The compiler keeps the definition of an inline function of a module
for the form of the function that takes the witnesses, and the
definition of an inline member of a type for the member itself; both
take the witnesses first, then the receiver, then the arguments. A
definition is applied only to arguments of the types it takes: a call
that is built through the API can pass an inline member no witnesses,
and such a call stays a call.

Reflected definitions and the argument groups of their methods are kept
for the process: FSharp.Core unpickles a definition on every lookup,
which took 16 microseconds here, and searches the resources of the
assembly for a method that has none, which took one. Substitution is
left to FSharp.Core where nothing inside the body needs care, because
it builds nodes without validating them, while rebuilding one member
access through the public API cost 4 microseconds of reflection.

Two things need care. One variable can be bound at several places,
since a quotation that is spliced twice arrives as the same nodes, so a
binding that hides the variable or would capture a variable of the
value is respected, also when the question is whether a mutable
variable is assigned to. And a value that is bound once and used at several
places has to be one node at all of them: the code computes it once,
and the stage that computes values outside the query tells by the node
that the places are one value. FSharp.Core builds every node of a body
anew, which makes two nodes of such a value as soon as something else
is written into the body that holds it. So a body with such a node is
substituted node by node, and the node is rewritten and normalized once
for all its places. The places are those of one call: the definition of
a function is one tree for the process, so every call after the first
normalizes a copy of it, and a value that the function binds is not one
node across calls, which each compute it for themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new test project, FSharp.Azure.Cosmos.Quotations.Tests, with unit
tests that need no emulator.

Most tests are pairs: one way to write a function next to the plain way
to write the same function. For every pair three things are checked:
both ways have one normal form, the normal form is returned as the same
node when it is normalized again, and it computes what the compiled
code computes on sample values. The compiled code and the quotation come
from one lambda, which the compiler passes both ways to a parameter
marked with ReflectedDefinition(true), and Unquote evaluates the
quotation.

The other tests state what a pair cannot: what must stay as it is, what
must be gone, how one variable that is bound at two nested places is
treated, that a value which is bound once stays one node at all its
places, what every error says and which of several errors is reported.

The test project is compiled with --quotations-debug, so the errors
carry the place in the source; a quotation built through the API covers
the case without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

Default-value evaluation incorrectly invokes explicit parameterless struct constructors instead of producing zero-initialized values.

1 open finding

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Evaluator.fs Outdated
xperiandri and others added 2 commits October 12, 2026 04:09
A query has parts that do not depend on the document, such as
DateTime.UtcNow.AddDays -30.0 or limit + 1. Their values are known
before the query runs and become its parameters. The evaluator computes
such a value from a quotation in normal form.

It works by reflection: properties and fields are read, methods and
constructors called, tuples, records, unions and arrays built and taken
apart, types and union cases tested, captured functions applied.
Nothing is compiled, because turning a quotation into a delegate costs
hundreds of microseconds. A choice computes only the branch its
condition chooses, so a && b does not compute b when a is false.

37 operators of FSharp.Core refuse to be called by reflection: -, /, %,
the unary and bitwise operators, the shifts, the conversions between
number types and their checked forms. Their methods raise
NotSupportedException, "Dynamic invocation of op_Subtraction is not
supported", while + and * can be called. For each of them
LanguagePrimitives has a function for operands of any types, which the
compiler passes to the operator as a hidden argument; the evaluator
takes the pairs from there. The checked operators are paired by hand:
the compiler passes them the unchecked functions, with which
Checked.byte 300 would give 44 instead of raising.

Those functions fail for an enumeration type, so they are called for
the number type under it, and a result that is an enumeration value is
made one again. That also covers int over an enumeration value, whose
own method can be called for other types.

What reflection finds out about a type or a method is kept for the
process: the precomputed constructors and readers of FSharp.Reflection
take a record from 8 microseconds to 0.3 and an item of a tuple from 3
to 0.3.

An exception of the code that is run becomes a TranslationError with
the new code ClientEvaluationFailed and the exception itself in the new
field Cause, taken out of the TargetInvocationException of reflection.
A receiver that is null raises NullReferenceException, as compiled code
does, and so does a function that is null when it is applied. Compiled
code finds the null after it has computed the arguments, so an argument
that raises comes first here as well.

A member of a nullable is computed by a function that takes the
nullable. Reflection has nothing to call such a member on: a nullable
is boxed as its value or as null, never as itself, so HasValue of a
nullable without a value would raise where it has to give false.

The default value of a struct is its zeroed value, for which no
constructor runs: a struct of another language can declare a
constructor without parameters, and what that constructor makes is
another value. A nullable without a value is null as an object.

A coercion is a cast: a value that is not of the target type raises
InvalidCastException, as the cast does in compiled code, and is not
passed on as a value of a type it does not have. The compiler writes
only coercions that cannot fail; a quotation that is built through the
API can hold one that does.

A node is computed once. The normalizer writes a value that a let
bound at every place it is used, as the very same node, and the code
computes a bound value once: let made = obj () in (made, made) is a
pair of one object. So the evaluator keeps the values of the nodes it
has computed, by their identity: for one part in tryEvaluate, and in
tryEvaluateOnce for all the parts of one quotation, whose caller keeps
them. That costs a part about 0.1 microseconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every case is code that reads no variable, passed to the test together
with its quotation by the compiler, so the expected value is what the
code gives when it runs. There is a case for every kind of node, for
every operator that FSharp.Core does not let reflection call, for
every member of a nullable with and without a value, and for
enumeration values, whose computed value also has to be of the
enumeration type and not the number under it.

Code that raises is checked for the error it becomes: the code, the
exception as the cause, its name in the message and the place in the
source. A coercion that its value does not fit is built through the
API and checked for the exception of the failed cast. The default
value of a struct with a constructor without parameters, which F#
does not declare, is checked on a type that the test makes at run
time.

A value that is bound once is checked to be computed once: at two
places of one part, and in two parts for a caller that keeps what was
computed.

One test asks FSharp.Core which operators it marks as not callable and
fails when one of them has no stand-in in the evaluator, so that a new
version of FSharp.Core cannot add one unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-evaluator branch from ecb6991 to a5b9d5c Compare October 12, 2026 02:20
xperiandri added a commit that referenced this pull request Oct 12, 2026
The default value of a struct is its zeroed value, for which no
constructor runs, also when a struct of another language declares a
constructor without parameters (#59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants