Skip to content

[Interactive Graph] Add logarithm graph state management and reducer#3422

Open
ivyolamit wants to merge 5 commits intoLEMS-3953/pr2-logarithm-kmathfrom
LEMS-3953/pr3-logarithm-state-management
Open

[Interactive Graph] Add logarithm graph state management and reducer#3422
ivyolamit wants to merge 5 commits intoLEMS-3953/pr2-logarithm-kmathfrom
LEMS-3953/pr3-logarithm-state-management

Conversation

@ivyolamit
Copy link
Copy Markdown
Contributor

@ivyolamit ivyolamit commented Mar 27, 2026

Summary:

PR series to add logarithm graph support to the Interactive Graph widget:

  1. Add logarithm graph type definitions and data
  2. Add logarithm math utilities to kmath
  3. ▶️ Add logarithm graph state management and reducer
  4. Add logarithm graph rendering, SR strings, and equation string
  5. Add logarithm graph scoring
  6. Add logarithm graph option in the Interactive Graph Editor

Add logarithm graph state management and reducer for supporting Logarithm graph in Interactive Graph

  • Add LogarithmGraphState to the internal state type system
  • Wire up reducer actions (movePoint + moveCenter) with logarithm-specific constraints
  • Add graph state initialization with sensible defaults
  • Add test data fixtures and question builder support

Details

This PR adds the state management layer for logarithm graphs, following the exponential pattern throughout. Logarithm is the vertical-asymptote mirror of exponential's horizontal-asymptote design.

Action registration: Reuses existing movePoint and moveCenter action creators (no new action types). The actions export object gets logarithm: { movePoint, moveCenter }, identical to exponential.

Reducer — doMovePoint:

  • Point cannot land on the asymptote's x-coordinate
  • Both points must have different y-values (prevents degenerate coefficient computation)
  • Cross-asymptote reflection: when a point is dragged past the asymptote, the other point is reflected (reflectedX = 2 * asymptoteX - otherX) so both points end up on the same side — matches Grapher widget behavior

Reducer — doMoveCenter:

  • Asymptote moves horizontally only (X component extracted, Y ignored)
  • Snap-through logic: when the new position would land between or on the curve points, snaps past all points using the midpoint heuristic for direction detection (prevents oscillation/flicker)
  • Final safety check: asymptote cannot land exactly on either point's x-coordinate

Initialization: getLogarithmCoords() follows getExponentialCoords() pattern — returns {coords, asymptote}. Default coords use normalized fractions [0.55, 0.55] and [0.75, 0.75] to ensure both points are to the right of the default asymptote at x=0 after normalization (x=0.5 would land exactly on the asymptote).

Placeholders: mafs-graph.tsx returns {graph: null, interactiveElementsDescription: null} for logarithm (replaced in PR 4). mafs-state-to-interactive-graph.ts has the real serialization (not a placeholder).

Co-Authored by Claude Code (Opus)

Issue: LEMS-3953

Test plan:

  • pnpm tsc passes
  • pnpm knip passes
  • pnpm lint passes
  • pnpm prettier . --check passes
  • Reducer tests pass (147 total, 14 new logarithm tests):
    • movePoint: same-y rejection, bounding-to-same-y rejection, valid move, on-asymptote rejection, cross-asymptote reflection
    • moveCenter: valid move, snap-through between points, Y-component ignored, final safety rejection
    • Initialization: given coords, startCoords, defaults
    • Gradable graph: logarithm state conversion
    • Serialization: logarithm state to interactive graph
  • Interactive graph tests pass (206 total, logarithm added to parameterized test maps)
  • Serialization tests pass (14 total)

…ithm graph state management and reducer for supporting Logarithm graph in Interactive Graph
@ivyolamit ivyolamit self-assigned this Mar 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

🗄️ Schema Change: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

🛠️ Item Splitting: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (54a615a) and published it to npm. You
can install it using the tag PR3422.

Example:

pnpm add @khanacademy/perseus@PR3422

If you are working in Khan Academy's frontend, you can run the below command.

./dev/tools/bump_perseus_version.ts -t PR3422

If you are working in Khan Academy's webapp, you can run the below command.

./dev/tools/bump_perseus_version.js -t PR3422

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

Size Change: +403 B (+0.08%)

Total Size: 495 kB

Filename Size Change
packages/perseus/dist/es/index.js 193 kB +403 B (+0.21%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.5 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.35 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-core/dist/es/index.item-splitting.js 12 kB
packages/perseus-core/dist/es/index.js 25.2 kB
packages/perseus-editor/dist/es/index.js 101 kB
packages/perseus-linter/dist/es/index.js 9.3 kB
packages/perseus-score/dist/es/index.js 9.66 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/strings.js 8.09 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

@ivyolamit
Copy link
Copy Markdown
Contributor Author

@claude review

@ivyolamit ivyolamit marked this pull request as ready for review March 27, 2026 16:48
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

Copy link
Copy Markdown
Contributor

@SonicScrewdriver SonicScrewdriver left a comment

Choose a reason for hiding this comment

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

This looks pretty much perfect to me, aside from the one issue regarding the asymptote bug (same one as the Exponential graph). I think it makes sense to fix it as part of this PR before releasing.

Feel free to grab me if you need help recreating

describe("movePoint on a logarithm graph", () => {
it("rejects the move when both points would share the same y-coordinate", () => {
// Arrange — point 0 at y=-3, point 1 at y=-7; trying to move point 0 to y=-7
const state = generateLogarithmGraphState();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I really like these comments, they're honestly very helpful for understanding the tests. :) I might go back and add some clarity for the exponential ones after this

if (newX === coords[0][X] || newX === coords[1][X]) {
return state;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You have the same bug that I have in the Exponential graph, where the asymptote can be dragged to the graph edge (invalid location) if a point is already at the left or right edge of the graph.

We need to check if the location is within the bounds of the graph here in order to determine if the movement is allowed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ooopppsss, i applied the fix in the wrong PR 🙈
https://github.com/Khan/perseus/pull/3423/changes#diff-ed3550c09301a3a9903bb56053e300ccd8339fac2822f32f26e69fc64ce56119R623

let me actually resolve that, move and fix that here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

oh it's a different one. Ok going to fix that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is now fixed in the latest commit.

@ivyolamit ivyolamit force-pushed the LEMS-3953/pr3-logarithm-state-management branch from 66a406e to ce9f761 Compare March 27, 2026 23:00
Comment on lines +521 to +524
function getLogarithmCoords(
graph: PerseusGraphTypeLogarithm,
range: [x: Interval, y: Interval],
step: [x: number, y: number],
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 getLogarithmCoords function in initialize-graph-state.ts is missing the export keyword, unlike its counterpart getExponentialCoords (line 490) which is exported. The design doc (logarithm.md:307) explicitly lists exporting getLogarithmCoords as a PR 3 deliverable, and PR 6 (editor support) will need to import it exactly the way the editor currently imports getExponentialCoords.

Extended reasoning...

What the bug is

getLogarithmCoords is declared as function getLogarithmCoords (no export keyword) at line 521 of initialize-graph-state.ts. Its counterpart getExponentialCoords is declared export function getExponentialCoords at line 490 of the same file. The PR description explicitly states it follows the exponential pattern throughout, and the design doc at packages/perseus/src/widgets/interactive-graphs/__docs__/notes/logarithm.md line 307 lists packages/perseus/src/index.ts — Export getLogarithmCoords as a PR 3 task.

The code path that will fail

getExponentialCoords is re-exported from packages/perseus/src/index.ts (line 113) and consumed by packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts (line 6) to compute default start coordinates in the editor UI. PR 6 (editor support for logarithm) will add logarithm handling to that same util.ts, importing getLogarithmCoords the same way. Without the export keyword here and a corresponding re-export in index.ts, that import will fail to compile.

Why existing code doesn't prevent it

The function is only called internally within initializeGraphState in the same file, so the missing export produces no error today. TypeScript module boundaries are only enforced at import sites, and no import site exists yet for this function.

Addressing the refutation

The refutation argues this is not a bug because the function works for its current purpose and the export can be added in PR 6. This is a reasonable position for a strict "does this PR break anything" review, but the PR description explicitly claims to follow the exponential pattern and the design doc explicitly lists the export as a PR 3 deliverable. Omitting it means this PR does not fully deliver on its stated scope, and the gap will need to be caught before PR 6 merges — exactly the kind of thing a PR review should flag.

Impact

No runtime or compilation failure in this PR or any currently merged code. The impact is forward-compatibility: PR 6 will fail to compile when it attempts to import getLogarithmCoords from @khanacademy/perseus.

How to fix it

  1. Change function getLogarithmCoords to export function getLogarithmCoords at line 521 of initialize-graph-state.ts.
  2. Add getLogarithmCoords to the re-exports in packages/perseus/src/index.ts alongside getExponentialCoords (line 113).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not too fussed if it's exported during this PR or the later PR, myself.

Comment on lines +894 to +897
// Final safety: asymptote must not land exactly on either point
if (newX === coords[0][X] || newX === coords[1][X]) {
return state;
}
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 final safety check at lines 894-897 (if (newX === coords[0][X] || newX === coords[1][X]) return state) is logically unreachable dead code introduced by this PR. Both code paths that reach it guarantee strict inequality between newX and each coord's X value, so this guard can never fire. The identical pattern exists pre-existing in the exponential case at lines 849-852.

Extended reasoning...

What the bug is

The logarithm doMoveCenter function (the case "logarithm": block added entirely by this PR) contains a final safety check at lines 894-897:

// Final safety: asymptote must not land exactly on either point
if (newX === coords[0][X] || newX === coords[1][X]) {
    return state;
}

This check is unreachable on every code path through the function.

Path 1 — snap-through not needed (allRight || allLeft is true at lines 867-868)

The snap-through block is skipped entirely. If allRight, both coords[i][X] > newX (strict), so newX cannot equal either coord X by definition. The same applies for allLeft with reversed strict inequality. The final check at lines 894-897 therefore can never fire on this path.

Path 2 — snap-through entered (lines 870-892)

After the inset-clamped newX is computed, the code checks stillAllRight (line 886-887, strict >) and stillAllLeft (line 888, strict <). If \!stillAllRight && \!stillAllLeft, the function returns state at line 889-891 — before reaching line 894. If we pass that gate, at least one of stillAllRight or stillAllLeft is true. Either way strict inequality holds between newX and every coord X value, so the final check cannot fire here either.

Why existing code does not prevent the confusion

The guard was presumably copied from the original structure with the intent of being a defensive backstop. But the earlier logic already exhaustively handles every case that could produce an exact match: the snap-through block's own rejection gate (if (\!stillAllRight && \!stillAllLeft)) fires first whenever the clamped value would be on or between the points.

Impact

There is no runtime impact — the check is logically a no-op on every execution path. The harm is purely documentary: the comment "Final safety: asymptote must not land exactly on either point" implies there is a code path the earlier logic does not handle, which misleads future maintainers. The identical dead code exists pre-existing in the exponential case (lines 849-852), but that code was not introduced by this PR.

Step-by-step proof (Path 1 cannot produce an exact match)

  1. State: range=[-10,10], snapStep=[1,1], coords=[[-4,-3],[-5,-7]], asymptote=-6.
  2. User drags asymptote to x=-8; boundAndSnapToGrid returns newX=-8.
  3. allRight: -4>-8 && -5>-8 → true. Snap-through block is skipped.
  4. Final check: -8 === -4? No. -8 === -5? No. Guard passes vacuously — correct behavior, but the guard is not needed.
  5. For the guard to trip on Path 1, we would need newX to equal a coord X value while allRight or allLeft is true — impossible because allRight means both coords strictly exceed newX, and allLeft means both coords are strictly less.

How to fix it

Remove lines 894-897 (the final safety check and its comment). Behavior is identical without it. Alternatively, replace with a comment explaining the invariant is already enforced by the earlier logic.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I vaguely recall chatting with Claude and finding one really obscure edge case that suggested keeping this, but I can't recall what it was. TBH, I'm fine with either cleaning this up or keeping it.

Comment on lines +591 to +601

// Both points at the same x is invalid for an exponential
if (updatedCoords[0][X] === updatedCoords[1][X]) {
return state;
}

return {
...state,
hasBeenInteractedWith: true,
coords: updatedCoords,
};
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 issue in the exponential doMovePoint cross-asymptote reflection block: after calling boundAndSnapToGrid on the reflected point, there is no check that updatedCoords[otherIndex][Y] === asymptoteY. If otherPoint[Y] is non-grid-aligned (possible via startCoords, which are stored verbatim) and within snapStep/2 of the asymptote, the reflected Y snaps to exactly asymptoteY, committing an invalid state with a curve point on the horizontal asymptote. The logarithm case added in this PR correctly includes the guard if (updatedCoords[otherIndex][X] === asymptoteX) return state; the fix is to add the symmetric if (updatedCoords[otherIndex][Y] === asymptoteY) return state to the exponential block.

Extended reasoning...

What the bug is and how it manifests

In the exponential doMovePoint cross-asymptote reflection branch (interactive-graph-reducer.ts, lines 581–601), after calling boundAndSnapToGrid([otherPoint[X], reflectedY], state) to produce updatedCoords[otherIndex], there is no guard checking whether updatedCoords[otherIndex][Y] === asymptoteY. If snapping maps reflectedY to exactly the asymptote Y-value, the returned state has a curve point sitting directly on the horizontal asymptote — an invariant violation. The coefficient computation for an exponential curve depends on both points being strictly above or below the asymptote, so placing a point on it produces degenerate or undefined coefficients and silently blanks the rendered curve.

The specific code path that triggers it

getExponentialCoords stores startCoords.coords verbatim without snapping, so a question author can supply a point at a non-grid-aligned Y value. When the user drags the other point across the horizontal asymptote, the cross-asymptote branch fires and computes reflectedY = 2 * asymptoteY - otherPoint[Y]. If otherPoint[Y] is non-grid-aligned and within snapStep/2 of asymptoteY, then Math.round(reflectedY / snapStep) * snapStep resolves to exactly asymptoteY.

Step-by-step proof

  1. Setup: asymptoteY = 0, snapStep = [1, 1], coords = [[1, 2], [2, 0.4]] (the second point placed via non-grid-aligned startCoords).
  2. User drags point 0 below the asymptote to [1, -1].
  3. boundDestination[Y] = -1 ≠ 0 → on-asymptote guard passes. newCoords[0][X] = 1 ≠ 2 → same-X guard passes.
  4. movedSide = (-1 > 0) = false; otherSide = (0.4 > 0) = true → sides differ, cross-asymptote branch entered.
  5. reflectedY = 2 * 0 - 0.4 = -0.4. boundAndSnapToGrid([2, -0.4], state)Math.round(-0.4 / 1) * 1 = 0 → reflected point lands at (2, 0) = the asymptote Y.
  6. The only post-reflection guard checks updatedCoords[0][X] === updatedCoords[1][X] (1 ≠ 2) → passes.
  7. State is committed with coords[1][Y] === asymptoteY — invalid.

Why existing code doesn't prevent it

The pre-reflection same-X guard (newCoords[0][X] === newCoords[1][X]) and the on-asymptote guard (boundDestination[Y] === asymptoteY) both check the dragged point, not the reflected other point. The post-reflection guard added by this PR checks only for same-X between the two curve points, not whether either point lands on the asymptote. None of these guards can catch the reflected-point-onto-asymptote scenario.

Why this is pre-existing and what the PR does

The exponential reflection logic — including the return { ...state, coords: updatedCoords } inside the cross-asymptote block — predated this PR (the return statement has no + prefix in the diff). This PR added the same-X guard and the comment // Mirrors the logarithm reducer, but did not add the analogous asymptote-Y guard. The logarithm case introduced in this very PR does correctly include if (updatedCoords[otherIndex][X] === asymptoteX) return state, demonstrating the author was aware of the pattern and applied it to the new code but left the pre-existing exponential gap unaddressed.

How to fix it

After computing updatedCoords[otherIndex] in the exponential cross-asymptote block, add:

if (updatedCoords[otherIndex][Y] === asymptoteY) {
    return state;
}

This is directly symmetric with the logarithm guard at line 660 and closes the gap.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I can make a separate PR for adding this logic, as it doesn't make sense to change exponential logic unless you absolutely have to.

Copy link
Copy Markdown
Contributor

@SonicScrewdriver SonicScrewdriver left a comment

Choose a reason for hiding this comment

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

Happy to approve this now! Claude had a couple comments, but I don't consider them blockers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants