Skip to content

feat(training): mouthfeel modality — 5 chemesthesis heads surfaced across the stack - #243

Merged
rvnminers-A-and-N merged 17 commits into
mainfrom
feat/training-mouthfeel-modality
Jul 28, 2026
Merged

rvnminers-A-and-N merged 17 commits into
mainfrom
feat/training-mouthfeel-modality

Conversation

@rvnminers-A-and-N

Copy link
Copy Markdown
Collaborator

Closes #232. Closes #235. Closes #237. Closes #239. Closes #242.

Flavor = taste + aroma + mouthfeel. Mouthfeel is trigeminal, not olfactory, so it gets its own modality end-to-end — build_mouthfeel_supplement.pybuild_mouthfeel_dataset.pytrain_mouthfeel.pymouthfeel_models/ — mirroring the independent taste subsystem rather than being bolted onto aroma. Five heads ship, all clearing the honest bar (≥10 public positives and CV-AUROC ≥ 0.70): cooling 0.94, pungent 0.98, warming 0.99, astringent 1.00, tingling 1.00. Sanity holds on the bench: capsaicin → pungent/warming 1.0, menthol → cooling 1.0.

cooling and pungent deliberately exist as both aroma and mouthfeel heads — the same way sweet is both a taste and an aroma head. The aroma head predicts what a molecule smells like; the mouthfeel head predicts the sensation (WS-23 cools with almost no odour). The profile index keys them by dimension (aroma:cooling vs mouthfeel:cooling) so they can't collide. tingling stayed strict — real sanshool/spilanthol/ZP-amide paresthesia agents only; the Piper amides that read pungent were routed to the aroma pungent head rather than used to pad the count.

The flavor-profile vector is now 175 dims (6 taste + 164 aroma + 5 mouthfeel); tox stays out of it deliberately — safety isn't a flavor-match dimension. Total trained heads: 187. Surfaced everywhere per the keep-in-sync rule: the read modal's Heads card gained Mouthfeel and Safety groups (every head as a ranked %-bar with its CV-AUROC, nothing filtered), MCP read_flavor and the CLI skill return mouthfeel_scores + tox_flags, and a new MCP list_heads tool exposes the head catalog via GET /api/heads.

Also in this branch: the 12 Tox21 heads now carry a CV-AUROC manifest (0.72–0.90) and are stored per-molecule in master_enrichment alongside mouthfeel, so the universe grid is complete (8,847 molecules); every curated supplement molecule now lands in that universe (17 were missing); training and the profile-index build were parallelized process-per-unit (index ~3 min → ~35 s, aroma retrain ~30 min → minutes), all worker counts derived from os.cpu_count() so it scales from a 4-core box up; model heads load on a background thread so uvicorn binds instantly and serves a branded warming-up page instead of a ~90 s startup 502; flavor-map axes no longer clip points at the frame; and the map + universe grid got loading overlays.

Data provenance unchanged: curated public-domain structure→sensation facts (menthol/WS coolants, capsaicinoids, tannins, sanshools), resolved to authoritative SMILES via PubChem. No restricted or NonCommercial source introduced. A head predicts a sensation, not a food-use clearance — the per-molecule food-safe flag and the IP/food review gate still govern.

Docs updated: new docs/MOUTHFEEL.md, plus CAPABILITIES.md and README.md corrected to 187 heads / 8,847 molecules. ruff check . clean and 25 unit tests pass.

…tes/neighbors

The read modal was slow (and tripping proxy 504s) because /api/substitutes and
include_aroma re-ran all 170 taste+aroma forests on the query molecule — ~40 s
cold on a novel structure. Two root causes fixed:

- Shared aroma cache: extract the 164-forest scoring into _aroma_scores, memoized
  on the CANONICAL SMILES (not threshold/top_k), so predict_aroma, _query_profile
  and /api/aroma collapse to one computation per molecule instead of each rerunning.
- Precomputed index lookup: the profile index already holds the exact 170-head
  profile for every corpus molecule (built at startup). In-corpus molecules — all
  demo chips and most real queries — now read their row directly (same numbers,
  zero forests). methyl anthranilate /api/aroma 39.8 s -> 0.01 s; /api/substitutes
  2.4 s -> 0.05 s; /api/predict 60 s -> 1.1 s.
- Cache the L2-normalized reference matrix once (paid at prewarm, not per request)
  and warm the profile path for the demo molecules at startup.

UX: substitutes and structural neighbors now return every match above a similarity
floor (profile >= 0.45 / Tanimoto >= 0.30), capped at 50, rendered in a scrollable
list with a match count — instead of a fixed 8. Propagated the higher-N behavior to
the MCP tools and the CLI skill. Added a threshold + index-lookup test.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…red modal actions

Startup / 502: model heads now load on a background thread, so uvicorn binds its port within a
second and serves a self-refreshing "warming the cauldron" page (live loaded/total head count +
ETA) instead of the old ~90 s startup 502. /healthz (liveness+readiness) and /api/status (load
progress) back it; a middleware returns the page for HTML navs and a clean 503 for API calls until
MODELS_READY. Loading stays SERIAL — joblib unpickling is GIL-bound, so a thread pool only added
contention (measured ~34 s serial vs ~86 s across 14 threads). The startup prewarm/precompute
threads now wait on MODELS_READY. Tests force a synchronous load via FLAVORMANCER_BLOCKING_LOAD=1.

Novel-molecule reads: the only path that still runs all 164 forests (genuinely out-of-corpus
molecules — in-corpus reads hit the precomputed index) now fans the heads across a shared,
bounded thread pool sized to ~3/4 of the box (FLAVORMANCER_INFER_WORKERS), cutting a cold read
~40 s -> ~22 s (GIL-limited). A new /api/precomputed lets the UI tell instant reads from fresh ones.

Modal UX: the Export/Copy-link buttons are grouped and centered at the top (red close X stays
top-right), with more breathing room below the top row, and they stay hidden until the read
finishes rendering. A rotating flavor-wizardry "cantrip" line runs under the title while any read
brews (5 s cadence), and out-of-corpus molecules get a "conjuring a fresh reading" note between
the flask and the cantrips so the longer wait reads as intentional.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…full-height swap sections

Reworked the read modal per the layout review:
- Order now reads structure -> flavor identity (name + "reads as" tags) -> heads. The molecule's
  own bars live in ONE combined "Heads" card grouped by category (Taste, Aroma; Mouthfeel + Safety
  groups join as the head-roster/tox-AUROC work lands), so aroma finally sits next to taste instead
  of a separate card far down the page.
- Top row is a 2-col dashboard (Flavor read | Heads); the aroma bar list scrolls inside its group
  so the card stays compact.
- Substitutes and Structural neighbors are two DISTINCT sections in a swap-grid, each filling its
  box (bounded height + internal scroll) so there's no wasted whitespace, and the difference between
  profile-match vs structural look-alike stays obvious.
- Behavior & safety stays as its own card. Structural-neighbors subtext now says known tastes AND
  smells. Tighter, more dashboard-like spacing throughout; responsive to a single column under 820px.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…, modal left-column

Index build now fans the profile heads across the box's cores as separate PROCESSES (each worker
loads only its shard of head models and scores them against the memmapped feature matrix), instead
of one serial pass. joblib unpickling and predict_proba each run without fighting a single GIL, so
the whole-universe build drops ~3 min -> ~35 s (now floored by the parent's RDKit featurization).
Writes atomically (temp + os.replace) so the live index is never half-written.

Worker counts are derived from os.cpu_count(), never hardcoded, so this scales from a 4-core box
up: the batch build uses min(#heads, cores) processes; the novel-read inference pool uses ~3/4 of
the cores (leaving headroom for the web server). CPU-bound work scales with cores; I/O-bound name
resolution keeps its fixed network-concurrency cap (core count is the wrong basis there).

_profile_heads() now sorts BOTH taste and aroma so the builder derives the exact column order from
filenames without loading a model; adds FLAVORMANCER_NO_MODELS=1 (the builder's parent only needs
featurization). Verified identical results: vanillin -> ethyl vanillin 0.95, carvone -> dill/fennel 1.0.

Modal: Behavior & safety moves under the molecule card in a left column, and the tall Heads card
fills the full height of the right column alongside both (the aroma list flexes + scrolls to match),
closing the gap under the short molecule card. Consistent 16px spacing between all cards.
Neighbor/substitute lists reset to the top on render so the best matches show first.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
train_tox.py now writes tox_models/manifest.json with each Tox21 head's 5-fold CV-AUROC + support
(all 12 land 0.72-0.90, above the 0.70 bar). predict.py loads it into _TOX_META and predict_tox
now reports each assay's auroc alongside its probability, so the safety heads get a bar + AUROC like
taste and aroma — they were trained but never showcased.

Adds head_catalog() + GET /api/heads: every head grouped by category (taste / aroma / mouthfeel /
safety) with AUROC. Categories are TAGS, not buckets — cooling and pungent appear under BOTH aroma
and mouthfeel (like sweet is taste + aroma). Backs the modal Heads card's Mouthfeel/Safety groups
and the library category pickers.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
Mouthfeel is a distinct modality (trigeminal, not olfactory), so it gets its own independent
pipeline mirroring taste: build_mouthfeel_supplement.py (curated public-domain agents) ->
build_mouthfeel_dataset.py (positives + broad random background) -> train_mouthfeel.py ->
mouthfeel_models/. predict.py loads it, tags it 'mouthfeel', and folds it into the profile vector.

- New heads: warming (capsaicinoids), astringent (tannins/polyphenols), tingling (sanshools /
  spilanthol / ZP-amides) — all clear >=10 verified public positives and the 0.70 CV-AUROC bar
  (0.99/1.00/1.00; high because the classes are structurally distinct — a narrow-class caveat).
- cooling & pungent stay AROMA heads but are TAGGED mouthfeel (multi-category, like sweet). The
  Piper pungent amides moved to the aroma pungent head (no molecule wasted).
- The flavor-profile vector is now taste + aroma + mouthfeel; _profile_heads() returns all three,
  the parallel index builder derives the same order from filenames, and head_catalog / /api/heads
  surface a mouthfeel group. tingling stayed strict (real sanshool/ZP-amide agents, not padded).

Also: build_enrichment folds the curated supplement CSVs so every trained molecule (17 that were
missing — sanshools, capsiate, ellagic acid, ...) lands in the universe; deterministic head order
sweep (import-only lint fixes across the build scripts); tighter modal card spacing (14px, not 0).

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…und-loader race

train_aroma now trains each head in its OWN process at n_jobs=1, so the ~177 heads train
concurrently across all cores (same process-per-unit pattern as the index build) instead of one
at a time with nested joblib — cutting the retrain from ~30 min to a few. Feature matrix is
memmapped so workers share it; each worker dumps only its own model (disjoint files, no locks).

Fix: since model heads now load on a background thread, any build script that imports predict and
runs inference could race the loader mid-populate ("dictionary changed size during iteration").
build_enrichment and build_flavor_map now wait on P.MODELS_READY before iterating/inferring.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
Following the sweet-is-taste-AND-aroma precedent: the SENSATION cooling/pungent is distinct from
the ODOUR descriptor of the same name (WS-23 cools but has ~no smell; menthol does both). So cooling
& pungent now train as first-class mouthfeel_models heads on TRPM8/TRPV1 agents (menthol family +
food-authorised WS-agents; capsaicinoids + isothiocyanates + alliaceous sulfur), separate from the
aroma odour heads that keep the same names. All five mouthfeel heads clear the bar (cooling 0.94,
pungent 0.98, warming 0.99, astringent 1.00, tingling 1.00).

The profile vector is taste + aroma + mouthfeel = 175 dims. Since cooling/pungent now exist as both
an aroma and a mouthfeel head, the index builder keys scores by DIM-qualified name ("mouthfeel:cooling"
vs "aroma:cooling") so they can't collide. _MOUTHFEEL_HEADS tagging is retired — the catalog's
mouthfeel group is the 5 dedicated heads. Universe folded the new coolant molecules (8830 unique).

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
… page

build_enrichment now predicts the 5 mouthfeel + 12 tox heads over the whole flavor universe and
stores them per molecule (mf_*, tox_*, tox_flags), so the browsable universe table is complete:
taste + aroma + mouthfeel + safety. tox uses the bare 2048-bit Morgan (_fp), not the _feat block
the other heads use. Note: the Tox21 TRAINING molecules are NOT imported into the flavor universe
(they're industrial/pharma compounds — a predictor we apply, not new members); and tox/mouthfeel
here are display-only, still OUTSIDE the substitute-match profile vector. 1,817 molecules carry
>=1 in-vitro tox flag (caution-only).

Warming-up page: reuses the modal's animated flask (ring + fizzing bubbles + aroma wisps), the
Flavormancer wordmark + logo, and the app's /static fonts (Grenze Gotisch / Cinzel Decorative) and
palette — a branded cold-start page instead of a bare spinner.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…id (first-open feedback)

On first page open the flavor map (~2 MB payload over the tunnel) showed only tiny grey corner
text, and the molecule-universe grid showed nothing until rows arrived. Both now get a proper
branded overlay: a brand-gradient spinner + a Cinzel label ("Charting the flavor map…" /
"Loading the molecule universe…"), so a visitor always sees that data is loading. The map overlay
fades out once the canvas is drawn; the grid shows a centered spinner row while each fetch runs
(also covers search/sort reloads). Served fresh per request — no restart, no tunnel disturbance.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…keep-in-sync)

predict() now returns a trained-mouthfeel read (predict_mouthfeel: the 5 trigeminal heads —
cooling/pungent/warming/astringent/tingling — each with score + CV-AUROC), and tox already rode in
safety.tox_screen. Validated: capsaicin -> pungent/warming 1.0, menthol -> cooling 1.0.

Modal Heads card: adds a Mouthfeel group and a Safety group beneath aroma, each rendering ALL its
heads as ranked % bars with AUROC (same treatment as taste/aroma — nothing filtered). The Heads
card now scrolls as a whole; the 164-bar aroma list is capped to its own 300px scroll so mouthfeel
and safety stay reachable. Tox bars go amber-red when an assay fires (>=0.5), caution-only framing.

MCP read_flavor + skill read now include mouthfeel_scores/confident_mouthfeel and tox_flags; new
MCP list_heads tool exposes the /api/heads catalog; fixed the stale "24 aroma scores" docstring.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…#219)

Points were jamming the frame in every layout. Similarity (UMAP) view: bumped the canvas margin
18px -> 40px so the cloud breathes. Properties 2D (MW × logP) and 3D (MW × logP × TPSA): the
percentile domains were tight (96-98th) and clamped outliers flat onto the edge/faces, piling them
into a wall. Now each axis extends to ~99.5th percentile + ~8% headroom, so points sit at their
true position with space around them instead of squashed on the border. Frontend-only, served fresh.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
Adds the Mouthfeel section (5 trained chemesthesis heads) and the Tox21 safety heads (12, with
CV-AUROC) to the capabilities list, splits stability/chemesthesis now that mouthfeel is its own
modality, and corrects the totals: 187 trained heads (6 taste + 164 aroma + 5 mouthfeel + 12
safety) over 8,847 unique molecules. Adds the mouthfeel + Tox21 training-set sizes.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…ync)

New docs/MOUTHFEEL.md — the canonical write-up of the third modality (parallel to AROMA.md): the 5
trained chemesthesis heads + AUROCs, why cooling/pungent exist as BOTH aroma and mouthfeel heads
(like sweet=taste+aroma), the build pipeline, the food-safety-is-separate rule, and the tingling
near-miss honesty note. CAPABILITIES.md: mouthfeel is now a trained modality (was rule/qualitative),
tox heads show their CV-AUROC (0.72-0.90), and the stale "24 aroma heads" edition note is corrected
to 164 aroma + 5 mouthfeel + 12 safety.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
ruff 0.16's isort rule flagged first-party imports (predict, chemfeatures,
build_properties) grouped with third-party ones across the build/train scripts.
Import-order only — no behavior change — but it was failing `ruff check .` in CI.

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
…its assets

The warming-up page uses the app's own wordmark font, header font and logo from
/static, but the warming gate returned 503 for every path except /api/status and
/healthz — so a cold-start visitor saw the page with fallback fonts and no logo.
/static/* is now exempt from the gate (it needs no models to serve).

Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
@rvnminers-A-and-N
rvnminers-A-and-N merged commit 321c4e8 into main Jul 28, 2026
4 checks passed
@rvnminers-A-and-N
rvnminers-A-and-N deleted the feat/training-mouthfeel-modality branch July 28, 2026 09:16
rvnminers-A-and-N added a commit that referenced this pull request Jul 28, 2026
Mouthfeel shipped as a modality in #243 but wasn't selectable anywhere. _precompute_design now
tags the design pool with the mouthfeel heads (batched, like aroma and taste), /api/studio_terms
returns a third `mouthfeel` list, and both pickers gained a Mouthfeel group — the Studio chip rail
and the Formulation Studio target profile — styled with a dotted amber edge.

Two bugs caught by verifying against the live box rather than assuming:

1. tingling matched 0 molecules and astringent 1, because the design pool is built from the HSDB
   *odor* corpus which barely contains trigeminal agents (0 of 11 tingling, 1 of 11 astringent).
   Curated mouthfeel agents now fold into the pool via a dedicated _MOUTHFEEL_CARRIERS loader.

2. `pungent` under Mouthfeel returned acetic acid, acetone and ammonia — sharp-SMELLING molecules
   — because the aroma head and the sensation head shared a bare tag and aroma had far more
   corpus molecules. Mouthfeel tags are now namespaced (mouthfeel:pungent), matching how the
   profile index keys aroma:cooling vs mouthfeel:cooling.

Verified after deploy: mouthfeel:pungent -> allyl isothiocyanate, diallyl disulfide, capsaicin;
note:pungent -> acetic acid, acetaldehyde, acetone, ammonia. All five sensations return real
agents. Part of #240, which stays open for the broader chip-picker usability pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment