Skip to content

Spoken material-density override: let a query state a custom density instead of the tabulated value #159

Description

@grzanka

Motivation

Every material in libdedx carries one tabulated density (service.getDensity(materialId),
src/lib/wasm/libdedx.ts's _dedx_get_density), and today that's the only density aidedx ever
uses. It's what turns libdedx's native mass-normalized numbers (MeV·cm²/g stopping power,
g/cm² CSDA range) into the physical, length-normalized numbers people actually ask for
(keV/µm, a range in cm) — see stoppingPowerToKevPerUm() / csdaRangeToCm() in src/lib/format.ts
and their call sites in src/lib/nlg/render.ts and (mirrored) AnswerFormatter.kt.

The tabulated value is a reasonable default (e.g. water = 1.0 g/cm³, PMMA ≈ 1.19 g/cm³), but real
dosimetry and treatment-planning work routinely deals with materials whose actual density
differs from the NIST reference value:

  • Water isn't always at reference density. Room-temperature liquid water is ~0.998 g/cm³, not
    the tabulated 1.0; some workflows care about that half-percent.
  • Snow, ice, and packed powders vary enormously with compaction — loose snow ~0.1–0.3 g/cm³,
    packed snow ~0.4–0.6 g/cm³, ice ~0.92 g/cm³ — and none of that is "water" in libdedx's tables.
  • Compressed or rarefied gases (air in a range-shifter or ion chamber at non-standard pressure)
    scale density roughly linearly with pressure; the tabulated "Air (dry, near sea level)" density
    is only right at 1 atm.
  • 3D-printed phantom materials (PLA, ABS) have an effective density well below the bulk
    polymer's, set by print infill percentage — a phantom printed at 60% infill is not the same
    density as solid PMMA/PLA.
  • Tissue-equivalent gel dosimeters and custom mixes are often batch-mixed to a target density
    that's close to, but not exactly, the reference value in the closest tabulated material.

In every one of these cases the person already knows the density they want to use — they measured
it, mixed it, or set the pressure themselves — and today has no way to tell aidedx that short of
picking a different (wrong) material or not using voice at all. This issue proposes letting a
spoken query carry that density explicitly: "water with a density of 0.99 grams per cubic
centimeter."

Example sentences / use cases

Direct, single-quantity queries:

  1. "What's the range of 150 MeV protons in water with a density of 0.99 grams per cubic
    centimeter?"
  2. "Stopping power of a 60 MeV proton in PMMA, but use a density of 1.15 g/cm3 instead of the
    default."
  3. "How far does a 200 MeV carbon ion go in snow at a density of 0.35 grams per cc?"
  4. "Range of 70 MeV protons in air compressed to 0.0024 g/cm3."
  5. "What is the dE/dx of a 100 MeV proton in ice, density 0.92?"
  6. "Stopping power of a 4 MeV alpha particle in 3D-printed PLA, density 1.1."
  7. "What's the CSDA range of 250 MeV protons in tissue-equivalent plastic at 1.127 g per cm3?"

Inverse queries (density needed to convert the given length/rate target, not just the answer —
this is the case rangeTargetToGcm2() / stpTargetToMassUnits() in src/lib/compute/compute.ts
already has a ComputeError for when density is missing):

  1. "What energy proton gives a 10 cm range in a gel dosimeter with density 1.02 g/cm3?"
  2. "What energy carbon ion gives 8 keV/µm in water at 0.998 g/cm3?"

Unit-variant and spelled-out phrasing (ASR realistically produces both):

  1. "Water with a density of zero point nine nine eight grams per cubic centimeter" (spelled-out
    decimal — same NUMBER_PREFIX_SRC spelled-number path issue Screenshot (Jul 29, 2026 22:13:57) #156 already built for energies).
  2. "Density of one thousand kilograms per cubic meter" (kg/m³ → ÷1000 → g/cm³).
  3. "Aluminum at a density of 2.7 grams per milliliter" (g/mL is numerically identical to g/cm³).

Adversarial / edge cases the matcher and schema must handle gracefully:

  1. "Water with a density of -1 g/cm3" — non-physical; should be dropped like the existing
    negative-energy handling (issue [polish] First-user-testing feedback: units, error messages, download modal, copy, docs, UI polish #42 §5), not silently used.
  2. "Water with a density of 0 g/cm3" — same treatment; would also divide-by-zero downstream.
  3. "Aluminum with a density of 2.7 g/cm3" — override happens to equal the tabulated value; should
    parse fine and just be a no-op numerically (still worth asserting it doesn't crash or
    double-count an assumption note).

Explicitly out of scope for v1 (see "Open questions" below):

  1. "If the water were half as dense, what would the range be?" — relative/comparative density,
    needs a base value to scale from.
  2. "Compare the range in water at 1.0 versus water at 0.92 g/cm3" — two densities for the same
    material
    in one query; today's material dedup (dedupeByKey keyed on resolved material id in
    matcher.ts) would collapse these into one slot. Flagged as a real limitation below, not
    silently mishandled.

What changes physically, and what doesn't

This has to be described honestly to whoever reads the rendered answer, so it's worth being
precise about what a density override actually does to the computation:

  • Mass stopping power (MeV·cm²/g) is unchanged. libdedx's tabulated stopping-power values
    already are the density-independent quantity (to the accuracy of the tables); nothing about the
    Bethe-Bloch/tabulated calculation is re-run.
  • What changes is purely the mass ⇄ length conversion layer: keV/µm display
    (stoppingPowerToKevPerUm), CSDA range in cm (csdaRangeToCm), and — for inverse queries — the
    reverse direction, converting a spoken length/MeV-per-cm target into the g/cm²/MeV·cm²/g units
    libdedx's inverse lookups need (rangeTargetToGcm2, stpTargetToMassUnits).
  • The density-effect correction inside the Bethe-Bloch formula itself is not recomputed.
    libdedx's tabulated values bake in the density-effect correction (Sternheimer parameters) for the
    tabulated density. At densities far from that reference (e.g. a compressed gas at high
    pressure), this is a real, known approximation error — small for condensed matter swapped between
    nearby densities (0.92 vs. 1.0 for ice/water), potentially not small for gases compressed by a
    large factor. The rendered answer's assumption note (below) should say this plainly so the number
    isn't mistaken for a from-scratch recomputation.

This scope is exactly why the feature is cheap to build: it's a conversion-layer parameter, not a
new physics computation, and it only touches code that already threads a density: number value
through both stacks (ComputeSeries.density on web, the densityGramPerCm3: Float? parameter of
AnswerFormatter.format() on Android) — the override just needs to out-rank service.getDensity()
/ LibdedxBridge.getDensity() as the source of that same value.

Schema change (QueryIntent)

Add an optional field to MaterialSlot in src/lib/intent/query-intent.ts:

export interface MaterialSlot {
  match: string;
  /**
   * Spoken density override in g/cm³, replacing libdedx's tabulated
   * `getDensity()` value for this material in unit conversions only (see
   * "What changes physically" — never the tabulated mass stopping power
   * itself). Already canonicalized to g/cm³ by the matcher; the schema
   * doesn't need to carry the original spoken unit.
   */
  densityOverride?: number;
}

Scoped to MaterialSlot (not a top-level intent field) because the spoken phrase binds locally to
one material ("water with a density of…"), and it keeps the door open for per-material overrides
in a compareDim: "material" query (e.g. comparing water at reference density against a
compressed-gas material at its own overridden density — a real, in-scope case, distinct from the
two-densities-of-the-same-material case flagged as out of scope above).

validateQueryIntent() needs one new check: densityOverride, when present, must be a positive
finite number (reuse the existing isPositiveFiniteNumber helper — the same rule already applied
to energies, issue #42 §5).

Add "density-override" to EVAL_TAGS in the same file, for tagging new eval examples.

Web implementation plan

Matcher grammar (src/lib/intent/matcher.ts)

Add a density grammar parallel to the existing LENGTH_TARGET_RE / STP_TARGET_RES patterns
(§"2. Energies / target" in the matcher's pipeline comment, matcher.ts lines ~470–515):

const DENSITY_RE =
  /(?:density(?:\s+of)?|at)\s+(\d+(?:\.\d+)?)\s*(g\s*\/\s*cm\s*\^?\s*3|g\s*\/\s*cc|g\s*\/\s*ml|
    grams?\s+per\s+(?:cubic\s+centimet(?:er|re)|millilit(?:er|re))|kg\s*\/\s*m\s*\^?\s*3|
    kilograms?\s+per\s+cubic\s+met(?:er|re))\b/i;
  • Runs after particle/energy/target extraction and before the material n-gram scan, so its
    matched span (the whole "with a density of 0.99 g/cm3" clause) is added to consumedSpans and
    never mis-parsed as a material or number elsewhere — same ordering rationale already documented
    for particles/energies at matcher.ts's "2. Particles and energies/target first" comment.
  • Normalizes the captured unit to g/cm³ at parse time: g/cm3/g/cc/g/mL/spelled-out grams
    variants pass through as-is; kg/m3/spelled-out kilograms variants divide by 1000.
  • Spelled-out decimals ("zero point nine nine eight") are already handled upstream by
    spellOutNumbers() (issue Screenshot (Jul 29, 2026 22:13:57) #156) before this regex ever runs, since matchIntent() runs that
    normalization pass over the whole query first.
  • Rejects non-positive values (issue [polish] First-user-testing feedback: units, error messages, download modal, copy, docs, UI polish #42 §5's existing pattern for energies): a parsed density
    ≤ 0 is dropped rather than filled in, exactly like negativeEnergySpans — the span still gets
    excluded from material matching so a nonsense number doesn't leak into the material scan.
  • Because there's no existing "phrase that names both a material and a density" coordination
    logic, keep v1 to one density clause per query, attached to the first (or only) material
    slot that appears in the query. Attaching a spoken density to a specific material in a
    multi-material comparison (in-scope but harder: "water" and "compressed air, density 0.002" in
    the same sentence) is a stretch goal, not v1 — flagged below.

compute.ts wiring

forwardSeries() and inverseSeries() currently call service.getDensity(material.id)
unconditionally (compute.ts lines ~436 and ~473). Both need an optional override parameter that
takes priority:

function resolveDensity(service: LibdedxService, materialId: number, override?: number): number | undefined {
  return override ?? service.getDensity(materialId);
}

computeIntent()'s three branches (compareDim: "material", "particle"/"program", and the
"none"/"energy" fallthrough) all resolve a MaterialSlot right before calling build(...)
thread m.densityOverride (or reqFirst(intent.materials, ...).densityOverride) through to
forwardSeries/inverseSeries alongside the already-resolved ResolvedMaterial.

Add an assumption note whenever an override is used, mirroring the existing
assumptions.push(...) pattern for isotope/per-nucleon assumptions:

if (override !== undefined) {
  assumptions.push(
    `${material.name}: using spoken density ${override} g/cm³ (tabulated value is ${tabulated ?? "unavailable"} g/cm³)`,
  );
}

This is also where the honesty note from "What changes physically" belongs — either folded into
that assumption string or appended once per answer, not per series, to avoid repeating it in every
row of a comparison.

NLG (src/lib/nlg/render.ts)

No structural change needed — valueText() already converts using whatever series.density holds
(render.ts line ~103), and it doesn't care whether that number came from getDensity() or an
override. The override surfaces purely through the assumptions line already rendered as
Note: … (render.ts line ~217-218) via the assumption text above.

Eval set (eval/intents.jsonl)

Add a handful of examples covering the sentences above (direct/unit-variant/adversarial), tagged
"density-override" (new tag) plus the existing quantity/phrasing tags, e.g.:

{
  "id": "dns-001",
  "text": "What's the range of 150 MeV protons in water with a density of 0.99 grams per cubic centimeter?",
  "expected": {
    "quantity": "csdaRange", "compareDim": "none",
    "particles": [{ "match": "protons" }],
    "materials": [{ "match": "water", "densityOverride": 0.99 }],
    "energies": [{ "value": 150, "unit": "MeV" }],
    "assumptions": [], "confidence": 1
  },
  "tags": ["direct", "quantity-csda-range", "single", "unit-MeV", "density-override"]
}

The two adversarial examples (non-positive density) should assert the override is absent from
expected (dropped, not clamped) — same convention as the existing negative-energy eval examples.
Run pnpm coverage:intents after adding these, per the existing workflow.

Android implementation plan

The Kotlin matcher (bench/android/full-app/app/src/main/java/com/aidedx/fullapp/nlu/ KotlinMatcher.kt) is a deliberately scoped-down subset of matcher.ts — single
particle+material+energy, stoppingPower/csdaRange only, no compareDim, no inverse queries/
target (see the file's own header comment). That scoping actually makes this feature simpler
to port than most: there's no inverse-query density dependency to worry about yet, only the
forward-answer keV/µm and cm conversions AnswerFormatter.kt already parameterizes on density.

Plan:

  1. KotlinMatcher.kt — add a DENSITY_RE mirroring the web pattern above (Kotlin Regex,
    same g/cm³ + kg/m³ + spelled-unit coverage), and a densityOverride: Float? field on
    MatchedIntent (currently: quantity, particleMatch, particleId, massNumber,
    materialMatch, materialId, energy — no target/density field exists yet). Consume the
    matched span before materialMatch is resolved, same ordering as web.
  2. MainActivity.kt — at the existing call site (val density = …; AnswerFormatter.format(matched, stp, csda, density),
    around line 253–278), prefer matched.densityOverride over LibdedxBridge.getDensity(materialId):
    val density = matched.densityOverride ?: LibdedxBridge.getDensity(matched.materialId).
  3. AnswerFormatter.kt — no change needed; it already only consumes a nullable
    densityGramPerCm3: Float? and doesn't care about its source (same "no structural NLG change"
    situation as web). Consider surfacing the override in the result line's text, matching whatever
    the web assumption note lands on, so the two apps stay behaviorally consistent.
  4. KotlinMatcherAgreementTest.kt / KotlinMatcherTest.kt — add unit tests for the new regex
    (parse + reject-non-positive), and extend the agreement harness with density-override examples
    once the corresponding eval/intents.jsonl entries exist, following the same "measure
    agreement, don't assume parity" discipline docs/android-full-app-spike.md §4 established for
    the rest of the matcher.

Not needed on Android for v1: any change to LibdedxBridge.kt / the JNI layer —
nativeGetDensity stays exactly as-is; the override never touches it, same as compute.ts never
touching service.getDensity()'s implementation on web.

Open questions / risks

  • Per-material density in a multi-material comparison (water override vs. a second material's
    own override in one compareDim: "material" query) is schema-compatible (densityOverride
    lives on each MaterialSlot) but the matcher grammar above only attaches one parsed density
    clause to the first material found — extending that to correctly associate multiple density
    clauses with multiple materials in one sentence is real NLU work, not a mechanical follow-on.
  • Comparing two densities of the same material is blocked by dedupeByKey's current key
    (resolved.id alone, matcher.ts) — it would need to key on (id, densityOverride) instead,
    which has knock-on effects on decideCompareDim (would a density-only variation count as a
    "material" comparison, or need a new compareDim value?). Worth a follow-up issue rather than
    folding into v1.
  • Plausibility bounds. Should a wildly implausible spoken density (e.g. "water at 50 g/cm3")
    be rejected outright, clamped, or passed through with a stronger warning than the assumption
    note? v1's plan above is "parse anything positive, let the assumption note carry the honesty
    burden" — deliberately not inventing a plausibility table per material, but flagged here since
    it's a real product decision, not an oversight.
  • Relative/comparative density phrasing ("half as dense", "twice the density of ice") — needs
    a base value to scale from and isn't covered by the direct value+unit grammar above at all; out
    of scope.

Implementation checklist

  • query-intent.ts: add MaterialSlot.densityOverride, validator check, "density-override"
    eval tag
  • matcher.ts: DENSITY_RE, span consumption ordering, non-positive rejection
  • compute.ts: override-aware density resolution in forwardSeries/inverseSeries,
    assumption note
  • eval/intents.jsonl: examples above (direct, unit-variant, adversarial), pnpm coverage:intents re-run
  • KotlinMatcher.kt: DENSITY_RE, MatchedIntent.densityOverride
  • MainActivity.kt: prefer override over LibdedxBridge.getDensity()
  • Kotlin matcher tests + agreement-harness extension
  • pnpm run format:check && pnpm run lint && pnpm run check && pnpm run validate:eval && pnpm test && pnpm build
    before opening a PR, per CLAUDE.md's CI gates

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions