Skip to content

feat(quotations): describe how values that are no objects are written - #62

Draft
xperiandri wants to merge 14 commits into
mainfrom
feat/quotations-layout-values
Draft

xperiandri wants to merge 14 commits into
mainfrom
feat/quotations-layout-values

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

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

The fifth step of phase 1 of ADR 0001 (#34). #61 told a query the name of a member and what a member without a value looks like. The query also has to know how the value itself was written when it is no JSON object by itself:

<@ fun (p : Person) -> p.Status = Status.Active && p.Tags |> List.contains "vip" && fst p.Range > 5 @>
  • p.Status = Status.Active compares the property with 1 or with "Active", and p.Status > Status.New has a meaning only for a number;
  • p.Tags |> List.contains "vip" is ARRAY_CONTAINS(p["tags"], "vip") only when the list is a JSON array;
  • fst p.Range is p["range"][0] or p["range"]["Item1"];
  • a key of p.Scores is a property of a JSON object or the first item of a pair in an array.

Each of these is written in more than one way, depending on the options of the serializer and on the converter a member names. IJsonLayout answers four more questions:

type IJsonLayout =
    // from #61: MemberName, OptionalEncoding
    abstract EnumEncoding : memberOfDocument : DocumentMember voption * enumType : Type -> EnumEncoding
    abstract TupleEncoding : memberOfDocument : DocumentMember voption * tupleType : Type -> TupleEncoding
    abstract CollectionEncoding : memberOfDocument : DocumentMember voption * collectionType : Type -> CollectionEncoding
    abstract MapEncoding : memberOfDocument : DocumentMember voption * mapType : Type -> MapEncoding

type EnumEncoding = Number | String | NotDescribed
type TupleEncoding = Array | Object | NotDescribed
type CollectionEncoding = Array of element : Type | NotDescribed
type MapEncoding = Object of key : Type * value : Type | ArrayOfPairs of key : Type * value : Type | NotDescribed

Each question is about a type and the member that holds the value, when a member does; an item of a collection has none. The member comes as the DocumentMember of #61: the member with the type it is read from. NotDescribed means that the layout does not know, and the translator will refuse the query instead of guessing.

Value System.Text.Json writes FSharp.SystemTextJson writes
enumeration a number; the name with JsonStringEnumConverter in the options or on the member nothing of its own: enumerations stay with System.Text.Json
reference tuple an object, {"Item1":1,"Item2":"a"}; from the eighth item on in Rest an array, [1,"a"], also for eight items and more
struct tuple {}, and the same object as above when the options include fields an array
array, ResizeArray, HashSet, ImmutableArray, seq an array nothing of its own
F# list, F# set an array an array
byte array one Base64 string nothing of its own
F# map an object, also when the keys are no strings: {"1":"a"} an object when the keys are strings and an array of pairs otherwise, [[1,"a"]]; MapFormat sets one form for every map
Dictionary, IReadOnlyDictionary an object nothing of its own

As in #61, the layout does not repeat this table in code. It asks the serializer for its contract of the type, which is what JsonSerializerOptions.GetTypeInfo returns: the description the serializer itself writes the type from. The contract says whether the type is written item by item (Kind = Enumerable, with the type of an item), as a dictionary, or as an object, and it names the converter, which is how the converters of FSharp.SystemTextJson for tuples, lists, sets and maps are told.

What the serializers really do, found by writing documents and kept as tests:

  • Nothing shows how an enumeration is written. System.Text.Json has one converter class for numbers and for names, and what an instance writes is private to it. 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. This also describes an enumeration that a converter of the application writes, unless that converter writes some values as names and others as numbers: such an enumeration is written in no one way, and is not described. (Found by the review: the layout wrote one value, and a converter of the application can write the next one differently.)
  • A value without a name is a number even where names are written. With JsonStringEnumConverter, enum<Colour> 7 is 7, and a combination of flags of which one has no name is a number as well. EnumEncoding.String says so in its documentation; a converter that was made to refuse such values throws instead, and an enumeration without any named value is not described then.
  • A member can name a converter, and the two libraries differ about it. System.Text.Json follows [<JsonConverter(typeof<JsonStringEnumConverter>)>] on a property of a class and on a field of a record that it writes as an object. FSharp.SystemTextJson does not read it on a member of a record. A converter on the member wins over one in the options: JsonNumberEnumConverter on a member gives a number where the options write names.
  • A converter of a member writes the whole value of the member. On a nullable the serializer has it write the value under the nullable, so the layout describes that value by it. On an option it writes the option, and nothing shows what it writes for the value in it: such a value is not described. Without a converter of the member, the value under an option is described as any value of its type, and OptionalEncoding says whether the option is written as that value.
  • A struct tuple is {} for System.Text.Json, because its items are fields. It is described as an object whose items have no names; MemberName names them when the options include fields. The translator will refuse to read an item that has no name.
  • A member that the serializer never writes is not described. Something else writes its property, in a way of its own. This is the same member that has a name only when it declares one (feat(quotations): describe how the serializer writes a member #61).
  • A converter factory of a member can hand the type back to the options, with options.GetConverter, to add something around the converter they have. The contract of such a member is asked from options that hold what the factory made, not the factory: options that hold the factory would ask it without end. (This part is in feat(quotations): describe how the serializer writes a member #61, which needs it for read-only collections.)

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

  • Four commits. The first moves the functions that walk the members of a model type out of the test class of feat(quotations): describe how the serializer writes a member #61 into a module, because the new tests walk a model the same way. The second gives the converter factory that counts its work in the tests of feat(quotations): describe how the serializer writes a member #61 a type argument, so that it counts for each test by itself: a test of this step counts too, and tests run at a time. The third is the library, the fourth the tests.
  • Tests: 200 in JsonLayoutValueTests (FSharp.Azure.Cosmos.Quotations.Tests has 1522 now). The serializer is the oracle again: the kind of JSON value that a document holds for a member says what the layout has to say.
    • 155 rows: five types of a model made for these values, each written with every way to set the serializer up, 31 in all. The types are 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. For every member the layout has to say what the kind of its JSON value shows, and nothing of a member that the document does not hold. The value under an option or a nullable is asked about too, where the member is written as that value.
    • 31 rows for values that no member holds, as an item of a collection is asked.
    • 14 tests of what a document cannot show: a type of another kind, an enumeration without named values, 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 converter of tuples that is derived from the one of FSharp.SystemTextJson, 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 (feat(quotations): describe how the serializer writes a member #61 has the rule), a value whose member is read from a class that inherits it, the extension data, that an answer of every kind is kept (the layout keeps its answers with the function of feat(quotations): describe how the serializer writes a member #61, which answers a question once), and that the model really holds every kind of value in its documents.
  • Six more ways to set the serializer up, which the tests of feat(quotations): describe how the serializer writes a member #61 run with as well (51 more rows there): 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, as the application does.
  • Can the tests fail? 44 one-line defects are planted in the new code, one at a time, and reverted: a converter not recognized or taken for another one, a number taken for a string, the converter of a member not seen or put after the ones of the options, a member that is not written described, the types of a key and a value swapped, only the first value of an enumeration written, an enumeration that is written in two ways described by one of them, an answer of one kind not kept. Each fails at least one test. The 105 defects of feat(quotations): describe how the serializer writes a member #61 are run against this branch too.
  • Where this departs from the sketch in the ADR (§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 in feat(quotations): describe how the serializer writes a member #61;
    • a struct tuple that System.Text.Json writes as {} is an object whose items have no names, and not "not serialized": the translator finds that out from MemberName, as for any member;
    • whether an application stores an empty collection as null or leaves it out is no fact of the serializer, so the layout does not say it. The ADR has a translator option for it (EmptyCollections).
  • Not in this step:
    • what is written for one value of an enumeration. EnumEncoding is the kind of JSON value, a number or a string, which decides what a query may do with the member: compare by order and test bits only on a number. The JSON of one value cannot be told from it: a naming policy can spell the name ("darkBlue"), an attribute can declare one, and a converter of the application writes what it likes. So what stands for such a value in a query has to come from the serializer, written with the contract of the member that it is compared with, since a member can name a converter. That is the step that turns values into parameters and literals, the translator; the parameterizer (feat(quotations): tell the values of a quotation from its shape #60) hands it the value together with its declared type. (Raised by the review; the documentation of EnumEncoding says now what the encoding is not.)
    • unions, type discriminators and Identity, the remaining members of IJsonLayout in the ADR;
    • how a key of a map is spelled as a property name (DictionaryKeyPolicy, a key that is no string), which comes with the translation of a lookup;
    • what a converter of the application writes for a tuple, a collection or a map: NotDescribed. That holds for a converter that is derived from a class of FSharp.SystemTextJson as well: the base class of its converters of tuples is public, and a class derived from it can write a tuple as it likes, so only a class of the library itself is taken for one of its converters. (Found by the review: such a converter was described as an array.)
  • Verification: dotnet build FSharp.Azure.Cosmos.slnx in Debug and Release with the warnings main has and none in the two Quotations projects, also with -p:WarnOn=3390; the tests pass in both configurations; Fantomas clean; the strict documentation build passes; every commit builds, and the first, the second and the last pass 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 02:23
xperiandri added a commit that referenced this pull request Oct 12, 2026
The result of the step that describes how enumerations, tuples,
collections and maps are written (#62), and where it departs from the
sketch of section 10: every question takes the member, one case says
that the layout does not know, an enumeration is written to see how it
is written, and a struct tuple that is written as an empty object is
an object whose items have no names.

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

Custom converter inference can report incorrect JSON layouts, and concurrent cache misses can execute converter work repeatedly.

3 open findings
What changed in this PR

Adds serializer-aware value encoding to the staged quotation-to-Cosmos-query translator.

Changes:

  • Describes enum, tuple, collection, and map JSON encodings.
  • Adds the underlying normalization, evaluation, and parameterization stages.
  • Introduces comprehensive emulator-free MSTest coverage.
File Description
.github/​copilot-instructions.md Documents the new projects and dependency.
Directory.Packages.props Adds FSharp.SystemTextJson centrally.
FSharp.Azure.Cosmos.slnf Adds quotation projects to the filter.
FSharp.Azure.Cosmos.slnx Adds quotation projects to the solution.
src/​Cosmos.Quotations/​AssemblyInfo.fs Defines generated assembly metadata.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates document-independent expressions.
src/​Cosmos.Quotations/​FSharp.Azure.Cosmos.Quotations.fsproj Configures the new library project.
src/​Cosmos.Quotations/​InternalsVisibleTo.fs Exposes internals to tests.
src/​Cosmos.Quotations/​JsonLayout.fs Implements serializer-aware layout descriptions.
src/​Cosmos.Quotations/​Normalizer.fs Normalizes quotation expressions.
src/​Cosmos.Quotations/​Parameterizer.fs Produces shape keys, slots, and templates.
src/​Cosmos.Quotations/​Query.fs Adds constant and parameter markers.
src/​Cosmos.Quotations/​TranslationError.fs Defines structured translation errors.
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/​JsonLayoutTests.fs Tests member layout behavior.
tests/​Cosmos.Quotations.Tests/​JsonLayoutValueTests.fs Tests non-object value encodings.
tests/​Cosmos.Quotations.Tests/​Model.fs Supplies quotation test models.
tests/​Cosmos.Quotations.Tests/​ModelMembers.fs Adds model reflection helpers.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization failures.
tests/​Cosmos.Quotations.Tests/​NormalizerSpellingTests.fs Tests equivalent quotation forms.
tests/​Cosmos.Quotations.Tests/​NormalizerTests.fs Tests normalization semantics.
tests/​Cosmos.Quotations.Tests/​ParameterizerShapeTests.fs Tests shape-key invariants.
tests/​Cosmos.Quotations.Tests/​ParameterizerTests.fs Tests parameter extraction and errors.
tests/​Cosmos.Quotations.Tests/​QuotationAssert.fs Adds quotation assertion helpers.
tests/​Cosmos.Quotations.Tests/​StoredModel.fs Defines serializer member fixtures.
tests/​Cosmos.Quotations.Tests/​StoredValues.fs Defines value-encoding fixtures.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Defines sorted test categories.
tests/​Cosmos.Quotations.Tests/​testconfig.json Enables parallel test execution.

🧠 Review effort: Balanced


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

Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-layout-values branch from 05c2fc4 to 03ad163 Compare October 12, 2026 04:22
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 04:22

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

Several string-keyed test collections omit the repository-required explicit ordinal comparers.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread tests/Cosmos.Quotations.Tests/StoredModel.fs Outdated
Comment thread tests/Cosmos.Quotations.Tests/StoredValues.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-layout-values branch from 03ad163 to 7a10f8e Compare October 12, 2026 05:24
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 05:25

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

The enum layout contract discards serialized values, preventing reliable query literals for customized converters.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment on lines +60 to +64
type EnumEncoding =
/// As the number under the value.
| Number
/// <summary>
/// As a JSON string: the name of the value in the spelling that its converter gives it, or what a converter of the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right: the encoding is only the kind of JSON value. That is what the question is for. The kind decides what a query may do with the member, since only a number can be compared by order and tested for bits.

It cannot give the JSON of one value, and I do not think an answer of this shape could: a naming policy spells the name, an attribute can declare one, and a converter of the application writes what it likes. A member can also name a converter of its own, so making the value a parameter is not enough either: the SDK writes a parameter with the contract of the type, not with the one of the member.

So what stands for a value in a query has to be written by the serializer, with the contract of the member that the value is compared with. That is the work of the step that turns values into parameters and literals, the translator. The parameterizer hands it a literal of an enumeration together with its declared type, so it has what it needs to ask. Nothing in this pull request writes a value into a query yet.

What changed here:

Whether the layout gets a question for it ("the JSON of this value as this member writes it") or the translator asks the serializer itself is a decision of that step, so I leave the thread open for the maintainer.

@xperiandri
xperiandri force-pushed the feat/quotations-layout-values branch from 7a10f8e to 6e4f63c 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

Enum converters rejecting values with NotSupportedException currently make layout discovery throw instead of returning NotDescribed.

4 open findings

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread tests/Cosmos.Quotations.Tests/ModelMembers.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-layout-values branch from 6e4f63c to 6578f3c Compare October 12, 2026 07:57
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 07:58

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

Application-defined tuple converter subclasses can be incorrectly reported as array encodings.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs
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>
xperiandri and others added 6 commits October 12, 2026 10:22
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 default value of a
  boolean, which the compiler writes for bool (), is false;
- 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>
xperiandri and others added 7 commits October 12, 2026 10:24
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, also one that is derived from a
converter of FSharp.SystemTextJson, 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>
@xperiandri
xperiandri force-pushed the feat/quotations-layout-values branch from 6578f3c to 948f538 Compare October 12, 2026 09:50
@xperiandri
xperiandri marked this pull request as draft October 12, 2026 09:52
@xperiandri

Copy link
Copy Markdown
Collaborator Author

Parked as a draft with #61, see the comment there: the layout is narrowed first, and this part of the full layout is kept as it is to be continued later.

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>
xperiandri added a commit that referenced this pull request Oct 12, 2026
The maintainer decided to build the JSON layout narrow first: it
supports the set-ups that it has been compared with the serializer for
and refuses the others. The layout of every setting of
FSharp.SystemTextJson (#61, #62, #63) is shelved, and what those steps
verified stays in the record under a heading of its own.

- the decision table (section 2) and the key decisions get the scope of
  the layout;
- section 10 says that the first layout is narrowed, and section 16 has
  the result of the narrow one (#65): the set-ups that it supports and
  refuses, the answers that it gives, and where it departs from the
  design;
- the record of the application's own converter of records, which the
  layout does not know, and the two ways to describe its records;
- the paragraph on dotnet test through FAKE says that FAKE 6.2.0 has
  DotNet.testMTP and that #64 replaces the hand-written call.

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