Repository navigation
Serialize an Entity as the expression it prints (#323) - #1031
Merged
Merged
Conversation
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
…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
This was referenced Aug 23, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:For
(Entity)3as much as forx + 2 * sin(y):Nodesis a node's enumeration of itself, so thereflecting object converter walks it until it hits the depth limit.
DataContractSerializerrefusesthe types outright (
InvalidDataContractExceptiononEntity+Sumf) andtypeof(Entity).IsSerializableisfalse. So anEntitycould not be a member of any serializabletype, 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
Entityserialize" but "what does a caller needthat the printed form does not already give them".
Stringize/FromStringround trips by contract,it is also the library's input format, and
EveryNodeSurvivesEveryPipelineTestenumerates the nodetypes 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 storyof 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.0release, 2000 iterations afterwarm-up, both arms in one process:
Stringize()JsonSerializer.SerializeMathS.FromString(a fresh string, so the parse cache misses)JsonSerializer.Deserialize<Entity>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— aJsonConverter<Entity>writingStringize()andreading
MathS.FromString, plusReadAsPropertyName/WriteAsPropertyNameso anEntityworks asa 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 onetest that is about the text.
Zero configuration:
Why every node type and not only
EntitySystem.Text.Jsonlooks a converter attribute up on the declared type withinherit: false. Anattribute on
Entityalone leaves a member declared asEntity.Variableto the object converter andthe cycle above — measured, not assumed. So all 77 public node types carry it, in one file, and
EveryPublicNodeTypeCarriesItsConverterenumerates them and names any that is missing, so a nodeadded later cannot join silently. Step 11 of
AddingNode.cssays the same in prose.Trimming and AOT
Nothing reflects and nothing scans assemblies.
JsonConverterAttributewould construct the converterwith
Activator.CreateInstance;EntityJsonConverterAttributeoverridesCreateConverterandreturns
new EntityJsonConverter(), so the type is reached statically. Built withEnableTrimAnalyzerandEnableAotAnalyzer, the only IL2xxx/IL3xxx warnings in the assembly are thetwo that were already there, both in
CompilationProtocol.cs.Frameworks
net8.0and later.netstandard2.0has noSystem.Text.Jsonin the box, and adding the packagereference means editing
AngouriMath.csproj, which another open PR owns right now. One conditionalPackageReferenceextends 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/Applicationwithbound names,
Provided,ConditionalSet, matrices, thesum/productbinders andpi/e/i—112 round trip and 3 do not:
Codomain. Nothing prints it, sodomain(x, ZZ)prints asxand reads back withDomain.Any. Filed as #1022 — the sameshape of defect as Stringize of a Rational parses back as a Divf, so the round trip is not an identity #873, and the parser already accepts
domain(expr, SET), so only the printinghalf is absent. Pinned here by a test written to fail when Stringize drops a node's Codomain, so domain(x, ZZ) prints as x and does not round trip #1022 is fixed.
Number.Complexwith both parts non-zero, which prints as a sum and reads back asSumf.Already recorded in
EveryNodeSurvivesEveryPipelineTest, and this test carries the same list.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:
Same for
*,and,orandxor. The node changes and the value does not, which is what makes itan output choice rather than a defect. An operator that is not associative keeps its brackets,
since #1009 —
AnOperatorThatIsNotAssociativeKeepsItsNestingasserts that here forimplies,provided,-,/,^,modand set minus.I filed #1032 for the
implieshalf 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
FunctionandTrigonometricFunctioninEntity.Continuous.Definition.cshad to becomepartialto be named in the attribute file. That is the whole of the change under
Core/Entity/..csfiles carry the2019-2022header, becauseSources/.editorconfigopts a pathinto
2019-2026per 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, itsparameterless constructor and its five overrides.
EntityJsonConverterAttributeisinternal.Measured
dotnet test Sources/Tests/UnitTests -c Release— 7743 passed, 0 failed, 14 skipped (7757total), 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 fornetstandard2.0,net8.0andnet10.0.Should #323 close?
The issue asks for two things. Serialization is here. The second half — "and make
FieldCachenon-serializable" — names a type that no longer exists; the memoisation is
LazyPropertyA<T>fieldsnow, 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 toa", with1 + 2 + 3as the example — hasnarrowed rather than gone.
StringizeRoundTripTestandEveryNodeSurvivesEveryPipelineTestenforcethat 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 whyit is written down here rather than fixed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd