Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stupid-oranges-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/kmath": minor
---

Add the logarithm math utilities to kmath for supporting Logarithm graph in Interactive Graph
89 changes: 89 additions & 0 deletions packages/kmath/src/coefficients.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getExponentialCoefficients,
getLogarithmCoefficients,
getTangentCoefficients,
} from "./coefficients";

Expand Down Expand Up @@ -89,6 +90,94 @@ describe("getExponentialCoefficients", () => {
});
});

describe("getLogarithmCoefficients", () => {
it("returns correct coefficients for Grapher test data (y = 4·log₂(x+6) − 7)", () => {
// coords [-4, -3] and [-5, -7] with asymptote at x = -6
const result = getLogarithmCoefficients(
[
[-4, -3],
[-5, -7],
],
-6,
);

// Verify the coefficients reproduce the correct y-values
expect(result).toBeDefined();
expect(result!.a * Math.log(result!.b * -4 + result!.c)).toBeCloseTo(
-3,
10,
);
expect(result!.a * Math.log(result!.b * -5 + result!.c)).toBeCloseTo(
-7,
10,
);
});

it("returns correct coefficients for the natural log (y = ln(x))", () => {
const result = getLogarithmCoefficients(
[
[1, 0],
[Math.E, 1],
],
0,
);

expect(result?.a).toBeCloseTo(1, 10);
expect(result?.b).toBeCloseTo(1, 10);
expect(result?.c).toBeCloseTo(0, 10);
});

it("returns correct coefficients when points are left of the asymptote (y = ln(−x))", () => {
const result = getLogarithmCoefficients(
[
[-1, 0],
[-Math.E, 1],
],
0,
);

expect(result?.a).toBeCloseTo(1, 10);
expect(result?.b).toBeCloseTo(-1, 10);
expect(result?.c).toBeCloseTo(0, 10);
});

it("returns undefined when both points share the same y-coordinate", () => {
expect(
getLogarithmCoefficients(
[
[1, 3],
[2, 3],
],
0,
),
).toBeUndefined();
});

it("returns undefined when a point lies on the asymptote", () => {
expect(
getLogarithmCoefficients(
[
[0, 1], // x === asymptote x
[2, 3],
],
0,
),
).toBeUndefined();
});

it("returns undefined when points are on opposite sides of the asymptote", () => {
expect(
getLogarithmCoefficients(
[
[-1, 1],
[1, 2],
],
0,
),
).toBeUndefined();
});
});

describe("getTangentCoefficients", () => {
it("returns correct coefficients for basic tangent", () => {
// Simplest case: inflection at origin, p2 one quarter-period away.
Comment on lines +158 to 183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The JSDoc for getLogarithmCoefficients omits the same-x-coordinate case from its list of invalid inputs, and there is no test covering this code path, unlike the sibling getExponentialCoefficients which documents and tests its analogous same-x case. The function does correctly return undefined when p1[0]===p2[0] (via the bExp===0 guard), but this behavior is undocumented and untested.

Extended reasoning...

What the bug is:

The getLogarithmCoefficients JSDoc (coefficients.ts ~line 100) describes three invalid-input cases: "same y, a point on the asymptote, or points on opposite sides of the asymptote." The same-x-coordinate case is conspicuously absent, and there is no corresponding test in coefficients.test.ts (lines 158–183), unlike getExponentialCoefficients which has an explicit test titled "returns undefined when both points share the same x-coordinate" (line ~56).

Why the refutation misses the point:

The refutation correctly notes the function is functionally correct — this is not in dispute. The issue is a documentation and test coverage gap. Good software practices require that: (1) documented behavior matches actual behavior, (2) all meaningful code paths are tested, and (3) sibling functions follow consistent patterns. The refutation argues "missing tests or incomplete JSDoc are code quality suggestions, not bugs" — but by the same logic, nothing is ever a bug if the computation returns an answer. The getExponentialCoefficients function sets the expectation for how these functions should be documented and tested.

Step-by-step proof of the undocumented code path:

Given p1=[3,1], p2=[3,5], asymptote=0:

  1. p1[1] \!== p2[1] (1 ≠ 5) — same-y guard passes
  2. p1[0] \!== asymptote && p2[0] \!== asymptote (3 ≠ 0) — asymptote guard passes
  3. Flipped: p1Flipped=[1,3], p2Flipped=[5,3]
  4. ratio = (3-0)/(3-0) = 1 — opposite-sides guard passes (1 > 0)
  5. bExp = Math.log(1) / (1-5) = 0 / (-4) = 0
  6. bExp === 0 guard fires → returns undefined

The function returns correctly, but this path is never exercised by any test, and the JSDoc gives no hint this case is handled.

How to fix it:

  1. Add "same x" to the JSDoc list: "Returns undefined if the inputs are geometrically invalid (same y, same x, a point on the asymptote, or points on opposite sides of the asymptote)."
  2. Add a test: expect(getLogarithmCoefficients([[3,1],[3,5]], 0)).toBeUndefined() — mirroring the existing getExponentialCoefficients test.

Impact:

Purely a documentation and test coverage gap. No functional impact on callers. However, downstream PRs 3–6 in this series will all rely on getLogarithmCoefficients, so establishing complete test coverage now is valuable before the function is widely consumed.

Expand Down
59 changes: 59 additions & 0 deletions packages/kmath/src/coefficients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@
return [amplitude, angularFrequency, phase, verticalOffset];
}

/** Coefficients for f(x) = a·ln(b·x + c). */
export type LogarithmCoefficient = {
/** Vertical scale factor. */
a: number;
/** Horizontal scale factor. */
b: number;
/** Horizontal shift (determines vertical asymptote at x = −c/b). */
c: number;
};

/** Coefficients for f(x) = a·eᵇˣ + c. */
export type ExponentialCoefficient = {
/** Vertical scale factor (amplitude). */
Expand Down Expand Up @@ -81,7 +91,56 @@
return {a, b, c};
}
Comment on lines 91 to 92
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 This is a pre-existing bug in getExponentialCoefficients (not modified by this PR): when both input points share the same y-coordinate but neither lies on the asymptote, the function returns a degenerate {a, b:0, c} constant function instead of undefined. The fix is to add a b===0 guard to getExponentialCoefficients, mirroring the bExp===0 guard already present in the new getLogarithmCoefficients.

Extended reasoning...

What the bug is and how it manifests:
In getExponentialCoefficients, when both input points have the same y-value but neither equals the asymptote, the function silently returns {a, b:0, c} — a constant function — instead of undefined. A constant function is not a valid exponential, so this is a degenerate result that downstream callers may not anticipate.

The specific code path:
At lines 83–84 of coefficients.ts, when p1[1] === p2[1] and neither equals c, the ratio (p1[1] - c) / (p2[1] - c) equals 1. Then b = Math.log(1) / denom = 0. After that, a = (p1[1] - c) / Math.exp(0 * p1[0]) = p1[1] - c, which is non-zero. The only guards at lines 87–88 are \!isFinite(a) || \!isFinite(b) || a === 0. Since isFinite(0) is true and a \!== 0, all guards pass, and the function returns {a, b:0, c}.

Why existing guards don't prevent it:
The guard checks a === 0 but not b === 0. A zero b produces f(x) = a·e^{0·x} + c = a + c (a constant), which is geometrically meaningless as an exponential function. By contrast, the newly added getLogarithmCoefficients explicitly guards bExp === 0 for exactly this analogous scenario, as visible at line 136 of the PR diff.

Step-by-step proof:
getExponentialCoefficients([[0,5],[1,5]], 0):

  1. p1[0]=0, p2[0]=1 → same-x guard passes (0 \!== 1)
  2. p1[1]=5, c=0 → asymptote guard passes (5 \!== 0)
  3. ratio = (5-0)/(5-0) = 1 > 0 → opposite-sides guard passes
  4. b = Math.log(1) / (0-1) = 0 / -1 = 0
  5. a = (5-0) / Math.exp(0*0) = 5 / 1 = 5
  6. Guards: \!isFinite(5)=false, \!isFinite(0)=false, a===0 is false → all pass
  7. Returns {a:5, b:0, c:0}

This represents f(x) = 5·e^0 + 0 = 5, a constant, not an exponential.

Impact and fix:
This bug exists entirely in pre-existing code that this PR does not touch. However, since this PR explicitly models getLogarithmCoefficients after getExponentialCoefficients and the PR description draws direct comparisons, this is a natural opportunity to fix both. The fix is a single guard addition to getExponentialCoefficients: change a === 0 to a === 0 || b === 0 at line 88, which mirrors the bExp === 0 guard already present in getLogarithmCoefficients.


/**
* Returns the coefficients {a, b, c} for f(x) = a·ln(b·x + c) given two
* points on the curve and the x-value of the vertical asymptote.
*
* Uses the inverse exponential approach: flip each coordinate (x, y) → (y, x),
* compute exponential coefficients from the flipped points, then invert.
*
* Returns undefined if the inputs are geometrically invalid (same y, a point
* on the asymptote, or points on opposite sides of the asymptote).
*/
export function getLogarithmCoefficients(
coords: ReadonlyArray<Coord>,
asymptote: number,
): LogarithmCoefficient | undefined {
const p1 = coords[0];
const p2 = coords[1];

if (p1[1] === p2[1]) {
return;
} // same y makes bExp undefined
if (p1[0] === asymptote || p2[0] === asymptote) {
return;
} // point on asymptote

// Flip coordinates: treat logarithm as inverse exponential
const p1Flipped: Coord = [p1[1], p1[0]];
const p2Flipped: Coord = [p2[1], p2[0]];
const cExp = asymptote;

const ratio = (p1Flipped[1] - cExp) / (p2Flipped[1] - cExp);
if (ratio <= 0) {
return;
} // points on opposite sides of asymptote

const bExp = Math.log(ratio) / (p1Flipped[0] - p2Flipped[0]);
const aExp = (p1Flipped[1] - cExp) / Math.exp(bExp * p1Flipped[0]);

if (!isFinite(bExp) || !isFinite(aExp) || aExp === 0 || bExp === 0) {
return;
}

// Invert exponential coefficients to get logarithm coefficients
const a = 1 / bExp;
const b = 1 / aExp;
const c = -cExp / aExp;

return {a, b, c};
}

// p1 is the inflection point (where tan = 0, i.e. the curve crosses

Check warning on line 143 in packages/kmath/src/coefficients.ts

View check run for this annotation

Claude / Claude Code Review

Missing isFinite check on final logarithm coefficients

getLogarithmCoefficients checks isFinite on intermediate values (bExp, aExp) but not on the final returned coefficients (a, b, c), allowing 1/bExp or 1/aExp to silently produce Infinity when those intermediates are tiny but non-zero. This is inconsistent with getExponentialCoefficients, which validates isFinite on a and b after computation; adding isFinite(a) && isFinite(b) && isFinite(c) after the inversion step would mirror the established defensive pattern.
// through its vertical offset). p2 is a quarter-period away and
// determines the amplitude and period of the tangent function.
export function getTangentCoefficients(
Expand Down
1 change: 1 addition & 0 deletions packages/kmath/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {default as KhanMath, sum} from "./math";
export type {Range, SineCoefficient, TangentCoefficient} from "./geometry";
export type {
ExponentialCoefficient,
LogarithmCoefficient,
AbsoluteValueCoefficient,
NamedSineCoefficient,
NamedTangentCoefficient,
Expand Down
Loading