Repository navigation
Stringize prints NaN as NaN, and the parser reads NaN as a variable, so the round trip is silently broken #906
Description
Activity
CSharpMath uses
\mathrm{undefined}as the special token here, I thinkReacted by darkfaderIn the project where I used AngouriMath, I just define NaN as 0/0 and I don't anyone would use NaN as variable so I'd suggest treating NaN as a special symbol just like we have built-in function names.
I don't think Stringize would need to introduce escape-codes, but we could use \mathrm{undefined} for LaTeX, yes.Our project default values for some variables: (and we reserve all unit names (or conversions) just in case we actually want to work with units hehe. So "kg" = "1000 g" for example.)
` // ===== SI constants =====
"c": {
"$type": "ExpressionInputChannel",
"Expression": "299792458 * m / s"
}, // speed of light
"h": {
"$type": "ExpressionInputChannel",
"Expression": "6.62607015e-34 * J * s"
}, // Planck's constant
"e": {
"$type": "ExpressionInputChannel",
"Expression": "1.602176634e-19 * C"
}, // elementary charge
"k": {
"$type": "ExpressionInputChannel",
"Expression": "1.380649e-23 * J / K"
}, // Boltzmann
"N_A": {
"$type": "ExpressionInputChannel",
"Expression": "6.02214076e23 / mol"
}, // Avogadro
"K_cd": {
"$type": "ExpressionInputChannel",
"Expression": "683 * lm / W"
},
"htf_Cs": {
"$type": "ExpressionInputChannel",
"Expression": "9192631770 * Hz"
}, // Cs-133 hyperfine frequency// ===== Non-numeric literals =====
// AngouriMath has no NaN literal, so a bare "NaN" in an expression parses as a free variable.
// Without an entry here that variable is an unresolved channel name, and the expression reports
// as missing rather than as NaN - silently, since it parses and reads correctly. Defining it as a
// symbol makes the obvious spelling mean what it looks like. 0/0 is the value: it is NaN and
// introduces no further variables.
//
// Prefer writing 0/0 directly in anything shipped in a device ID file. Depending on this symbol
// means depending on a defaults revision, and a DefaultsVersionShortfall is deliberately not
// fatal - so on an older deployed defaults file the expression quietly returns to reporting
// missing. There is no such entry for Infinity, which is why ExpressionValidation still rejects
// that one.
"NaN": {
"$type": "ExpressionInputChannel",
"Expression": "0/0"
},// ===== More constants =====
// TODO: load the stuff from https://pml.nist.gov/cuu/Constants/
"G": {
"$type": "ExpressionInputChannel",
"Expression": "6.67430e-11 * m^3 / kg / s^2"
}, // gravitational constant
"μ_0": {
"$type": "ExpressionInputChannel",
"Expression": "4e-7 * pi * H / m"
}, // vacuum permeability
"ε_0": {
"$type": "ExpressionInputChannel",
"Expression": "1 / (μ_0 * c^2)"
}, // vacuum permittivity
"R": {
"$type": "ExpressionInputChannel",
"Expression": "8.314462618 * J / mol / K"
}, // gas constant"τ": {
"$type": "ExpressionInputChannel",
"Expression": "2 * pi"
}, // tau
"φ": {
"$type": "ExpressionInputChannel",
"Expression": "(1 + sqrt(5)) / 2"
}, // golden ratio
"γ": {
"$type": "ExpressionInputChannel",
"Expression": "0.5772156649"
}, // Euler-Mascheroni constant
`- added a commit that references this issue
on Aug 12, 2026
Stringizeprints theNaNvalue asNaN, and the parser readsNaNas a variable of that name. Sothe printed form does not parse back, and the difference is silent: a variable behaves like an ordinary
symbol, which means it cancels, collects and compares as one.
Measured on
master(8c56b59f), .NET 10:(0/0) - (0/0)— a computed NaNNaNNaN"NaN - NaN".Simplify()0NaN"NaN / NaN".Simplify()1 provided not NaN = 0NaN"NaN + NaN".EvaledNaN + NaN, unevaluatedNaN"NaN * 0".Evaled0NaN"NaN * 2".EvaledNaN * 2, unevaluatedNaNThe last two are the same rewrite an ordinary symbol gets —
x * 0is0,x * 2stays as written —which is what a variable named
NaNis. The third row is the clearest tell:1 provided not NaN = 0is
x/xcancelling, printed with the symbol's name in the condition.Why it is silent
The two are indistinguishable on the page.
Real.NaN.Stringize()is"NaN"andVariable("NaN").Stringize()is"NaN", so nothing in the printed output says which one is in hand.It takes an operation that treats them differently — subtraction from itself, or a zero factor — to
tell them apart.
AGENTS.md states the contract
this breaks, and says why it matters in exactly this way:
Why no test caught it
StringizeRoundTripTest.AssertRoundTriptakes a string, parses it, prints it, and re-parses:So it can only reach expressions the parser can produce, and the
NaNvalue is not one of them —there is no
NaNtoken inAngouriMath.gat all. The round trip cannot be started from a value thathas no source form, which is a gap in the harness rather than a missing case: the test enumerates
inputs, and this defect is about an output.
It is
NaNaloneThe infinities have tokens and behave as values, so they are not affected:
sqrt(-1)prints asiand reads back as the imaginary unit;piandelikewise.NaNis the oneprinted form with no reading.
Where it bites
Any path that prints an expression and reads it back, which is the normal shape of persistence and of
tooling: a value stored as text, a sample in the documentation, an expression handed between processes.
It also crosses the LaTeX boundary AGENTS.md warns about — CSharpMath reads
Latexizeoutput back intoan
Entity, so whateverLatexizeemits forNaNhas the same question to answer.What the fix has to decide
Adding a
NaNtoken to the grammar is the obvious move, and it is a language change:NaNbecomes akeyword, so an expression using it as a variable name stops parsing. That is the same trade
modtook in 2.0 (BREAKING-CHANGES.md
records it), and there is precedent for preferring the reading to the name.
The alternative is to print it as something already in the grammar —
0/0round trips today — but thatprints an operation where there is a value, and
Stringizewould no longer be showing what theexpression is.
Worth deciding alongside it: whether the round-trip test should be inverted for cases like this, so
that it starts from entities rather than strings. Every node type reachable by construction, printed
and re-parsed, would have caught this and would catch the next one. The node list can be enumerated by
reflection, which
work/crashcheckalready does for a different purpose.Found while measuring #880: the literal
NaNwas being used to check that arithmetic stays strict where a connective does not, and it reportedNaN * 0as0, which sent the measurement the wrong way for a few minutes before the cause turnedout to be the parse rather than the arithmetic.