Skip to content

Give floor and ceil a limit case, and answer the floor of an infinity (#829, #830) - #831

Merged
Rafael-SOWNet merged 2 commits into
masterfrom
fix/floor-ceil-limit-recursion
Aug 9, 2026
Merged

Give floor and ceil a limit case, and answer the floor of an infinity (#829, #830)#831
Rafael-SOWNet merged 2 commits into
masterfrom
fix/floor-ceil-limit-recursion

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes #829. Closes #830.

Two defects introduced by #827, in the order they have to be fixed: the second commit's answer at the
infinities depends on the first.

#830floor(+oo) threw OverflowException

The Real branch of evaluation converts through EInteger, which refuses the infinities and NaN,
and the exception reached the caller. An internal PeterO.Numbers fault is not one a caller of
Evaled has any reason to expect, and the neighbours do not raise it — abs(+oo) is +oo and
abs(0/0) is NaN.

An infinity is its own floor and its own ceil: there is no greatest integer below +oo. NaN
propagates. Both are exact, so the new branch sits ahead of the !isExact guard and Simplify
answers them too.

eval(floor(+oo))   OverflowException -> +oo
eval(ceil(-oo))    OverflowException -> -oo
eval(floor(0/0))   OverflowException -> NaN

#829 — a limit over floor or ceil killed the process

Neither node overrode ComputeLimitDivideEtImpera, so both took the base implementation, which
returns an unevaluated limit of the very node being asked about. The two-sided path compares its
one-sided results by evaluating them, evaluating a limit computes it, and control arrives back at the
same node. It is a stack overflow, so it is not an exception a caller can catch.

This is #704 on a new pair of nodes. The Signumf override written for that issue carries a comment
saying exactly this, and Modf's says it again for its own jumps; this adds the third case rather
than a third copy of the reasoning.

Away from its jumps a step function is locally constant, so the limit is the function of the
argument's limit. On a jump the two sides disagree, and which side the argument arrives from is not
decided by the side x approaches its destination from — lim(x -> 0+) floor(2 - x^2) reaches 2 from
below and lim(x -> 0+) floor(2 + x^2) from above, and both are limits from the right. So a jump is
declined with null, which hands the caller an unevaluated limit, and no value is invented for it.

lim(x -> 0)   floor(x)   *** stack overflow *** -> limit(floor(x), x, 0)
lim(x -> 2)   floor(x)   *** stack overflow *** -> limit(floor(x), x, 2)
lim(x -> 1/2) floor(x)   *** stack overflow *** -> 0
lim(x -> +oo) floor(x)   *** stack overflow *** -> +oo
lim(x -> -oo) ceil(x)    *** stack overflow *** -> -oo

The imaginary part is only checked for a genuinely complex limit: a real value's imaginary part is
identically zero rather than tending to zero, and the floor of a constant zero is constant. Like the
Absf and Signumf overrides beside it, this reads the argument as real-valued along the path;
narrowing that wants a way to decide realness the library does not have yet (#721), and it is written
into the remarks rather than assumed silently.

Declining is conservative in a couple of places where an answer exists — lim(x -> 0) floor(cos(x))
is 0 on a punctured neighbourhood and comes back unevaluated, because cos reaches the integer 1.
Unevaluated is honest there; inventing one of the two one-sided values would not be.

Measured

C# suite 5903 passed, 0 failed, 14 skipped
F# suite 130 passed
casbench 113/117 solved, 0 wrong, 0 error, 0 timeout — unchanged
rootcheck 596/596 clean
simpsweep 10463/10463 agree
propcheck 1340 checks, 0 failures

Worth doing next, and not in this PR

Neither of these is a mathematics mistake — #827 wired the node, evaluation, ToString, Latexise,
ToSympy, differentiation, InvertNode, Substitute, Sort, Domains, the grammar and a full test
class, and missed one file. Nothing in the repository lists where a new Entity subtype has to be
wired, so there was nothing to check against.

A test generated by reflection over every Function subtype, asserting that a limit of it terminates,
would have caught #829 before merge. round, trunc, min, max and the rest of #809 hit the same
trap.

Rafael-SOWNet and others added 2 commits August 9, 2026 01:00
floor(+oo), ceil(-oo) and floor(0/0) threw System.OverflowException out of
evaluation. The Real branch converts through EInteger, which refuses both the
infinities and NaN, and the exception reached the caller -- an internal
PeterO.Numbers fault is not one a caller of Evaled has any reason to expect, and
the neighbouring nodes do not raise it: abs(+oo) is +oo and abs(0/0) is NaN.

An infinity is its own floor and its own ceil: there is no greatest integer
below +oo, and every other system answers the infinity. NaN propagates, as it
does everywhere else. Both are exact, so the new branch sits ahead of the
!isExact guard and Simplify answers them too.

    eval(floor(+oo))   OverflowException -> +oo
    eval(ceil(-oo))    OverflowException -> -oo
    eval(floor(0/0))   OverflowException -> NaN

Introduced by #827. Suite 5903 passed, casbench 113/117 with 0 wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A limit over floor or ceil overflowed the stack and killed the process. Neither
node overrode ComputeLimitDivideEtImpera, so both took the base implementation,
which returns an unevaluated limit of the very node being asked about. The
two-sided path then compares its one-sided results by evaluating them,
evaluating a limit computes it, and control arrives back at the same node. It is
not an exception a caller can catch.

This is #704 on a new pair of nodes. The Signumf override written for that issue
carries a comment saying exactly this, and Modf's says it again for its own
jumps; this adds the third case rather than a third copy of the reasoning.

Away from its jumps a step function is locally constant, so the limit is the
function of the argument's limit. On a jump the two sides disagree, and which
side the argument arrives from is not decided by the side x approaches its
destination from -- lim(x -> 0+) floor(2 - x^2) reaches 2 from below and
lim(x -> 0+) floor(2 + x^2) from above, and both are limits from the right. So a
jump is declined with null, which hands the caller an unevaluated limit, and no
value is invented for it.

An infinity is not a jump: it is its own floor, which is what #830 made
answerable, so the limits at the infinities come out rather than being declined.

    lim(x -> 0)   floor(x)   *** stack overflow *** -> limit(floor(x), x, 0)
    lim(x -> 2)   floor(x)   *** stack overflow *** -> limit(floor(x), x, 2)
    lim(x -> 1/2) floor(x)   *** stack overflow *** -> 0
    lim(x -> +oo) floor(x)   *** stack overflow *** -> +oo
    lim(x -> -oo) ceil(x)    *** stack overflow *** -> -oo

The imaginary part is only checked for a genuinely complex limit: a real value's
imaginary part is identically zero rather than tending to zero, and the floor of
a constant zero is constant. Like the Absf and Signumf overrides beside it, this
reads the argument as real-valued along the path; narrowing that wants a way to
decide realness the library does not have yet (#721), and it is noted in the
remarks rather than assumed silently.

Introduced by #827. Suite 5903 passed, F# 130, casbench 113/117 with 0 wrong,
rootcheck 596/596, simpsweep 10463/10463, propcheck 1340 checks 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rafael-SOWNet added a commit that referenced this pull request Aug 9, 2026
… (#836)

ComputeLimitDivideEtImpera defaulted to new Limitf(this, x, dist, side). That is
the cycle AGENTS.md names, with this exact expression as its example: the caller
evaluates the returned node to compare it, evaluating a Limitf computes the limit,
and computing arrives back at the default. It overflows the stack, which kills the
process rather than raising anything a caller can catch.

So a node crashed by inheriting the default -- by not being mentioned anywhere.
Seven did:

  limit(floor(x), x, 0)     stack overflow, process dies
  limit(ceil(x), x, 0)      stack overflow
  limit(round(x), x, 0)     stack overflow
  limit(min(x, 1), x, 0)    stack overflow
  limit(max(x, 1), x, 0)    stack overflow
  limit(gcd(x, 2), x, 0)    stack overflow
  limit(phi(x), x, 0)       stack overflow

The first six arrived with #827 and #828, which added the nodes without the
ComputeLimitDivideEtImpera override that AddingNode.cs lists at step 3d. phi
predates both and is #833, so this is not only my regression -- the default has
been a landmine for every node that ever inherited it, which is what #704 was.

Returning null is what AGENTS.md prescribes and what every caller already expects:
each of the eleven call sites tests the result with `is { }` or switches on it, and
the signature was already Entity?. All seven now come back as an unevaluated limit
node, which is the honest "I could not settle this", and nothing else moves --
5928 tests passed before this change and after it.

LimitTerminatesOnEveryNodeTest enumerates the node types by reflection rather than
listing them, because the defect is in what a node inherits by not being
mentioned, and a per-node test cannot catch that: 45 types, each over three
destinations and three sides, asserting only that the call returns. Checked
against the unfixed code, where the run aborts.

This does not replace #831, which gives floor and ceil real limit answers and
fixes the OverflowException of #830. That adds capability; this removes the way to
crash. They compose, and the order does not matter.

Tests: 5973 passing, 0 failed, 14 skipped; F# 130.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rafael-SOWNet
Rafael-SOWNet merged commit aa0046d into master Aug 9, 2026
25 checks passed
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.

floor and ceil of an infinity or NaN throw OverflowException Limit over floor or ceil overflows the stack and kills the process

1 participant