Skip to content

feat(quotations): describe how a union is written - #63

Open
xperiandri wants to merge 16 commits into
mainfrom
feat/quotations-layout-unions
Open

xperiandri wants to merge 16 commits into
mainfrom
feat/quotations-layout-unions

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

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

The sixth step of phase 1 of ADR 0001 (#34). A query that tests the case of a union and reads a field of it has to know what a value of that case looks like in the document:

<@ fun (o : Order) ->
    match o.Payment with
    | Card (number, _) -> number = wanted
    | Cash -> false @>

FSharp.SystemTextJson writes Card ("1234", 2030) and Cash in a way that its options choose:

Encoding of unions Card ("1234", 2030) Cash
the tag next to the fields (the default) {"Case":"Card","Fields":["1234",2030]} {"Case":"Cash"}
the same with named fields {"Case":"Card","Fields":{"number":"1234","year":2030}} {"Case":"Cash"}
the tag as the name of the fields {"Card":["1234",2030]} {"Cash":[]}
the tag before the fields ["Card","1234",2030] ["Cash"]
the tag among named fields {"Case":"Card","number":"1234","year":2030} {"Case":"Cash"}
no tag {"number":"1234","year":2030} {}
any of these, with a case without fields as its tag alone as above "Cash"

IJsonLayout answers one more question, about one case of a union:

type IJsonLayout =
    // from #61 and #62: MemberName, OptionalEncoding, EnumEncoding, TupleEncoding, CollectionEncoding, MapEncoding
    abstract UnionCaseEncoding : memberOfDocument : DocumentMember voption * case : UnionCaseInfo -> UnionCaseEncoding

type UnionCaseEncoding =
    | Written of mark : UnionCaseMark * fields : ImmutableArray<UnionFieldPlace>
    | NotDescribed

type UnionCaseMark =
    | Tag of path : ImmutableArray<JsonStep> * tag : UnionTag   // the value at the path is the tag
    | Property of name : string                                 // the object has a property of this name
    | Null                                                      // the value is null
    | Unmarked                                                  // nothing tells the case

type UnionFieldPlace =
    | Value of path : ImmutableArray<JsonStep>     // the value of the field is at the path
    | Members of path : ImmutableArray<JsonStep>   // the field is a record whose members are in the object at the path

type JsonStep = Property of name : string | Item of index : int
type UnionTag = String of text : string | Number of number : int | Boolean of flag : bool

The mark is what a query tests to know the case: o["Payment"]["Case"] = "Card", o["Payment"][0] = "Card", IS_DEFINED(o["Payment"]["Card"]), or o["Payment"] = "Cash" when the path has no step. A place is the path from the value of the union to the value of a field: ["Fields"][0], ["Card"]["number"], [1]. Both start at the value of the union, so they hold wherever the union is: in a member, in an item of a collection, in a field of another union. The question is asked as the ones of #62 are, with the member that holds the union when a member does.

As before, the layout asks the serializer who writes the type: the contract of the union names the converter, and FSharp.SystemTextJson has a converter class for a union in general, one for a union without any field and one for a union that is written as its one field. The options that the union is written with say the rest; they are found as the ones of a record are (#61), which is one function for both now.

What the library really does, found in its source and by writing documents, and kept as tests:

  • A null is null before anything else. The case that a union marked UseNullAsTrueValue represents by null is written as null in every encoding, also where a case without fields is its tag alone. The converter that the library has for a union without any field writes a tag for every case, but it never meets such a null: the compiler lets a union be marked so only when it has a case with fields as well (error FS1196 otherwise). The mark of such a case is Null; how a member that holds the null is written is the question of feat(quotations): describe how the serializer writes a member #61, OptionalEncoding.
  • A case without fields is its tag alone only when the encoding says so (UnwrapFieldlessTags), and then in every encoding. Without it such a case is {"Case":"Cash"}, {"Cash":[]}, ["Cash"] or {}.
  • One field in place of the fields (UnwrapSingleFieldCases): {"Case":"Circle","Fields":3} and {"Circle":3}. The tag among the fields and the encoding without tags do not do it.
  • The members of a record in place of the fields (UnwrapRecordCases): the flag holds the one of named fields, so every case of the union gets named fields with it. Next to an internal tag the object is more than the record, {"Case":"Signed","Name":"n"}, which is the place Members; everywhere else the object is the record as it is written anywhere, which is the place Value. The converter of the union writes the members of such a record itself: with its own options of the serializer, and with the options of the library that it has or that it finds for the record. So the case is described only when the union is written with the common options and the converter among the options that writes the union makes the converter of the record as well. Otherwise what the layout says about the members of the record would not be about this object: a converter that [<JsonFSharpConverter>] on the record makes by itself has no naming policy, and another converter of the library among the options has other options. (The last two found by the review.)
  • A union with one case that has one field is the value of that field (UnwrapSingleCaseUnions, on by default): StoredTicket guid is the text of the GUID. Its mark is Unmarked and the place of its field has no step. With several fields or none, the one case is written as a case of any union.
  • The tag is the first name of the JsonName attribute of the case, which can be a text, a number or a boolean ({"Case":7}, and "7" and "true" as the name of a property), then the name of its JsonPropertyName, then the name of the case spelled by UnionTagNamingPolicy. The naming policy of the serializer does not reach a tag, and a declared name is not spelled by any policy.
  • The name of a field is the name that F# gives it, Item for the one field of a case that the source does not name and Item1, Item2 for several. With UnionFieldNamesFromTypes such a field has the name of its type, and a name that the case then has twice gets a number: Int321, Int322. The case can declare another name for a field with JsonName("x", Field = "Item1"). Without one the name is spelled by UnionFieldNamingPolicy, and by the naming policy of the serializer when there is none.
  • A part of the encoding in the attribute of a type is lost. [<JsonFSharpConverter(UnionNamedFields = true, UnionTagName = "kind")>] makes options that say that they inherit the encoding, and the library then takes the whole encoding from the common options: the fields stay an array, and the name of the tag is the one of the attribute. Only an attribute that sets the whole encoding (UnionEncoding = …) keeps it. The same holds for options in the overrides that are made with JsonFSharpOptions.InheritUnionEncoding ().
  • System.Text.Json refuses a union, with NotSupportedException when it is to write a value. It writes a null without asking what it is a null of, so the null of a union does not show the refusal.
  • A type that FSharp.SystemTextJson refuses arrives wrapped. A case with two JsonName attributes is refused when the converter is made, and the library makes its converters by reflection: the serializer throws TargetInvocationException. The layout takes it for a type that is not described, as the other refusals.

Not described, because nothing shows how the union is written: a member that names a converter of its own, also when that converter is the one of FSharp.SystemTextJson (it is made with options that nothing shows); a union whose contract a resolver of the application makes; a union that a class derived from the converter of FSharp.SystemTextJson writes; an encoding with two places for the tag, which the library fails to write. An option, a value option and a skippable value that are written as their value are no unions to this question: OptionalEncoding describes them. An option that the library writes as a union (UnwrapOption off) is described as any union, with None as its null.

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

  • Two commits: the library, then the tests.
  • Tests: 126 in JsonLayoutUnionTests (FSharp.Azure.Cosmos.Quotations.Tests has 1637 now). The serializer is the oracle, and the test does not know the rules: it only reads the document.
    • 58 rows, one for every way to set the serializer up: the 31 of the layout tests and 27 that write a union differently (every place for the tag with every way to write the fields, names and naming policies of tags and fields, options for single types). Twelve union types are written, a value of every case: every way to declare a case and its fields, a union without fields with a number and a boolean for a tag, the three kinds of unions with one case, a case that is null, a struct union, private cases, a generic union, and two unions with options in an attribute. For every value the document has to hold the mark that the layout gives its case and the mark of no other case, and at the place of every field the JSON that the serializer writes for the value of the field. A union that the serializer refuses has to be not described.
    • 58 rows that ask the same about the union as the value of a member of a record: the value is looked for under the name that the layout gives the member.
    • 10 tests of what the rows cannot show: that the model is written with every kind of mark and place that there is (otherwise a layout that knows one way would pass), a part of the encoding in an attribute, a union that the library refuses, two places for the tag, a member with a converter of its own, an option as a union and as a value, a record in a case that is not written as the record is by itself, a contract that a resolver makes, a factory derived from the one of the library, and that an answer is kept.
  • Can the tests fail? 67 one-line defects are planted in the new code, one at a time, and reverted: a name read from the wrong attribute or spelled by the wrong policy, an unwrapping flag ignored or always applied, a tag at the wrong place, the fields at the wrong index, a converter not recognized, the options of a type taken for the common ones. Each fails at least one test. The 105 defects of feat(quotations): describe how the serializer writes a member #61 and the 43 of feat(quotations): describe how values that are no objects are written #62 are run against this branch too.
  • Where this departs from the sketch in the ADR (§10, §20.9):
    • the ADR has one layout for a union, a place for the tag and a way to write the fields. The library decides both for each case: a case without fields can be its tag alone next to cases that are objects, and one field or one record is unwrapped case by case. So the question is about a case;
    • the answer gives paths and no names of encodings, so the translator needs no table of its own, and a layout of another serializer can describe a union that fits none of these names;
    • a union that is written as its one field is no case of its own (ScalarWrapper in the ADR): it is the case Unmarked with a field in place of the union;
    • the encodings that a document was written with in the past (withLegacyUnionEncoding) come with phase 2, as the ADR has them.
  • Not in this step:
    • a member of a case as a member of a document: whether a field of a case that is None is left out, and how an enumeration, a tuple, a collection or a map in a field of a case is written. FSharp.SystemTextJson leaves a named field out as it does a field of a record, and never an item of an array; the next step lets OptionalEncoding and the questions of feat(quotations): describe how values that are no objects are written #62 answer for such a member;
    • type discriminators of class hierarchies and Identity, the remaining members of IJsonLayout in the ADR;
    • OverrideMembers of FSharp.SystemTextJson, which gives a case other attributes by its name: the library keeps it internal, so the layout cannot read it (feat(quotations): describe how the serializer writes a member #61 has the same limit for records).
  • 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; 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 12, 2026 05:36
xperiandri added a commit that referenced this pull request Oct 12, 2026
The result of the sixth step of phase 1 (#63): IJsonLayout answers how
a case of a union is written, as a mark that tells the case and a
place for each of its fields, both made of steps from the value of the
union. The record says where this departs from the sketch of sections
10 and 20.9, what FSharp.SystemTextJson really does, what is not
described, and how the step is tested.

The ninth review found two things. A node that a quotation comes with
at several places, a quotation that is spliced twice, was one node in
the normal form only as long as nothing was rewritten in it or around
it; the normalizer keeps it one node now and walks the quotation once
for such nodes, which is measured. And a reference or a nullable of a
member that names a converter is described as any other member: the
serializer leaves its null out, or writes it, before it asks the
converter.

The counts of tests and planted defects follow.

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

Enum-like fieldless unions can report a null mark even though the serializer writes their case tag.

1 open finding
What changed in this PR

Adds the serializer-aware quotation infrastructure needed to translate F# union operations into Cosmos DB queries.

Changes:

  • Adds quotation normalization, evaluation, parameterization, and error reporting.
  • Describes serializer layouts for members, values, and union cases.
  • Adds extensive unit tests and integrates the project into builds and documentation checks.
File Description
.github/​copilot-instructions.md Documents project standards and architecture.
.github/​workflows/​strict-documentation.yml Adds strict documentation validation.
Directory.Packages.props Adds centrally managed dependencies.
FSharp.Azure.Cosmos.slnf Adds the quotation projects.
FSharp.Azure.Cosmos.slnx Adds the quotation projects.
src/​Cosmos.Quotations/​AssemblyInfo.fs Defines assembly metadata.
src/​Cosmos.Quotations/​Cosmos.Quotations.fsproj Configures the quotation library.
src/​Cosmos.Quotations/​Internals.fs Adds shared internal helpers.
src/​Cosmos.Quotations/​TranslationError.fs Models translation failures and locations.
src/​Cosmos.Quotations/​Normalizer.fs Normalizes quotation trees.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates variable-free expressions.
src/​Cosmos.Quotations/​Query.fs Identifies query constants and parameters.
src/​Cosmos.Quotations/​Parameterizer.fs Produces query shapes, templates, and values.
src/​Cosmos.Quotations/​JsonLayout.fs Describes serializer document layouts.
tests/​Cosmos.Quotations.Tests/​Cosmos.Quotations.Tests.fsproj Configures the quotation tests.
tests/​Cosmos.Quotations.Tests/​testconfig.json Enables parallel test execution.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Defines test categories.
tests/​Cosmos.Quotations.Tests/​Model.fs Supplies quotation test models.
tests/​Cosmos.Quotations.Tests/​QuotationAssert.fs Adds quotation assertion helpers.
tests/​Cosmos.Quotations.Tests/​NormalizerSpellingTests.fs Tests normalized-expression formatting.
tests/​Cosmos.Quotations.Tests/​NormalizerTests.fs Tests normalization behavior.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization failures.
tests/​Cosmos.Quotations.Tests/​EvaluatorTests.fs Tests expression evaluation.
tests/​Cosmos.Quotations.Tests/​ParameterizerShapeTests.fs Tests query-shape identity.
tests/​Cosmos.Quotations.Tests/​ParameterizerTests.fs Tests parameter extraction and templates.
tests/​Cosmos.Quotations.Tests/​ModelMembers.fs Defines serializer member fixtures.
tests/​Cosmos.Quotations.Tests/​StoredModel.fs Defines stored-document fixtures.
tests/​Cosmos.Quotations.Tests/​StoredValues.fs Defines serialized value fixtures.
tests/​Cosmos.Quotations.Tests/​StoredUnions.fs Defines serialized union fixtures.
tests/​Cosmos.Quotations.Tests/​JsonLayoutTests.fs Tests member and optional layouts.
tests/​Cosmos.Quotations.Tests/​JsonLayoutValueTests.fs Tests non-object value layouts.
tests/​Cosmos.Quotations.Tests/​JsonLayoutUnionTests.fs Tests union-case layouts.

🧠 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/JsonLayout.fs
@xperiandri
xperiandri force-pushed the feat/quotations-layout-unions branch from ff084f4 to 9d25912 Compare October 12, 2026 06:28
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 06:28
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): a function that is applied to the document is a
  captured compiled one whenever it reads no variable of the quotation,
  however the quotation gets to it, and a quotation that is nested too
  deeply stays the error inside a construct whose own error would print
  it;
- the parameterizer (#60): the null and the default value of a function
  type are refused as a function is;
- the layout (#61): the layout asks the resolver that the serializer
  asks, and why its modifiers are not copied;
- the values of the layout (#62): the encoding of an enumeration is a
  kind of JSON value, and what stands for one value in a query is a
  requirement on the translator step;
- the unions of the layout (#63): why the converter of the unions
  without fields never writes a case that is null.

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

Unwrapped records can be described using different converter options than those used by their containing union.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
xperiandri and others added 15 commits October 12, 2026 08:38
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,
  however the quotation gets to it: by its name, from a property or a
  field of a captured value, or as what a call returns. A function
  that the document chooses, as the result of a choice does, is
  reported as one that is chosen when the query runs. 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. A quotation that is nested too deeply
stays the error whatever stands around it: the error of a construct
shows the construct, and printing something that deep needs the stack
that has run out.

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. A node that the
quotation comes with at several places is kept one node in the same way:
a quotation that is spliced into two places is the same nodes at both,
and code can build a tree with one node twice. The quotation is walked
once for such nodes before it is normalized, and not to its end when it
is nested too deeply, which the normalization then reports.

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, and so does a quotation that is spliced into two 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>
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>
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 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.
  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;
- 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, 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>
A query reads a member of a document under the name the serializer
wrote it with, and a query that asks whether a member has a value has
to know what a member without one looks like: null, or not there at
all. IJsonLayout answers both, and JsonLayout.ofSerializerOptions is
the layout of System.Text.Json, with or without the converter of
FSharp.SystemTextJson among its options.

The layout asks the serializer who writes a type: the contract that
the options resolve for a type names the converter. So the order of
the converters, the kinds of types FSharp.SystemTextJson is set to
handle and a converter that a type names in an attribute count as they
do when a document is written, and nothing is told to the layout by
hand.

The layout is asked about a member together with the type of the value
that the member is read from, a DocumentMember. The member alone does
not tell that type: a quotation names an inherited property as its
declaring type has it, also when the property is read from a value of
a derived type, and the serializer can write the two types in
different ways. In the contract of that type the layout looks for the
entry of the member itself, and when there is none, for the entry of
the property that overrides it. What the serializer reads from a
member, the layout reads from the one the entry was made of.

- A type that System.Text.Json writes as an object: the contract lists
  the properties with their names, so the naming policy, declared names,
  included fields and an override next to the declaration it overrides
  are what the serializer makes of them. A member without a value
  follows its own JsonIgnore condition, then the default condition of
  the options. A value option and a skippable value are structs, so
  "when null" never leaves them out and "when default" does. A read-only
  member is never written when the options ignore read-only members,
  which the contract does not show: the property keeps its getter and
  gets no rule. The rule of the serializer is applied, with its
  exceptions for a member that has a rule, from a JsonIgnore condition
  of its own or from code, and for a collection. A member is a
  collection by the converter that writes it, which can be one that the
  member names: a converter that writes the items as something else
  makes it no collection, and a factory that hands the type back to the
  converter of the options leaves it one. One function gives the
  contract of such a member. It has the factory make the converter with
  the options first: a factory that asks the options it is given for a
  converter would be asked without end by options that hold the factory
  itself. The contract is asked by the rules of the serializer and not
  of the resolver that the options have: the serializer writes the
  member with its converter whatever contract a resolver of the
  application makes for the type. One case cannot be told: code that
  sets the rule of a member to null has set a rule to the serializer,
  which then writes the read-only member, and the contract shows the
  same null as for a member that nothing touched. Such a member is taken
  to be left out. The extension data has no name and no absence that the
  layout describes: its entries are properties of the document itself,
  and nothing is written for it when it is null.
- A record that FSharp.SystemTextJson writes: the rules of that library
  are followed, read from its source and compared with what it writes. A
  field is named by JsonName, then by JsonPropertyName, then by the
  policy. A field with JsonIgnore is never written, whatever its
  condition says. An option without a value is left out when every
  option is skippable, or when the options ignore null and the setting
  follows them; a value option only in the first case; a skipped value
  always. A reference that is null is left out when null is ignored, but
  not a record, a union or a tuple of F#, which are written even then. A
  union with a case that is null is left out again, because that null is
  a value to the library, and so is a union that is written as the one
  value it wraps where that value can be null: an option, a value option
  that is written as its value, or any type where the options let every
  field be null. The rule is the one of the function of the library that
  finds the value it reads from a null. The options of a record type
  come from the overrides, then from the attribute on the type when that
  is allowed, then from the converter that takes the type: the first one
  among the options that does, since the options can hold several
  converters for different kinds of types. Only the factory of
  FSharp.SystemTextJson itself shows them: a class that an application
  derives from it can make the converter of a record with other options,
  so its records are not described. A converter of records that is added
  to the options by itself keeps its options to itself, so its record is
  not described, and neither is a record whose converter an attribute of
  the application makes: without a converter of the options that takes
  it, the options of a record are read only from the attribute of
  FSharp.SystemTextJson, which shows them. All of this holds for a
  contract that the serializer made itself, which the contract tells: a
  resolver of the application can make the contract of a record from any
  converter, also from the one of FSharp.SystemTextJson with options of
  its own, and such a record is not described. Of the properties that
  are no fields the library looks at the public ones only, so another
  one is not written, whatever the options or its JsonInclude say.
- A member that is never written and declares a name has that name: it
  stands for a property that something else writes, such as a type
  discriminator. Without a value it is null or left out, as whatever
  writes the property decides. That holds for a member of an object and
  for a member of a record that FSharp.SystemTextJson does not write,
  because it is marked to be ignored or is a property that is not
  included.

What a member declares is its own: an override has neither the name
nor the JsonIgnore condition of the declaration it overrides.

What is written for a value without a value where it is not left out
is asked of the serializer: it is given a null, or the value option or
the skippable value that has none, to write with the contract of the
type or of the converter that a member names. The serializer writes
null itself unless the converter asks for the null with HandleNull.
Such a converter writes what it likes, and the ones of
FSharp.SystemTextJson for tuples, for maps and for unions without
fields ask for it and fail on it. Only null is described: whatever
else comes out, or an exception, means that the layout does not
describe the value without one. A type that the serializer has no
contract for is not described either: the serializer writes no
document with a member of such a type.

The obsolete setting IgnoreNullValues counts as the default condition
"when null", for an object and for a record: both serializers still
follow it, and neither the condition of the options nor the contract
shows it.

The layout keeps a copy of the options: a contract can be asked only
from options that have a resolver and do not change any more, and the
options of the caller are not to be touched. The copy asks the resolver
of the caller, which cannot be copied, since it can be any object. So
the layout and the serializer ask one resolver, and the one of
System.Text.Json takes no change after either has asked it: the two
cannot come to disagree unnoticed. What the layout finds out is kept,
and every question is answered once, also when several threads ask it at
a time: finding an answer asks the serializer, which can run code of the
application.

An option that FSharp.SystemTextJson writes as a union with the name of
its case and a type that another converter writes are reported as not
described. A member can name a converter of its own. The serializer
leaves a reference or a nullable of such a member out, or writes it as
null, before it asks that converter, so the member is described as any
other, unless the converter asks for null itself. An option, a value
option or a skippable value of such a member is not described: the
converter writes all of it.

FSharp.SystemTextJson 1.4.36 becomes a dependency of the Quotations
assembly, as ADR 0001 decided.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle. Nine types of a model made for the
layout are written with twenty-five ways to set the serializer up, once
with every optional member present and once with every one absent.
Each member is looked for in the document by a value that no other
member holds. The layout has to give the name it is found under, and
for a member without a value what the second document shows: null,
nothing, or a form the layout does not describe. The class of the
model has a read-only collection for each thing a converter of the
member can do with it: write it as one text, and hand it back to the
converter of the options.

A null of every type of F# that the library writes in a way of its
own, leaves out or fails to write is asked about by itself, in a field
of a record and in a property of a class, with nine ways to set the
library up: a tuple, the maps, a union without fields, a record, a
list, a set, and a union of one case around every kind of value. The
layout has to say what the serializer does: null where it writes null,
left out where the member is missing, and nothing where it fails.

The other tests state what a document cannot: the name of a member that
is never written, also when it is left out for being read-only, an
override next to the declaration it overrides, a member that code
decides about, a member with a converter of its own, a type that another
converter writes or that the serializer refuses, a member that is read
from a derived class, inherited or overridden, as a quotation names it,
a record that a factory made of the one of FSharp.SystemTextJson writes
itself or with options of its own, a record that names a converter in an
attribute of its type, a record whose converter was added to the options
by itself or is made by an attribute of the application, a record whose
contract a resolver of the application makes, a member with a converter
of its own whose type has a contract of such a resolver, a member of a
union, a member of a record that is not written, the extension data, a
read-only member with a rule that code has set, a value whose converter
writes null itself, a skippable value that System.Text.Json alone would
write, a null of a type that the serializer has no contract for, the
encoding of unions that the options of a record inherit, a value that
is no member, that a question is
answered once, also for several threads that ask it at a time, that
the options of the caller keep their resolver and stay as they are, and
that the resolver takes no change once the layout has asked it. F#
declares no public field that is read-only, so the test of such a field
makes its type at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests of the JSON layout compare what the layout says of every
member of a model type with a document that the serializer wrote. The
functions that list those members, read their values, give their types
and take an optional value off its outer layer move from the test
class into a module, ModelMembers, because the tests of the next step
walk a model the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The converter factory that counts how often it makes a converter kept
one count for all its uses, and tests run at a time: two tests that
count would each see the converters of the other. The factory takes a
type argument now and keeps a count for each, so a test that counts
names a type that no other test names. The tests of the next step
count as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query compares an enumeration with a number or with a name, reads an
item of a tuple at an index or under a name, and looks a key up in a
JSON object or in an array of pairs, depending on how the serializer
wrote the value. IJsonLayout answers four more questions, each about a
type and the member that holds the value, when a member does:

- EnumEncoding: a number or a string. System.Text.Json has one converter
  for both, which keeps to itself what it writes, so the layout has the
  serializer write every value of the enumeration that has a name and
  looks at what comes out: a number for each of them, or a string for
  each. That describes a converter of the application as well; one that
  writes some values as names and others as numbers is not described,
  and neither is one that refuses a value, with either of the two
  exceptions that the serializer has for that.
- TupleEncoding: an array, as FSharp.SystemTextJson writes both kinds
  of tuple, or an object with a property for every item that is
  written, as System.Text.Json does. MemberName names those
  properties, and has no name for the items of a struct tuple unless
  the options include fields.
- CollectionEncoding: an array, with the type of an item, for what
  System.Text.Json writes item by item and for the lists and sets of
  FSharp.SystemTextJson. An array of bytes is one string and is not
  described as an array.
- MapEncoding: an object, or an array of pairs of a key and a value,
  with the types of both.

A member can name a converter of its own. System.Text.Json follows it,
also for a field of a record that it writes as an object, and
FSharp.SystemTextJson does not read it. Such a converter writes the
whole value of the member, so the type of the member, or the type that
the member is a nullable of, is described by it, and a value under an
option that such a converter writes is not described. Without such a
converter the value under an option is described as any value of its
type: OptionalEncoding says whether the option is written as that
value.

A member that the serializer never writes is not described: something
else writes its property, in a way of its own. Neither is a value that
a converter of the application writes, except for an enumeration.

The layout keeps every answer, with the function that keeps what it
finds out about names and missing values.

Where this departs from the sketch of ADR 0001, section 10: the
questions about an enumeration and a map take the member too, because
a member can name a converter; an answer that cannot be given is the
case NotDescribed and no missing value, as for OptionalEncoding; a
struct tuple that System.Text.Json writes as an empty object is an
object whose items have no names; and whether an application stores an
empty collection as null is no fact of the serializer, so the layout
does not say it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle, as for the names: the kind of JSON value
that a document holds for a member says what the layout has to say. A
number or a string for an enumeration, an array or an object for a
tuple, an array for a collection, an object or an array for a map, and
nothing at all for a member that was not written.

Five types of a model made for these values are written with every
way to set the serializer up: a class and a record with a member for
every kind of value, a class whose members name a converter that
writes text, a class whose members name a converter factory that hands
them back to the options, and a class whose members are only read. The
value under an option or a nullable is asked about as well, where the
member is written as that value. The same values are asked about
without a member, as an item of a collection is.

Six ways to set the serializer up join the ones of the layout tests,
which run with them too: enumerations by name with and without
FSharp.SystemTextJson, every map as an object, every map as an array
of pairs, FSharp.SystemTextJson without its converters of tuples, and
without its converters of lists, sets and maps. The set-up of the
application writes enumerations by name now.

The other tests state what a document cannot: a type of another kind, an
enumeration without a named value, a converter that refuses the value, a
converter that writes one value as a name and another as a number, a
nullable asked by the type under it, a part of a value that a converter
of the member writes, a converter of the options, a member of a type
that another converter writes, a value whose member names a converter
while a resolver of the application makes the contract of its type, a
value whose member is read from a class that inherits it, the extension
data, that an answer of every kind is kept, and that the model holds
every kind of value in its documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query that tests the case of a union and reads a field of the case
has to know what a value of that case looks like in a document.
FSharp.SystemTextJson writes a union in many ways: the tag next to the
fields, as the name of the fields, before or among the fields, or no
tag at all, the fields as an array or under names, and a case without
fields, a case with one field or a case with a record in ways of their
own. IJsonLayout answers one more question, about a case and the
member that holds the union, when a member does:

- UnionCaseEncoding: the mark of the case and the place of each of its
  fields. The mark is what tells that a value is of the case: a tag at
  the end of some steps from the value of the union, a property that
  only values of the case have, null, or nothing. A place is the steps
  from the value of the union to the value of a field. A record that a
  case unwraps next to an internal tag has no value of its own, so its
  place says where its members are.

The contract of the union names the converter, and the library has one
class for a union in general, one for a union without any field and
one for a union that is written as its one field. The options that the
union is written with say the rest. They are found as the ones of a
record are, which is one function for both now: the overrides, then
the attribute on the type when that is allowed, then the converter
among the options that takes the type. Options that say that they
inherit the encoding get the whole encoding of the common ones, as the
library does it.

The rules are the ones of the library, read from its source and compared
with what it writes. A value that is null is written as null before
anything else. A case without fields is its tag alone when the encoding
unwraps such cases, in every encoding. One field stands in place of the
fields only where the tag is next to the fields or names them. The flag
that unwraps a record holds the one of named fields, and the converter
of the union writes the members of the record itself: with its own
options of the serializer, and with the options of the library that it
has or that it finds for the record. So such a case is described only
when the union is written with the common options and the converter
among the options that writes the union makes the converter of the
record as well. A converter that an attribute of the record makes by
itself has no naming policy, and another converter of the library among
the options has other options; then what the layout says of the record
is not about the record in the case. A tag is the first name that the
case declares, a text, a number or a boolean, or the name of the case as
the policy of the tags spells it. A field has the name that F# gives it,
the name of its type when the encoding says so, or the name that the
case declares for it, spelled by the policy of the fields and then by
the one of the serializer.

Not described: a union of a member that names a converter, a union
whose contract a resolver of the application makes, a union that a
class derived from the converter of the library writes, an encoding
with two places for the tag, and an option, a value option or a
skippable value that is written as its value, which OptionalEncoding
describes. FSharp.SystemTextJson makes its converters by reflection,
so a type that one of them refuses arrives as a
TargetInvocationException, which counts as a refusal now.

Where this departs from the sketch of ADR 0001, sections 10 and 20.9:
the question is about a case and not about a union, because the
library decides case by case; the answer is made of steps and not of
names of encodings; and a union that is written as its one field is a
case without a mark whose field is in place of the union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle, and the tests do not know its rules. A
value of every case of twelve union types is written with every way to
set the serializer up. The document has to hold the mark that the
layout gives the case and the mark of no other case, and at the place
of every field the JSON that the serializer writes for the value of
the field. A union that the serializer refuses has to be not
described. The same is asked about each union as the value of a member
of a record.

Twenty-seven ways to set the serializer up join the ones of the layout
tests for these rows: every place for the tag with every way to write
the fields, names and naming policies for tags and fields, and options
for single types, in the overrides and as ones that inherit the
encoding.

The other tests state what the rows cannot: that the model is written
with every kind of mark and place, a part of the encoding in an
attribute, a union that the library refuses, an encoding with two
places for the tag, a member with a converter of its own, an option as
a union and as a value, a record in a case that is not written as the
record is by itself, a contract that a resolver makes, a factory
derived from the one of the library, and that an answer is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-layout-unions branch from 9d25912 to e2b4aa0 Compare October 12, 2026 08:03
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 08:03
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): the null of a union type is a case of the
  union, which the compiler writes as a case, so as a value it is data
  and no null literal;
- the layout (#61): what is written for a null is asked of the
  serializer, because three converters of FSharp.SystemTextJson ask
  for the null and fail on it; a null in a field of a record is left
  out by the rule of the library, which a union that is written as the
  value it wraps follows too; and a type without a contract is not
  described;
- the values of the layout (#62): a converter of an enumeration that
  refuses a value with either exception leaves it not described;
- the unions of the layout (#63): a record that a case unwraps is
  described only when the converter of the union makes the converter
  of the record.

The counts of tests and planted defects follow the branches.

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