Skip to content

Serialize an Entity as the expression it prints (#323) - #1031

Merged
Rafael-SOWNet merged 4 commits into
masterfrom
feat/entity-serialization
Aug 23, 2026
Merged

Rafael-SOWNet merged 4 commits into
masterfrom
feat/entity-serialization

Conversation

@Rafael-SOWNet

@Rafael-SOWNet Rafael-SOWNet commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

Roadmap #746 item 18, and
#323. See the end for whether #323 should
close — I have not touched it.

What was wrong

Measured on v2.3.0 (6b93b40), before any change:

JsonSerializer.Serialize(entity)
// JsonException: A possible object cycle was detected ...
// Path: $.DirectChildren.Nodes.Nodes.Nodes.Nodes ... (x32)

For (Entity)3 as much as for x + 2 * sin(y): Nodes is a node's enumeration of itself, so the
reflecting object converter walks it until it hits the depth limit. DataContractSerializer refuses
the types outright (InvalidDataContractException on Entity+Sumf) and
typeof(Entity).IsSerializable is false. So an Entity could not be a member of any serializable
type, which is what #323 asks for.

The position

The printed form is the serialization, and there is no second one.

The question worth answering is not "how does an Entity serialize" but "what does a caller need
that the printed form does not already give them". Stringize/FromString round trips by contract,
it is also the library's input format, and EveryNodeSurvivesEveryPipelineTest enumerates the node
types by reflection and fails the day a new one stops satisfying it. A structural JSON schema would
be a second description of the same tree — per-node code, a place in AddingNode.cs, a version story
of its own, and freedom to drift from the first with nothing detecting it.

What a structural format would buy is speed, and the number is measured rather than assumed. A
textbook-sized expression — 43 nodes, 97 characters — on net10.0 release, 2000 iterations after
warm-up, both arms in one process:

time allocated
Stringize() 45 us 115 kB
JsonSerializer.Serialize 41 us 115 kB
MathS.FromString (a fresh string, so the parse cache misses) 430 us 793 kB
JsonSerializer.Deserialize<Entity> 420 us 883 kB
the same tree built from constructors, no text at all 6.5 us 25 kB

The JSON layer costs nothing over printing and parsing — it is printing and parsing. Reading costs
about 65x what building the tree costs, and that last row is the floor a structural format could aim
at. It is a real gap, and it is an argument for a faster parser, which every caller who writes
Entity e = "..." would also get.

What is in it

  • Core/Serialization/EntityJsonConverter.cs — a JsonConverter<Entity> writing Stringize() and
    reading MathS.FromString, plus ReadAsPropertyName/WriteAsPropertyName so an Entity works as
    a dictionary key.
  • Core/Serialization/Entity.Serialization.cs — the attribute, on every public node type.
  • Docs/Contributing/Serialization.md — the position and the measurements.
  • EntitySerializationTest — 107 tests, comparing entities and never printed forms, except the one
    test that is about the text.

Zero configuration:

public sealed record Problem(string Title, Entity Body);
JsonSerializer.Serialize(new Problem("quadratic", "x ^ 2 - 3 * x + 2"));
// {"Title":"quadratic","Body":"x ^ 2 - 3 * x + 2"}

Why every node type and not only Entity

System.Text.Json looks a converter attribute up on the declared type with inherit: false. An
attribute on Entity alone leaves a member declared as Entity.Variable to the object converter and
the cycle above — measured, not assumed. So all 77 public node types carry it, in one file, and
EveryPublicNodeTypeCarriesItsConverter enumerates them and names any that is missing, so a node
added later cannot join silently. Step 11 of AddingNode.cs says the same in prose.

Trimming and AOT

Nothing reflects and nothing scans assemblies. JsonConverterAttribute would construct the converter
with Activator.CreateInstance; EntityJsonConverterAttribute overrides CreateConverter and
returns new EntityJsonConverter(), so the type is reached statically. Built with
EnableTrimAnalyzer and EnableAotAnalyzer, the only IL2xxx/IL3xxx warnings in the assembly are the
two that were already there, both in CompilationProtocol.cs.

Frameworks

net8.0 and later. netstandard2.0 has no System.Text.Json in the box, and adding the package
reference means editing AngouriMath.csproj, which another open PR owns right now. One conditional
PackageReference extends it whenever that is convenient.

What the printed form does not carry

Properties of printing, not of this converter, so fixing them there fixes them here. Measured over
115 shapes — every node type the reflective enumeration reaches, plus Lambda/Application with
bound names, Provided, ConditionalSet, matrices, the sum/product binders and pi/e/i —
112 round trip and 3 do not:

And one that is not about a node type but about how operands nest. An associative operator whose
operands nest to the right comes back nested to the left, because a right operand at its own
precedence is not bracketed:

MathS.FromString("1 + (2 + 3)").Stringize()   // "1 + 2 + 3", which reads back as (1 + 2) + 3

Same for *, and, or and xor. The node changes and the value does not, which is what makes it
an output choice rather than a defect. An operator that is not associative keeps its brackets,
since #1009 — AnOperatorThatIsNotAssociativeKeepsItsNesting asserts that here for implies,
provided, -, /, ^, mod and set minus.

I filed #1032 for the implies half of that before merging master, and #1009 had already fixed it —
I measured on v2.3.0, which was the tip when this branch was cut and stale by the time I wrote it up.
Closed with the re-measurement.

Two things worth a reviewer's eye

  • Function and TrigonometricFunction in Entity.Continuous.Definition.cs had to become partial
    to be named in the attribute file. That is the whole of the change under Core/Entity/.
  • The two new .cs files carry the 2019-2022 header, because Sources/.editorconfig opts a path
    into 2019-2026 per directory and editing it would have conflicted with an open PR. Two sections —
    [AngouriMath/Core/Serialization/*.cs] and [Tests/UnitTests/Common/EntitySerializationTest.cs] —
    and the headers bumped, is the follow-up.

Public surface

Seven lines in PublicApi.txt: AngouriMath.Core.Serialization.EntityJsonConverter, its
parameterless constructor and its five overrides. EntityJsonConverterAttribute is internal.

Measured

  • dotnet test Sources/Tests/UnitTests -c Release — 7743 passed, 0 failed, 14 skipped (7757
    total), with master merged in. The branch adds 107 tests and changes no existing one: its diff
    against master is the six new files plus seven lines of PublicApi.txt.
  • dotnet test Sources/Tests/FSharpWrapperUnitTests -c Release — 134 passed, 0 failed.
  • dotnet build Sources/AngouriMath -c Release — clean for netstandard2.0, net8.0 and net10.0.

Should #323 close?

The issue asks for two things. Serialization is here. The second half — "and make FieldCache
non-serializable"
— names a type that no longer exists; the memoisation is LazyPropertyA<T> fields
now, and no serializer reads a field of a node any more, because the expression is written as text.
So the reason for that half is gone rather than answered, which is worth a maintainer's word before
the issue is closed.

Happypig375's "why not an existing and widespread format like JSON" and "or not just
Parse/ToString?"
are both what this does. WhiteBlackGoose's objection — "we don't guarantee
a.ToString().ToEntity() be parsable and equal to a"
, with 1 + 2 + 3 as the example — has
narrowed rather than gone. StringizeRoundTripTest and EveryNodeSurvivesEveryPipelineTest enforce
that guarantee for every node type today, and his example is precisely the part still standing:
1 + (2 + 3) does come back as (1 + 2) + 3. It is a change of shape and not of value, which is why
it is written down here rather than fixed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

JsonSerializer.Serialize(entity) threw for every entity, (Entity)3 included:
Nodes is a node's enumeration of itself, so the reflecting object converter
walked it until it reported an object cycle. DataContractSerializer refused
the types outright and Entity was never [Serializable]. So an expression
could not be a member of any serializable type.

The format is the printed form. It is an exact serialization already, and
one the library has to keep exact whatever else it does, because it is also
the input format -- so this inherits that contract instead of adding a second
description of the same tree with its own per-node code and its own way of
drifting. Reading costs about 430 us and 0.8 MB for a 43-node expression
against 6.5 us and 25 kB to build the tree from constructors, which is an
argument for a faster parser rather than for a second representation.

The attribute is on every public node type and not only on Entity, because
System.Text.Json looks it up with inherit: false and a member declared as
Entity.Variable would otherwise reach the object converter and the cycle.
EntitySerializationTest names any node type that is missing, so a new one
cannot join the list silently, and step 11 of AddingNode.cs says so.

EntityJsonConverterAttribute overrides CreateConverter so the converter is
reached statically rather than through Activator.CreateInstance; the trim
and AOT analyzers report nothing on this code. Compiled for net8.0 and later,
since netstandard2.0 has no System.Text.Json in the box and the csproj takes
no package reference for it.

Function and TrigonometricFunction had to become partial to be named.

What the printed form does not carry, measured over every node type: a
Codomain, which nothing prints (#1022, filed, and pinned here by a test
written to fail when it is fixed), and a Complex with both parts non-zero,
which prints as a sum -- already recorded in EveryNodeSurvivesEveryPipelineTest.
Everything else round trips, binders and bound constants included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
Measuring the claim rather than repeating it: an operator whose operands nest
to the right comes back nested to the left, because the printed form does not
bracket a right operand of equal priority. For +, *, and, or and xor that is
shape and not value, since each is associative -- which is the whole of what
is left of #323's objection that a.ToString().ToEntity() need not equal a.

For implies it is value. a implies (b implies c) prints as
a implies b implies c, which reads back as (a implies b) implies c, and the
two disagree at a=False, b=True, c=False. Filed as #1032, in the printer's
parenthesisation guard, with Providedf the mirror of it. Pinned here in both
directions so the entry cannot outlive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
Rafael-SOWNet and others added 2 commits August 23, 2026 13:20
…zation

# Conflicts:
#	Sources/Tests/UnitTests/Common/PublicApi.txt
Master gained the fix while this branch was open. a implies (b implies c),
(a provided b) provided c and the set-minus case now survive the round trip,
so the test asserts that instead of asserting the defect, and the two documents
say associative rather than naming implies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
@Rafael-SOWNet
Rafael-SOWNet merged commit ba680dc into master Aug 23, 2026
28 checks passed
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.

1 participant