diff --git a/training/predict.py b/training/predict.py index 5389141..8702f9d 100644 --- a/training/predict.py +++ b/training/predict.py @@ -1149,6 +1149,17 @@ def _ensure_sub_index(): _build_sub_index() +def _predicted_tastes_at(profiles, i): + """The PREDICTED taste heads (score >= 0.5) for reference-set row i, from the profile matrix. + Lets neighbor / substitute cards show a taste read even when nothing is *documented* — the + taste columns are the first len(_CLASSIFIERS) of the profile vector. Marked predicted in the UI.""" + if profiles is None: + return [] + taste_heads = sorted(_CLASSIFIERS) + row = profiles[i] + return [t for j, t in enumerate(taste_heads) if float(row[j]) >= 0.5] + + def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict: """STRUCTURAL neighbors: the k labeled molecules most structurally similar to the query (Tanimoto over Morgan fingerprints), each with its known tastes. Structural look-alikes — @@ -1158,7 +1169,7 @@ def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) - if mol is None: return {"error": f"unparseable SMILES: {smiles}"} _ensure_sub_index() - fps, smis, tastes, _aromas, _profiles, _dims = _SUB_INDEX + fps, smis, tastes, _aromas, profiles, _dims = _SUB_INDEX if not fps: return {"neighbors": [], "note": "no reference set loaded (taste_master.parquet absent)"} q = _MORGAN.GetFingerprint(mol) @@ -1179,6 +1190,7 @@ def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) - continue neighbors.append({"smiles": smis[i], "similarity": round(float(sims[i]), 3), "known_tastes": tastes[i], + "predicted_tastes": _predicted_tastes_at(profiles, i), # confident aromas precomputed once in the index — reused so the # endpoint never re-runs the 24 aroma heads per neighbor (8x ~1.3s saved) "aromas": _aromas[i] if i < len(_aromas) else []}) @@ -1220,7 +1232,8 @@ def substitutes(smiles: str, k: int = 8) -> dict: if ni is None or Chem.MolToInchiKey(ni).split("-")[0] == self_skel: continue subs.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3), - "known_tastes": tastes[i], "aromas": aromas[i] if i < len(aromas) else []}) + "known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i), + "aromas": aromas[i] if i < len(aromas) else []}) if len(subs) >= k: break return {"query": Chem.MolToSmiles(mol), "substitutes": subs, diff --git a/training/workbench.html b/training/workbench.html index b1d2b44..862a0fa 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -177,6 +177,7 @@ .kt{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px} .rx-from{font-size:10px;color:var(--muted);line-height:1.3;margin-top:3px} .kt .tchip{font-size:10px;font-weight:600;padding:1px 8px;border-radius:20px;color:#10140F;text-transform:capitalize} + .kt .tchip.pred{background:transparent!important;border:1px solid;font-weight:500} .kt .muted{color:var(--muted);font-size:11px} .browse{margin:22px 0 4px;border:1px solid #2C6E74;border-radius:12px;padding:15px 18px;background:linear-gradient(180deg,#15211F,#181D24)} .browse-head{font-size:12px;font-weight:650;text-transform:uppercase;letter-spacing:.04em;color:var(--brand-2);margin:0 0 12px;display:flex;align-items:center;justify-content:space-between;gap:8px} @@ -1416,7 +1417,7 @@

Software & type

${x.name||'—'}${x.gras?'Food-listed':''}
${x.iupac?`
${x.iupac}
`:''}
${x.smiles}
-
${x.known_tastes.length?x.known_tastes.map(t=>`${t}`).join(''):'no known taste'}
+
${(()=>{const doc=(x.known_tastes||[]).map(t=>`${t}`);const pred=(x.predicted_tastes||[]).filter(t=>!(x.known_tastes||[]).includes(t)).map(t=>`${t} •`);return [...doc,...pred].join('')||'no taste read';})()}
${(x.aroma&&x.aroma.length)?`
${x.aroma.map(a=>`${a.odor}${a.source==='predicted'?' •':''}`).join('')}
`:''}
${Math.round((x[scoreKey]||0)*100)}% match