Repository navigation
feat(quotations): bring a quotation into its normal form - #58
xperiandri wants to merge 3 commits into
Conversation
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>
#58 is the first stage of the translator: it brings a quotation into its normal form. The record states where the implementation departs from the design and why: one walk instead of rounds, with recursion found exactly and two limits; substitution with care for a variable that is bound at two nested places, which Expr.Substitute gets wrong; the mutable copy of a struct that the compiler binds; what stays although it has a reflected definition; and the passes that wait for the registry. It also records what the stage costs and where the time goes in FSharp.Core, with the proposal that follows for the plan cache, and how the tests are built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The extensive quotation transformer covers substitution, reflected-definition inlining, recursion limits, and compiler-generated forms that warrant final human validation.
0 open findings
What changed in this PR
Introduces the first quotation-translation stage: normalization into a canonical form with structured translation errors.
Changes:
- Adds the internal quotation normalizer and public error contract.
- Adds extensive emulator-free normalization tests.
- Registers the new projects and updates repository guidance.
| 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 projects to the solution. |
src/Cosmos.Quotations/AssemblyInfo.fs |
Defines assembly metadata. |
src/Cosmos.Quotations/FSharp.Azure.Cosmos.Quotations.fsproj |
Configures the non-packable library. |
src/Cosmos.Quotations/InternalsVisibleTo.fs |
Exposes internals to tests. |
src/Cosmos.Quotations/Normalizer.fs |
Implements quotation normalization. |
src/Cosmos.Quotations/TranslationError.fs |
Defines translation errors and source ranges. |
tests/Cosmos.Quotations.Tests/FSharp.Azure.Cosmos.Quotations.Tests.fsproj |
Configures the test project. |
tests/Cosmos.Quotations.Tests/Model.fs |
Supplies test quotation models. |
tests/Cosmos.Quotations.Tests/NormalizerErrorTests.fs |
Tests normalization failures. |
tests/Cosmos.Quotations.Tests/NormalizerSpellingTests.fs |
Tests equivalent quotation spellings. |
tests/Cosmos.Quotations.Tests/NormalizerTests.fs |
Tests normalization invariants and edge cases. |
tests/Cosmos.Quotations.Tests/QuotationAssert.fs |
Adds quotation assertion utilities. |
tests/Cosmos.Quotations.Tests/TestCategories.fs |
Defines the normalizer test category. |
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.
b4e9187 to
289fdd6
Compare
289fdd6 to
c35e6c5
Compare
c35e6c5 to
e998031
Compare
What the reviews of #58 to #60 on the evening of 2026-10-11 changed: - the normalizer inlines an inline member of a type, whose definition the compiler keeps for the member itself and not for its form with witnesses, and applies a definition only to arguments it takes; - the evaluator computes a node once, for one part and for a caller that keeps what was computed; - the parameterizer tells a function by a class made of the type of functions too, and Query.constant takes only a value that a literal can be. The counts of tests and of planted defects follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e998031 to
2ed58b2
Compare
The normalizer reports the outermost construct that a query cannot express also when the rewriting fails inside it, and an inner binding of a variable hides an assignment from the outer one (#58). The layout reads the obsolete setting that ignores null, which both serializers still follow (#61). The counts of tests and planted defects follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2ed58b2 to
cc18c2c
Compare
Every call of a function with a reflected definition gets nodes of its own (#58). The type that a value is declared with decides whether it is a literal, so a nullable and an option are none (#60). In the layout an override has no condition of the declaration it overrides, a property of a record that is not public is not written, and a value whose converter writes null itself is not described (#61). The counts of tests and planted defects follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cc18c2c to
9ac526f
Compare
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>
9ac526f to
d3622e1
Compare
| let private lookUpReflectedDefinition = | ||
| Func<MethodBase, Expr voption>(fun method -> | ||
| Expr.TryGetReflectedDefinition method | ||
| |> ValueOption.ofOption | ||
| ) |
There was a problem hiding this comment.
Not changed. The guidance is about unwrapping: an option that an API hands over is unwrapped with Option.defaultValue directly, and not turned into a value option first only to call ValueOption.defaultValue. Here nothing is unwrapped. The answer is kept, in a cache that every later lookup reads, and a value that is kept is a value option by the same guidance. So one conversion at the border is needed, and ValueOption.ofOption is that conversion; a match that maps Some to ValueSome and None to ValueNone is the same function written out.
I leave the thread open for the maintainer, whose rule it is.
A call of a function that calls itself stays a call and is an error only when it is left in the normal form and reads the document, so code that never runs may hold one however the constant gets to its condition (#58). The layout is asked about a member together with the type it is read from, because a quotation names an inherited property as its declaring type has it, and a union with a case that is null is left out where a null is left out (#61). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Proposed Changes
The first step of phase 1 of ADR 0001 (#34): the first stage of the translator of F# quotations into Cosmos DB queries. It needs nothing from the open pull requests of phase 0, so the branch is built on
mainand holds only its own three commits.The problem. One condition can be written in F# in many ways:
They mean the same and have to become the same query. If every later stage (the one that turns captured values into parameters, the one that writes the query text) had to know every way of writing, each would know them a little differently.
What this adds.
Normalizer.normalize : Expr -> Result<Expr, TranslationError>rewrites a quotation into its normal form: the one form that every way of writing a condition has. Only operators, members of the document and calls whose code cannot be read are left in it. All five quotations above become the first one.x |> f,f <| x,(a, b) ||> f,f <|| (a, b)and the pipes of three valuesf x,f a bf >> g,g << ffun x -> g (f x), with the parameter name offwhenfis written as a function(fun x -> body) value,let x = value in bodybodywithvalueat the places ofx: the very same node at every place, so that the next stage can tell that the places are one value and computes it once, as the code does[<ReflectedDefinition>]fst (a, b),{ Low = a; High = b }.Low,{| A = a |}.A,match Some a with Some x -> …aflag = true,flag <> false,not (not flag),if flag then true else false,flag && trueflagif true then a else b,false && ba,false. The branch that the constant does not choose is not read at all: code that never runs needs no normal formmatchlooks at, the parts of a tuple pattern, the copy of a struct before a member of it is read, the hidden arguments of+and other operators with a member constraintWhat it reports instead of rewriting. A
TranslationErrorwith a code, an English message, hints on what to write instead, the offending part of the quotation as text and, when the code is compiled with--quotations-debug, the place in the source:let isVip = fun p -> … in <@ fun p -> isVip p @>CapturedFunctionValue, with the hints to mark the function[<ReflectedDefinition>]or to splice a quotation[<ReflectedDefinition>]that calls itself, directly or through others, when the call reads a value of the documentNormalizationDiverged, naming the function or the propertyNormalizationDivergedafter 10 000 steps, or earlier when the stack of the thread runs lowtry,let rec, the address of a valueUnsupported, naming the constructOf the constructs that stay in the normal form, the outermost one is reported. An assignment, a sequence of statements, a loop,
tryandlet recare errors whatever is inside them, so the outermost of these is reported also when the quotation inside it has no normal form at all: a loop whose condition calls a function that calls itself is reported as the loop. (Found by the review: the function was reported, because the parts of a node are rewritten before the node is looked at.)Only what stays is an error. A call of a function that calls itself is not replaced by its definition, which would never end, so it stays a call, and what becomes of it is decided when the whole quotation is normalized:
p.Age > factorial 5, it is a value to compute outside the query, as a call of any compiled function is;let, passed to a function or to a function with a definition,isActiveOrCounted true p.(Found by the review: such a call was an error at the moment it was met, so it was one also in a branch that a constant passed as an argument does not choose, while the same branch behind a constant written in the condition was not read at all.)
New projects
src/Cosmos.Quotationsis the assemblyFSharp.Azure.Cosmos.Quotations. It holds the public error contract (TranslationError,TranslationErrorCode,SourceRange) and the internal normalizer. No package is made of it until the stages produce a query end to end (IsPackable=false).tests/Cosmos.Quotations.Testsholds its unit tests; none needs the emulator.Types of changes
A new assembly that is not packed yet; the packages are unchanged.
Checklist
Further comments
NormalizerSpellingTests: 84 pairs of one way to write a function and the plain way to write the same function. Three things are checked for every pair: both have one normal form; the normal form is returned as the very 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: the compiler passes both to a parameter marked[<ReflectedDefinition(true)>]. Unquote evaluates the quotation.NormalizerTests: what a pair cannot state. What must stay as it is (a captured boolean, a module-level value, also one that is a function of the unit value, a call without a definition, a member that a derived class can override), what must be gone (the hidden arguments of operators, the address of a struct record, the copies of structs), how one variable that is bound at two nested places is treated, under a function and under a delegate, and that a value which is bound once stays one node at all its places: after a secondlet, after a variable inside it is replaced, and after a later rewrite changes it. A value that a function with a definition binds is one node in a call and another node in another call of the function (found by the review: the normal form of a definition was kept for the next call, so two calls shared the node of a value that each of them computes for itself; now every call after the first normalizes a copy of the definition). And that a call of an inline member which is built through the API without its witnesses stays a call.NormalizerErrorTests: every error, with its code, what its message names and its place in the source. And which of several errors is reported: the outermost construct that a query cannot express, and the binding that is assigned to when an outer binding has the same variable (found by the review: the outer one was reported, which nothing assigns to). For a function that calls itself: that its call is shown as the quotation has it, that the first of two is reported, that a property is named as a property, and that a call which reads nothing of the document stays as it is written.Expr.Substitute. It is used where it is safe, which is nearly always. One variable can be bound at two places, one inside the other, because a quotation that is spliced twice arrives as the same nodes, variables included; thereExpr.Substitutereplaces the variable inside the inner binding too, which is wrong, so those cases are substituted by a function of this assembly. So is a body that holds a value which was bound once and is used at several places (review of feat(quotations): tell the values of a quotation from its shape #60). Such a value has to stay one node, andExpr.Substitutebuilds every node of the body anew: with twolets, writing the second value into the body made two nodes of the first, and the next stage would have computed it twice. The nodes that stand at several places are remembered for one normalization; one of them is rewritten and normalized once for all its places. A quotation without such a value pays nothing for it, one with twolets about a seventh more.invoice.Note.IsSome, whereNoteis avoptionproperty of a class, the compiler binds the value to a mutable variable that nothing assigns to. Such a variable is written into its uses like an immutable one; without that, a document that is a class could not be queried by avoptionproperty.inlinefunction that is marked[<ReflectedDefinition>]is inlined here: the compiler keeps a second definition of it that takes its operators as arguments. One that is not marked stays a call, and the stage that translates calls will report it. For an inline member of a type, static or not, the compiler keeps that definition for the member itself and not for a second form of it; it takes the operators first all the same, then the receiver, then the arguments (found by the review: such a call raisedArgumentExceptionout of the normalizer, because the receiver was given to the definition in the place of the operator). A definition is applied only to arguments of the types it takes, so a call that is built through the API without the operators stays a call.etaExpand), delegates,sprintfand interpolated strings, and never inlining a member that has a translation of its own.Call (None, op_GreaterThan, …)). Printing it as F# source is a later step; the contract does not change with it.Expr.TryGetReflectedDefinitioncall: 16 µs for a method that has one, 1 µs for one that has none. The normalizer keeps the answers for the process.Expr.PropertyGet,ExprShape.RebuildShapeCombination) costs 4 µs: FSharp.Core asks reflection whether the declaring type is a union, every time.Expr.Substitutebuilds nodes without that check and takes 0.2 µs, which is the other reason it is used where it is safe.[<ReflectedDefinition>]on an instance member of a struct (FS1220), so there are no such definitions to inline.TestDataRow<struct (Expr * string)>into the two parameters of a plain test method. The ADR had this as an open check.dotnet build FSharp.Azure.Cosmos.slnxin Debug and Release with the warningsmainhas and none in the two new projects, also with-p:WarnOn=3390; the tests pass in both configurations; Fantomas clean; the strict documentation build passes; everycrefof the new library files names a member that a documentation file declares; each of the three commits builds, and the last one passes the tests.🤖 Generated with Claude Code