Skip to content

Parse a quotient of two integer literals as the Rational it denotes (#873) - #957

Merged
Rafael-SOWNet merged 6 commits into
masterfrom
fix/parse-integer-quotient-as-rational
Aug 16, 2026
Merged

Parse a quotient of two integer literals as the Rational it denotes (#873)#957
Rafael-SOWNet merged 6 commits into
masterfrom
fix/parse-integer-quotient-as-rational

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Draft, and stacked on #956 — it contains that branch's commits until it merges. Addresses #873.

@Happypig375's rule, implemented: a quotient of two integer literals parses as the Rational it denotes, so a Rational's printed form parses back to a Rational and the round trip is an identity.

input prints back as equal
3.5 7/2 Rational yes (was no)
0.1 1/10 Rational yes (was no)
1/2 1/2 Rational yes
4/2 4 / 2 Divf yes, unchanged
1/0 1 / 0 Divf yes, guarded

A quotient that reduces to an integer is deliberately left alone. Parsing is not simplification: making 4/2 into 2 would discard what the caller wrote, and it already round-tripped. Only the non-integer case is one a Rational can print as.

This also deletes the ["1/2"] entry from EveryNodeSurvivesEveryPipelineTest.KnownRoundTripFailures.

Draft, because the fix has a measurable cost and the call is yours

Implementing it produced the mirror asymmetry the issue body predicted, and 8 existing tests detect it:

"pi / 2".ToEntity().Substitute("pi", 1)   // Divf(1, 2)   — the node already existed
"1 / 2".ToEntity()                        // Rational 1/2
//   structurally equal:      false   (was true)
//   after InnerSimplified:   true

It is narrower than "parse disagrees with construct" — building via operator / gives a Rational that does equal the parsed one, and InnerSimplified reconciles the two. The gap is only a Divf that already existed and whose children became integers by substitution, which is consistent with how substitution behaves generally ("x + 0".Substitute("x", 1) is 1 + 0, not 1).

The 8 failures are all of that one shape: 6 TestDivideByEntityStrict rows, 1 ToSympyCodeTest row that exists only to document #873, and 1 SubstituteTest. Each compares a substituted tree against an expectation written as a string, and the string now denotes a different tree.

I have not edited them, because making a test agree with new output is how a real signal gets buried, and this one is worth your eye: it is the cost of the rule, not a defect in it. If you want it landed I will update them and say in each what changed and why.

Not included

Complex has the identical defect. A constructed Complex prints 1 + 2i and parses back as a Sumf, or a Minusf for 3 - 4i, so KnownRoundTripFailures still has one entry after this. The parallel rule is "a sum or difference of a real literal and an imaginary one is a Complex" — happy to add it here or separately, whichever you prefer.

Why it is stacked

The first attempt failed in a way that was not a parser bug: domain(1/2, ZZ) stopped answering NaN, because rewriting the parsed tree rebuilt the node and dropped the codomain domain(...) had put on it. That is #955, fixed in #956. Any post-parse rewrite had the same hole, so this could not work around it.

🤖 Generated with Claude Code

Entity.Replace rebuilds every node on the path to a change, and each rebuild went
through a New(...) helper whose `new(...)` starts from the type's default codomain.
So any rewrite dropped a domain(...) annotation, Substitute included, since that is
built on Replace:

    "domain(sqrt(x), ZZ)".ToEntity().Substitute("x", "4/9")   evaluated to 2/3

2/3 is not an integer and the constraint existed to refuse it. The failure was silent
and one-sided -- constraints were only ever weakened, so a rewrite could make an
undefined expression look defined and never the reverse.

47 rebuild sites, all of the same shape: 41 target-typed `? this : new(...)` and 6
written with an explicit type in Piecewise, Interval, ConditionalSet and Matrix. Each
now carries the original's codomain.

The blast radius was the open question on the issue and it is zero: the suite passes
7263 with the fix in, unchanged, so nothing depended on the annotation being lost.
Three tests pin it -- through Replace, through Substitute, and one level deeper --
and all three fail without the change.

Found while implementing #873, whose parser-side fix is blocked behind this: any
post-parse rewrite has the same hole.
…quotient-as-rational

# Conflicts:
#	BREAKING-CHANGES.md
@Rafael-SOWNet Rafael-SOWNet added the Early PR For pull requests. Mark it if you follow the Early PR pattern. label Aug 16, 2026
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Sharper finding, and the reason this stays a draft rather than me updating the eight tests.

I went to fix them and found the change costs something bigger than eight expectations: after it, no textual spelling produces an unevaluated Divf of two integer literals at all.

written parses to
1/2 Rational
1 / 2 Rational
(1)/(2) Rational
((1))/((2)) Rational
1/(2) Rational
+1/2 Rational

That shape is still reachable by construction and by substitution — "pi / 2".Substitute("pi", 1) gives it — but it is no longer writable. InnerSimplifyTest.TestDivideByEntityStrict is exactly a test about unevaluated shape, and its twelve rows express their expectations as strings; six of them can no longer say what they mean.

I could make them pass by comparing InnerSimplified on both sides, and I deliberately have not: that turns a test named Strict into one that no longer checks strictness, which is the failure mode of editing a test until it agrees. The alternative is rewriting those expectations as constructed entities, which works and is honest, but the fact that the suite must resort to it is the finding.

So the trade, stated plainly:

Whether that is a good trade is a call about what the parser is for, and it is yours rather than mine. If you want it landed I will rewrite the six expectations as constructed entities and say so in each; if you would rather have the round trip fixed some other way — a rational literal in the grammar, as the issue's second option suggested — this branch is the wrong shape and should be closed.

A quotient of two integer literals now parses as the Rational it denotes, so
TestDivideByEntityStrict's expectations could no longer name the unevaluated trees the
operation produces. They are constructed instead, and kept as their own test rather
than folded back into the theory: comparing InnerSimplified on both sides would make
them pass and would stop the test being about strictness, which is what it is named
for.

ToSympyCode now emits sympy.Rational(1, 2) for an unsimplified 1/2, which is what the
simplified case already emitted -- the two spellings converge, which is the point of
the fix rather than a casualty of it.

SubstituteTest.TupleSub3 likewise: substitution leaves Divf(8, 11) and no text spells
that any more.

Suite 7261 passed, 0 failed.
…uotient-as-rational

# Conflicts:
#	BREAKING-CHANGES.md
@Rafael-SOWNet
Rafael-SOWNet marked this pull request as ready for review August 16, 2026 03:47
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Out of draft — the eight failures are resolved and the suite is green (7261 passed, 0 failed).

I said above I would not edit those tests until you had seen the cost. Here is how they were resolved, since the resolution is itself the evidence for what the change costs:

Six TestDivideByEntityStrict rows are now constructed rather than parsed. Their expectations name unevaluated quotients, and since this change no text spells one — so they moved into their own test with new Entity.Divf(1, 2) and a comment saying why. I deliberately did not make them pass by comparing InnerSimplified on both sides: that would work, and would stop the test being about strictness, which is what it is named for.

ToSympyCode improved rather than regressed. An unsimplified 1/2 now emits sympy.Rational(1, 2) where it emitted sympy.Integer(1) / 2 before — which is exactly what the simplified case already emitted one theory above. The two spellings converge, so the special handling that existed because of this issue is no longer needed.

SubstituteTest.TupleSub3 is the same shape as the six: substitution leaves Divf(8, 11) and that can no longer be written as a string.

So the trade stands as described — a Rational now survives print-and-reparse, at the cost of being unable to write an unevaluated integer quotient — and the test suite is the clearest illustration of both halves. Rebased onto master after #956 merged.

…uotient-as-rational

# Conflicts:
#	BREAKING-CHANGES.md
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Synced with master after #959#963 merged.

Merged rather than rebased, deliberately: upstream squash-merges, so this branch's earlier merge of the #956 work is in master by content but not by identity — a rebase would replay those commits against a tree that already contains them.

The conflict was BREAKING-CHANGES.md in two places, both the additive kind: #958's table row and entry against this one's. Both kept, master's first.

Suite 7275 passed, 0 failed on the merged tree, so this composes with all five.

@Rafael-SOWNet
Rafael-SOWNet merged commit 4b2c104 into master Aug 16, 2026
25 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the fix/parse-integer-quotient-as-rational branch August 16, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Early PR For pull requests. Mark it if you follow the Early PR pattern.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant