Chapter 3
15 min read
Section 24 of 353

The Three Conditions for Continuity

Continuity - No Breaks, No Jumps

Learning Objectives

After this section you will be able to:

  1. State the three conditions that must ALL hold for ff to be continuous at a point aa.
  2. Diagnose which of the three conditions fails for a given discontinuity.
  3. Distinguish a hole (Condition 1), a jump (Condition 2), and a mismatched dot (Condition 3) by looking at a graph.
  4. Implement the three-condition test in plain Python and in PyTorch.
  5. Apply the test by hand to a small family of functions without software.

Why We Need a Formal Test

In §3.1 we described continuity with an evocative picture: you can sketch the graph without lifting your pencil. That metaphor is perfect for a quick feel, but it is hopeless as a test. Does the graph of f(x)=sin(1/x)x\displaystyle f(x) = \tfrac{\sin(1/x)}{x} count? Your pen would blur into noise near the origin. Does the graph of a function defined only on the rationals count? You can't draw it at all. We need a test that doesn't rely on anyone's drawing hand.

That test is the subject of this section. It has exactly three parts, and each part plugs directly into the language of limits we developed in Chapter 2. The beauty of the test is that the three parts are INDEPENDENT — each can fail on its own, each produces a different visual signature in the graph, and each can be detected numerically.

Continuity at a point is a contract between three quantities: f(a)f(a), limxaf(x)\lim_{x \to a^{-}} f(x), and limxa+f(x)\lim_{x \to a^{+}} f(x). Continuity says all three exist and are equal. Break any one of the three, and the contract is broken.

The Three Conditions — Stated

Definition 3.2.1 — Continuity at a Point

A function ff is continuous at x=ax = a if and only if all three of the following hold:

(1)  f(a)  is definedf(a)\ \ \text{is defined}
(2)  limxaf(x)  exists (as a finite number)\displaystyle \lim_{x \to a} f(x)\ \ \text{exists (as a finite number)}
(3)  limxaf(x)=f(a)\displaystyle \lim_{x \to a} f(x) = f(a)

If any one of (1), (2), (3) fails, we say ff is discontinuous at aa.

Logicians love this definition because it packs a lot of machinery into three short lines. Condition 1 invokes the domain of ff. Condition 2 invokes the entire ε\varepsilon-δ\delta theory of limits. Condition 3 links the two worlds — the value of ff at a single point must agree with the trend of ff around that point.

The next three subsections walk through each condition in turn.


Condition 1 — f(a)f(a) Must Be Defined

This is the most primitive of the three: before we can even talk about continuity at a point, the function must actually produce a value there. The point aa must be in the domain of ff.

The canonical failure mode is a removable singularity — a single isolated point where the formula produces 0/00/0, a square root of a negative, a logarithm of zero, or any other forbidden operation.

f(x)=x29x3,a=3\displaystyle f(x) = \frac{x^{2} - 9}{x - 3}, \qquad a = 3

Plug in every number except 3 and you get a sensible output — in fact you always get x+3x + 3, because x29=(x3)(x+3)x^{2} - 9 = (x-3)(x+3). Plug in 3 and you are handed 0/00/0. Condition 1 fails on sight: there is no f(3)f(3).

A subtlety

The two-sided limit as x3x \to 3 DOES exist — it is 6 (both sides point there). So conditions 2 and 3 can't even be evaluated in the usual sense; the function is not continuous at 3 purely because it doesn't live at 3. We call this a removable discontinuity because if we redefine the function to equal 6 at the missing point, we glue the hole shut and recover continuity.


Condition 2 — The Limit Must Exist

Even if f(a)f(a) is perfectly well defined, continuity further demands that the function have a consistent target as inputs approach aa. Formally, both one-sided limits must exist and agree:

limxaf(x)=limxa+f(x)=LR\displaystyle \lim_{x \to a^{-}} f(x) = \lim_{x \to a^{+}} f(x) = L \in \mathbb{R}

The classic failure is a jump discontinuity: a piecewise-defined function whose left and right pieces disagree at the joint. Consider

