Skip to content

Multi-quantity intents ("range and stopping power" from one query): schema, compute reorganization, a text-only NLU benchmark, and the regex-maintainability ceiling #160

Description

@grzanka

Summary

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:

  1. the feature itself — multi-quantity intents, schema and compute/NLG reorganization;
  2. eval data — what the recordings database and a new text-only benchmark suite need to
    look like for this to be verifiable at all;
  3. 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:

if (isForward) {
  const encoded = encodeEnergies(intent.energies);
  params.set("energies", encoded.list);
  params.set("uanchor", encoded.anchor);
}

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:

export interface ComputePoint {
  energyMeVPerNucl: number;
  stoppingPower?: number;   // forward
  csdaRange?: number;       // forward
  energy?: number;          // inverse
}

forwardSeries() deliberately suppresses one of them purely as a cost optimization:

// Stopping-power queries don't need the CSDA integrator; skip it.
const computeCsda = 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:

val stp = if (matched.quantity == Quantity.STOPPING_POWER) { … } else null
val csda = if (matched.quantity == Quantity.CSDA_RANGE) { … } else null
resultLine = AnswerFormatter.format(matched, stp, csda, density)

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 energyFromStp with no target
at all
and no particle. Cause: detectInverse()'s flavor test scans the whole sentence —

const isStp = STP_UNIT_RE.test(lower) || pack.mentionsStoppingPowerSynonym(lower, text) ||
              pack.mentionsStoppingPowerKeyword(lower);

— 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)

  1. "What is the range and the stopping power of a 150 MeV proton in water?"
  2. "Give me both the stopping power and the CSDA range of a 100 MeV proton in PMMA."
  3. "Tell me everything about a 200 MeV proton in water." (→ full report; see §3.5)
  4. "Range and dE/dx of a 250 MeV proton in aluminum."
  5. "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)
  6. "Stopping power of a 100 MeV proton in water, and also how deep it penetrates."
  7. "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)

  1. "What energy gives a 10 cm range in water, and what is the stopping power at that energy?"
  2. "Which proton energy stops at 15 cm in PMMA, and what's the LET there?"
  3. "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

  1. "Compare the range and the stopping power of 100 MeV protons in water and PMMA."
    (compareDim: "material" × 2 quantities → a 2×2 result table)
  2. "Range and LET of carbon and neon ions in water at 200 MeV per nucleon."
  3. "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)

  1. "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.

  1. "Range of protons and alphas, and their stopping powers, in water at 50 MeV."
  2. "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

  1. "Tell me everything about a 200 MeV proton in water."
  2. "Give me the full picture for a 150 MeV proton in PMMA."
  3. "Summarize a 100 MeV proton in water."
  4. "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)

  1. "What's the maximum stopping power of a proton in water?"
  2. "Where is the Bragg peak for a 150 MeV proton in water?"
  3. "What's the peak LET for carbon ions in water?"

3.7 Negative cases — must not become multi-quantity

  1. "What is the stopping power of a 150 MeV proton in water?" (control: exactly one quantity)
  2. "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)
  3. "Compare the range in water and PMMA." (one quantity, compareDim: "material")
  4. "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).

export interface QueryIntent {
  /** 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:

const needsTarget = 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.tsthe 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.tsisForward 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.svelteQUANTITY_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.tsquantity: 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 —

interface ComputePlan {
  /** 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.
  • Keep the existing speaker-holdout discipline ([i18n] Polish eval set: frozen intents.pl.jsonl + speaker-holdout gap (issue #79 remainder) #135) — a new phenomenon added only in one
    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:intents on current main reports:

slots  100.0% (122/122)   exact  100.0% (122/122)

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.
  • Mutation/fuzz layer — apply ASR-like corruptions to canonical sentences and measure
    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.
  • 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
scripts/nlu-quantity-prepass.ts 9 a fourth copy of the stopping-power synonym list
bench/android/.../KotlinMatcher.kt + AsrCorrections.kt ported subset hand-ported, measured agreement, no shared source

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. composeHundredscomposeTensOnes
    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

  1. §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.
  2. (a) lexicon consolidation + codegen — independently valuable, low risk, fixes the drift this
    feature would otherwise multiply across four copies.
  3. §6's plan/execute split — no user-visible change, removes the branching ceiling.
  4. Multi-quantity itself, forward-only first (§3.1), then §3.2's derived-forward pass.
  5. (c) ITN formalization, targeted at the measured bug distribution.
  6. (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.
  • Interaction with Spoken material-density override: let a query state a custom density instead of the tabulated value #159 (spoken density override), which also adds matcher grammar and also
    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 reporting
  • Mutation/fuzz layer (ASR-like corruptions) with a baseline degradation number
  • Wire into CI as a non-blocking metric alongside coverage:intents

Consolidation

  • Lexicons (synonyms, idioms, units, number words) → data files
  • Codegen for the TS and Kotlin tables (follow scripts/generate-aliases.ts)
  • Retire nlu-quantity-prepass.ts's fourth synonym copy

Refactor

  • planIntent() / executePlan() split; ComputePlan type
  • ComputeSeries.points keyed by quantity

Feature

  • QueryIntent.alsoReport, validator rules, "multi-quantity" eval tag
  • Matcher: quantity-set detection, span consumption, clause-scoped inverse flavor (§2b fix)
  • Quantity coordination grammar vs. existing LIST_SEP_SRC overlap (§3.4)
  • computeIntent() multi-quantity; forward-only first
  • Derived-forward pass for §3.2 + round-trip self-consistency check
  • render.ts compound/multi-quantity answers
  • IntentChips.svelte multi-quantity chips (+ add/remove)
  • dedx-web-link.ts: forward multi-quantity (drops a constraint); null for mixed
  • coverage.ts set-comparison for quantities
  • Bragg-peak quantity using the already-wrapped, currently-unused getBraggPeakStp()

Android

  • Quantity set on MatchedIntent; drop MainActivity's two conditionals
  • AnswerFormatter compound answers
  • Extend KotlinMatcherAgreementTest to the new surface

Eval data

  • Multi-quantity rows in eval/intents.jsonl (incl. §3.7 negatives)
  • ~15–20 multi-quantity datagen tuples, EN + PL, all connector families
  • Human recordings prioritizing §3.4 double-and prosody, speaker-holdout preserved

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions