You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Multi-quantity intents ("range and stopping power" from one query): schema, compute reorganization, a text-only NLU benchmark, and the regex-maintainability ceiling #160
aidedx's QueryIntent.quantity is a single scalar — one query asks for exactly one of stoppingPower / csdaRange / energyFromRange / energyFromStp. dedx_web's calculator doesn't
work that way: in the forward direction it has no quantity selector at all — you enter an
energy and it returns both the stopping power and the CSDA range. This issue proposes closing
that gap ("two quantities from one input"), and uses that feature as the lens for three connected
concerns it exposes:
the feature itself — multi-quantity intents, schema and compute/NLG reorganization;
eval data — what the recordings database and a new text-only benchmark suite need to
look like for this to be verifiable at all;
the maintainability ceiling of the regex-based matcher, which this feature pushes on
directly — and whether a more formalized grammar or an external library should replace it.
Everything under "Measured today" below was run against the current main (397e1df); everything
else is a proposal.
1. What dedx_web already offers, and what aidedx narrows away
This project's own deep-link builder is the cleanest evidence. In src/lib/nlg/dedx-web-link.ts,
the forward branch emits:
No quantity parameter is emitted, because dedx_web's calculator has none in the forward
direction. It takes particle + material + program + energies and shows stopping power and
range together. aidedx takes the same inputs, computes both (see below), and then throws one away
because the intent schema can only name one.
The inverse direction does carry a mode (imode=csda / imode=stp), so the asymmetry is real
and is worth preserving in the schema — but "which single quantity do you want" is an
aidedx-invented narrowing on the forward side, not something the underlying calculator imposes.
The compute layer already produces both.LibdedxService.calculate() returns { energies, stoppingPowers, csdaRanges } in one call (src/lib/wasm/types.ts), and ComputePoint already has three independent optional fields:
forwardSeries() deliberately suppresses one of them purely as a cost optimization:
// Stopping-power queries don't need the CSDA integrator; skip it.constcomputeCsda=quantity!=="stoppingPower";
So the data model is already multi-quantity-shaped. The blockers are the intent schema, the NLG, and the binary forward/inverse branch in computeIntent() — not the physics.
The same is true on Android, more starkly: AnswerFormatter.format()already takes both stoppingPowerMevCm2PerG and csdaRangeGramPerCm2 as parameters and discards one based on the
scalar quantity, and MainActivity.kt guards each libdedx call behind an if on that same scalar:
Dropping those two conditionals is most of the Android work.
Unused capability worth folding in
LibdedxService.getBraggPeakStp() is implemented in src/lib/wasm/libdedx.ts:350 and called by
nothing — no compute path, no test, no UI. "What's the peak stopping power / where's the Bragg
peak" is a natural member of the same multi-quantity family and comes for free from an API that's
already wrapped and shipped.
2. Measured today: what actually happens to these sentences
Run against current main via matchIntent() (reproducible: eight sentences, no eval-set
involvement):
Sentence
quantity
compareDim
conf.
incomplete
"What is the range and the stopping power of a 150 MeV proton in water?"
stoppingPower
none
0.97
false
"Give me both the stopping power and the CSDA range of a 100 MeV proton in PMMA."
stoppingPower
none
0.97
false
"For a 200 MeV proton in water, what is the LET and how far does it go?"
stoppingPower
none
0.97
false
"Range and dE/dx of a 250 MeV proton in aluminum."
stoppingPower
none
0.97
false
"Tell me the stopping power as well as the range for 100 MeV protons in water."
stoppingPower
none
0.97
false
"What is the range and stopping power of 100 and 200 MeV protons in water?"
stoppingPower
energy
0.97
false
(control) "Stopping power of a 150 MeV proton in water."
stoppingPower
none
0.97
false
"What energy gives a 10 cm range in water, and what is the stopping power at that energy?"
energyFromStp
none
0.40
true
Two distinct failure modes, both worth fixing:
(a) Silent half-answers at full confidence. Every forward multi-quantity sentence collapses to stoppingPower and is indistinguishable from the single-quantity control — same quantity, same compareDim, same 0.97 confidence, incomplete: false. The user asked for two numbers, gets one,
and nothing anywhere in the pipeline signals that half the question was dropped. This is the same
failure class as #132 (a plausible-looking wrong answer instead of a loud failure), and it is not
even "first mention wins": detectForwardQuantity() tests DIRECT_STOPPING before DIRECT_RANGE
unconditionally, so "Range and dE/dx" still resolves to stopping power.
(b) Mixed inverse+forward corrupts the inverse parse outright. The last row is the interesting
one. "What energy gives a 10 cm range in water, and what is the stopping power at that energy?" is
an energyFromRange query with a 10 cm target. It comes back as energyFromStpwith no target
at all and no particle. Cause: detectInverse()'s flavor test scans the whole sentence —
— so the trailing, secondary mention of "stopping power" flips the inverse flavor to stp, which
then runs extractStpTarget() instead of extractRangeTarget(), which finds no stopping-power
value, so the 10 cm is never captured. It fails loudly (incomplete: true, conf 0.40) but for
entirely the wrong reason, and no amount of widening extractStpTarget fixes it — the flavor
decision is structurally wrong once a sentence can name two quantities.
3. Example sentences and use cases
3.1 Forward: both quantities from one energy (the core case)
"What is the range and the stopping power of a 150 MeV proton in water?"
"Give me both the stopping power and the CSDA range of a 100 MeV proton in PMMA."
"Tell me everything about a 200 MeV proton in water." (→ full report; see §3.5)
"Range and dE/dx of a 250 MeV proton in aluminum."
"For a 200 MeV proton in water, what is the LET and how far does it go?" (one direct keyword +
one indirect idiom in the same sentence — neither detector currently yields to the other)
"Stopping power of a 100 MeV proton in water, and also how deep it penetrates."
"What's the LET and the penetration depth of a 4 MeV alpha in tissue?"
3.2 Inverse + derived forward (a genuinely new compute path)
"What energy gives a 10 cm range in water, and what is the stopping power at that energy?"
"Which proton energy stops at 15 cm in PMMA, and what's the LET there?"
"What energy carbon ion has a stopping power of 8 keV/µm in water, and how far does it travel?"
These need a second computation pass: resolve the energy from the inverse lookup, then run the
forward calculate() at that resolved energy. Nothing in inverseSeries() does this today — it
returns a single point carrying the resolved energy plus an echo of the input target.
Worth noting these also enable a free self-consistency check: for #8, the forward CSDA range
computed at the resolved energy should reproduce the 10 cm that was asked for. A visible round-trip
mismatch would be a real signal (interpolation error, wrong branch of the inverse STP curve), and
this is the only query shape in the project that can surface it.
3.3 Multi-quantity crossed with an existing comparison dimension
"Compare the range and the stopping power of 100 MeV protons in water and PMMA." (compareDim: "material" × 2 quantities → a 2×2 result table)
"Range and LET of carbon and neon ions in water at 200 MeV per nucleon."
"What are the range and stopping power at 100, 200, and 300 MeV for protons in water?" (compareDim: "energy" × 2 quantities)
These are where the current single-quantity + single-compareDim model stops being enough to
describe the result shape at all — see §5.
3.4 The ambiguous-"and" problem (why this is real NLU work, not a keyword list)
"What is the range and stopping power of 100 and 200 MeV protons in water?"
Two ands in one sentence doing two completely different jobs: the first coordinates quantities,
the second coordinates energies. The energy-list grammar (LIST_SEP_SRC), the particle-list
grammar (PARTICLE_LIST_RE), and any new quantity-list grammar all compete for the same connector
tokens. Today this happens to parse correctly only because the quantity list is silently dropped
(measured above: compareDim: "energy", two energies, one quantity). Once quantities can be
coordinated too, the grammars genuinely overlap.
"Range of protons and alphas, and their stopping powers, in water at 50 MeV."
"Stopping power and range in water and PMMA for protons and carbon at 100 and 200 MeV." (deliberately adversarial: three coordinated dimensions plus a quantity list)
3.5 "Full report" / open-ended phrasings
"Tell me everything about a 200 MeV proton in water."
"Give me the full picture for a 150 MeV proton in PMMA."
"Summarize a 100 MeV proton in water."
"What can you tell me about carbon ions at 200 MeV per nucleon in water?"
These should map to "all applicable forward quantities" rather than being forced onto one. They're
also the phrasings most likely to arrive from a voice user who doesn't know the vocabulary — which
makes them a decent proxy for first-time-user behavior.
3.6 Bragg peak (the unused-API family)
"What's the maximum stopping power of a proton in water?"
"Where is the Bragg peak for a 150 MeV proton in water?"
"What's the peak LET for carbon ions in water?"
3.7 Negative cases — must not become multi-quantity
"What is the stopping power of a 150 MeV proton in water?" (control: exactly one quantity)
"Compute the proton range in water at 200 MeV with both Bethe and ICRU models." (already in eval/intents.jsonl — "both" here coordinates programs, not quantities; a
naive \bboth\b trigger would break this existing passing example)
"Compare the range in water and PMMA." (one quantity, compareDim: "material")
"How does stopping power relate to range?" (a conceptual question, not a computation — should
stay unmatched rather than silently computing something)
4. Schema design
Three options considered.
Option A — quantities: Quantity[] (replace the scalar). Cleanest conceptually; breaks all 122 eval/intents.jsonl rows, validateQueryIntent(), compareIntent(), the Kotlin MatchedIntent,
and every intent.quantity === site. High churn for a project whose regression suite is its most
valuable artifact.
Option B — keep quantity, add alsoReport?: Quantity[] (recommended).
exportinterfaceQueryIntent{/** Primary quantity — drives the answer sentence, the chips, and the deep link. */quantity: Quantity;/** * Additional quantities to report alongside `quantity`, in the order they * were asked for. Absent/empty for an ordinary single-quantity query, so * every existing eval row, validator, and consumer stays valid unchanged. */alsoReport?: Quantity[];…}
Backward compatible: all 122 eval rows, parseEvalRecords, the Kotlin port, and the coverage
harness keep working untouched; only code that wants the new behavior opts in. "Primary" is the
first quantity mentioned in reading order, which is also the one the answer sentence should lead
with and the only one representable in some downstream surfaces.
Option C — a report: "single" | "full" verbosity flag. Handles §3.5 but not §3.1/§3.2, where
the user names exactly two of three. Rejected as insufficient on its own; "full report" is better
expressed as alsoReport populated with every applicable quantity.
Validation changes (validateQueryIntent)
The current target invariant is strictly binary and would reject every §3.2 sentence:
constneedsTarget=q==="energyFromRange"||q==="energyFromStp";if(needsTarget&&!hasTarget)errors.push(`${path}.target: required for quantity "${q}"`);if(!needsTarget&&hasTarget)errors.push(`${path}.target: only allowed for inverse quantities`);
A mixed intent (quantity: "energyFromRange", alsoReport: ["stoppingPower"]) has both an
inverse quantity and forward quantities and must still carry its target. New rule: a target is
required iff any quantity in [quantity, ...alsoReport] is inverse, and forbidden iff none
is. Additional rules worth adding: alsoReport must not contain duplicates, must not contain quantity itself, and must not contain two inverse quantities (energyFromRange + energyFromStp would need two independent targets, which the singular target slot can't express
— a real schema limitation to state explicitly rather than discover later).
Also add a "multi-quantity" entry to EVAL_TAGS.
5. Blockers, file by file
Everything below is a place the single-quantity assumption is load-bearing today.
src/lib/intent/query-intent.ts
quantity: Quantity scalar; the target invariant above.
EVAL_TAGS has no multi-quantity tag.
src/lib/intent/matcher.ts — the substantial work
detectForwardQuantity() returns one{quantity, source} with hard precedence
(DIRECT_STOPPING before DIRECT_RANGE), so it can't report "both were named". Needs to return
an ordered set with per-quantity provenance.
detectInverse()'s isStp flavor test scans the entire sentence — the §2(b) root cause. Needs to
scope the flavor decision to the clause containing the actual inverse ask, which is a parsing
change, not a regex-widening change.
Quantity keyword spans are never recorded as consumed spans (unlike particles/energies/targets),
because there was only ever one. With a quantity list they have to be, or the material n-gram scan
can re-mine them.
No coordination grammar exists for quantity lists, and it has to share connector tokens with LIST_SEP_SRC / PARTICLE_LIST_RE (§3.4).
scoreConfidence() has no notion of "recognized two quantities but only one confidently".
src/lib/compute/compute.ts
const build = isInverse ? buildInverse : buildForward; — a binary switch that a mixed intent
breaks by construction.
ComputeResult.quantity is scalar; ComputeSeries.points is a flat list with no record of which
quantity each point serves.
forwardSeries()'s computeCsda optimization must become "compute whichever quantities were
asked for".
No path exists for §3.2's derived-forward pass (inverse → resolved energy → forward calculate() at that energy). This is genuinely new code, not a parameterization.
src/lib/nlg/render.ts — every function is quantity-parameterized: QUANTITY_PHRASE, valueText(), compareLine(), singleSentence(), introLine(). A multi-quantity answer needs a
different shape — a compound sentence ("The range … is X and the stopping power is Y") or a
labeled list — plus a decision on how multi-quantity × compareDim renders (§3.3 is a 2-D table
being flattened into lines).
src/lib/nlg/dedx-web-link.ts — isForward is binary. Good news: a forward multi-quantity
intent maps onto dedx_web better than today's, since no quantity param exists there anyway. Mixed
forward+inverse (§3.2) has no dedx_web representation and should return null, consistent with the
module's existing "a missing link is harmless, a wrong link undermines the trust loop" rule.
src/lib/components/answer/IntentChips.svelte — QUANTITY_LABELS[intent.quantity] renders one
non-editable quantity chip. Multi-quantity needs N chips, and ideally add/remove affordances (the
trust loop's whole point is that a mis-heard slot is correctable — "I asked for range too" is
exactly the correction this feature invites).
src/lib/intent/coverage.ts — quantity: predicted.quantity === expected.quantity is a scalar
equality; needs set comparison, and a decision on whether order matters (recommend: compare as an
ordered list, since primary-quantity choice affects the rendered sentence).
src/lib/intent/fill-defaults.ts, src/lib/compute/validate.ts — both re-derive isInverse from the scalar with the same binary assumption.
Android (bench/android/full-app/…)
enum class Quantity { STOPPING_POWER, CSDA_RANGE } and MatchedIntent.quantity scalar.
MainActivity.kt's two if (matched.quantity == …) guards around the libdedx calls — the actual
fix is deleting the conditionals, since AnswerFormatter.format() already accepts both values.
AnswerFormatter renders one quantityPhrase; needs the same compound-sentence treatment as web.
KotlinMatcherAgreementTest must be extended, or the two matchers silently diverge on exactly the
new surface — the drift risk docs/android-full-app-spike.md §4 already flags.
6. Suggested architecture reorganization
The deeper issue the blocker list exposes: computeIntent() conflates "what to vary" with "what
to report."compareDim is the fan-out axis; quantity is the reporting axis; today both are
single scalars and the code branches on their combination, which is why every new combination
(multi-quantity × compare, inverse × derived-forward) needs another branch.
Proposal: introduce an explicit compute plan between intent and compute —
interfaceComputePlan{/** Fan-out: one entry per (particle, material, program) series to evaluate. */series: {particle: ResolvedParticle;material: ResolvedMaterial;programId: number;label: string}[];/** Reporting: which quantities each series should produce, in answer order. */report: Quantity[];/** Energies (forward) and/or target (inverse) driving each series. */inputs: {energiesMeVPerNucl: number[];target?: TargetSlot};}
computeIntent() becomes planIntent() (all the compareDim branching, entity resolution, and
program auto-selection — pure, trivially testable without WASM) followed by executePlan() (the
libdedx calls). Benefits directly relevant here:
multi-quantity is just a longer report, not a new branch;
§3.2's derived-forward pass is a plan with an inverse step feeding a forward step, expressible
instead of special-cased;
the plan is a natural, inspectable debugging artifact and a natural cache key;
ComputeSeries.points can become Record<Quantity, ComputePoint[]>, removing the "which optional
field is meaningful right now" implicit contract that valueText() currently has to re-derive.
This is a refactor with no user-visible change, and it should land before multi-quantity rather
than as part of it — otherwise the new feature adds a fourth dimension to branching logic that's
already at its limit.
7. Recordings database
Current state: 289 WAVs across 6 speaker sets (km 30, lg 30, lgpixel 100, lgpixel-en 50, lgpixel-pl 50, mn 29), plus the 50 bilingual datagen tuples (eval/datagen-sentences.json) and
the generated 1000-sentence TTS batches. Not one recording, and not one of the 122 eval/intents.jsonl rows, asks for two quantities — grep confirms the only two uses of "both" are
a conversational filler and the program-comparison row (§3.7 #25).
So this feature is currently unmeasurable on real speech. What to add:
A multi-quantity block in the datagen set (~15–20 tuples), following the existing canonical / display / slotTruth convention in eval/RECORDING.datagen.md, with slotTruth.quantities as an ordered list. Keep the existing conventions rather than inventing
new ones: length units always spelled out, the 45/5 abbreviated/expanded energy split, LET
letter-spelled in EN display and left alone in PL.
Prosody is the actual research question here. A coordinated quantity list is spoken with list
intonation and often a longer pause at the connector than a single-quantity sentence has anywhere.
That is exactly the acoustic context where ASR inserts or deletes the connector — and the
connector is the token the new grammar depends on. This can't be answered by TTS-generated audio;
it needs human recordings. Prioritize §3.4's double-and sentences.
Cover all three connector families separately, since they're acoustically very different: and
/ , (juxtaposition) / as well as / along with / plus. Whisper and Parakeet should be
expected to behave differently on the unstressed multi-syllable ones.
Polish needs its own list — eval/RECORDING.pl.md's conventions plus the fact that Polish
coordinates with i/oraz/a także and inflects the quantity nouns (zasięg i zdolność hamowania). The PL matcher pack differs in shape, not just vocabulary, so PL multi-quantity is
not a translation exercise.
Every tag reads 100.0% | 100.0%. Every single one. Confidence calibration has zero examples
below the 0.80 band. The misses list is empty.
That is not evidence the matcher is robust; it's evidence the instrument has stopped
discriminating. The mechanism is visible in the git history: #26 (6 fixes), #103 (4 bugs), #122
(4 fixes), #132, #147, #151, #153, #156 — each one found a real failure on a real recording, fixed
it by widening a regex, and added eval rows covering it. The eval set therefore measures "did we
remember to add a row for each bug we already fixed," not "does the grammar generalize to phrasings
nobody has written down yet." Its role as a frozen regression suite is valuable and should not
change — but it cannot also be the thing that tells us whether a change is an improvement.
The multi-quantity feature makes this acute: there is currently no instrument that could detect
whether adding a quantity-coordination grammar broke energy or particle coordination (§3.4), and
the one number we'd look at already reads 100%.
Proposed design
A generated, text-only, held-out corpus — no audio, no TTS, no models, runs in CI in seconds:
Reuse the existing generator infrastructure.scripts/generate-1000-sentences.mjs already
does template + slot-pool sampling with a closed generate→validate loop against the real matchIntent() + libdedx WASM, resampling on failure. A text benchmark is that machinery with the
TTS stage removed — cheap, deterministic, seedable.
Hard discipline: templates must be authored from the physics/user side, not derived from the
matcher's regexes. A generator written by reading en.ts measures nothing. Ideally the template
author works from the recorded human sentences and the domain, and template additions are reviewed
for exactly this.
Report per-slot and per-phenomenon accuracy (quantity / particle / material / energy / target /
compareDim × coordination / indirect / spelled-out numbers / unit variants), not one aggregate.
Held-out split: report separately on sentences whose surface form appears nowhere in eval/intents.jsonl. That is the number that actually tracks generalization.
Gate on regression, not on an absolute threshold. Track the headline held-out number in CI as
a non-blocking metric first (the way coverage:intents runs today), then tighten.
This is also the only honest way to evaluate §9's rewrite question.
9. The regex maintainability question
Measured extent of the problem
Location
Regex/rule count
Notes
src/lib/intent/matcher.ts
~14
plus new RegExp composition from pack sources
src/lib/intent/lang/en.ts
~25
idiom table, direct keywords, list/suffix grammar sources
src/lib/intent/lang/pl.ts
~14
different shape, not just vocabulary
src/lib/asr/correct/en.ts
~75 rules
shipped transcript corrector
scripts/asr-correct.mjs + asr-correct-ext.mjs
~25+
deliberately kept unmodified as a research artifact
The English stopping-power synonym list exists in at least four places. Adding one synonym
correctly means editing en.ts (matcher), correct/en.ts (corrector), nlu-quantity-prepass.ts,
and KotlinMatcher.kt — with only the Kotlin one having a test that would notice the omission, and
only as an aggregate agreement percentage.
Beyond duplication, there are specific fragility signals:
The length-preserving padEnd invariant.spellOutNumbers() pads digit substitutions with
trailing spaces so character offsets survive, because every downstream span (particle, material,
energy) is computed against the substituted string. It's clever and well-documented, but it's an
invariant enforced by convention across four functions, with no test that would catch a future
substitution that forgets it.
Order-dependent normalization pipeline.composeHundreds → composeTensOnes → composeDecimals → per-word substitution, where the comments explicitly say things like "must run
before composeDecimals()". Four passes, each a regex built by string-concatenating alternations
out of the pack's word list.
Precedence standing in for parsing.DIRECT_STOPPING before DIRECT_RANGE, "inverse takes
precedence", "shared-unit lists first so the trailing match isn't also matched as a lone energy" —
these are parser decisions encoded as ordering between independent regexes. §2's two failure modes
are both direct consequences.
Constraint that rules out most off-the-shelf NLU
The matcher must run in-browser in a static site with no server (GitHub Pages, no COOP/COEP) and in Kotlin on Android, offline. That eliminates spaCy, Rasa, Snips, Duckling, and every
Python/JVM-server NLU stack — which is why hand-written regexes were the right initial call, and
that reasoning still holds. Any replacement has to satisfy the same two-runtime constraint or accept
permanent hand-porting.
Options, honestly assessed
(a) Keep regexes, kill the duplication — single source of truth + codegen. Move the synonym /
idiom / unit / number lexicons into data files (JSON/YAML) and generate the TS and Kotlin
tables from them, exactly as scripts/generate-aliases.ts already does for the alias tables. Low
risk, no new runtime dependency, directly fixes the four-copies problem and the Kotlin drift. Does not reduce grammar complexity. Recommended regardless of what else happens.
(b) A real grammar (ANTLR4). The only mature option that generates parsers for both
JavaScript and Java/Kotlin from one grammar file — which is exactly this project's constraint, and
would eliminate the hand-ported Kotlin matcher rather than just synchronizing it. A parse tree makes
§3.4's ambiguous and a scoping question rather than a regex-precedence accident, and makes
multi-quantity natural. Costs: a real dependency and build step, generated-parser bundle size in a
static site, error recovery on ungrammatical ASR output needs design (the current scan-and-ignore
behavior is quite robust to garbage, and a strict parser is not), and Polish's freer word order is
harder to express in a CFG than in the current span-scan. This is a spike, not a decision —
and it cannot be evaluated at all until §8 exists.
(c) Narrow the formalization to number/unit normalization only (best value-for-risk). The four compose* functions plus the bulk of the ~75 corrector rules are a hand-rolled inverse text
normalization (ITN) system — a well-studied problem with mature grammar-based solutions (NeMo's nemo_text_processing WFST grammars being the reference implementation). It's also where nearly
every recent bug landed (#122, #147, #151, #153, #156). Options: adopt a small tested JS library for
spelled-out numbers (with the span-preservation adaptation), or express just this sub-grammar
formally and generate both runtimes. Much smaller blast radius than (b) and targets the actual
observed bug distribution.
(d) A learned slot-filling model. The project already ships transformers.js and has a
model-download pipeline, so a small ONNX token-classifier is deployable. But it needs labeled
training data (the 122-row eval set is nowhere near enough), it's non-deterministic, and it cuts
against the project's stated north star of deterministic-first with LLM only as fallback. Not
recommended as a replacement; possibly interesting as a third tier below the existing LLM fallback.
Recommended sequencing
§8's text benchmark first. Nothing else in this list can be evaluated without it — the current
instrument reads 100% and cannot show a regression.
(a) lexicon consolidation + codegen — independently valuable, low risk, fixes the drift this
feature would otherwise multiply across four copies.
§6's plan/execute split — no user-visible change, removes the branching ceiling.
Multi-quantity itself, forward-only first (§3.1), then §3.2's derived-forward pass.
(c) ITN formalization, targeted at the measured bug distribution.
(b) ANTLR spike, scoped and judged by the §8 benchmark — explicitly after, never before.
Do not rewrite the matcher before the benchmark exists. A rewrite that scores 100% on intents.jsonl tells us nothing, because the current one already does.
10. Open questions
Primary-quantity choice. Reading order, or a fixed preference? Affects the answer sentence,
the chips, and the deep link. Reading order is proposed; it makes "range and stopping power" and
"stopping power and range" render differently, which seems right but should be confirmed against
real recordings.
Two inverse quantities in one query ("what energy gives 10 cm range and what energy gives
8 keV/µm") can't be expressed — target is singular. Promote target to a list, or reject
explicitly? Rejecting is proposed for v1, but it should reject loudly.
compareDim × multi-quantity rendering (§3.3) is a 2-D table flattened into answer lines.
Which is the outer loop? Does TTS read the whole table aloud, or a summary?
Does "everything"/"full report" (§3.5) include the Bragg peak? And should it include
quantities the user's phrasing gave no evidence for at all?
Cost. Computing CSDA when only stopping power was asked is why computeCsda exists. What's
the actual measured cost of always computing both — negligible on desktop WASM, but the Android
numbers in docs/android-full-app-spike.md (0.029 ms/call JNI vs 10.098 ms/call cold wasm3) mean
it's worth measuring rather than assuming.
Summary
aidedx's
QueryIntent.quantityis a single scalar — one query asks for exactly one ofstoppingPower/csdaRange/energyFromRange/energyFromStp. dedx_web's calculator doesn'twork that way: in the forward direction it has no quantity selector at all — you enter an
energy and it returns both the stopping power and the CSDA range. This issue proposes closing
that gap ("two quantities from one input"), and uses that feature as the lens for three connected
concerns it exposes:
look like for this to be verifiable at all;
directly — and whether a more formalized grammar or an external library should replace it.
Everything under "Measured today" below was run against the current
main(397e1df); everythingelse is a proposal.
1. What dedx_web already offers, and what aidedx narrows away
This project's own deep-link builder is the cleanest evidence. In
src/lib/nlg/dedx-web-link.ts,the forward branch emits:
No quantity parameter is emitted, because dedx_web's calculator has none in the forward
direction. It takes particle + material + program + energies and shows stopping power and
range together. aidedx takes the same inputs, computes both (see below), and then throws one away
because the intent schema can only name one.
The inverse direction does carry a mode (
imode=csda/imode=stp), so the asymmetry is realand is worth preserving in the schema — but "which single quantity do you want" is an
aidedx-invented narrowing on the forward side, not something the underlying calculator imposes.
The compute layer already produces both.
LibdedxService.calculate()returns{ energies, stoppingPowers, csdaRanges }in one call (src/lib/wasm/types.ts), andComputePointalready has three independent optional fields:forwardSeries()deliberately suppresses one of them purely as a cost optimization:So the data model is already multi-quantity-shaped. The blockers are the intent schema, the
NLG, and the binary forward/inverse branch in
computeIntent()— not the physics.The same is true on Android, more starkly:
AnswerFormatter.format()already takes bothstoppingPowerMevCm2PerGandcsdaRangeGramPerCm2as parameters and discards one based on thescalar quantity, and
MainActivity.ktguards each libdedx call behind anifon that same scalar:Dropping those two conditionals is most of the Android work.
Unused capability worth folding in
LibdedxService.getBraggPeakStp()is implemented insrc/lib/wasm/libdedx.ts:350and called bynothing — no compute path, no test, no UI. "What's the peak stopping power / where's the Bragg
peak" is a natural member of the same multi-quantity family and comes for free from an API that's
already wrapped and shipped.
2. Measured today: what actually happens to these sentences
Run against current
mainviamatchIntent()(reproducible: eight sentences, no eval-setinvolvement):
quantitycompareDimincompletestoppingPowerstoppingPowerstoppingPowerstoppingPowerstoppingPowerstoppingPowerstoppingPowerenergyFromStpTwo distinct failure modes, both worth fixing:
(a) Silent half-answers at full confidence. Every forward multi-quantity sentence collapses to
stoppingPowerand is indistinguishable from the single-quantity control — same quantity, samecompareDim, same0.97confidence,incomplete: false. The user asked for two numbers, gets one,and nothing anywhere in the pipeline signals that half the question was dropped. This is the same
failure class as #132 (a plausible-looking wrong answer instead of a loud failure), and it is not
even "first mention wins":
detectForwardQuantity()testsDIRECT_STOPPINGbeforeDIRECT_RANGEunconditionally, so "Range and dE/dx" still resolves to stopping power.
(b) Mixed inverse+forward corrupts the inverse parse outright. The last row is the interesting
one. "What energy gives a 10 cm range in water, and what is the stopping power at that energy?" is
an
energyFromRangequery with a10 cmtarget. It comes back asenergyFromStpwith no targetat all and no particle. Cause:
detectInverse()'s flavor test scans the whole sentence —— so the trailing, secondary mention of "stopping power" flips the inverse flavor to
stp, whichthen runs
extractStpTarget()instead ofextractRangeTarget(), which finds no stopping-powervalue, so the
10 cmis never captured. It fails loudly (incomplete: true, conf 0.40) but forentirely the wrong reason, and no amount of widening
extractStpTargetfixes it — the flavordecision is structurally wrong once a sentence can name two quantities.
3. Example sentences and use cases
3.1 Forward: both quantities from one energy (the core case)
one indirect idiom in the same sentence — neither detector currently yields to the other)
3.2 Inverse + derived forward (a genuinely new compute path)
These need a second computation pass: resolve the energy from the inverse lookup, then run the
forward
calculate()at that resolved energy. Nothing ininverseSeries()does this today — itreturns a single point carrying the resolved
energyplus an echo of the input target.Worth noting these also enable a free self-consistency check: for #8, the forward CSDA range
computed at the resolved energy should reproduce the 10 cm that was asked for. A visible round-trip
mismatch would be a real signal (interpolation error, wrong branch of the inverse STP curve), and
this is the only query shape in the project that can surface it.
3.3 Multi-quantity crossed with an existing comparison dimension
(
compareDim: "material"× 2 quantities → a 2×2 result table)(
compareDim: "energy"× 2 quantities)These are where the current single-
quantity+ single-compareDimmodel stops being enough todescribe the result shape at all — see §5.
3.4 The ambiguous-"and" problem (why this is real NLU work, not a keyword list)
Two
ands in one sentence doing two completely different jobs: the first coordinates quantities,the second coordinates energies. The energy-list grammar (
LIST_SEP_SRC), the particle-listgrammar (
PARTICLE_LIST_RE), and any new quantity-list grammar all compete for the same connectortokens. Today this happens to parse correctly only because the quantity list is silently dropped
(measured above:
compareDim: "energy", two energies, one quantity). Once quantities can becoordinated too, the grammars genuinely overlap.
(deliberately adversarial: three coordinated dimensions plus a quantity list)
3.5 "Full report" / open-ended phrasings
These should map to "all applicable forward quantities" rather than being forced onto one. They're
also the phrasings most likely to arrive from a voice user who doesn't know the vocabulary — which
makes them a decent proxy for first-time-user behavior.
3.6 Bragg peak (the unused-API family)
3.7 Negative cases — must not become multi-quantity
(already in
eval/intents.jsonl— "both" here coordinates programs, not quantities; anaive
\bboth\btrigger would break this existing passing example)compareDim: "material")stay unmatched rather than silently computing something)
4. Schema design
Three options considered.
Option A —
quantities: Quantity[](replace the scalar). Cleanest conceptually; breaks all 122eval/intents.jsonlrows,validateQueryIntent(),compareIntent(), the KotlinMatchedIntent,and every
intent.quantity ===site. High churn for a project whose regression suite is its mostvaluable artifact.
Option B — keep
quantity, addalsoReport?: Quantity[](recommended).Backward compatible: all 122 eval rows,
parseEvalRecords, the Kotlin port, and the coverageharness keep working untouched; only code that wants the new behavior opts in. "Primary" is the
first quantity mentioned in reading order, which is also the one the answer sentence should lead
with and the only one representable in some downstream surfaces.
Option C — a
report: "single" | "full"verbosity flag. Handles §3.5 but not §3.1/§3.2, wherethe user names exactly two of three. Rejected as insufficient on its own; "full report" is better
expressed as
alsoReportpopulated with every applicable quantity.Validation changes (
validateQueryIntent)The current target invariant is strictly binary and would reject every §3.2 sentence:
A mixed intent (
quantity: "energyFromRange",alsoReport: ["stoppingPower"]) has both aninverse quantity and forward quantities and must still carry its target. New rule: a target is
required iff any quantity in
[quantity, ...alsoReport]is inverse, and forbidden iff noneis. Additional rules worth adding:
alsoReportmust not contain duplicates, must not containquantityitself, and must not contain two inverse quantities (energyFromRange+energyFromStpwould need two independent targets, which the singulartargetslot can't express— a real schema limitation to state explicitly rather than discover later).
Also add a
"multi-quantity"entry toEVAL_TAGS.5. Blockers, file by file
Everything below is a place the single-quantity assumption is load-bearing today.
src/lib/intent/query-intent.tsquantity: Quantityscalar; the target invariant above.EVAL_TAGShas no multi-quantity tag.src/lib/intent/matcher.ts— the substantial workdetectForwardQuantity()returns one{quantity, source}with hard precedence(
DIRECT_STOPPINGbeforeDIRECT_RANGE), so it can't report "both were named". Needs to returnan ordered set with per-quantity provenance.
detectInverse()'sisStpflavor test scans the entire sentence — the §2(b) root cause. Needs toscope the flavor decision to the clause containing the actual inverse ask, which is a parsing
change, not a regex-widening change.
because there was only ever one. With a quantity list they have to be, or the material n-gram scan
can re-mine them.
LIST_SEP_SRC/PARTICLE_LIST_RE(§3.4).scoreConfidence()has no notion of "recognized two quantities but only one confidently".src/lib/compute/compute.tsconst build = isInverse ? buildInverse : buildForward;— a binary switch that a mixed intentbreaks by construction.
ComputeResult.quantityis scalar;ComputeSeries.pointsis a flat list with no record of whichquantity each point serves.
forwardSeries()'scomputeCsdaoptimization must become "compute whichever quantities wereasked for".
calculate()at that energy). This is genuinely new code, not a parameterization.src/lib/nlg/render.ts— every function isquantity-parameterized:QUANTITY_PHRASE,valueText(),compareLine(),singleSentence(),introLine(). A multi-quantity answer needs adifferent shape — a compound sentence ("The range … is X and the stopping power is Y") or a
labeled list — plus a decision on how multi-quantity ×
compareDimrenders (§3.3 is a 2-D tablebeing flattened into lines).
src/lib/nlg/dedx-web-link.ts—isForwardis binary. Good news: a forward multi-quantityintent maps onto dedx_web better than today's, since no quantity param exists there anyway. Mixed
forward+inverse (§3.2) has no dedx_web representation and should return
null, consistent with themodule's existing "a missing link is harmless, a wrong link undermines the trust loop" rule.
src/lib/components/answer/IntentChips.svelte—QUANTITY_LABELS[intent.quantity]renders onenon-editable quantity chip. Multi-quantity needs N chips, and ideally add/remove affordances (the
trust loop's whole point is that a mis-heard slot is correctable — "I asked for range too" is
exactly the correction this feature invites).
src/lib/intent/coverage.ts—quantity: predicted.quantity === expected.quantityis a scalarequality; needs set comparison, and a decision on whether order matters (recommend: compare as an
ordered list, since primary-quantity choice affects the rendered sentence).
src/lib/intent/fill-defaults.ts,src/lib/compute/validate.ts— both re-deriveisInversefrom the scalar with the same binary assumption.Android (
bench/android/full-app/…)enum class Quantity { STOPPING_POWER, CSDA_RANGE }andMatchedIntent.quantityscalar.MainActivity.kt's twoif (matched.quantity == …)guards around the libdedx calls — the actualfix is deleting the conditionals, since
AnswerFormatter.format()already accepts both values.AnswerFormatterrenders onequantityPhrase; needs the same compound-sentence treatment as web.KotlinMatcherAgreementTestmust be extended, or the two matchers silently diverge on exactly thenew surface — the drift risk
docs/android-full-app-spike.md§4 already flags.6. Suggested architecture reorganization
The deeper issue the blocker list exposes:
computeIntent()conflates "what to vary" with "whatto report."
compareDimis the fan-out axis;quantityis the reporting axis; today both aresingle scalars and the code branches on their combination, which is why every new combination
(multi-quantity × compare, inverse × derived-forward) needs another branch.
Proposal: introduce an explicit compute plan between intent and compute —
computeIntent()becomesplanIntent()(all thecompareDimbranching, entity resolution, andprogram auto-selection — pure, trivially testable without WASM) followed by
executePlan()(thelibdedx calls). Benefits directly relevant here:
report, not a new branch;instead of special-cased;
ComputeSeries.pointscan becomeRecord<Quantity, ComputePoint[]>, removing the "which optionalfield is meaningful right now" implicit contract that
valueText()currently has to re-derive.This is a refactor with no user-visible change, and it should land before multi-quantity rather
than as part of it — otherwise the new feature adds a fourth dimension to branching logic that's
already at its limit.
7. Recordings database
Current state: 289 WAVs across 6 speaker sets (
km30,lg30,lgpixel100,lgpixel-en50,lgpixel-pl50,mn29), plus the 50 bilingual datagen tuples (eval/datagen-sentences.json) andthe generated 1000-sentence TTS batches. Not one recording, and not one of the 122
eval/intents.jsonlrows, asks for two quantities — grep confirms the only two uses of "both" area conversational filler and the program-comparison row (§3.7 #25).
So this feature is currently unmeasurable on real speech. What to add:
canonical/display/slotTruthconvention ineval/RECORDING.datagen.md, withslotTruth.quantitiesas an ordered list. Keep the existing conventions rather than inventingnew ones: length units always spelled out, the 45/5 abbreviated/expanded energy split,
LETletter-spelled in EN
displayand left alone in PL.intonation and often a longer pause at the connector than a single-quantity sentence has anywhere.
That is exactly the acoustic context where ASR inserts or deletes the connector — and the
connector is the token the new grammar depends on. This can't be answered by TTS-generated audio;
it needs human recordings. Prioritize §3.4's double-
andsentences.and/
,(juxtaposition) /as well as/along with/plus. Whisper and Parakeet should beexpected to behave differently on the unstressed multi-syllable ones.
eval/RECORDING.pl.md's conventions plus the fact that Polishcoordinates with
i/oraz/a takżeand inflects the quantity nouns (zasięg i zdolność hamowania). The PL matcher pack differs in shape, not just vocabulary, so PL multi-quantity isnot a translation exercise.
speaker's voice measures that speaker, not the phenomenon.
8. A text-only benchmark suite (
pnpm bench:nlu)Why this is urgent, independent of the feature
pnpm coverage:intentson currentmainreports:Every tag reads 100.0% | 100.0%. Every single one. Confidence calibration has zero examples
below the 0.80 band. The misses list is empty.
That is not evidence the matcher is robust; it's evidence the instrument has stopped
discriminating. The mechanism is visible in the git history: #26 (6 fixes), #103 (4 bugs), #122
(4 fixes), #132, #147, #151, #153, #156 — each one found a real failure on a real recording, fixed
it by widening a regex, and added eval rows covering it. The eval set therefore measures "did we
remember to add a row for each bug we already fixed," not "does the grammar generalize to phrasings
nobody has written down yet." Its role as a frozen regression suite is valuable and should not
change — but it cannot also be the thing that tells us whether a change is an improvement.
The multi-quantity feature makes this acute: there is currently no instrument that could detect
whether adding a quantity-coordination grammar broke energy or particle coordination (§3.4), and
the one number we'd look at already reads 100%.
Proposed design
A generated, text-only, held-out corpus — no audio, no TTS, no models, runs in CI in seconds:
scripts/generate-1000-sentences.mjsalreadydoes template + slot-pool sampling with a closed generate→validate loop against the real
matchIntent()+ libdedx WASM, resampling on failure. A text benchmark is that machinery with theTTS stage removed — cheap, deterministic, seedable.
matcher's regexes. A generator written by reading
en.tsmeasures nothing. Ideally the templateauthor works from the recorded human sentences and the domain, and template additions are reviewed
for exactly this.
compareDim × coordination / indirect / spelled-out numbers / unit variants), not one aggregate.
eval/intents.jsonl. That is the number that actually tracks generalization.degradation: drop articles, spell out numbers, split acronyms letter-wise ("Me V", "el ee tee"),
glue units to numbers ("100MeV"), substitute homophones, drop the connector in a coordinated list.
Every one of Screenshot (Jul 29, 2026 22:13:08) #147 / Energy-unit ASR correction: TeV support + a silent TeV→MeV miscorrection bug, plus a 0%-coverage gap on spoken-out unit readings #151 / Energy-unit corrector: NUMBER_PREFIX_SRC doesn't handle spelled-out hundreds ("five hundred kilo electronvolt" still fails) #153 / Screenshot (Jul 29, 2026 22:13:57) #156 was a corruption of this kind, found one at a time, by
hand, after a real recording failed. A fuzz layer finds that class in bulk, for free, before
anyone records anything.
a non-blocking metric first (the way
coverage:intentsruns today), then tighten.This is also the only honest way to evaluate §9's rewrite question.
9. The regex maintainability question
Measured extent of the problem
src/lib/intent/matcher.tsnew RegExpcomposition from pack sourcessrc/lib/intent/lang/en.tssrc/lib/intent/lang/pl.tssrc/lib/asr/correct/en.tsscripts/asr-correct.mjs+asr-correct-ext.mjsscripts/nlu-quantity-prepass.tsbench/android/.../KotlinMatcher.kt+AsrCorrections.ktThe English stopping-power synonym list exists in at least four places. Adding one synonym
correctly means editing
en.ts(matcher),correct/en.ts(corrector),nlu-quantity-prepass.ts,and
KotlinMatcher.kt— with only the Kotlin one having a test that would notice the omission, andonly as an aggregate agreement percentage.
Beyond duplication, there are specific fragility signals:
padEndinvariant.spellOutNumbers()pads digit substitutions withtrailing spaces so character offsets survive, because every downstream span (particle, material,
energy) is computed against the substituted string. It's clever and well-documented, but it's an
invariant enforced by convention across four functions, with no test that would catch a future
substitution that forgets it.
composeHundreds→composeTensOnes→composeDecimals→ per-word substitution, where the comments explicitly say things like "must runbefore
composeDecimals()". Four passes, each a regex built by string-concatenating alternationsout of the pack's word list.
DIRECT_STOPPINGbeforeDIRECT_RANGE, "inverse takesprecedence", "shared-unit lists first so the trailing match isn't also matched as a lone energy" —
these are parser decisions encoded as ordering between independent regexes. §2's two failure modes
are both direct consequences.
Constraint that rules out most off-the-shelf NLU
The matcher must run in-browser in a static site with no server (GitHub Pages, no COOP/COEP)
and in Kotlin on Android, offline. That eliminates spaCy, Rasa, Snips, Duckling, and every
Python/JVM-server NLU stack — which is why hand-written regexes were the right initial call, and
that reasoning still holds. Any replacement has to satisfy the same two-runtime constraint or accept
permanent hand-porting.
Options, honestly assessed
(a) Keep regexes, kill the duplication — single source of truth + codegen. Move the synonym /
idiom / unit / number lexicons into data files (JSON/YAML) and generate the TS and Kotlin
tables from them, exactly as
scripts/generate-aliases.tsalready does for the alias tables. Lowrisk, no new runtime dependency, directly fixes the four-copies problem and the Kotlin drift. Does
not reduce grammar complexity. Recommended regardless of what else happens.
(b) A real grammar (ANTLR4). The only mature option that generates parsers for both
JavaScript and Java/Kotlin from one grammar file — which is exactly this project's constraint, and
would eliminate the hand-ported Kotlin matcher rather than just synchronizing it. A parse tree makes
§3.4's ambiguous
anda scoping question rather than a regex-precedence accident, and makesmulti-quantity natural. Costs: a real dependency and build step, generated-parser bundle size in a
static site, error recovery on ungrammatical ASR output needs design (the current scan-and-ignore
behavior is quite robust to garbage, and a strict parser is not), and Polish's freer word order is
harder to express in a CFG than in the current span-scan. This is a spike, not a decision —
and it cannot be evaluated at all until §8 exists.
(c) Narrow the formalization to number/unit normalization only (best value-for-risk). The four
compose*functions plus the bulk of the ~75 corrector rules are a hand-rolled inverse textnormalization (ITN) system — a well-studied problem with mature grammar-based solutions (NeMo's
nemo_text_processingWFST grammars being the reference implementation). It's also where nearlyevery recent bug landed (#122, #147, #151, #153, #156). Options: adopt a small tested JS library for
spelled-out numbers (with the span-preservation adaptation), or express just this sub-grammar
formally and generate both runtimes. Much smaller blast radius than (b) and targets the actual
observed bug distribution.
(d) A learned slot-filling model. The project already ships transformers.js and has a
model-download pipeline, so a small ONNX token-classifier is deployable. But it needs labeled
training data (the 122-row eval set is nowhere near enough), it's non-deterministic, and it cuts
against the project's stated north star of deterministic-first with LLM only as fallback. Not
recommended as a replacement; possibly interesting as a third tier below the existing LLM fallback.
Recommended sequencing
instrument reads 100% and cannot show a regression.
feature would otherwise multiply across four copies.
Do not rewrite the matcher before the benchmark exists. A rewrite that scores 100% on
intents.jsonltells us nothing, because the current one already does.10. Open questions
the chips, and the deep link. Reading order is proposed; it makes "range and stopping power" and
"stopping power and range" render differently, which seems right but should be confirmed against
real recordings.
8 keV/µm") can't be expressed —
targetis singular. Promotetargetto a list, or rejectexplicitly? Rejecting is proposed for v1, but it should reject loudly.
compareDim× multi-quantity rendering (§3.3) is a 2-D table flattened into answer lines.Which is the outer loop? Does TTS read the whole table aloud, or a summary?
quantities the user's phrasing gave no evidence for at all?
computeCsdaexists. What'sthe actual measured cost of always computing both — negligible on desktop WASM, but the Android
numbers in
docs/android-full-app-spike.md(0.029 ms/call JNI vs 10.098 ms/call cold wasm3) meanit's worth measuring rather than assuming.
touches the mass↔length conversion layer that multi-quantity answers use twice per answer instead
of once.
11. Checklist
Instrumentation (must land first)
pnpm bench:nlu— generated text-only corpus, held-out split, per-phenomenon reportingcoverage:intentsConsolidation
scripts/generate-aliases.ts)nlu-quantity-prepass.ts's fourth synonym copyRefactor
planIntent()/executePlan()split;ComputePlantypeComputeSeries.pointskeyed by quantityFeature
QueryIntent.alsoReport, validator rules,"multi-quantity"eval tagLIST_SEP_SRCoverlap (§3.4)computeIntent()multi-quantity; forward-only firstrender.tscompound/multi-quantity answersIntentChips.sveltemulti-quantity chips (+ add/remove)dedx-web-link.ts: forward multi-quantity (drops a constraint);nullfor mixedcoverage.tsset-comparison for quantitiesgetBraggPeakStp()Android
Quantityset onMatchedIntent; dropMainActivity's two conditionalsAnswerFormattercompound answersKotlinMatcherAgreementTestto the new surfaceEval data
eval/intents.jsonl(incl. §3.7 negatives)andprosody, speaker-holdout preserved