Skip to content

Print a node's Codomain, so the printed form stops losing it (#1022) - #1047

Merged
Rafael-SOWNet merged 3 commits into
masterfrom
fix/stringize-prints-the-codomain
Aug 24, 2026
Merged

Rafael-SOWNet merged 3 commits into
masterfrom
fix/stringize-prints-the-codomain

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Closes #1022.

Stringize printed no node's Codomain. Measured on a build of master (cd0b56b) and a build of
this branch:

master this branch
"domain(x, ZZ)".ToEntity().Stringize() x domain(x, ZZ)
... read back, .Codomain Any Integer
... e == e.Stringize().ToEntity() False True
"domain(x + 1, RR)".ToEntity().Stringize() x + 1 domain(x + 1, RR)
"domain(sqrt(-1), RR)".ToEntity().Stringize() sqrt(-1), which reads back as i domain(sqrt(-1), RR), which reads back as NaN
"domain([1, 2], RR)".ToEntity().Stringize() [1, 2] domain([1, 2], RR)
Sin(Var("x").WithCodomain(Integer)) + Var("y").WithCodomain(Real) sin(x) + y sin(domain(x, ZZ)) + domain(y, RR)
.Latexize() of the first x {\left(x\right)}_{\mathbb{Z}}
JsonSerializer.Serialize of the first "x" "domain(x, ZZ)"

This matters past the round trip because #746 rests on it: "the printed form is contractually a
lie-free channel — parsing what Stringize prints gives back the expression printed."
That
sentence had to be qualified with this defect; it does not any more, bar the two corners below.

The rule for when to print, and why it is a comparison

Print domain(inner, SET) exactly where the codomain is not the one parsing the bare text would
give back — that is, not the node type's default. Anything wider would put a wrapper on every
expression the library prints and move hundreds of expected outputs for nothing.

That default is not uniform, which is the interesting part of the task and why the rule cannot be
"is it Complex":

default node types
Any Variable (hence Constant), Matrix, every Set but Interval, Providedf, Piecewise, Application, Lambda
Real Modf, Absf, Minf, Maxf, Interval
Boolean every boolean node, and Inf
Integer Number.Integer, Phif
Rational / Real / Complex the numeric literal of that type
Complex the remaining 30-odd continuous nodes

So DefaultCodomain is declared beside each Codomain in Domains.Classes.cs, both abstract on
Entity
: a node that declares one and forgets the other does not compile, and a test asserts that
every freshly built node of every node type carries its own default.

What moved besides the printer

  • Latexize had the same gap. It renders as {\left(x\right)}_{\mathbb{Z}}. The parentheses are
    unconditional, because a variable renders its own index as a subscript and x_{\mathbb{Z}} would
    be indistinguishable from a variable spelled that way. New output for CSharpMath.Evaluation, which
    has no notion of a codomain — it only appears for an expression carrying a narrowed one (Update AGENTS.md for Latexise  #822).

  • EntityJsonConverter needed no change: it serialises what Stringize prints, so Stringize drops a node's Codomain, so domain(x, ZZ) prints as x and does not round trip #1022's entry
    in its remarks and in Docs/Contributing/Serialization.md is now a description of a fixed thing.

  • MathS.ToSympyCode did need one. It emitted {v.Stringize()} = sympy.Symbol(...) for each
    variable, which after this change is domain(x, ZZ) = sympy.Symbol('domain(x, ZZ)') — not a Python
    identifier. It uses Name now, and its output is byte-identical to master's on both sides.

  • A sum stops collecting two terms that are not the same term. Simplify's polynomial collection
    keys a monomial by its base's printed form:

    "x - domain(x, ZZ)".ToEntity().Simplify()      master: 0        now: x - domain(x, ZZ)
    "domain(x, ZZ) + x".ToEntity().Simplify()      master: 2 * x    now: domain(x, ZZ) + x
    

    0 is the answer only where x is an integer, and the annotation is exactly what says it might
    not be. Confirmed as the cause rather than inferred: putting the collision back — keying on the
    base with its codomain erased — brings 0 and 2 * x straight back.

What this does not fix

Two annotations still do not read back, and both are the grammar's limit, not the printer's.
Each is pinned by a test that fails if it starts working, so neither can outlive itself.

  • Domain.Any cannot be written. domain(...)'s second argument must be a SpecialSet, and
    there is none for "no restriction". A node widened to Any from a narrower default prints as
    though it had not been. Printing CC instead would be a different lie, and throwing from a printer
    would take out every diagnostic message that quotes an expression.
  • No input yields a Rational whose codomain is Complex. The pass that reads 1/2 as a
    rational rather than a quotient (Stringize of a Rational parses back as a Divf, so the round trip is not an identity #873) uses Complex as its "nobody annotated this" sentinel,
    because that is what an un-annotated Divf carries. So domain(1/2, CC) parses to the same node
    1/2 does. Worth its own issue; it is a parser gap and there is nothing the printer can emit
    instead.

The public surface

Each node declared its own public override string Stringize() and Latexize(). The codomain
wrapper is one decision and now lives once on Entity, with the per-node rendering behind
private protected abstract StringizeNode() / LatexizeNode(). PublicApi.txt therefore loses 130
overrides and gains nothing. Nothing a caller writes stops compiling — someSumf.Stringize()
still resolves, inherited — but an assembly compiled against 2.3.0 binds to
Entity+Sumf::Stringize() wherever its static type is a concrete node, and has to be rebuilt.
BREAKING-CHANGES.md says so.

Tests

dotnet test Sources/Tests/UnitTests -c Release, full run each time, build lines read:

before after
passed 8070 8516
failed 0 0
skipped 14 14
total 8084 8530

The F#, Interactive and Terminal suites are green too (134 / 18 / 7).

Exactly two expected outputs moved, both deliberately:

  • EntitySerializationTest.ACodomainDoesNotSurviveBecauseNothingPrintsIt, written to fail when this
    is fixed (Serialize an Entity as the expression it prints (#323) #1031), is now ACodomainSurvivesBecauseThePrintedFormCarriesIt, plus a subnode case.
  • PublicApi.txt, regenerated with AM_UPDATE_PUBLIC_API=1: 130 deletions, 0 additions.

New: CodomainSurvivesPrintingTest — every node type there is × every writable domain, comparing
entities and reusing EveryNodeSurvivesEveryPipelineTest.EveryNodeType, so it cannot silently stop
covering a node; the default-agreement check; the two pinned residuals; the LaTeX shape. Ten more
cases in StringizeRoundTripTest, including the binder positions (lambda, derivative,
integral, set-builder), which all round trip.

No wiki or website sample uses domain( or WithCodomain, so docsamples is unaffected; the
corpus gate is in the suite above. Docs/Usage/Syntax.md, Docs/Contributing/Serialization.md,
NodeContract.md, Packaging.md and AddingNode.cs are corrected where they described the old
behaviour or counted the abstract members (eleven capabilities, now twelve).

Against #746: this is a standing-condition fix rather than a tier item — it removes the exception
that had to be written into the printed-form claim that machine-to-machine exchange, caches and
agent tool calls all rest on.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

`Stringize` printed no node's `Codomain`, so `domain(x, ZZ)` printed as `x` and
parsing that gave a `Variable` with `Codomain = Any` back. The annotation is not
decoration: `sqrt(-1)` is `i` and the same expression over the reals is `NaN`,
and the two printed the same string.

The parser already had the syntax -- `domain(expr, SET)` maps onto
`WithCodomain` and works for every node -- so only the printing half was
missing. A node whose codomain is not the one its type carries by default now
prints inside `domain(...)`, and LaTeX renders it as a subscripted set.

The default is not uniform, which is why the rule is a comparison and not a
check against `Complex`: a variable and a matrix default to `Any`, `abs` and an
interval to `Real`, every boolean node to `Boolean`, each numeric literal to its
own type's domain. Each node declares that default beside its `Codomain`, both
abstract so that a node declaring one and forgetting the other does not compile.

The wrapper is one decision, so it lives once on `Entity` and the per-node
rendering moved behind `StringizeNode`/`LatexizeNode`. That removes 130
overrides from the recorded public surface; every one of them is still callable,
inherited.

`Simplify` collected `x` and the same `x` narrowed to the integers as one
monomial, because it keys a monomial by its base's printed form: `x - domain(x,
ZZ)` was `0`, which is the answer only where `x` is an integer. It is left
alone now.

Two annotations still do not survive, both the grammar's limit rather than the
printer's, and both pinned by tests that fail if they start working: `Any` has
no special set to name it, and no input yields a `Rational` whose codomain is
`Complex`, since the pass that reads `1/2` as a rational treats `Complex` as
"nobody annotated this" (#873).

Measured on a build of each side; 8070 -> 8516 passed, 0 failed. The two
expected outputs that moved are the test written to fail when this is fixed and
the recorded public surface.

#1022
#746
@Rafael-SOWNet
Rafael-SOWNet force-pushed the fix/stringize-prints-the-codomain branch from 4409014 to 3b30a1e Compare August 23, 2026 23:44
Rafael-SOWNet and others added 2 commits August 24, 2026 18:09
The entry said removing the 130 per-node `Stringize()`/`Latexize()` overrides
forces a consumer compiled against 2.3.0 to be rebuilt. It does not, and the
difference matters under an `AssemblyVersion` pinned at 2.0.0.0 for the whole
of 2.x: there is no binding redirect to soften a break that reaches a caller.

Measured, not reasoned about. A consumer calling `((Entity.Sumf)e).Stringize()`
was compiled against a build of 2.3.0 and run unchanged against a build of this
branch; it prints what it printed before. Its metadata says why -- the emitted
reference is `AngouriMath.Entity::Stringize()` whatever the receiver's static
type, because C# binds a virtual call to the declaring type, so no consumer
names `Entity+Sumf::Stringize()` for the removal to reach.

Devirtualising `Stringize()` cannot orphan an outside override either: `Entity`
already had five `internal`/`private protected` abstract members, so no other
assembly could ever derive a node from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
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.

Stringize drops a node's Codomain, so domain(x, ZZ) prints as x and does not round trip

1 participant