g(x)={x+1x<23x=2x+2x>2\displaystyle g(x) = \begin{cases} x + 1 & x < 2 \\ 3 & x = 2 \\ x + 2 & x > 2 \end{cases}

Condition 1 passes: g(2)=3g(2) = 3. But as xx climbs toward 2 from the left, the outputs approach 33; as it descends from the right, the outputs approach 44. Two one-sided targets, no single two-sided target, no limit. Condition 2 fails.

Don't confuse blow-up with a jump

A second way Condition 2 can fail is if either one-sided limit is infinite — think of 1/(x2)1/(x-2) near x=2x = 2. Even though the limit in some loose sense “is ±\pm \infty”, our definition demands a finite LL. Infinite limits are discussed in §2.4 and §3.3; for continuity they are just another way Condition 2 fails.


Condition 3 — The Two Must Match

Here is the most subtle condition. Even if f(a)f(a) exists and the two-sided limit exists, continuity further requires the two to be EQUAL. The trend and the value must agree on the same number.

It sounds like this should be automatic — and for “nice” functions it is — but a sadistic textbook author can easily break it with a single line of piecewise definition:

h(x)={x+3x15x=1\displaystyle h(x) = \begin{cases} x + 3 & x \neq 1 \\ 5 & x = 1 \end{cases}

Everywhere except the single input 1, this is the innocuous line y=x+3y = x + 3. The two-sided limit as x1x \to 1 is 44. But the value h(1)h(1) has been artificially set to 55. Conditions 1 and 2 pass; Condition 3 fails because 454 \neq 5. Graphically, a filled dot floats one unit above the line at a single point.

Why this matters

Condition 3 is exactly what lets calculus be local. If you know the function near a point and you know the function AT a point, continuity says those two pieces of information cannot contradict each other. The derivative, the integral, and every approximation theorem depends on this agreement.


Interactive — Continuity Tester

Pick a scenario. Shrink the probe distance hh to sub-pixel precision. The panel below the plot reports, in real time, which of the three conditions pass and which fail. Each scenario is rigged so that a DIFFERENT condition is the one that breaks — verify this for yourself by reading the verdict.

Loading continuity tester…

How to read the panel

Each condition shows a green ✓ (passing), a red ✗ on the actual failer, and a grey ✗ on conditions that can't even be tested because an earlier one failed. The verdict at the bottom names the specific reason the function is (or isn't) continuous.


Interactive — Drag the Point

The previous tester is fixed: you cannot change what f(a)f(a) is. Here we flip that — the line is locked as y=x+3y = x + 3 with a hole at (1,4)(1, 4), and YOU get to drag the filled dot (that is, f(1)f(1)) up and down. Condition 3 flips the moment the dot leaves the line.

Loading drag-the-point demo…

This demo is the cleanest possible picture of Condition 3: the limit is the target the function is heading toward; the dot is the value we chose to put at aa. Continuity is the demand that target and value coincide. Everything else — jumps, holes, asymptotes — boils down to this alignment, possibly with one of its operands missing.


Numerical Table Check

Before touching software, let us rehearse the three-condition check by hand on our four scenarios. Each row reports what each condition sees; the final column records the verdict.

Scenariof(a)left limitright limitCondition failedContinuous?
f(x) = (x²−9)/(x−3) at a = 3undefined661no
g(x) piecewise at a = 23342no
h(x) = x+3, h(1) = 5 at a = 15443no
p(x) = x² + 1 at a = 2555noneyes

Each row lines up with a button in the interactive tester above. The numbers in the middle columns are what the sliders report as you shrink hh. Reading the table horizontally is exactly the three-condition test: look at f(a)f(a), then check the two one-sided limits, then compare them to f(a)f(a).


Worked Example — By Hand

Let us commit one of the scenarios to paper and execute the test step-by-step. We take the mismatch scenario because it exercises all three conditions: two pass, one fails.

Click to expand — apply the three-condition test to h(x)h(x) at a=1a = 1
Setup.
h(x)={x+3x15x=1,a=1h(x) = \begin{cases} x + 3 & x \neq 1 \\ 5 & x = 1 \end{cases}, \quad a = 1

We want to decide whether hh is continuous at 11. We must verify all three conditions; a single failure kills continuity.

Condition 1 — Is h(1)h(1) defined?
h(1)=5h(1) = 5 \quad \checkmark

The piecewise definition gives us h(1)=5h(1) = 5 directly. Condition 1 passes.

Condition 2 — Does limx1h(x)\lim_{x \to 1} h(x) exist?

Because every nearby input satisfies x1x \neq 1, the value of h(x)h(x) on those inputs is exactly x+3x + 3. So

limx1h(x)=limx1(x+3)=4,limx1+h(x)=limx1+(x+3)=4\displaystyle \lim_{x \to 1^{-}} h(x) = \lim_{x \to 1^{-}} (x + 3) = 4, \qquad \lim_{x \to 1^{+}} h(x) = \lim_{x \to 1^{+}} (x + 3) = 4

Both one-sided limits equal 4, so the two-sided limit exists and equals 4. Condition 2 passes.

limx1h(x)=4\displaystyle \lim_{x \to 1} h(x) = 4 \quad \checkmark
Condition 3 — Do the limit and the value agree?
limx1h(x)=4,h(1)=5,45×\displaystyle \lim_{x \to 1} h(x) = 4, \qquad h(1) = 5, \qquad 4 \neq 5 \quad \times

The limit insists the function is heading toward 4, but the value we have chosen at 11 is 5. Condition 3 fails.

Conclusion. hh is not continuous at 11. The discontinuity is removable: redefine h(1)=4h(1) = 4 and continuity is restored.

Self-check

Redo the same three-step check on the polynomial p(x)=x2+1p(x) = x^{2} + 1 at a=2a = 2. You should find: p(2)=5p(2) = 5, limx2p(x)=5\lim_{x \to 2} p(x) = 5, and they match — so pp is continuous at 2. This is the positive control that confirms the test works on continuous functions too, not just on pathological ones.


Python — The Three-Condition Checker

We now convert the test into code. The goal is to build a single function, is_continuous_at, that takes any scalar function and any point, and returns each of the three verdicts plus the overall call. We then run it on the four scenarios from the interactive tester. Click any line of code to see the exact values flowing through it.

Plain Python — three-condition continuity checker
🐍continuity_three_conditions.py
1def f(x) → float | None

Our HOLE scenario. The formula (x² − 9)/(x − 3) is undefined when x = 3, because the denominator hits zero. We return None explicitly at x = 3 so our continuity check can distinguish 'undefined' from an actual number. Away from x = 3, the formula simplifies (factor the numerator) to x + 3 — so the whole graph looks like the line y = x + 3, with a single hole at the point (3, 6).

EXECUTION STATE
⬇ input: x = Any real number. The only forbidden input is 3 — at that exact value the formula would be 0/0.
⬆ returns = float — the value of (x² − 9)/(x − 3) — OR None if x == 3. Away from x = 3 this equals x + 3; at x = 3 the function is undefined.
2Docstring — "Hole at x = 3"

Announces the scenario. Future readers instantly know the function's role: it is our Condition-1 failure case — defined everywhere except at the one input we care about.

3if x == 3:

Guards the forbidden input. Python's == compares values (not identity). For floats this is usually fine, but beware: 3.0 == 3 returns True; 3.0000000001 == 3 returns False. For this pedagogical check, exact comparison is what we want.

4return None

Returns the sentinel value None — Python's built-in 'no value here' object. None is not 0, not False, not an empty string; it is its own singleton type (NoneType). Downstream code can test 'is None' to detect 'the function was undefined at this input'.

EXECUTION STATE
📚 None = Python's null singleton. Used whenever a function has nothing meaningful to return. Comparison: None is None → True, None == False → False, None == 0 → False. Always test with 'is None' not '== None'.
⬆ return (at x = 3) = None
5return (x * x - 9) / (x - 3)

Computes the raw formula for every x other than 3. x * x is the square (faster than x ** 2 for scalars). Example: at x = 3.001, numerator = 9.006001 − 9 = 0.006001, denominator = 0.001, result ≈ 6.001. At x = 2.999, result ≈ 5.999. Both lean toward 6 — the number the line would 'want' to pass through at x = 3.

EXECUTION STATE
x * x - 9 = Numerator. At x = 2.999 this is 8.994001 − 9 = −0.005999. At x = 3.001 this is 9.006001 − 9 = 0.006001. Both are tiny — of order |x − 3|.
x - 3 = Denominator. At x = 2.999 this is −0.001. At x = 3.001 this is 0.001. Vanishes exactly when the numerator does.
⬆ return: f(3.001) = 0.006001 / 0.001 = 6.001000
7def g(x) → float

Our JUMP scenario. A piecewise function that behaves like x + 1 on the left, x + 2 on the right, and takes the loner value 3 right at x = 2. The two pieces have a vertical gap of exactly 1 unit at x = 2. This breaks condition 2: the two one-sided limits disagree.

EXECUTION STATE
⬇ input: x = Any real number.
⬆ returns = float. Values: g(1.9) = 2.9, g(2.0) = 3, g(2.1) = 4.1. Notice the value AT 2 (= 3) is neither the left-limit target (= 3) nor the right-limit target (= 4) in a way that matters — the left limit IS 3 which matches f(2), but right limit is 4 which does not.
8Docstring — "Jump"

Tells the reader this is a three-branch piecewise function. The comment is the only place where the jumping structure is written in one line — the code below spells it out branch by branch.

9if x < 2:

First branch. Matches every input strictly below 2. Note: Python evaluates branches top-down and returns as soon as one matches — no explicit elif needed because each branch returns.

10return x + 1 # left piece

Left-piece formula. At x = 1.99, returns 2.99. As x climbs toward 2 from below, this output approaches 3. That 3 is the LEFT limit at x = 2.

EXECUTION STATE
⬆ return: g(1.99) = 2.99
⬆ return: g(1.999) = 2.999
⬆ left limit target = 3.000
11if x > 2:

Second branch. Matches every input strictly above 2. Because the first branch already returned for x < 2, by the time we reach this line we know x >= 2.

12return x + 2 # right piece

Right-piece formula. At x = 2.01, returns 4.01. As x descends toward 2 from above, this output approaches 4. That 4 is the RIGHT limit at x = 2 — different from the left limit (= 3). The limit fails to exist.

EXECUTION STATE
⬆ return: g(2.01) = 4.01
⬆ return: g(2.001) = 4.001
⬆ right limit target = 4.000
13return 3 # the single point at x = 2

Only reached when x == 2 (because both earlier branches returned). The function is defined at 2, and its value is 3. So condition 1 passes (f(2) exists), but condition 2 fails because left-target 3 ≠ right-target 4.

EXECUTION STATE
⬆ return: g(2) = 3
15def h(x) → float

Our MISMATCH scenario. Everywhere except x = 1, the function behaves like x + 3 — a clean straight line. But exactly at x = 1, we override the value to 5. Condition 1 passes (h(1) = 5). Condition 2 passes (both sides approach 4). Condition 3 fails (5 ≠ 4).

EXECUTION STATE
⬇ input: x = Any real number.
⬆ returns = float. h(0.999) = 3.999, h(1) = 5, h(1.001) = 4.001. The graph is the line y = x + 3 with a single filled dot floating ABOVE the line at (1, 5) and a hollow dot on the line at (1, 4).
16Docstring — "Mismatch"

Announces the pedagogical role: this function is defined everywhere and has a proper limit everywhere, but the value AT x = 1 is deliberately wrong. The dot has been pushed off the line.

17return 5 if x == 1 else x + 3

Python's ternary expression: <value if cond> if <cond> else <value if not cond>. Equivalent to a one-line if/else. At x = 1 we return 5; at all other x we return x + 3.

EXECUTION STATE
📚 ternary expression = Syntax: A if condition else B. Evaluates the condition; returns A if True, B if False. Example: 'pass' if score >= 60 else 'fail'. This is a single Python expression — can be used inside a return, an assignment, a list comprehension, etc.
⬆ return: h(1) = 5 — the misplaced dot
⬆ return: h(0.999) = 3.999
⬆ return: h(1.001) = 4.001
19def p(x) → float

Our CONTINUOUS scenario. A textbook polynomial. Polynomials are continuous everywhere by §3.4 — but we do not need that result yet: we can verify continuity at x = 2 using only the three-condition test.

EXECUTION STATE
⬇ input: x = Any real number — polynomials have domain ℝ.
⬆ returns = float = x² + 1. Example: p(2) = 5, p(1.999) = 4.996001, p(2.001) = 5.004001. All three numbers agree tightly near 2.
20Docstring — "Continuous"

Signals that this is the positive control — the only function in our suite for which all three conditions will pass.

21return x * x + 1

Computes the polynomial. At x = 2 we get 4 + 1 = 5. At x = 1.999999 we get 3.999996000001 + 1 ≈ 4.999996. At x = 2.000001 we get 5.000004. The three numbers collapse onto the same value as the probe shrinks.

EXECUTION STATE
⬆ return: p(2) = 5.0
⬆ return: p(1.999999) = 4.999996
⬆ return: p(2.000001) = 5.000004
23def is_continuous_at(fn, a, tol=1e-6) → dict

The three-condition test itself — the machinery that mirrors the formal definition in code. It takes ANY scalar-valued Python function fn, a candidate point a, and a tolerance tol. It returns a dictionary summarising each condition's verdict plus the overall call.

EXECUTION STATE
⬇ input: fn = A Python function. Must accept a single float and return either a float or None (None means 'undefined here'). We will pass f, g, h, p in turn.
⬇ input: a = The candidate point where we test continuity. A float such as 3, 2, or 1. The test is LOCAL — it only asks about behaviour at a and immediately around a.
⬇ input: tol = 1e-6 = How close to a the left/right probes sit. 1e-6 = 0.000001. Smaller tol is more faithful to the real limit but can introduce floating-point noise. Default is a sensible middle ground.
⬆ returns = A dict with six keys: f(a) (the value), limit (the two-sided target if it exists), c1/c2/c3 (the three condition verdicts), continuous (the AND of all three).
24Docstring — "the three-condition test"

Reminds the reader that this ONE function is the computational shadow of Definition 3.2.1. Every branch below corresponds to a line of the formal definition.

25fa = fn(a) # Condition 1

Calls fn at the target point and stores the result. For f at a = 3 this returns None (the function is undefined). For g at a = 2 it returns 3. For h at a = 1 it returns 5. For p at a = 2 it returns 5.

EXECUTION STATE
fa for each scenario =
f(3) = None   (undefined)
g(2) = 3      (branch 3 of the piecewise)
h(1) = 5      (the misplaced dot)
p(2) = 5      (x² + 1 at 2)
26cond1 = fa is not None

The first condition: 'f(a) is defined'. We translate this to 'fa is not the None sentinel'. 'is not' is the identity check (fast, canonical); 'is not None' is the idiomatic way to test for non-None in Python.

EXECUTION STATE
📚 is not = Python's identity operator. a is not b is True iff a and b refer to different objects in memory. For None there is exactly one None object in the whole runtime, so 'x is not None' is the canonical 'x is something' test.
cond1 per scenario =
f at 3  → False  (fa is None — condition 1 fails)
g at 2  → True
h at 1  → True
p at 2  → True
28left = fn(a - tol) # Condition 2

Left-side probe. Computes fn at a point 1e-6 below a. If the function is undefined there (very rare for our scenarios), we get None back. The key idea: we NEVER evaluate fn AT a for this line — we only care about what's happening NEAR a.

EXECUTION STATE
a - tol per scenario =
f: 3 − 1e-6 = 2.999999
g: 2 − 1e-6 = 1.999999
h: 1 − 1e-6 = 0.999999
p: 2 − 1e-6 = 1.999999
left per scenario =
f(2.999999) = 5.999999
g(1.999999) = 2.999999   (uses x + 1 branch)
h(0.999999) = 3.999999   (uses x + 3 branch)
p(1.999999) = 4.999996
29right = fn(a + tol)

Right-side probe. Symmetric to line 28. For the jump scenario this is where the drama happens — the right-side probe uses the x + 2 branch and returns something completely different from the left-side probe.

EXECUTION STATE
right per scenario =
f(3.000001) = 6.000001
g(2.000001) = 4.000001   (x + 2 branch — JUMP)
h(1.000001) = 4.000001
p(2.000001) = 5.000004
30cond2 = (left is not None and right is not None

Start of the condition-2 predicate. First we ensure that both probes returned actual numbers — if either side is None the limit can't exist in the usual sense (we'd have a one-sided-only function, like √x at 0).

31 and abs(left - right) < 1e-3)

The actual numerical test: 'the left and right probes agree to within 1e-3'. |left − right| is the gap between the two one-sided readings. If this gap is tiny, we accept that the two sides are converging on the same target — the two-sided limit EXISTS.

EXECUTION STATE
📚 abs() = Python builtin: absolute value. abs(−3) = 3, abs(2.5) = 2.5. For floats it returns a non-negative float.
📚 1e-3 = Scientific notation: 1 × 10⁻³ = 0.001. Our tolerance for 'the two sides agree'. Looser than tol=1e-6 (the probe distance) because tiny floating-point roundoff can accumulate even when the math is exact.
|left − right| per scenario =
f: |5.999999 − 6.000001| = 2e-6    < 1e-3 → True
g: |2.999999 − 4.000001| = 1.000002 > 1e-3 → FALSE (jump!)
h: |3.999999 − 4.000001| = 2e-6    < 1e-3 → True
p: |4.999996 − 5.000004| = 8e-6    < 1e-3 → True
cond2 per scenario =
f → True   (limit = 6 exists)
g → False  (condition 2 fails — JUMP)
h → True   (limit = 4 exists)
p → True   (limit = 5 exists)
33limit = (left + right) / 2 if cond2 else None

If the two sides agreed, take their midpoint as our estimate of the limit. The average suppresses floating-point asymmetry between the two probes. If the sides disagreed (cond2 False), we have no limit to report — set limit to None so condition 3 has a clear signal.

EXECUTION STATE
limit per scenario =
f: (5.999999 + 6.000001)/2 = 6.000000
g: None  (cond2 was False)
h: (3.999999 + 4.000001)/2 = 4.000000
p: (4.999996 + 5.000004)/2 = 5.000000
34cond3 = cond1 and cond2 and abs(limit - fa) < 1e-3

The third condition with short-circuit protection. Python's 'and' is lazy — the moment one operand is False, the rest is skipped. So we only compute abs(limit - fa) when cond1 and cond2 are both True. If either previous condition failed, limit or fa might be None, and subtracting None from a float would raise TypeError.

EXECUTION STATE
📚 short-circuit evaluation = Python's 'and' and 'or' don't necessarily evaluate all operands. For A and B and C, if A is False, Python stops and returns False — B and C are never touched. This is why we can safely write abs(limit - fa) here; if cond2 was False we never reach it.
|limit − fa| per scenario =
f: cond1 False → skip        → cond3 False
g: cond2 False → skip        → cond3 False
h: |4.000 − 5| = 1.000 > 1e-3 → cond3 False (MISMATCH)
p: |5.000 − 5| ≈ 1e-12         → cond3 True
cond3 per scenario =
f → False
g → False
h → False  (limit = 4 but f(1) = 5)
p → True
36return {"f(a)": fa, "limit": limit,

Starts a dictionary literal. Each key is a string describing what the value means. Dict literals use {} braces with key: value pairs separated by commas.

EXECUTION STATE
📚 dict literal = Python's mapping type. {} creates an empty dict; {'a': 1, 'b': 2} creates a two-entry dict. Keys can be any hashable value (strings, ints, tuples); values can be anything. Access with d['a'].
37 "c1": cond1, "c2": cond2, "c3": cond3,

Adds the three verdicts. Using short keys c1/c2/c3 keeps printed output readable on a terminal line.

38 "continuous": cond1 and cond2 and cond3}

The final AND. Continuity at a means ALL three conditions hold. If any one is False, the entire expression is False and we correctly report 'not continuous'. Closes the dict literal.

EXECUTION STATE
continuous per scenario =
f → False  (condition 1 failed — the hole)
g → False  (condition 2 failed — the jump)
h → False  (condition 3 failed — the mismatch)
p → True   (all three pass — continuous)
40for name, fn, a in [...]:

Iterates over four (name, function, point) triples. Each iteration unpacks the three elements of the tuple into the loop variables.

EXECUTION STATE
📚 tuple unpacking = Python syntax: 'for a, b, c in list_of_triples' binds each element of a triple to a, b, c in turn. Saves the boilerplate of indexing [0], [1], [2]. Equivalent to: for t in list: name, fn, a = t.
LOOP TRACE · 4 iterations
iter 1
name, fn, a = "f", f, 3
iter 2
name, fn, a = "g", g, 2
iter 3
name, fn, a = "h", h, 1
iter 4
name, fn, a = "p", p, 2
41print(f"{name} at x = {a}: {is_continuous_at(fn, a)}")

Calls the three-condition test and prints the result. The f-string embeds both the scenario name and the returned dictionary.

LOOP TRACE · 4 iterations
iter 1 (f at 3 — HOLE)
⬆ printed = f at x = 3: {'f(a)': None, 'limit': 6.0, 'c1': False, 'c2': True, 'c3': False, 'continuous': False}
iter 2 (g at 2 — JUMP)
⬆ printed = g at x = 2: {'f(a)': 3, 'limit': None, 'c1': True, 'c2': False, 'c3': False, 'continuous': False}
iter 3 (h at 1 — MISMATCH)
⬆ printed = h at x = 1: {'f(a)': 5, 'limit': 4.0, 'c1': True, 'c2': True, 'c3': False, 'continuous': False}
iter 4 (p at 2 — CONTINUOUS)
⬆ printed = p at x = 2: {'f(a)': 5, 'limit': 5.0, 'c1': True, 'c2': True, 'c3': True, 'continuous': True}
8 lines without explanation
1def f(x):
2    """Hole at x = 3: (x^2 - 9) / (x - 3) is undefined when x = 3."""
3    if x == 3:
4        return None
5    return (x * x - 9) / (x - 3)
6
7def g(x):
8    """Jump at x = 2: left piece x+1, right piece x+2, value at 2 is 3."""
9    if x < 2:
10        return x + 1
11    if x > 2:
12        return x + 2
13    return 3
14
15def h(x):
16    """Mismatch at x = 1: f is x+3 everywhere except f(1) = 5."""
17    return 5 if x == 1 else x + 3
18
19def p(x):
20    """Continuous: a polynomial."""
21    return x * x + 1
22
23def is_continuous_at(fn, a, tol=1e-6):
24    """Apply the three-condition test to fn at x = a."""
25    fa    = fn(a)                                       # Condition 1
26    cond1 = fa is not None
27
28    left  = fn(a - tol)                                 # Condition 2
29    right = fn(a + tol)
30    cond2 = (left is not None and right is not None
31             and abs(left - right) < 1e-3)
32
33    limit = (left + right) / 2 if cond2 else None
34    cond3 = cond1 and cond2 and abs(limit - fa) < 1e-3  # Condition 3
35
36    return {"f(a)": fa, "limit": limit,
37            "c1": cond1, "c2": cond2, "c3": cond3,
38            "continuous": cond1 and cond2 and cond3}
39
40for name, fn, a in [("f", f, 3), ("g", g, 2), ("h", h, 1), ("p", p, 2)]:
41    print(f"{name} at x = {a}: {is_continuous_at(fn, a)}")

The four output lines line up exactly with the four scenarios of the interactive tester — hole fails Condition 1, jump fails Condition 2, mismatch fails Condition 3, polynomial passes all three. No calculus was required at runtime; the function did the detective work by poking at the three predicates.

Numerical caveat

A finite probe distance tol\text{tol} can mis-diagnose wildly oscillating functions (think sin(1/x)\sin(1/x) near 0): at some small but non-zero tol\text{tol}, the left and right probes might accidentally agree even though the true limit does not exist. Our plain-Python checker will happily report continuity in that case — wrongly. The formal ε\varepsilon-δ\delta definition closes this loophole; numerical probes cannot.


PyTorch — Checking Many Points at Once

The plain-Python checker handles one point per call. What if we want to scan a thousand candidate points and find all the discontinuities? A Python loop would work but would be slow. PyTorch lets us probe every candidate in a single tensor operation. We demonstrate on the jump scenario: the checker must flag x=2x = 2 as discontinuous while passing all other candidates.

PyTorch — vectorised three-condition check over many points
🐍continuity_pytorch_batch.py
1import torch

Loads PyTorch. We need three of its features for this example: (1) torch.tensor — N-dimensional array of floats; (2) torch.where — element-wise conditional that lets us write a piecewise function without a Python loop; (3) broadcasting — arithmetic on tensors happens element-wise in C++, so we can probe many candidate points in parallel.

EXECUTION STATE
torch = PyTorch library. Tensor operations, auto-differentiation, neural-network modules, GPU execution.
3Comment — the piecewise scenario

We are reusing the JUMP function from the plain-Python block. Left piece is x + 1, right piece is x + 2. In the plain-Python version we wrote three if-branches; here the entire piecewise will fit in ONE line thanks to torch.where.

4def g(x: torch.Tensor) → torch.Tensor

Type-annotated function. Takes a tensor of x-values and returns a tensor of g-values of the same shape. The key insight: this works for ANY shape — a scalar tensor, a 1-D tensor of 6 points, a 2-D grid of 100×100 points. PyTorch broadcasts the comparison and the arithmetic.

EXECUTION STATE
⬇ input: x = A tensor of arbitrary shape. Every element will be processed in parallel. Example input used below: tensor([0.4999, 0.9999, 1.4999, 1.9999, 2.4999, 2.9999]) (shape [6]).
⬆ returns = Tensor of the same shape as x. Each element holds g evaluated at the corresponding element of x.
5return torch.where(x < 2, x + 1, x + 2)

torch.where is the vectorised ternary. For each element xi of x, it returns (x + 1)[i] if the condition (x < 2)[i] is True, otherwise (x + 2)[i]. No Python-level branching — all three of the condition tensor, the true-branch tensor, and the false-branch tensor are computed eagerly in C++, then selected element by element.

EXECUTION STATE
📚 torch.where(condition, a, b) = PyTorch function: element-wise select. For each index i, returns a[i] if condition[i] is True, else b[i]. All three arguments are broadcasted to a common shape. Example: torch.where(torch.tensor([True, False]), torch.tensor([1,2]), torch.tensor([10,20])) → tensor([1, 20]).
⬇ arg 1: x < 2 = Element-wise comparison. Returns a BoolTensor of the same shape. Example for our a below: tensor([True, True, True, False, False, False]).
⬇ arg 2: x + 1 = True-branch values. Computed for every element regardless of the condition (PyTorch is eager). The elements where the condition is False are then discarded by torch.where.
⬇ arg 3: x + 2 = False-branch values. Same shape as arg 2; same eager-evaluation remark.
→ Why eager both branches? = torch.where is not short-circuit. If one branch would divide by zero or blow up on SOME elements, you must protect it before passing it in — e.g., torch.where(x != 0, 1/x, 0.0) still evaluates 1/x on the x == 0 elements and produces inf/nan there, which is then discarded. For this piecewise we are safe: x + 1 and x + 2 are defined for every real x.
7Comment — vectorised test plan

Announces the strategy: we will NOT loop over candidate points in Python. One tensor of candidate points, one tensor of left probes, one tensor of right probes, one tensor of values-at-point. Every comparison is done in one tensor op.

8a = torch.tensor([0.5, 1.0, 1.5, 2.0, 2.5, 3.0])

Six candidate points spanning the interesting territory. We deliberately include 2.0 (the jump location) so the test will catch it. The other five points are placed on both sides of the jump to confirm the function is continuous everywhere else.

EXECUTION STATE
📚 torch.tensor(data) = Factory function that builds a tensor from a Python list / nested list / numpy array. The dtype is inferred (here float32 because the data contains floats).
⬆ a = tensor([0.5000, 1.0000, 1.5000, 2.0000, 2.5000, 3.0000])
a.shape = torch.Size([6]) — a 1-D tensor of length 6
9h = 1e-4

Probe distance. Smaller than the plain-Python version's 1e-6 because our cond2 tolerance is 1e-2 (see below), and we want the probe offset to be well under the tolerance. 1e-4 gives comfortable headroom.

EXECUTION STATE
h = 0.0001
11left = g(a - h)

Vectorised left-side probe. a - h is a new tensor with every element shifted by 0.0001. Passing it through g returns another tensor of the same shape.

EXECUTION STATE
a - h = tensor([0.4999, 0.9999, 1.4999, 1.9999, 2.4999, 2.9999])
⬆ left = g(a - h) = tensor([1.4999, 1.9999, 2.4999, 2.9999, 4.4999, 4.9999]) (1st three use x+1; last three use x+2)
12right = g(a + h)

Vectorised right-side probe. Symmetric to the previous line. The interesting row is a = 2.0: left = 2.9999 (uses x + 1 because 1.9999 < 2), right = 4.0001 (uses x + 2 because 2.0001 >= 2). The jump!

EXECUTION STATE
a + h = tensor([0.5001, 1.0001, 1.5001, 2.0001, 2.5001, 3.0001])
⬆ right = g(a + h) = tensor([1.5001, 2.0001, 2.5001, 4.0001, 4.5001, 5.0001]) (all use x+2 branch — even the first three, because 0.5001 < 2 is True, so wait...)
→ careful read = Actually the first three use the x + 1 branch (0.5001, 1.0001, 1.5001 are all < 2). Only a = 2.0 is the problem: a − h = 1.9999 uses x + 1 (= 2.9999) while a + h = 2.0001 uses x + 2 (= 4.0001). That's the jump — a gap of ~1 between the two sides.
13at_a = g(a)

The value AT each candidate — plain (no perturbation). For a = 2.0 exactly, Python evaluates 2.0 < 2 → False, so g(2.0) falls into the x + 2 branch and returns 4.0. This is a quirk of our particular piecewise: at the boundary, strict-less-than sends us right.

EXECUTION STATE
⬆ at_a = g(a) = tensor([1.5000, 2.0000, 2.5000, 4.0000, 4.5000, 5.0000])
→ at a = 2 = The condition `2 < 2` is False, so g(2) returns 2 + 2 = 4.
15gap_limit = (left - right).abs()

Element-wise |left − right|. Tiny where the function is continuous; ≈ 1 at the jump point. Notice .abs() is a tensor METHOD (called on the tensor), not a standalone function — both torch.abs(t) and t.abs() work.

EXECUTION STATE
📚 .abs() = Tensor method: element-wise absolute value. tensor([-1.0, 2.0, -3.0]).abs() → tensor([1.0, 2.0, 3.0]).
⬆ gap_limit = tensor([0.0002, 0.0002, 0.0002, 1.0002, 0.0002, 0.0002])
→ reading = Five of the six gaps are just rounding-error sized (2e-4). The fourth entry (the a = 2 row) is 1.0 — the jump is visible as an outlier.
16gap_match = ((left + right) / 2 - at_a).abs()

How far the midpoint of the two one-sided probes sits from f(a). Parentheses matter: we FIRST average left and right, THEN subtract at_a, THEN take absolute value. At a = 2 the midpoint is (2.9999 + 4.0001)/2 = 3.5, and at_a is 4.0 — so gap_match = 0.5 (non-zero, flags a problem).

EXECUTION STATE
(left + right) / 2 = tensor([1.5000, 2.0000, 2.5000, 3.5000, 4.5000, 5.0000])
⬆ gap_match = tensor([0.0000, 0.0000, 0.0000, 0.5000, 0.0000, 0.0000])
18cond2 = gap_limit < 1e-2

Condition 2 per candidate point. Element-wise comparison returns a BoolTensor of the same shape. '1e-2' (0.01) is our looseness tolerance — large enough to absorb float32 noise from the 1e-4 probe, but small enough to catch a jump of size 1.

EXECUTION STATE
⬆ cond2 = tensor([True, True, True, False, True, True])
→ reading = Exactly one candidate fails condition 2: the 4th element (a = 2, the jump). The other five have continuous-looking behaviour — both sides agreed.
19cond3 = cond2 & (gap_match < 1e-2)

Condition 3 per candidate point. We use the bitwise '&' on boolean tensors — it acts element-wise as logical AND. ('and' is Python's SCALAR-boolean and, it doesn't vectorise; '&' is the tensor version.) Cond3 requires cond2 to have passed AND the mid-probe to match the at-a value.

EXECUTION STATE
📚 & (bitwise AND on BoolTensor) = Tensor operator: element-wise logical AND. For tensors of booleans, a & b returns True iff both a and b are True at each position. CANNOT use Python's 'and' keyword on tensors — that would try to evaluate one tensor as a Python bool and raise 'Boolean value of Tensor is ambiguous'.
gap_match < 1e-2 = tensor([True, True, True, False, True, True])
⬆ cond3 = tensor([True, True, True, False, True, True])
→ reading = Same mask as cond2 here — for this particular example cond3 fails exactly where cond2 failed. In general the two masks can differ (the MISMATCH scenario in our Python block has cond2 True but cond3 False).
21print(torch.stack([a, left, right, at_a,

Begins building a six-column, six-row report. torch.stack takes a list of tensors of matching shape and stacks them along a NEW axis. Each of our inputs is a 1-D tensor of length 6, so the stack produces a 6×6 tensor.

22 cond2.float(), cond3.float()]).T)

Finishes the stack. We convert the BoolTensors cond2, cond3 to floats (1.0 for True, 0.0 for False) so that they can be stacked with the other float tensors — torch.stack requires all inputs to share dtype. The trailing .T transposes the 6×6 into the shape we want: one row per candidate point, one column per quantity.

EXECUTION STATE
📚 torch.stack(tensors, dim=0) = Factory function: concatenates a list of tensors along a NEW dimension. torch.stack([a, b]) with a, b of shape (6,) produces a tensor of shape (2, 6). DIFFERENT from torch.cat which concatenates along an EXISTING dimension.
📚 .float() = Tensor method: casts to float32. BoolTensor .float() → True → 1.0, False → 0.0. Needed because torch.stack requires matching dtypes across all tensors.
📚 .T = Tensor property: transpose the last two dimensions. For a 2-D tensor it is the usual matrix transpose (rows ↔ columns). Here 6×6 .T is still 6×6 but with rows and columns flipped, giving us 6 rows (one per candidate) × 6 columns (the quantities).
⬆ printed (6 rows × 6 cols) =
      a    left   right   at_a   c2   c3
  0.5000  1.4999  1.5001  1.5000  1.0  1.0
  1.0000  1.9999  2.0001  2.0000  1.0  1.0
  1.5000  2.4999  2.5001  2.5000  1.0  1.0
  2.0000  2.9999  4.0001  4.0000  0.0  0.0   ← JUMP
  2.5000  4.4999  4.5001  4.5000  1.0  1.0
  3.0000  4.9999  5.0001  5.0000  1.0  1.0
→ moral = The jump is a single bad row in a single tensor call. For 60,000 candidate points, PyTorch would still finish in milliseconds — no Python loop, no per-point overhead.
6 lines without explanation
1import torch
2
3# Piecewise jump at x = 2: y = x + 1 if x < 2, else y = x + 2.
4def g(x: torch.Tensor) -> torch.Tensor:
5    return torch.where(x < 2, x + 1, x + 2)
6
7# Check continuity at many candidate points in ONE tensor op — no Python loop.
8a = torch.tensor([0.5, 1.0, 1.5, 2.0, 2.5, 3.0])
9h = 1e-4
10
11left  = g(a - h)           # one-sided probe from below
12right = g(a + h)           # one-sided probe from above
13at_a  = g(a)               # the value AT each candidate
14
15gap_limit = (left - right).abs()                 # 0 → limit exists
16gap_match = ((left + right) / 2 - at_a).abs()    # 0 → limit == f(a)
17
18cond2 = gap_limit < 1e-2                         # Condition 2 per point
19cond3 = cond2 & (gap_match < 1e-2)               # Condition 3 per point
20
21print(torch.stack([a, left, right, at_a,
22                   cond2.float(), cond3.float()]).T)

Why this matters later

This is the same mental move that lets training neural networks scale: instead of looping in Python, put your data into a tensor and let the framework broadcast. Continuity checks are trivial, but the pattern — evaluate at many shifted inputs, take element-wise differences, compare against a tolerance — is EXACTLY how finite- difference gradient checks, Lipschitz estimators, and many numerical calculus primitives work in practice.


Common Pitfalls

  • Confusing “the limit exists” with “the function is continuous”. A function can have a perfectly good limit at aa and still be discontinuous there — either because f(a)f(a) is undefined (Condition 1) or because f(a)Lf(a) \neq L (Condition 3).
  • Thinking piecewise always means discontinuous. Not at all. x|x| is defined piecewise (it is x-x for x<0x < 0 and xx for x0x \geq 0) yet is continuous everywhere because at the joint x=0x = 0 both pieces evaluate to 0. The joint is where continuity must be CHECKED, not where it is automatically broken.
  • Dismissing Condition 3 as pedantic. It is exactly Condition 3 that breaks when you override a function's value at a single point — and that override is what distinguishes removable from essential discontinuities (§3.3). Without Condition 3 we could not classify discontinuities at all.
  • Forgetting that infinite limits don't count. Condition 2 requires LL to be a finite real number. “Goes to infinity” is not a limit in the sense of this definition — it is a vertical asymptote, and the function is discontinuous there.
  • Trusting a numerical check alone. Our Python probe at tol=106\text{tol} = 10^{-6} can be fooled by pathological oscillation. Numerical continuity checks are a useful smoke test; they are not proofs. The formal ε\varepsilon-δ\delta definition is.

Summary

  • Continuity at a point aa is defined by three conditions: f(a)f(a) is defined, limxaf(x)\lim_{x \to a} f(x) exists, and the two are equal.
  • Each condition fails with its own visual signature — Condition 1 is a hole, Condition 2 is a jump (or a blow-up), Condition 3 is a misplaced dot.
  • The test is straightforward to run both by hand and in code. Plain Python implements it one point at a time; PyTorch vectorises it across any number of candidates at once.
  • Numerical checks with a finite probe can be fooled by wild oscillation; the formal definition is what ultimately protects us.

What's next

§3.3 uses these three conditions to classify discontinuities: removable, jump, infinite, and oscillating. Each class corresponds to a specific pattern of passing and failing conditions, and each motivates a different repair strategy.

Loading comments...