Skip to content

Commit ba680dc

Browse files
Serialize an Entity as the expression it prints (#323) (#1031)
* Serialize an Entity as the expression it prints (#323) 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 * Record what a right nesting does to the round trip (#1032) 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 * Assert the brackets that #1009 put back, rather than their absence 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 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5bdd2c6 commit ba680dc

7 files changed

Lines changed: 756 additions & 2 deletions

File tree

‎Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Definition.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ public abstract partial record ContinuousNode : Entity
2323
/// Describes any node that is a function (e. g. sin, cos, etc.) or calculus operator (e. g. derivative, integral, limit)
2424
/// but not an arithmetic operator or leaf
2525
/// </summary>
26-
public abstract record Function : ContinuousNode
26+
public abstract partial record Function : ContinuousNode
2727
{
2828
internal override Priority Priority => Priority.Func;
2929
}
3030

3131
/// <summary>
3232
/// Describes any function that is related to trigonometry
3333
/// </summary>
34-
public abstract record TrigonometricFunction : Function
34+
public abstract partial record TrigonometricFunction : Function
3535
{
3636

3737
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//
2+
// Copyright (c) 2019-2022 Angouri.
3+
// AngouriMath is licensed under MIT.
4+
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
5+
// Website: https://am.angouri.org.
6+
//
7+
8+
#if NET8_0_OR_GREATER
9+
10+
using AngouriMath.Core.Serialization;
11+
12+
namespace AngouriMath
13+
{
14+
/*
15+
* Which node types System.Text.Json is told about, and nothing else. It is here rather than
16+
* beside the node definitions because none of them changes to become serializable -- the
17+
* whole of it is one attribute per type and the converter next to this file.
18+
*
19+
* Every type is named, and not only Entity, because System.Text.Json looks the attribute up
20+
* with inherit: false. A member declared as Entity.Variable or Entity.Matrix would otherwise
21+
* fall through to the reflecting object converter, which walks Nodes -- a node's own
22+
* enumeration of itself -- and reports an object cycle. EntityCarriesItsConverterTest
23+
* enumerates the public node types and fails on any that is missing from this list, so a new
24+
* node cannot join it silently.
25+
*/
26+
[EntityJsonConverter] partial record Entity
27+
{
28+
[EntityJsonConverter] partial record Absf;
29+
[EntityJsonConverter] partial record Andf;
30+
[EntityJsonConverter] partial record Application;
31+
[EntityJsonConverter] partial record Arccosecantf;
32+
[EntityJsonConverter] partial record Arccosf;
33+
[EntityJsonConverter] partial record Arccotanf;
34+
[EntityJsonConverter] partial record Arcsecantf;
35+
[EntityJsonConverter] partial record Arcsinf;
36+
[EntityJsonConverter] partial record Arctanf;
37+
[EntityJsonConverter] partial record Boolean;
38+
[EntityJsonConverter] partial record CalculusOperator;
39+
[EntityJsonConverter] partial record Ceilf;
40+
[EntityJsonConverter] partial record ComparisonSign;
41+
[EntityJsonConverter] partial record Constant;
42+
[EntityJsonConverter] partial record ContinuousNode;
43+
[EntityJsonConverter] partial record Cosecantf;
44+
[EntityJsonConverter] partial record Cosf;
45+
[EntityJsonConverter] partial record Cotanf;
46+
[EntityJsonConverter] partial record Derivativef;
47+
[EntityJsonConverter] partial record Divf;
48+
[EntityJsonConverter] partial record Equalsf;
49+
[EntityJsonConverter] partial record Factorialf;
50+
[EntityJsonConverter] partial record Floorf;
51+
[EntityJsonConverter] partial record Function;
52+
[EntityJsonConverter] partial record Gcdf;
53+
[EntityJsonConverter] partial record GreaterOrEqualf;
54+
[EntityJsonConverter] partial record Greaterf;
55+
[EntityJsonConverter] partial record Impliesf;
56+
[EntityJsonConverter] partial record Integralf;
57+
[EntityJsonConverter] partial record Lambda;
58+
[EntityJsonConverter] partial record LessOrEqualf;
59+
[EntityJsonConverter] partial record Lessf;
60+
[EntityJsonConverter] partial record Limitf;
61+
[EntityJsonConverter] partial record Logf;
62+
[EntityJsonConverter] partial record Matrix;
63+
[EntityJsonConverter] partial record Maxf;
64+
[EntityJsonConverter] partial record Minf;
65+
[EntityJsonConverter] partial record Minusf;
66+
[EntityJsonConverter] partial record Modf;
67+
[EntityJsonConverter] partial record Mulf;
68+
[EntityJsonConverter] partial record Notf;
69+
[EntityJsonConverter] partial record Number
70+
{
71+
[EntityJsonConverter] partial record Complex;
72+
[EntityJsonConverter] partial record Integer;
73+
[EntityJsonConverter] partial record Rational;
74+
[EntityJsonConverter] partial record Real;
75+
}
76+
[EntityJsonConverter] partial record Orf;
77+
[EntityJsonConverter] partial record Phif;
78+
[EntityJsonConverter] partial record Piecewise;
79+
[EntityJsonConverter] partial record Powf;
80+
[EntityJsonConverter] partial record Productf;
81+
[EntityJsonConverter] partial record Providedf;
82+
[EntityJsonConverter] partial record Roundf;
83+
[EntityJsonConverter] partial record Secantf;
84+
[EntityJsonConverter] partial record Set
85+
{
86+
[EntityJsonConverter] partial record ConditionalSet;
87+
[EntityJsonConverter] partial record FiniteSet;
88+
[EntityJsonConverter] partial record Inf;
89+
[EntityJsonConverter] partial record Intersectionf;
90+
[EntityJsonConverter] partial record Interval;
91+
[EntityJsonConverter] partial record SetMinusf;
92+
[EntityJsonConverter] partial record SpecialSet
93+
{
94+
[EntityJsonConverter] partial record Booleans;
95+
[EntityJsonConverter] partial record Complexes;
96+
[EntityJsonConverter] partial record Integers;
97+
[EntityJsonConverter] partial record Rationals;
98+
[EntityJsonConverter] partial record Reals;
99+
}
100+
[EntityJsonConverter] partial record Unionf;
101+
}
102+
[EntityJsonConverter] partial record Signumf;
103+
[EntityJsonConverter] partial record Sinf;
104+
[EntityJsonConverter] partial record Statement;
105+
[EntityJsonConverter] partial record Sumf;
106+
[EntityJsonConverter] partial record Summationf;
107+
[EntityJsonConverter] partial record Tanf;
108+
[EntityJsonConverter] partial record TrigonometricFunction;
109+
[EntityJsonConverter] partial record Variable;
110+
[EntityJsonConverter] partial record Xorf;
111+
}
112+
}
113+
114+
#endif
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//
2+
// Copyright (c) 2019-2022 Angouri.
3+
// AngouriMath is licensed under MIT.
4+
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
5+
// Website: https://am.angouri.org.
6+
//
7+
8+
#if NET8_0_OR_GREATER
9+
10+
using System;
11+
using System.Text.Json;
12+
using System.Text.Json.Serialization;
13+
14+
namespace AngouriMath.Core.Serialization
15+
{
16+
/// <summary>
17+
/// Reads and writes an <see cref="Entity"/> as a JSON string holding the expression in
18+
/// the library's own syntax — what <see cref="Entity.Stringize()"/> prints and
19+
/// <see cref="MathS.FromString(string)"/> reads.
20+
/// </summary>
21+
/// <remarks>
22+
/// <para>
23+
/// Every node type carries it already (<c>Core/Serialization/Entity.Serialization.cs</c>), so
24+
/// an expression is serializable wherever it appears — a property, an array, a dictionary key
25+
/// — with no configuration. Adding an instance to
26+
/// <see cref="JsonSerializerOptions.Converters"/> is only needed to place it ahead of another
27+
/// converter for the same type.
28+
/// </para>
29+
/// <para>
30+
/// There is deliberately no second, structural, format. The printed form is already an exact
31+
/// serialization and it is the one the library has to keep exact anyway, since it is also the
32+
/// input format: parsing what <c>Stringize</c> prints gives back the expression printed, and
33+
/// <c>EveryNodeSurvivesEveryPipelineTest</c> enumerates the node types by reflection and fails
34+
/// the day a new one stops satisfying that. A structural schema would be a second contract
35+
/// over the same tree, with its own per-node code to keep in step and its own way of drifting
36+
/// from the first. What it would buy is speed — reading a 43-node expression costs about
37+
/// 430 us and 0.8 MB through the parser against 6 us and 25 kB to build the same tree from
38+
/// constructors — and that is an argument for a faster parser, which every caller who writes
39+
/// <c>Entity e = "..."</c> would also get, rather than for a second representation that only
40+
/// serialization uses.
41+
/// </para>
42+
/// <para>
43+
/// What the printed form does not carry, and neither therefore does this:
44+
/// <see cref="Entity.Codomain"/>, which no node prints, so a node narrowed with
45+
/// <see cref="Entity.WithCodomain(Domain)"/> comes back with the default
46+
/// (<a href="https://github.com/asc-community/AngouriMath/issues/1022">#1022</a>); and an
47+
/// <see cref="Entity.Number.Complex"/> with both parts non-zero, which prints as a sum and
48+
/// reads back as one — the same number, a different node. Nor is a right nesting of an
49+
/// <em>associative</em> operator kept, since such an operand is not bracketed at its own
50+
/// precedence: <c>1 + (2 + 3)</c> comes back as <c>(1 + 2) + 3</c>, the same value written the
51+
/// other way round. Both are properties of printing rather than of this converter, and fixing
52+
/// them there fixes them here.
53+
/// </para>
54+
/// <a href="https://github.com/asc-community/AngouriMath/issues/323">#323</a>
55+
/// </remarks>
56+
/// <example>
57+
/// <code>
58+
/// using System.Text.Json;
59+
/// using AngouriMath;
60+
///
61+
/// public sealed record Problem(string Title, Entity Body);
62+
///
63+
/// var json = JsonSerializer.Serialize(new Problem("quadratic", "x ^ 2 - 3 * x + 2"));
64+
/// // {"Title":"quadratic","Body":"x ^ 2 - 3 * x + 2"}
65+
/// var back = JsonSerializer.Deserialize&lt;Problem&gt;(json);
66+
/// // back.Body == (Entity)"x ^ 2 - 3 * x + 2"
67+
/// </code>
68+
/// </example>
69+
public sealed class EntityJsonConverter : JsonConverter<Entity>
70+
{
71+
/// <summary>
72+
/// Every node type, not just <see cref="Entity"/> itself, so that a member declared as
73+
/// <see cref="Entity.Variable"/> or <see cref="Entity.Matrix"/> is served too.
74+
/// </summary>
75+
public override bool CanConvert(Type typeToConvert)
76+
=> typeof(Entity).IsAssignableFrom(typeToConvert);
77+
78+
/// <inheritdoc/>
79+
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
80+
=> Parse(Text(ref reader), typeToConvert);
81+
82+
/// <inheritdoc/>
83+
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
84+
=> writer.WriteStringValue(value.Stringize());
85+
86+
/// <inheritdoc/>
87+
public override Entity ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
88+
=> Parse(Text(ref reader), typeToConvert);
89+
90+
/// <inheritdoc/>
91+
public override void WriteAsPropertyName(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
92+
=> writer.WritePropertyName(value.Stringize());
93+
94+
private static string Text(ref Utf8JsonReader reader)
95+
=> reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName
96+
? reader.GetString()!
97+
: throw new JsonException(
98+
$"An expression is written as a JSON string, and this is {reader.TokenType}.");
99+
100+
/// <summary>
101+
/// A malformed expression is reported the way <see cref="MathS.FromString(string)"/>
102+
/// reports it, rather than wrapped: the parser's message names the position and what it
103+
/// expected, and that is worth more than uniformity with the other converters.
104+
/// </summary>
105+
private static Entity Parse(string text, Type typeToConvert)
106+
{
107+
var parsed = MathS.FromString(text);
108+
// A member declared as a specific node type is a claim about what may be read into
109+
// it. Checking here says which expression and which type; letting it through gives
110+
// the caller an InvalidCastException from inside the serializer with neither.
111+
if (!typeToConvert.IsInstanceOfType(parsed))
112+
throw new JsonException(
113+
$"\"{text}\" is {parsed.GetType().Name}, which is not {typeToConvert.Name}.");
114+
return parsed;
115+
}
116+
}
117+
118+
/// <summary>
119+
/// Puts <see cref="EntityJsonConverter"/> on <see cref="Entity"/> without the reflective
120+
/// construction the plain attribute would use. Trimming and NativeAOT are a constraint here
121+
/// (<a href="https://github.com/asc-community/AngouriMath/issues/746">#746</a>), and
122+
/// <see cref="JsonConverterAttribute"/> falls back to <c>Activator.CreateInstance</c> only
123+
/// when <see cref="CreateConverter"/> returns <see langword="null"/>.
124+
/// </summary>
125+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
126+
internal sealed class EntityJsonConverterAttribute : JsonConverterAttribute
127+
{
128+
public override JsonConverter CreateConverter(Type typeToConvert) => new EntityJsonConverter();
129+
}
130+
}
131+
132+
#endif

‎Sources/AngouriMath/Docs/Contributing/AddingNode.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,8 @@
5252
/// (and tests to SolveOneEquation.cs)
5353
/// 10. ToSympy (if applicable) (<see cref="AngouriMath.Entity.Sinf.ToSymPy"/>) (Tip: Enter 'import sympy' into https://live.sympy.org/ then test)
5454
/// (and tests to ToSymPyTest.cs)
55+
/// 11. Name it in Core/Serialization/Entity.Serialization.cs, which is what tells System.Text.Json
56+
/// that the node is written as its printed form. Nothing else about serialization changes -- see
57+
/// Serialization.md in the same folder as this file. EntitySerializationTest fails if it is missed.
5558

5659
#pragma warning restore CS1587 // XML comment is not placed on a valid language element

0 commit comments

Comments
 (0)