Skip to content

feat: infer substance for unit-only dose entries#7

Merged
ErikBjare merged 2 commits into
masterfrom
feat/infer-implicit-substances
May 29, 2026
Merged

feat: infer substance for unit-only dose entries#7
ErikBjare merged 2 commits into
masterfrom
feat/infer-implicit-substances

Conversation

@ErikBjare

Copy link
Copy Markdown
Owner

Summary

Entries that give only an amount and a unit with no named substance were
being mishandled by the parser: depending on whether the unit was already in
the grammar, they were either dropped as parse errors or mis-parsed with
the unit itself as the substance
. On real logs this silently lost or
mislabeled a large fraction of such entries.

This PR makes the substance optional and infers it from context:

  • Grammar: a dose may now be just an amount + unit, with no substance.
  • Inference (load._infer_implicit_substances): for a unit-only entry, the
    substance is inferred from the most common substance recorded with that
    unit
    (and ROA, when the entry specifies one). Inference only happens when
    there is a clear winner (≥3 observations, a majority share, and ≥2× the
    runner-up); otherwise the entry is left unresolved and a warning is logged.
    When a substance is inferred, the dominant ROA for that unit+substance is
    filled in if the entry didn't specify one. Inferred entries are flagged with
    implicit_substance: True.

Related parser fixes surfaced by this change

  • Treat hit/hits as units (matching the existing dose-unit definitions in
    dose.py), so they're no longer parsed as a substance.
  • Accept approximate percentages in notes, e.g. (~5%).
  • Recognize ratio notes like (10:1) before attempting to parse a dose, so the
    now-optional substance doesn't turn them into phantom subdoses.

Test plan

  • pytest — 53 passed (7 new: unit-only parsing, approx-percent note, and
    inference behavior incl. clear-winner / ambiguous / below-threshold /
    explicit-not-overwritten)
  • mypy --ignore-missing-import --check-untyped-defs — clean
  • Validated against real logs: parse-error rate dropped substantially and
    previously-mishandled unit-only entries are now recovered with inferred
    substances + ROAs

Entries that give only an amount and unit with no named substance were
either dropped as parse errors or mis-parsed with the unit itself as the
substance. Now:

- the grammar accepts unit-only doses (substance is optional)
- load infers the substance from the most common substance recorded with
  that unit (and ROA, when the entry specifies one), assigning only when
  there is a clear winner and warning otherwise; the dominant ROA is
  filled in for inferred entries

Also includes related parser fixes surfaced by this change:

- treat 'hit'/'hits' as units (matching the dose-unit definitions)
- accept approximate percentages in notes, e.g. "(~5%)"
- recognize ratio notes like "(10:1)" before attempting to parse a dose,
  so the now-optional substance does not turn them into phantom subdoses
@ErikBjare

Copy link
Copy Markdown
Owner Author

@greptileai review

1 similar comment
@ErikBjare

Copy link
Copy Markdown
Owner Author

@greptileai review

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes substance optional in the dose grammar and recovers those unit-only entries by inferring the substance from historical frequency data (_infer_implicit_substances), dropping entries that cannot be resolved instead of leaving them as substance=None dose events. Three companion grammar fixes are bundled: hit/hits as recognised units, ~N% approximate-percent notes, and ratio_note inserted before dose_list in extra_data to prevent ratio patterns like 10:1 from being mis-parsed as subdoses.

  • Grammar (parsimonious.py): substance? made optional; prefixlessunit gains ~\"hits?\"; percent gains approx?; ratio_note added to extra_data ordered choice before dose_list; visit_dose unpacks the resulting 0-or-1-element list correctly.
  • Inference (load.py): Two-pass approach builds by_unit, by_unit_roa, and roa_by_unit_sub counters from explicit-substance events, then applies _clear_winner (≥3 observations, majority share, ≥2× runner-up) to each substance-less entry; unresolvable entries are dropped with an aggregate warning.
  • Tests: Seven new tests cover unit-only parsing variants, ROA fill-in, ambiguous/below-threshold drops, and explicit-substance pass-through.

Confidence Score: 5/5

Safe to merge; all changed code paths are guarded, tested, and integrated correctly into the load pipeline.

The grammar change (optional substance), inference logic, and drop-on-failure behaviour are all correct and well-tested. The two-pass frequency-map approach avoids any feedback loop, the _clear_winner thresholds are conservative, the plural-normalisation allowlist correctly replaces the earlier blanket suffix-strip, and unresolvable entries are cleanly dropped rather than silently persisted. No new data-loss or mis-classification paths were found across the changed files.

No files require special attention.

Important Files Changed

Filename Overview
qslang/load.py Adds _infer_implicit_substances with two-pass frequency-map approach, _norm_unit plural allowlist, and _clear_winner with conservative thresholds; wired into load_events between abbreviation expansion and tagging.
qslang/parsimonious.py Makes substance optional in the dose rule; adds ratio_note before dose_list in extra_data; extends prefixlessunit with hits? and percent with approx?; handles the resulting optional-list substance in visit_dose.
tests/test_infer.py New test file covering clear-winner inference, ambiguous/below-threshold drops, plural normalization, ROA fill-in, and explicit-substance pass-through.
tests/test_parse.py Three new parse tests: unit-only dose (substance=None), approximate unit-only (~2x), and approximate-percent note (~5%).

Reviews (3): Last reviewed commit: "refactor: address review on implicit-sub..." | Re-trigger Greptile

Comment thread qslang/load.py Outdated
Comment on lines +326 to +332
def _norm_unit(unit: str) -> str:
"""Normalize a unit for substance inference, collapsing simple plurals
(e.g. unit/units) so singular and plural share an inference key."""
unit = unit.lower()
if len(unit) > 1 and unit.isalpha() and unit.endswith("s"):
unit = unit[:-1]
return unit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Naive 's'-stripping could produce unexpected unit key collisions

_norm_unit strips a trailing s from any alphabetic string longer than one character. This works correctly for all current grammar units (puffspuff, hitshit, servingsserving), but units that legitimately end in s as their singular form (e.g., a hypothetical "bonus" or "IUs") would be incorrectly normalized, silently merging distinct inference buckets. Consider using an explicit allowlist of known plural units instead of the blanket suffix rule.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good call — replaced the blanket trailing-'s' strip with an explicit plural→singular allowlist (_UNIT_PLURALS) in a9ac780, so units that legitimately end in 's' won't be mangled.

Comment thread qslang/load.py
Comment on lines +381 to +407
for e in events:
if e.type != "dose" or e.substance:
continue
dose = e.data.get("dose", {})
unit = dose.get("unit")
if not unit or unit == "unknown":
continue
unit = _norm_unit(unit)
roa = dose.get("roa")

winner = None
if roa and (unit, roa) in by_unit_roa:
winner = _clear_winner(by_unit_roa[(unit, roa)])
if winner is None:
winner = _clear_winner(by_unit[unit])
if winner is None:
unresolved[unit] += 1
continue

e.data["substance"] = winner
e.data["implicit_substance"] = True
if not roa:
roa_winner = _clear_winner(roa_by_unit_sub[(unit, winner)])
if roa_winner:
dose["roa"] = roa_winner
n_inferred += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unresolved unit-only entries silently persist as type="dose" with substance=None

Entries that fail inference remain in the pipeline as type="dose" events with substance=None. Whenever any caller accesses e.dose on such an entry, event.py's dose property fires assert self.substanceAssertionErrorprint(self.data) to stdout plus a log.warning — once per access, per unresolved entry. Consider either changing the event type for unresolved entries or guarding the e.dose property against substance=None without printing.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in a9ac780: unit-only entries that can't be resolved are now dropped (with one aggregate warning) instead of lingering as substance=None "dose" events, so Event.dose never hits the assert/print path. Verified on real data: 0 substance=None dose events remain after load.

- _norm_unit: use an explicit plural->singular allowlist instead of a
  blanket trailing-'s' strip, which could silently merge unrelated unit
  buckets (e.g. a unit whose singular legitimately ends in 's')
- _infer_implicit_substances: drop entries that have no substance and for
  which none can be inferred, instead of leaving them as substance=None
  "dose" events (which made Event.dose emit a warning + stdout print on
  every access). Returns the filtered list; logs one aggregate warning.
@ErikBjare ErikBjare merged commit dacd573 into master May 29, 2026
2 checks passed
@ErikBjare

Copy link
Copy Markdown
Owner Author

@greptileai review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant