Auto-Generating Interactive Function Analysis and Quizzes in 4 Seconds #554
ibenian
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Click the ƒ button on any node of a semantic graph and AlgeBench replaces the graph with a page it did not have a second earlier: a title, a one-sentence story about what the expression does, one to three interactive charts with the interesting bits already inside the sweep, sliders on every parameter, the full list of everything the maths says about the curve — and an open-ended quiz that asks you to predict what the curve does before it draws it for you, and keeps writing new questions for as long as you keep asking for them.
None of that is authored. It is generated per expression, on demand, in about four seconds.
This post is about how it is built, and specifically about the one design decision everything else follows from: the computer algebra system does the mathematics, and the language model is never allowed to do any. The LM ranks, frames, narrates, and quizzes. It never computes, and it never writes code that runs.
The big picture
One HTTP endpoint, two layers that are separable on purpose, and a hard trust boundary between the model's proposals and the code that actually runs.
1. The CAS fingerprint — mechanical, symbolic, and honest about failure
backend/experts/modules/expression_analysis/features.pyis the whole mathematical layer, and it contains zero LM code. It runs a fixed ontology of behavioural features over one expression:Three decisions in that file matter more than the feature list.
Symbolic first, numeric second
A detected peak comes back as an expression, not a number:
{"location": {"latex": "\\frac{v_{0}}{g}", "approx": 1.0}, "value": {"latex": "\\frac{v_{0}^{2}}{2 g}", "approx": 0.5}, "kind": "maximum"}t = v_0/gexplains itself;t ≈ 1.02does not. The numeric approximation rides along for the chart, but the LaTeX is what the page shows and what the quiz can build a question around. This is the difference between "the peak is here" and "the peak is where the upward push runs out".Representative constants, labelled as such
Symbolic parameters mean SymPy often cannot decide a sign — is
-gnegative? So when classification stalls, every free symbol other than the swept one is pinned to 1 and the point carries anassumedmarker:The answer is still useful. The UI just never gets to present it as if it were unconditional.
unresolvedis not the same as "none"Every heavy SymPy call goes through the project's killable CAS guard — the subject of its own show & tell — one guarded op per feature, so a pathological
solvesetdegrades that single feature instead of taking the analysis down:A timed-out op returns
{"status": "unresolved"}. That distinction is load-bearing all the way to the front end, because "we found no vertical asymptotes" and "we ran out of time looking" are very different claims to make to a learner. The page says so out loud:It cost us a real bug to learn to be this careful. A limit that failed used to come back as
limit: None— indistinguishable from a genuine "no limit exists" — and the proposer then reasoned confidently from a computation failure (issue #532, fixed in #534). Now a failure is a structural marker, and if the whole report resolves nothing, the proposer is never even called:That
failedflag exists purely so the page can say "the analysis failed" instead of the much worse lie "nothing interesting here".2. Context sharing — what the maths cannot know
The CAS knows the mathematical domain of
v_0 t - ½ g t²: all of ℝ. It has no way to know that negative time is nonsense in this lesson and merely interesting in an algebra one.So the client assembles context from every layer of the app it is standing in and sends it along with the expression:
buildEnrichContextwalks the enclosing structure and collects whatever exists:lessonTitle,lessonDescriptionsceneTitle,sceneDescriptionproofTitle,proofGoal,proofTechniquestepLabel,stepMath,stepJustification,stepExplanationmathDomainThe same builder feeds the derivation expert, so an expression analysed inside a re-entry-heating lesson and the same expression analysed in a calculus drill get genuinely different apparatus. The signature says as much:
Context also decides sweep ranges, which is where it shows up most visibly:
Context travels the other way too. Every AI-written block on the page carries a hover-revealed ask button, and each one appends a description of the chart as currently configured — sliders, hidden series, off-chart features and all. That machinery is §6's subject.
3. One LM call, no thinking
The proposal is a single
dspy.Predict— notChainOfThought, and with Gemini's internal thinking disabled, scoped to this call so the rest of the expert stack keeps full reasoning:That is a measured decision, not a hunch. We benchmarked 10 scenarios × 3 thinking configurations (30 calls, temperature 0.7, cache off):
Roughly 3× faster than a low thinking budget and 4× faster than default, with quality equal or better. Full thinking was measurably sloppier: in 4 of 10 cases it ranked a feature 5/5 and then marked it in no view at all (the damped oscillator's decay envelope, ranked top, drawn nowhere), plus a duplicated rank entry. Default thinking also spent anywhere from 434 to 5,065 thinking tokens on the same kind of task.
The explanation is the architecture, not the model: the CAS fingerprint already carries the hard reasoning. What is left for the LM is structured selection — which of these eight detected things matters most, what range shows it, what question probes it. Deliberation on a task like that mostly buys opportunities to wander.
The best evidence that this division works is the trap case. For$\frac{x^3}{e^x - 1}$ the CAS reported the $x = 0$ singularity with
vertical_asymptote: false. The no-thinking model correctly demoted it — dodging the "singularity ⇒ blow-up" reflex — not by thinking harder, but because the fingerprint handed it the truth.What the call returns
A title, a one-sentence story, ranked features with a 0–5 usefulness and a why, 1–3 viewports, companion plots, annotations, 1–4 probes, and a one-line gloss for every variable (which becomes the hover tooltip on every slider). Or an abstention — with a reason:
Every ceiling in the pipeline follows one rule inherited from elsewhere in the codebase: a proposal that needs more than this isn't a proposal, it's an unreviewable dump.
MAX_VIEWS = 3,MAX_PROBES = 4,MAX_RANKED = 10,MAX_PLOTS = 3per view,MAX_ANNOTATIONS = 4per view,MAX_POINTS = 8per feature kind.4. Plots — the model proposes an expression, SymPy writes the code
This is the trust boundary, and it is worth stating precisely.
The LM may propose a companion curve (an envelope, a limiting form, a linear approximation) and marker positions. It proposes them as LaTeX. The handler then compiles that LaTeX to an evaluable mathjs script with SymPy:
Two gates in six lines. LaTeX that will not convert is dropped; a script needing a symbol the CAS report never heard of is dropped. Survivors are counted, not hidden — the view carries
dropped_plots/dropped_annotationsso a reviewing author can see the pruning.LM-authored code never reaches a browser. Everything the page evaluates — the main curve (
characteristics.chartScript), companion plots (view.plots[].script), annotation positions (annotation.at/to.script) — is SymPy-generated and runs through the existing math.js sandbox inexpr.js. The page even ships aƒ(x)toggle that shows you both sides of that boundary, per curve: the LaTeX proposed, and the script actually executed.There is a second, quieter guard. The proposer is told to use the report's variable names verbatim, but it can still emit a display-form name — so the handler checks every view's
x_varand pins against the report and flags mismatches rather than silently binding them:The page then declines to render flagged views at all.
What a plot actually is on the wire
Every drawable thing on the page is the same three-part object, and it is worth seeing the shape before the mechanics:
{ "latex": "\\frac{v_0}{g}", // for display — KaTeX renders this "script": "v_0 / g", // for evaluation — SymPy wrote this "variables": ["g", "v_0"] } // what it needs to be evaluatedlatexandscriptare the two sides of the trust boundary. The third field is the one that quietly makes the whole apparatus composable.Expressions know their own dependency variables.
latex_to_mathjsdoesn't just return code — it returns the code and the free symbols that code needs:That list is not decoration. It is load-bearing at three separate points, and each one would be guesswork without it:
variablesinclude aname the CAS report doesn't know is an expression the LM invented, and it is dropped before it ever reaches a browser — the check in
compile_okis literallyany(v not in known for v in script_vars). Without a declared dependency list you would have to either parse the generated code or find out at render time, when it evaluates toNaNand draws nothing with no explanation.to feed it.
pinnedmap and the scripts'variablesare thesame vocabulary, which is why dragging a slider named
gis guaranteed to move every curve and marker that depends ong— and guaranteed not to silently miss one.The top-level report carries the same idea for the analysed expression itself:
variables_latexis the mechanical display form (v_0→v_{0}), kept separate from the AI'svariable_glossary(v_0→ "initial launch speed, m/s"). One is typography and always correct; the other is meaning and is a proposal. They are never mixed.How the client actually draws it
The whole render is about 30 lines, and it is deliberately dumb — all the intelligence happened upstream.
Compile once, per series. Each script goes through the
expr.jsmath.js sandbox once, at chart build:A script that won't compile yields
nulland that series simply draws nothing — one bad companion curve never takes the chart down with it.Sample 221 points across the sweep, building a fresh scope per point:
And
_scopeForis where the dependency lists pay off — every declared variable gets a default, the view's pins override it, and the swept variable is set last:Non-finite means
null, andnullmeans a real gap. A pole, a negative square root, a log of zero — all becomenullrather than 0 or a clamped extreme, and the chart runs withspanGaps: false, so the curve genuinely breaks at a singularity instead of drawing a vertical line through it. That gap is also what the singularity marker keys off (see Markers are re-detected below): a transition betweennulland finite.Three Chart.js features are switched off on purpose:
animationplugins.legendfillTextcan't render KaTeX —\text{rad/s}would show as sourceplugins.tooltipAnything that has to display mathematics lives in an HTML layer above the canvas — the legend, the tooltip, the annotation labels, the axis titles. The canvas draws only geometry.
One custom plugin draws everything the library can't.
faFeatureshooksafterDrawand reads live state offchart.$fa:Reading state at draw time rather than closing over it is what lets sliders and legend toggles update overlays with
chart.update('none')and no rebuild. Note the first line of the body: hiding the main curve takes its feature markers with it, because those markers are re-detected from that curve — leaving them floating over a hidden series would be marking a curve that isn't there.One last default worth defending: markers start off. The curve reads clearly on its own and the legend keys are the switch. They were on by default at first, and because they are numerically re-detected over the plotted window, a feature the CAS found outside the current range legitimately drew nothing — which looked exactly like a bug.
An annotation is a compiled expression, not a coordinate
This is the part worth dwelling on, because it is easy to assume a marker line is a number. It is not. An annotation's
at— and a band'sto— arrive as LaTeX and are compiled by the samelatex_to_mathjspath as the curves:Both forms survive into the payload: the LaTeX for display, the script for evaluation. So the AI can say "mark the apex at $\frac{v_0}{g}$" and get a dashed vertical line that is genuinely at$v_0/g$ — a live expression in the same variables the sliders drive, not a pixel offset baked in at generation time.
The handler's own docstring gives the reason in four words: so markers stay slider-reactive. §6 is what that buys.
A band needs both scripts to compile or the whole annotation is dropped — half a band is a shaded region with an invented edge, which is worse than no band.
Markers are re-detected on the drawn curve
One subtlety worth calling out: the dots on the chart are not placed from the CAS report's coordinates. They are re-detected numerically from the 220 sampled points actually plotted — sign changes for roots, five-point windows for extrema, null transitions for singularities:
The reason is the sliders. Drag
gand the true root moves; a marker frozen at the CAS's symbolic location would drift off the curve and quietly lie. Re-detection keeps the marks on the line the learner is looking at, while the features panel beside it keeps the exact symbolic truth. And because a CAS point can sit outside the plotted window, feature rows outside the sweep are labelled(off-chart)rather than pretending to be drawn.5. Range repair — and the receipt the server now leaves
The bug: a sweep chosen in one regime, drawn in another
Every numeric
approxin the CAS report is computed with every non-swept symbol pinned to 1. That is a reasonable thing for the report to do — it has no physical values to work with. But the proposer reads those numbers when choosingx_min/x_max, and the view it writes then renders at its ownpinnedvalues, which are the physical ones. The sweep is chosen in one parameter regime and drawn in another, and until this branch nothing reconciled the two.The barometric entry velocity is the worst case:
Under the report's unit pinning the scale height is$H = 1$ , so a sweep of $H = 6360$ , it spans 0.4% of one scale height: $e^{-h/H}$ never leaves 1, the curve is flat to 0.16%, and an exponential reaches the learner as a horizontal line.
[-5, 20]is a perfectly sensible twenty scale heights. Rendered at the view's ownNote what kind of failure that is. Nothing errored, nothing was flagged, no number was wrong. The apparatus was internally consistent and pedagogically worthless.
Two layers, because telling the model is not enough
The first fix is a prompt change — the proposer is now told, in the step where it pins parameters:
The second fix is the one that matters, and it is the same contract as everywhere else in this feature: the LM proposes, the CAS disposes.$2^{24}$ .
view_ranges.pysubstitutes each view's own pins, lambdifies, samples 96 points, and measures the curve's relative peak-to-peak variation. Below 2% — comfortably above float noise, below anything a learner reads as movement — the window gets widened by a ladder of doublings, up toThe measure that looks obvious and is wrong
The interesting part is how a candidate window is scored. The obvious move is to reuse the same relative-variation measure that detected the flatness. It fails, and it fails quietly:
So the ladder scores by coverage of the curve's whole span across every scale sampled, and stops at the smallest window showing 90% of it — widened to where the curve reads, and no further.
Two more decisions worth naming:
Rescaling is proportional, not centred.$t = 0$ case, and stays pinned at 0.
[-5, 20]sits one fifth left of zero and stays one fifth left of zero at every size, so the proposer's judgement about how much of the negative side to show survives the rescale. A left edge already at 0 — the physical bound §2's prompt asks for on a quantity that cannot go negative — is the same rule'sIt only ever widens. The mirror problem — a sweep so wide the story is crushed against one edge — is explicitly out of scope, because it cannot be decided by the same measure. Relative variation grows without bound on an unbounded function, so "take the tightest span with the most variation" would crop a parabola's arms and clip a projectile at its apex. That is a different change, and the module says so in its own docstring rather than half-doing it.
The construction trail
Four passes now sit between the proposer's answer and the artifact a client renders: unknown symbols flagged, glossary entries dropped, uncompilable plots and annotations pruned, flat sweeps widened. Every one of them knew exactly what it changed — and threw it away.
So the response now carries a
constructionobject: a one-line summary plus one note per decision, in prose aimed at whoever is looking at the artifact rather than at whoever is reading the server log.Each note carries
stage,level,view, the message, and adetailobject holding the raw numbers — so a client can render the sentence or the values without parsing prose back apart.Four design decisions in that log are worth pulling out.
okentries are kept. The trail records what happened, not what went wrong. Every view produces exactly one line whether or not anything changed, because a missing entry reads as "this view was never looked at" — which is exactly the ambiguity a log-on-change-only version has, and exactly the first question anyone debugging a suspicious range asks.Warnings can never be swallowed by the summary. It first read "returned as proposed" on a live call carrying two unplottable views — true, since nothing was changed, and useless, because they were untouched precisely because they could not be checked. A proposal that came back clean and one that could not be inspected are not the same result and must not print the same.
Notes name a viewport by id, never by position. Views are minted
v1-h,v2-tserver-side — never asked of the model, which addresses views by ordinal in the wire format because that is the thing it cannot get wrong. But an ordinal stops being true the moment a later pass drops or reorders a view, and a note pointing at the wrong picture is worse than no note. The id carries the swept variable so a bare handle in a log line still says something.stageandlevelare a typed model, not dict keys. Both are switched on downstream, so a typo doesn't produce a cosmetic error — the entry lands in no bucket and vanishes from the summary, which is precisely the swallowed-warning failure the module exists to prevent. A malformed note is logged and dropped rather than raised, though: failing an analysis request over bad telemetry would be a worse outcome than losing the note.The trail earned its keep immediately
On its first run through the full handler, the trail caught a bug in the range repair it had just been built to report on:
_repair_onehad been re-deriving a single variable from the expression and applying it to every view, instead of using each view's ownx_var. When the guess disagreed with the view, the view's own axis variable looked like an unpinned parameter and the repair silently refused to run at all.The honest consequence, in the commit's own words: "some of the 'didn't need to fire' I reported earlier was really 'couldn't fire'." A pass that declines and a pass that cannot run are indistinguishable from the outside — which is the whole argument for keeping the
oklines, made by the trail against its own author, within minutes of existing.The branch carries 39 tests for this file alone.
6. The apparatus — views, sliders, and everything you can poke
The charts are not illustrations. Everything on the page is a control, and the design rule behind all of it is the same: whatever the learner does to the picture, the picture stays honest, and the tutor knows about it.
Views — each one has to earn its tab
The proposer may return one to three viewports, rendered as tabs (
View 1: t ∈ [0, 2.5]). The signature is strict about what a second tab costs:When it works, it works well. For the damped oscillator$e^{-bt}\cos\omega t$ the model proposed a dual-scale pair:
t ∈ [0, 10]withb = 0.5, ω = 2to show the wiggles, andt ∈ [0, 100]withb = 0.1, ω = 1to show the envelope dying. Two views, one function, two genuinely different lessons.Each view carries its own
rationale— one line on what a learner sees here that the other tabs don't — and its own ask button. Switching tabs resets the per-view interaction state (_hiddenGroups,_hiddenMarks,_hiddenSeries) and re-seeds the pins from that view's ownpinnedvalues, so a tab is a clean slate rather than a partly-inherited one.Views flagged with
unknown_symbolsnever get a tab at all. If nothing survives: "No renderable viewport was proposed." — which is a truthful thing to say, and a much better one than a chart bound to a symbol that does not exist.Sliders — the pins become instruments
Every symbol the proposer pinned becomes a slider. The range is a deliberately dumb heuristic around the AI's chosen value —
[min(0, 3v₀), max(3v₀, 0.001)], or[-10, 10]when the pin is zero — in 200 steps:The AI picked a representative value; the slider's job is to let you walk far enough either side of it to see what that value was hiding.
Two details make the sliders feel like instruments rather than form controls.
The chart is built once per view and updated in place. Dragging calls
_updateChartData, not a destroy-and-recreate — no animation, no flicker, so the curve deforms continuously under your hand.The markers follow. Because feature dots are re-detected numerically from the plotted samples (§4), dragging
gmoves the root and the root's dot moves with it. This is the payoff for not trusting the CAS's symbolic coordinates for drawing: the apparatus stays correct while you are actively invalidating the numbers it was generated with.Each slider's symbol carries the AI's one-line gloss as an instant hover tooltip — native
titleis too slow and too subtle for a teaching surface — and its own ask button.Three legends, and why they are mirrored
The legend row holds three independent kinds of toggle:
_hiddenSeries_hiddenMarksgrouplabel_hiddenGroupsChart.js already tracks dataset visibility, so mirroring it into
_hiddenSerieslooks redundant. The comment says why it isn't:A learner who has switched off the envelope and then asks "why does this decay?" should not get an answer about a curve they cannot see. So hidden things are not dropped from the tutor's context — they are listed and flagged
(hidden), which is strictly more useful than either extreme.The readout: hover, pin, drag, and snap
Hovering the chart opens a readout listing every visible series' value at that x. It is not read off the 220 plotted samples — it re-evaluates the compiled script at the exact x:
Click, and the readout pins: it stops following the pointer, becomes draggable, keeps its markers painted on the curves, and grows an ask button for the exact frozen set of values (#515).
Then there is my favourite control on the page. Clicking an axis tick label snaps the note to that value — and the two axes read in opposite directions:
page does one — bracket the crossings among the plotted samples, pick the one nearest whatever is already pinned (so repeated clicks stay local), then bisect to float precision on the compiled expression:
Fifty bisection steps cost nothing next to the redraw that follows, and they are what turns "works approximately" into an exact answer.
And when the curve simply never reaches the value you clicked, the page says so — with the range it does cover, because the pins are live and moving a slider may well bring the value into reach:
Silence would have read as a broken control. This is the same instinct as
unresolvedvs "none" in §1, applied to a UI affordance.The ask network
Every AI-written or CAS-derived block on the page has a hover-revealed sparkle button. Click sends the message straight to the chat tutor; ⌘-click drops it into the input to edit first.
Two things make this more than a mailto link.
Messages are built at click time, not at render time. The closure runs when you press the button, so it quotes the sliders where you actually dragged them and the legend as you actually left it. A message captured at render would describe a chart that no longer exists.
Everything chart-related appends
_configSummary— sweep variable and range, every pin with its live value, the entire CAS report in prose, and every companion curve and marker line, with(hidden)and(off-chart)flags. Nothing is dropped:One walk of the CAS report (
_featureRows) builds both this prompt text and the visible features panel, so what the tutor is told and what the learner sees can never drift apart.7. The quiz — predict before reveal
AlgeBench's pedagogy leans on predict-before-reveal: the learner commits to an answer before the curve confirms or refutes it. The probes are that mechanism — the first batch generated in the same call as the charts, grounded in the same report, and open-ended from there: a More… button keeps producing fresh questions for as long as the learner wants them.
The signature is blunt about the stakes:
Asking is not enforcing, though, so three mechanical defences sit behind it.
One. The wire format uses 1-based indices, because models count options from one when asked to. The conversion happens exactly once, in code:
An off-by-one here marks the wrong answer correct, and no downstream validator can catch it — the index is still in range. So it is converted at the boundary rather than hoped for in the prompt.
Two. Structurally broken probes are dropped, not rendered:
An out-of-range index marks no option correct and makes the post-answer message tell the tutor "the correct answer is ''". Better to show three questions than four with one broken.
Three — and this is the one I like most — the quiz is allowed to be wrong, and says so. When the learner answers, the post-answer ask button hands the tutor the full outcome and this instruction:
The generator and the tutor are independent LM calls over the same CAS report. Letting the second audit the first is cheap, and it means a bad probe degrades into a teaching moment rather than into a learner being told they are wrong when they are right.
Two ask buttons, deliberately different
A quiz question has an ask button before you answer and a different one after. The pre-answer one is Socratic by construction:
If the hint button gave away the answer, the predict-before-reveal value of the whole page would evaporate. The post-answer button is the opposite — it gets the question, all options, what you picked, what was correct, whether you got it, and instructions to celebrate briefly and deepen, or to encourage without scolding and build from whatever your wrong choice got partially right.
"More…" — the quiz has no last question
The quiz is open-ended. The first call returns one to four probes, but the More… button underneath them keeps going: press it and you get more questions, press it again and you get more still. There is no fixed length, no final question, and nothing to exhaust — the learner decides when they have had enough, not the author and not the model.
That is the whole reason
more_probesis a separate verb rather than a biggerMAX_PROBES. A quiz of a fixed size is a quiz you can finish and stop thinking about; a quiz that keeps offering is one you leave when you are satisfied you understand the curve.Each press re-invokes the same endpoint under a second verb:
The expert behind the extra questions
One endpoint, but two DSPy modules behind it. The handler dispatches on the verb, and
more_probeslands on a second, narrower expert:analyzemore_probesVizProposalSigMoreProbesSigVizProposerMoreProbesGeneratoraskedPredict, thinking disabledLineAdapterBoth are a bare
dspy.Predicton the same no-thinking LM — the module-level_LMbuilt byscoped_lm— and both run underLineAdapter, the project's wire format with no JSON escape layer. That matters here because probe prose carries$…$math: under a JSON-decoded wire format a model writing\rightwith one backslash yields a valid escape that decodes to a carriage return plusight, and the option comes back mangled.Splitting them rather than re-running the analysis expert is the point. A quiz round should not re-title the page, re-rank the features, or re-propose the charts — the learner is looking at those and they must not move underfoot. So
MoreProbesSighas exactly one output field:Everything else about it is deliberately identical — the same
ProbePlanwire shape with its four flat option slots, the same 1-basedcorrect_indexconverted once inas_probe(), the same_usable_probesfilter, the same ground rules about referencing only what the CAS report contains. A question from round 9 is validated exactly as strictly as one from round 1, and fails exactly as gracefully:propose_more_probesreturns an empty list on any exception, and the page says "No new questions."Three things make that work round after round.
The endpoint writes nothing — no session, no store, no server-side cache key. So the client sends the original characteristics back up every time, and round 7 is generated against exactly the same CAS report as round 1. The whole conversation's worth of state lives in the artifact in the browser; the server stays stateless and the questions stay grounded in the same mathematics.
askedgrows with every round, and it is what stops the model rewording itself:Note the second clause. The instruction is not "find an unused feature" — an expression only has so many — it is "find a genuinely different angle". Once every feature has been probed for location, the model is pointed at scaling, at limiting behaviour, at what happens when a parameter changes sign. That is what keeps an unbounded quiz from degenerating once the feature list runs dry.
The new probes are folded into the artifact's in-memory JSON, so they are not transient UI: re-opening the page re-renders every round you generated, and the
{ }popup shows them all.When a round genuinely finds nothing new, it says so — "No new questions." — rather than padding.
Two honest limits. The exclusion list is capped at
_MAX_ASKED = 20, and it takes the first twenty:So past twenty questions the model is only guaranteed not to repeat the earliest twenty — in a long enough session, a question from round 6 could resurface. And each press is a real LM call (~4 s, ~700 tokens); unlike the initial analysis, which is cached client-side on
{latex, variable, context}with a 32-entry bound,more_probesis deliberately uncached, because a cache hit is exactly the one thing a "give me another" button must never return.8. Numbers, and what we are not claiming
adds a one-time ≈ 4 s CAS pool spin-up.
view's
x_varand pins named a real report variable.Honest caveats, kept from the original report: one run per benchmark cell at temperature 0.7, so single samples carry variance; quality was judged by mechanical checks plus a human read, not a formal eval metric; all ten benchmark expressions have ≤ 4 meaningful features, so a ten-feature monster with subtle context trade-offs remains untested. And if the proposer's job ever grows real derivation, thinking has to come back for that call — a one-line change.
The probe
featureattribution field also still comes back empty more often than it should. It is unused by the UI today, so it has stayed a known-empty field rather than a bug.9. How it shipped
The feature landed in four PRs over four days, then spent the next fortnight getting its correctness and its wire format hardened. That ordering is the honest story: the apparatus was the easy part.
expression_analysisexpert (CAS features + proposer), the in-app page, the quiz, and themore_probesverb?fa=/?fax=), and a ƒ button on every proof step, not just graph nodesThen the corrections — every one of them a case of something wrong being presented with normal confidence:
LineAdapter— a wire format with no JSON escape layer, so model-authored LaTeX survives verbatim (issue #517)\Delta vis one symbol, not the productDelta × v— a phantom variable with its own slider (issue #531)LineAdapterholes, plus proof-edit LaTeX (issue #543)LineAdapterfor every non-stroutput field — the class fix, not the instance fix (issue #543)Two neighbouring PRs are worth naming because the no-thinking result generalised beyond this expert: #509 (proof-edit, 2.8× faster) and #519 (derive expert, 2.6× faster, and the shared
scoped_lmhelper this one uses). Three independent experts, three independent measurements, same conclusion — when the hard reasoning is precomputed, thinking tokens buy latency and little else.Where it runs
The ƒ button lives on every semantic-graph node and on every proof step, and the page is deeplinkable —
?fa=<artifact-id>for one already open in the session,?fax=<expression>to analyse one cold (PR #512). Artifacts attach to the current proof step and appear as its children in the Math tab tree.They are deliberately kept off the step objects themselves: steps get serialized wholesale into chat context and proof saves, and an artifact both back-references its step (a JSON cycle) and carries kilobytes of analysis. So they live in a session-scoped
WeakMapbeside the steps instead.And the whole thing is inspectable. The
{ }button on the header shows the raw artifact JSON — CAS report, proposal, probes, compiled scripts — with a copy button. If you want to check whether the quiz is grounded in the mathematics, you do not have to take our word for it.Add
"propose": falsefor the CAS report alone — no LM, ~30 ms, and every number in it is something SymPy proved.All reactions