Skip to content

feat(quotations): tell the values of a quotation from its shape - #60

Open
xperiandri wants to merge 8 commits into
mainfrom
feat/quotations-parameterizer
Open

xperiandri wants to merge 8 commits into
mainfrom
feat/quotations-parameterizer

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

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

The third step of phase 1 of ADR 0001 (#34). Two runs of one query differ only in the values it was written with:

let adults (limit : int) (name : string) =
    <@ fun (p : Person) -> p.Age > limit && p.Name = name.Trim () @>

The text of the query is the same every time, … WHERE (p["age"] > @limit) AND (p["name"] = @p1); only the values of the two parameters change. So a query has to be translated once, and every later run only has to find that translation and supply the values. This pull request adds what tells the two apart, Parameterizer. For a quotation in the normal form of #58 it gives three things:

  • The values that go into the query as parameters, each at a numbered place. The code calls such a place a slot. Here they are limit and what name.Trim () computes.
  • The key (ShapeKey): everything else in the quotation, in a form that can be compared and hashed. Two quotations with equal keys give the same query text, so the key is what a cache of translations will be looked up by. A quotation cannot be its own key: it compares its variables by identity, and every evaluation of <@ … @> creates new variables; it also compares the captured values, which are what a key has to leave out.
  • The template, for the first run of a query: the quotation with a variable at the place of every slot. It is what the translator, the next step, reads.
In the quotation Becomes
a literal: 18, "vip", true, null, StringComparison.Ordinal, a value declared [<Literal>] a part of the key, later of the query text
a decimal: 18m a slot without a name. The compiler writes it as a call that makes the decimal, also when it is declared [<Literal>]; as a parameter it keeps its digits, where a number in the text of a query is read as a floating-point number
a number that is not finite: nan, infinity a slot without a name: the text of a query has no such number
the default value of a type: Unchecked.defaultof<int>, Unchecked.defaultof<string> a part of the key when the type has such a literal (zero, false, null); a slot without a name when it has none: the default value of a date, a guid, a nullable, a struct or a union
a captured value: limit a slot named after the variable, at every place it is used
a part that reads nothing of the document, the largest one at its place: limit + 1, name.Trim (), settings.Tenant, DateTime.UtcNow.AddDays -30.0, a module-level value computed by the evaluator of #59; a slot without a name
a tuple, record, union case or array that the quotation builds: Some name, [ a; b ], (limit, 1) stays what it is, with its parts treated by the rows above
Query.parameter 18 a slot, although it is a literal
Query.constant kind a part of the key, although it is captured. Only for a value of a type that a literal can have

Query.constant and Query.parameter are new public functions; outside a quotation each returns its argument.

What needed a decision

  • A literal is a part of the text. The ADR made a literal a parameter by default and, in another place, wrote a value declared [<Literal>] into the text. Both cannot hold: the compiler writes such a value into the quotation exactly as it writes 18, so the two cannot be told apart. The rule here is the one of EF Core: what is written as a constant is a constant, what is captured is a parameter. It is also what the translator needs: it has to see that p.Name = null compares with null, and which StringComparison was written. Query.parameter is for the literal that should be a parameter all the same.

  • The default value of a type is a literal where the type has one. Unchecked.defaultof<int> is 0, a default bool is false, a default string is null: literals like any other. The default value of a date, a guid, a nullable, a struct or a union is data: the compiler writes it as a node of its own (Unchecked.defaultof<DateTime> and Guid () are such nodes), and a query text has no way to write a date or a guid, so it is a slot. A function type is the exception that is an error, as above. The type tells whether the default value is a literal; no value is made to ask it. A struct can have a constructor without parameters (C# can declare one), which a default value never runs and Activator.CreateInstance does, so asking by a made value would run code of the application, and a constructor that throws would stop the parameterizer. (Found by the review: first every default value was written into the key as a literal, so a translator would have been asked to write a date into the text of a query; then the fix made a value to look at it.)

  • A value that the quotation builds stays visible. The ADR computed every part that reads nothing of the document, Some name and [ a; b ] included, and each would have been one parameter. But the ADR also wants p.Nick = Some name to become "has a value, and the value is @name", and a list written item by item to become IN (@a, @b). The translator can do neither with a parameter that holds an option or a list. So what is computed is code — calls, readings of properties, constructors of classes — and what the quotation builds is left for the translator, part by part.

  • A branch that is not taken is not computed. In

    <@ fun (p : Person) -> filter.IsNone || p.Name = filter.Value.Trim () @>

    compiled code never reads filter.Value when there is no filter. Computing every part beforehand raises NullReferenceException here, although the function is correct. So when the condition of a choice is known before the query runs, the parts of the branch it does not take are slots without a value. The key still holds both branches and does not depend on which one is taken, so the query text stays the same. The query does not take that branch either, so whatever stands in for the missing value changes no result.

  • One node, one slot. A slot stands for one node of the quotation. The compiler writes every use of a captured variable as a node of its own, so p.Owner = userId || p.Editor = userId has two slots named userId. The first version of this pull request made them one slot, by name, type and value. But nothing in a quotation says that two such nodes are one variable: two quotations that are spliced together can each have captured a userId, and then telling by the values made the query text depend on them. byOwner a || byOwner b had one parameter when a and b happened to be equal and two when they were not (found by the review). The ADR rules that out, "SQL text never depends on run-time values", so the values decide nothing any more. This replaces the sentence of the ADR that identical captured values are one parameter. A value that has to be one parameter is bound inside the quotation, let id = userId in …: the normalizer writes a bound value at every place as the very same node.

  • A value that is bound once is computed once, however often and wherever it is used, as in compiled code. let now = DateTime.UtcNow in p.From <= now && p.To >= now.AddDays 1.0 reads the clock once, although now and now.AddDays 1.0 are two parameters: what the parameterizer has computed is kept node by node for the whole quotation, by the evaluator of feat(quotations): compute the parts of a quotation that read no variable #59. The review led to two cases where this did not hold. With two lets the normalizer made two nodes of one of the values (fixed in feat(quotations): bring a quotation into its normal form #58), and a bound value inside two different computed parts ran its code once for each.

  • The key is the key of the normal form. feat(quotations): bring a quotation into its normal form #58 left this open: a key of the quotation as it is written would spare normalizing a query that was seen before. Three things speak for the normal form. Two ways to write one condition share one translation. The slots are the ones the translator sees, with no mapping between two sets of them. And a value that a let binds is one slot because the normalizer writes it as one node. The price is the normalization on every run, 5 to 25 µs; evaluating the quotation literal itself costs more (24 to 75 µs, test(benchmarks): measure what a quotation query costs on the client #56). A query on a hot path is a compiled query of the ADR, which is normalized once.

  • A function value is an error, CapturedFunctionValue: a compiled function can neither be read nor be the value of a parameter. feat(quotations): bring a quotation into its normal form #58 reports the function that is applied to a value of the document; this is the function that is passed, as in p.Tags |> List.exists isVip. It is refused by the type of its part before anything is computed, and by its value where the type cannot say what it is, as for a function passed as an obj. Query.constant refuses a function the same way. A function is told by its type also when the type is a class made of the type of F# functions, which code can declare and which the class of every compiled function is. The null and the default value of a function type, which only code can build into a quotation (Expr.Value (null, typeof<string -> bool>)), are refused the same way. (Found by the review: such a null looked like a literal and was written into the key before its type was asked.)

  • Query.constant takes only what a literal can be: a boolean, a character, a string, an integer or a finite floating-point number of a built-in type, a value of an enumeration or the null of a reference type that is no union of F#. The text of a query has no NaN and no infinity (NUMERIC_LITERAL of the grammar is digits, a fraction and an exponent), so such a number is a slot when code builds it into a quotation, and Query.constant reports it (found by the review). The key holds the value, and equal keys have to mean one query text. Only such a value says by its equality what is written for it: an object is equal to itself after it has changed, an array is equal to nothing but itself, and two objects can be equal and be written differently. Any other value is reported and stays what it was without the function, a parameter. (Found by the review.) The type that a value is declared with decides, so a nullable is no literal, with a value or without one, and neither is the null of a union type: it is the case of the union that is represented by null, as the None of an option is, and the compiler writes a case as a case. (Found by the review too: a nullable with a value is boxed as that value, so Query.constant (Nullable 5) was taken, and a nullable that code put into a quotation as a value was a literal. And only the null of an option was told: the null of a union marked UseNullAsTrueValue was a literal.)

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), so 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: 416 for the parameterizer and 2 more for the evaluator (FSharp.Azure.Cosmos.Quotations.Tests has 883 now). A struct with a constructor without parameters, which F# cannot declare, is made at run time by one helper, DynamicStruct, which the test of the parameterizer shares with the one test of the evaluator that had its own copy of the code.

    • ParameterizerShapeTests: 47 functions of a person, every one of a shape of its own, written twice over two sets of captured values. With the second set every choice takes its other branch. Five laws: a function has one key whatever values it captured (and one hash code); no two of the 47 have one key; the key and the values are the same whether or not the template is asked for; the template holds a variable for every slot and no captured value; and the template with the values put back computes what the compiled code computes. As in feat(quotations): bring a quotation into its normal form #58, the compiler passes each function together with its quotation, and Unquote evaluates the template.
    • ParameterizerTests: what the laws cannot state. Which values are slots, under which names and with which values; what is a part of the key; that two nodes are two slots whatever they hold, with equal keys; that a value which is bound once is one slot and is computed once, also after a second let and inside two computed parts, and that a function with a definition that is called twice has the slots of each call; that a quotation which is spliced into two places is one slot with one value, also when the normalizer has to rewrite it (the review of feat(quotations): describe how the serializer writes a member #61 found that it came out as two nodes then, which feat(quotations): bring a quotation into its normal form #58 corrects); that a branch that is not taken is not computed, also under another such branch, and that a value used both inside and outside it is; the two functions of Query; the tokens of a small key, one by one; and every error.
    • EvaluatorTests: the check that tells which parts can be computed accepts every part the evaluator computes, refuses a function, a variable and a quotation, and runs no code to tell.
  • Can the tests fail? 73 one-line defects are planted, one at a time, and reverted: a field left out of the comparison of keys, a captured value treated as a literal, a number that is not finite treated as one, a default value treated as a literal whatever its type, two nodes made one slot because they hold one value, a node computed anew for every part that reads it, a branch computed that is not taken, the template built with its parts in another order, a function value let through, and others. Each of them fails at least one test. Where one did not when it was first planted, a test was missing and is added: the fields of a token one by one, the index of a slot, the kind of a tuple, a choice that is computed as a whole. Writing the defects down also found one in the code: a condition that is a captured variable used earlier in the quotation did not keep its branch from being computed. It is fixed and has a test.

  • Cost, measured in a Release build on a machine under other load, so orders of magnitude. For a quotation in normal form:

    Quotation Key and values Template
    one comparison with a captured value 1 µs 1.2 µs
    four comparisons, three slots 3.5 µs 7 µs
    p.Created > DateTime.UtcNow.AddDays -30.0 && p.Age > limit + 1 && p.Tenant = settings.Tenant 4 µs 6.5 µs

    The template is built only for a query that has no translation yet: building a node of a quotation through FSharp.Core costs microseconds (feat(quotations): bring a quotation into its normal form #58), and a query that is found by its key needs no node built. One thing made a difference: to refuse a part as not computable, the evaluator has to walk down to the variable that is the reason, and the walk then asked about every part on that way again. The parts that were refused are remembered for the walk now (from about 4.6 to about 3.5 µs for the four comparisons).

  • Where an error is. A captured value has no place in the source of its own, and a node that the normalizer built has the place of a part under it. An error about such a part gets the place of the nearest node around it that has one.

  • Review of feat(quotations): compute the parts of a quotation that read no variable #59. Its five findings are fixed in the commits of feat(quotations): bring a quotation into its normal form #58 and feat(quotations): compute the parts of a quotation that read no variable #59, and this branch is built on the result.

  • Not in this step: the cache that the key is for (phase 2 of the ADR); the EvaluationPolicy of the ADR, which comes with the options of the translator; the list of functions that must never be computed on the client, which comes with those functions (Cosmos.Functions); the names of the parameters in the query text, which the translator gives. A slot only carries the name of its variable.

  • Verification: dotnet build FSharp.Azure.Cosmos.slnx in Debug and Release with the warnings main has and none in the two Quotations projects; 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; each of the three commits builds, and the last one 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 17:23
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>

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

Decimal literals, constant function values, and invalid runtime coercions are currently handled incorrectly.

2 open findings
What changed in this PR

Adds the quotation-processing pipeline that normalizes expressions, evaluates document-independent parts, and parameterizes values for future query translation and caching.

Changes:

  • Adds normalization, evaluation, shape-key, template, and parameter-slot infrastructure.
  • Adds Query.constant and Query.parameter.
  • Adds extensive emulator-free MSTest coverage and project integration.
File Description
.github/​copilot-instructions.md Documents the quotation projects.
FSharp.Azure.Cosmos.slnf Adds new projects to the filter.
FSharp.Azure.Cosmos.slnx Adds new projects to the solution.
src/​Cosmos.Quotations/​AssemblyInfo.fs Defines assembly metadata.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates document-independent expressions.
src/​Cosmos.Quotations/​FSharp.Azure.Cosmos.Quotations.fsproj Configures the quotation library.
src/​Cosmos.Quotations/​InternalsVisibleTo.fs Exposes internals to tests.
src/​Cosmos.Quotations/​Parameterizer.fs Builds shape keys, slots, and templates.
src/​Cosmos.Quotations/​Query.fs Adds constant/parameter markers.
src/​Cosmos.Quotations/​TranslationError.fs Defines translation errors and locations.
tests/​Cosmos.Quotations.Tests/​EvaluatorTests.fs Tests expression evaluation.
tests/​Cosmos.Quotations.Tests/​FSharp.Azure.Cosmos.Quotations.Tests.fsproj Configures the unit-test project.
tests/​Cosmos.Quotations.Tests/​Model.fs Supplies quotation test models.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization failures.
tests/​Cosmos.Quotations.Tests/​NormalizerSpellingTests.fs Tests equivalent expression forms.
tests/​Cosmos.Quotations.Tests/​NormalizerTests.fs Tests normalization behavior.
tests/​Cosmos.Quotations.Tests/​ParameterizerShapeTests.fs Tests shape and template laws.
tests/​Cosmos.Quotations.Tests/​ParameterizerTests.fs Tests parameterization details.
tests/​Cosmos.Quotations.Tests/​QuotationAssert.fs Adds quotation assertion helpers.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Defines sorted test categories.
tests/​Cosmos.Quotations.Tests/​testconfig.json Enables method-level parallelism.

🧠 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/Parameterizer.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 395c222 to e0729c3 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 force-pushed the feat/quotations-parameterizer branch from e0729c3 to 6928ad0 Compare October 11, 2026 17:38
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:39

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

Function values can bypass parameter validation when boxed or supplied through Query.constant.

2 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Parameterizer.fs
Comment thread src/Cosmos.Quotations/TranslationError.fs
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 6928ad0 to 573f72a Compare October 11, 2026 17:51
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 18:02

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/Parameterizer.fs
Comment thread tests/Cosmos.Quotations.Tests/ParameterizerShapeTests.fs Outdated
Comment thread tests/Cosmos.Quotations.Tests/QuotationAssert.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 573f72a to 8d42d45 Compare October 11, 2026 18:17
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 18:18

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/Parameterizer.fs
Comment thread src/Cosmos.Quotations/Normalizer.fs Outdated
Comment thread src/Cosmos.Quotations/Parameterizer.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 8d42d45 to a14a43d 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

Function subclasses can escape validation, and arbitrary constant equality can make cache keys inconsistent with query text.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Parameterizer.fs
Comment thread src/Cosmos.Quotations/Parameterizer.fs
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from a14a43d to e1514b1 Compare October 11, 2026 20:43
xperiandri and others added 3 commits October 12, 2026 10:22
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>
The stage that turns values into parameters has to choose the parts of
a quotation to compute before it computes any of them: a part that
turned out not to be computable half way would have run a half of its
code for nothing. Evaluator.canEvaluate tells without running anything
whether a part is made only of what the evaluator computes: it reads no
variable and holds no function, delegate or quotation that is written
in the quotation.

That stage asks about a part and then about the parts under it. To
refuse a part, the evaluator walks down to the reason, so it would look
at the same parts once for every part above them. The parts it refused
are remembered in a set that the caller keeps for all its questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 82958a4 to 43f5fab Compare October 12, 2026 08:54
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 09:52
xperiandri added a commit that referenced this pull request Oct 12, 2026
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the normalizer (#58, raised on #60): the default value of a boolean,
  which the compiler writes for bool (), is the constant false, so the
  branch that it does not choose is gone from the normal form and the
  parameterizer computes nothing of it;
- the values of the layout (#62): a converter of tuples is the one of
  FSharp.SystemTextJson only when its class is the library's, because
  an application can derive a converter from the public base class;
- the unions of the layout (#63): no case of a union is described when
  one of its cases is written with the name of the tag twice, for the
  fields or for one field next to an internal tag.

The counts of tests and planted defects follow the branches.

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

Non-finite floating-point values are accepted as Cosmos SQL literals despite not having valid literal syntax.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Parameterizer.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 43f5fab to 35f00c5 Compare October 12, 2026 14:57
xperiandri added a commit that referenced this pull request Oct 12, 2026
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the parameterizer (#60): a number that is not finite is no literal,
  because the text of a query has no NaN and no infinity, so such a
  number that code builds into a quotation is a slot and Query.constant
  of one is reported;
- the layout (#65): a record that writes two of its members under one
  name is not described, nor is a union whose cases share a tag, and
  next to an internal tag a case that writes the members of a record
  that the layout does not describe is taken to write the name of the
  tag twice.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a balanced review from Copilot October 13, 2026 01:27

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

Non-literal default-value expressions bypass parameter computation and leave no value available for binding.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Parameterizer.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch 2 times, most recently from 2071a8d to 04f02d7 Compare October 13, 2026 01:51
@xperiandri
xperiandri requested a balanced review from Copilot October 13, 2026 02:05

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 classification can execute user struct constructors, and the public constant documentation contradicts supported union-null behavior.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/Parameterizer.fs Outdated
Comment thread src/Cosmos.Quotations/Query.fs
xperiandri added a commit that referenced this pull request Oct 13, 2026
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri and others added 2 commits October 13, 2026 03:39
Two runs of one query differ only in the values the quotation was
written with, so a query has to be translated once, and a later run
only has to find that translation and supply the values. The
parameterizer tells the two apart for a quotation in normal form:

- the values that become parameters, each at a numbered place, a slot;
- the key, ShapeKey: every node of the quotation without those values,
  in a form that can be compared and hashed. A quotation cannot be its
  own key: it compares its variables by identity, and every evaluation
  of a quotation literal creates new ones;
- the template, for a query that has no translation yet: the quotation
  with a variable at the place of every slot.

The rules:

- a literal is a part of the key: a boolean, a character, a string, an
  integer or finite floating-point number, a value of an enumeration,
  unit and null. The ADR made literals parameters and wrote a value
  declared with Literal into the text, but the compiler writes such a
  value into a quotation exactly as it writes 18, so the two cannot be
  told apart. NaN and the infinities are no literals: a number in the
  text of a query is digits with a fraction and an exponent, so they
  are slots. A decimal is no literal here: the compiler writes 18m as a
  call that makes the decimal, so it is computed and is a slot, which
  keeps its digits. The type that a value is declared with decides, so
  a nullable is no literal, with a value or without one: a nullable
  with a value is boxed as that value, and the compiler writes neither
  as a value. The null of a union type is no literal either: it is the
  case of the union that is represented by null, as the None of an
  option is, and the compiler writes a case as a case. The default
  value of a type is the literal of that type when the type has one,
  as the zero of a number is, and data when it has none, as that of a
  date, a guid, a nullable or a union is, so it is a slot. The type
  tells, and no value is made to ask it: a struct can have a constructor
  without parameters, which a default value never runs;
- a captured value is a slot named after its variable;
- a part that reads nothing of the document, the largest one at its
  place, is computed by the evaluator and is a slot without a name;
- a tuple, record, union case or array that the quotation builds stays
  what it is, because the translator has to see that Some limit is an
  option with a value and that a list is written item by item;
- Query.constant and Query.parameter, two new public functions, say the
  opposite for their argument. A constant has to be a value that a
  literal can be: the key holds it, and equal keys have to mean one
  text, which the equality of an object does not promise.

A slot stands for one node of the quotation. The compiler writes every
use of a captured variable as a node of its own, so a variable that is
used twice is two slots with one name. Nothing in those nodes says that
they are one variable, because two spliced quotations can each have
captured a variable of that name, and their values must not say it
either: the key would depend on whether two values happen to be equal,
and one query would have two texts. A value bound by let is written at
every place it is used by the normalizer as the very same node, so it
is one slot, and its code runs once, also when two different computed
parts read it: the walk keeps what the evaluator computed for it.

A choice whose condition is known before the query runs computes only
the parts of the branch it takes, as compiled code does: in
filter.IsNone || p.Name = filter.Value.Trim () nothing is trimmed
without a filter. The parts of the other branch are slots without a
value, and the key does not depend on which branch is taken. A
condition under Query.constant decides its branch the same way.

A function value that would have to be a parameter or a constant is
reported as CapturedFunctionValue, also when it is passed as an object
and the type of the part does not say what it is. A class made of the
type of F# functions, which the class of every compiled function is
and which code can declare, is a function by its type. The null and
the default value of a function type, which only code can build into
a quotation, are reported the same way: a literal by their looks, and
no value that a query can use.

An error about a part that has no place in the source of its own, as a
captured value has none, gets the place of the nearest node around it.

The template is built only when it is asked for: building a node of a
quotation costs microseconds, and a quotation whose translation is
known needs no node built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
47 functions of a person, every one of a shape of its own, are written
twice over two sets of captured values; with the second set every
choice takes its other branch. Five laws are checked on them: a
function has one key and one hash code whatever values it captured, no
two functions have one key, the key and the values are the same with
and without the template, the template holds a variable for every slot
and no captured value, and the template with the values put back
computes what the compiled code computes. The compiler passes each
function together with its quotation, and Unquote evaluates the
template.

The other tests state what the laws cannot: which values are slots,
under which names and with which values, which default value is a
literal and which is data, also for a struct whose constructor throws
(a struct that F# cannot declare, which one helper makes at run time
for this test and for the one of the evaluator), that two nodes are two
slots whatever they
hold, what is a part of the key, that a value which is
bound once is computed once, that a quotation which is spliced into two
places is one slot, that a branch that is not taken is not computed,
what the functions of Query do, the tokens of a small key one by one,
and every error.

The check that tells which parts can be computed is tested with the
evaluator: it accepts every part the evaluator computes, refuses a
function, a variable and a quotation, and runs no code to tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-parameterizer branch from 04f02d7 to 0f9552d Compare October 13, 2026 02:53
@xperiandri
xperiandri requested a balanced review from Copilot October 13, 2026 02:53
xperiandri added a commit that referenced this pull request Oct 13, 2026
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query. The type tells that, and no value is made to ask it,
  because a struct can have a constructor that a default value never
  runs;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 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 reflection-heavy quotation transformations and dependency-stacked scope warrant final human validation despite comprehensive tests.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

xperiandri added a commit that referenced this pull request Oct 13, 2026
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query. The type tells that, and no value is made to ask it,
  because a struct can have a constructor that a default value never
  runs;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described; and options with a policy for the keys of dictionaries
  are refused, because the policy spells the property that a key is
  written as, which the layout does not say.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit that referenced this pull request Oct 13, 2026
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query. The type tells that, and no value is made to ask it,
  because a struct can have a constructor that a default value never
  runs;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described; options with a policy for the keys of dictionaries are
  refused, because the policy spells the property that a key is written
  as, which the layout does not say; and a case with two fields that a
  naming policy spells alike is not described, as the document holds one
  property for both.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
xperiandri added a commit that referenced this pull request Oct 13, 2026
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query. The type tells that, and no value is made to ask it,
  because a struct can have a constructor that a default value never
  runs;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described; options with a policy for the keys of dictionaries are
  refused, because the policy spells the property that a key is written
  as, which the layout does not say; and a case with two fields that a
  naming policy spells alike is not described, as the document holds one
  property for both.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 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