Skip to content

Stringize prints NaN as NaN, and the parser reads NaN as a variable, so the round trip is silently broken #906

Description

@Rafael-SOWNet

Stringize prints the NaN value as NaN, and the parser reads NaN as a variable of that name. So
the 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:

expression result what it should be
(0/0) - (0/0) — a computed NaN NaN NaN
"NaN - NaN".Simplify() 0 NaN
"NaN / NaN".Simplify() 1 provided not NaN = 0 NaN
"NaN + NaN".Evaled NaN + NaN, unevaluated NaN
"NaN * 0".Evaled 0 NaN
"NaN * 2".Evaled NaN * 2, unevaluated NaN

The last two are the same rewrite an ordinary symbol gets — x * 0 is 0, x * 2 stays as written —
which is what a variable named NaN is. The third row is the clearest tell: 1 provided not NaN = 0
is x/x cancelling, 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" and
Variable("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:

Parsing what Stringize prints must give back the expression it printed. Anything else makes the
printed form a lie, and a silent one, since a wrong reading is usually still a valid expression.

Why no test caught it

StringizeRoundTripTest.AssertRoundTrip takes a string, parses it, prints it, and re-parses:

var original = source.ToEntity();
var printed = original.Stringize();
Assert.Equal(original, printed.ToEntity());

So it can only reach expressions the parser can produce, and the NaN value is not one of them —
there is no NaN token in AngouriMath.g at all. The round trip cannot be started from a value that
has 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 NaN alone

The infinities have tokens and behave as values, so they are not affected:

"+oo - +oo".Simplify()   =>  NaN     (the value, not a symbol minus itself)
"+oo / +oo".Simplify()   =>  NaN

sqrt(-1) prints as i and reads back as the imaginary unit; pi and e likewise. NaN is the one
printed 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 Latexize output back into
an Entity, so whatever Latexize emits for NaN has the same question to answer.

What the fix has to decide

Adding a NaN token to the grammar is the obvious move, and it is a language change: NaN becomes a
keyword, so an expression using it as a variable name stops parsing. That is the same trade mod
took 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/0 round trips today — but that
prints an operation where there is a value, and Stringize would no longer be showing what the
expression 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/crashcheck already does for a different purpose.

Found while measuring #880: the literal
NaN was being used to check that arithmetic stays strict where a connective does not, and it reported
NaN * 0 as 0, which sent the measurement the wrong way for a few minutes before the cause turned
out to be the parse rather than the arithmetic.

Activity

  1. Happypig375 commented on Aug 12, 2026

    @Happypig375
    Member

    CSharpMath uses \mathrm{undefined} as the special token here, I think

  2. darkfader commented on Aug 12, 2026

    @darkfader
    Contributor

    In 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
    `

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions