Miyu Horiuchi commited on
Commit
572e624
·
1 Parent(s): 4c18dfd

Surface hybrid oxygen source in UI

Browse files
api/main.py CHANGED
@@ -67,6 +67,57 @@ def _phylum(tax: str | None) -> str:
67
  return "—"
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @app.on_event("startup")
71
  def _load_resources() -> None:
72
  print("Loading recommender models...")
@@ -79,12 +130,13 @@ def _load_resources() -> None:
79
  zip(media_meta["medium_id"].astype(str), media_meta["name"], strict=True)
80
  )
81
 
82
- unc = pd.read_parquet(config.ARTIFACTS / "uncultured_predictions.parquet")
83
  unc["phylum"] = unc["gtdb_taxonomy"].map(_phylum)
84
  unc["truly_uncultured"] = (
85
  unc["ncbi_organism_name"].fillna("").str.lower().str.startswith("uncultured")
86
  )
87
  _state["catalog"] = unc
 
88
  print(f" → {len(unc):,} catalog rows ({int(unc['truly_uncultured'].sum()):,} truly uncultured)")
89
 
90
 
@@ -106,6 +158,7 @@ class CatalogRow(BaseModel):
106
  pH: float
107
  O2: str
108
  O2_conf: float
 
109
  salt: float
110
  top_medium_id: str
111
  top_medium_name: str
@@ -127,6 +180,7 @@ def health():
127
  "ok": True,
128
  "models_loaded": len(_state.get("models", {})),
129
  "catalog_rows": len(_state.get("catalog", [])),
 
130
  }
131
 
132
 
@@ -162,6 +216,7 @@ def catalog():
162
  "pH": _safe_float(m["pred_optimal_ph"], 2),
163
  "O2": _safe_str(m["pred_oxygen_requirement"], "—"),
164
  "O2_conf": _safe_float(m.get("pred_oxygen_requirement_confidence"), 3, 0.0),
 
165
  "salt": _safe_float(m["pred_salt_tolerance_pct"], 2),
166
  "top_medium_id": _safe_str(m["top1_medium_id"], "—"),
167
  "top_medium_name": _safe_str(m["top1_medium_name"], "—"),
@@ -173,7 +228,7 @@ def catalog():
173
  "top3_medium_name": _safe_str(m.get("top3_medium_name")),
174
  "top3_confidence": _safe_float(m.get("top3_confidence"), 4) if pd.notna(m.get("top3_confidence")) else None,
175
  })
176
- return {"count": len(rows), "rows": rows}
177
 
178
 
179
  @app.get("/api/ncbi-search")
@@ -235,7 +290,7 @@ def predict(req: PredictRequest):
235
  feats, accession, n_contigs = _load_genome_features(target)
236
 
237
  feats_series = pd.Series(feats)
238
- phenotypes = _predict_phenotypes(feats_series)
239
 
240
  models = _state["models"]
241
  feature_cols = _state["feature_cols"]
 
67
  return "—"
68
 
69
 
70
+ def _load_catalog_frame() -> tuple[pd.DataFrame, str]:
71
+ """Load the deploy catalog and overlay hybrid phenotype predictions if available."""
72
+ base = pd.read_parquet(config.ARTIFACTS / "uncultured_predictions.parquet")
73
+ if "pred_oxygen_requirement_source" not in base.columns:
74
+ base["pred_oxygen_requirement_source"] = "tabular"
75
+
76
+ hybrid_path = config.ARTIFACTS / "hybrid_predictions.parquet"
77
+ if not hybrid_path.exists():
78
+ return base, "tabular"
79
+
80
+ hybrid = pd.read_parquet(hybrid_path)
81
+ if "genome_accession" not in hybrid.columns:
82
+ print(f" ! ignoring {hybrid_path}: missing genome_accession")
83
+ return base, "tabular"
84
+
85
+ pred_cols = [c for c in hybrid.columns if c.startswith("pred_")]
86
+ if not pred_cols:
87
+ print(f" ! ignoring {hybrid_path}: no pred_* columns")
88
+ return base, "tabular"
89
+
90
+ hybrid = hybrid[["genome_accession", *pred_cols]].drop_duplicates("genome_accession")
91
+ merged = base.merge(hybrid, on="genome_accession", how="left", suffixes=("", "_hybrid"))
92
+
93
+ for col in pred_cols:
94
+ hcol = f"{col}_hybrid" if col in base.columns else col
95
+ if hcol not in merged.columns:
96
+ continue
97
+ if col in base.columns and hcol != col:
98
+ merged[col] = merged[hcol].combine_first(merged[col])
99
+ merged = merged.drop(columns=[hcol])
100
+
101
+ if "pred_oxygen_requirement_source" not in merged.columns:
102
+ merged["pred_oxygen_requirement_source"] = "tabular"
103
+ merged["pred_oxygen_requirement_source"] = merged["pred_oxygen_requirement_source"].fillna("tabular")
104
+ return merged, "hybrid"
105
+
106
+
107
+ def _tag_prediction_sources(phenotypes: dict[str, Any]) -> dict[str, Any]:
108
+ """Expose model source metadata to the UI without changing prediction values."""
109
+ for key, source in {
110
+ "optimal_temperature_c": "tabular",
111
+ "optimal_ph": "tabular",
112
+ "oxygen_requirement": "tabular",
113
+ "salt_tolerance_pct": "tabular",
114
+ }.items():
115
+ item = phenotypes.get(key)
116
+ if isinstance(item, dict):
117
+ item.setdefault("source", source)
118
+ return phenotypes
119
+
120
+
121
  @app.on_event("startup")
122
  def _load_resources() -> None:
123
  print("Loading recommender models...")
 
130
  zip(media_meta["medium_id"].astype(str), media_meta["name"], strict=True)
131
  )
132
 
133
+ unc, catalog_source = _load_catalog_frame()
134
  unc["phylum"] = unc["gtdb_taxonomy"].map(_phylum)
135
  unc["truly_uncultured"] = (
136
  unc["ncbi_organism_name"].fillna("").str.lower().str.startswith("uncultured")
137
  )
138
  _state["catalog"] = unc
139
+ _state["catalog_source"] = catalog_source
140
  print(f" → {len(unc):,} catalog rows ({int(unc['truly_uncultured'].sum()):,} truly uncultured)")
141
 
142
 
 
158
  pH: float
159
  O2: str
160
  O2_conf: float
161
+ O2_source: str = "tabular"
162
  salt: float
163
  top_medium_id: str
164
  top_medium_name: str
 
180
  "ok": True,
181
  "models_loaded": len(_state.get("models", {})),
182
  "catalog_rows": len(_state.get("catalog", [])),
183
+ "catalog_source": _state.get("catalog_source", "tabular"),
184
  }
185
 
186
 
 
216
  "pH": _safe_float(m["pred_optimal_ph"], 2),
217
  "O2": _safe_str(m["pred_oxygen_requirement"], "—"),
218
  "O2_conf": _safe_float(m.get("pred_oxygen_requirement_confidence"), 3, 0.0),
219
+ "O2_source": _safe_str(m.get("pred_oxygen_requirement_source"), "tabular"),
220
  "salt": _safe_float(m["pred_salt_tolerance_pct"], 2),
221
  "top_medium_id": _safe_str(m["top1_medium_id"], "—"),
222
  "top_medium_name": _safe_str(m["top1_medium_name"], "—"),
 
228
  "top3_medium_name": _safe_str(m.get("top3_medium_name")),
229
  "top3_confidence": _safe_float(m.get("top3_confidence"), 4) if pd.notna(m.get("top3_confidence")) else None,
230
  })
231
+ return {"count": len(rows), "source": _state.get("catalog_source", "tabular"), "rows": rows}
232
 
233
 
234
  @app.get("/api/ncbi-search")
 
290
  feats, accession, n_contigs = _load_genome_features(target)
291
 
292
  feats_series = pd.Series(feats)
293
+ phenotypes = _tag_prediction_sources(_predict_phenotypes(feats_series))
294
 
295
  models = _state["models"]
296
  feature_cols = _state["feature_cols"]
web/src/components/Accuracy.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React from 'react';
2
  import { THEME, tempColor, pHColor, saltColor, O2_COLOR } from '../theme.js';
3
- import { MediaConfBar, OxygenConfArc, IntervalBar } from './Primitives.jsx';
4
 
5
  const TARGETS = [
6
  { key: 'T', label: 'Temperature optimum', metric: 'MAE', value: '3.28', unit: '°C', color: tempColor(45),
@@ -9,9 +9,9 @@ const TARGETS = [
9
  { key: 'pH', label: 'pH optimum', metric: 'MAE', value: '0.52', unit: '', color: pHColor(7),
10
  verdict: 'Marginal — distinguishes acidic / neutral / alkaline, not finer.',
11
  detail: 'Trained on 4,652 BacDive strains. Quantile regression for 80% prediction interval.' },
12
- { key: 'O2', label: 'Oxygen requirement', metric: 'F1', value: '0.28', unit: '', color: O2_COLOR,
13
- verdict: 'Weak 9 imbalanced classes, frequent aerobe aerotolerant confusion.',
14
- detail: 'Trained on 10,426 BacDive strains. Multinomial logistic on top of XGBoost.' },
15
  { key: 'salt', label: 'Salt tolerance', metric: 'MAE', value: '2.51', unit: '%', color: saltColor(3),
16
  verdict: 'Decent — separates freshwater / marine / halotolerant.',
17
  detail: 'Trained on 4,793 BacDive strains.' },
@@ -23,7 +23,7 @@ export default function Accuracy() {
23
  <div style={{ marginBottom: 24, padding: '14px 16px', background: '#ede4cd', border: `1px solid ${THEME.rule}` }}>
24
  <div style={{ font: `500 11px ${THEME.mono}`, color: THEME.accent, letterSpacing: '0.05em', textTransform: 'uppercase', marginBottom: 4 }}>The verdict</div>
25
  <div style={{ font: `400 14px ${THEME.serif}`, color: THEME.ink, fontStyle: 'italic' }}>
26
- v1 handcrafted features is the working baseline. Trust temperature and pH; verify oxygen and salt with a tube.
27
  </div>
28
  </div>
29
 
@@ -32,7 +32,7 @@ export default function Accuracy() {
32
  <div key={t.key} style={{ border: `1px solid ${THEME.rule}`, padding: '16px 18px', background: THEME.paper }}>
33
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
34
  <span style={{ font: `500 12px ${THEME.font}`, color: THEME.ink }}>{t.label}</span>
35
- <span style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkFaint }}>5-fold GroupKFold by family</span>
36
  </div>
37
  <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
38
  <span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase' }}>{t.metric}</span>
@@ -58,11 +58,12 @@ export default function Accuracy() {
58
  </div>
59
 
60
  <div style={{ marginTop: 24, font: `400 12px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.6 }}>
61
- Trained on <span style={{ color: THEME.ink, fontWeight: 500 }}>17,047</span> BacDive strains
62
- with growth conditions; uncultured catalog is <span style={{ color: THEME.ink, fontWeight: 500 }}>5,000</span> held-out
 
63
  GTDB genomes scored against <span style={{ color: THEME.ink, fontWeight: 500 }}>24</span> DSMZ media.
64
  Features: 353 handcrafted genome statistics — GC, codon usage, tetranucleotide frequencies, amino-acid composition.
65
- XGBoost classifiers for media; quantile regression XGBoost for prediction intervals.
66
  </div>
67
  </div>
68
  );
@@ -83,10 +84,13 @@ function Legend({ kind }) {
83
  if (kind === 'oxygen') {
84
  return (
85
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '12px 14px', background: THEME.paper }}>
86
- <OxygenConfArc value={0.72} size={36} />
 
 
 
87
  <div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, marginTop: 8 }}>Oxygen confidence</div>
88
  <div style={{ font: `400 11.5px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.5 }}>
89
- Max softmax probability across 9 imbalanced classes. Low values mean the model can't pick between near-neighbour categories.
90
  </div>
91
  </div>
92
  );
 
1
  import React from 'react';
2
  import { THEME, tempColor, pHColor, saltColor, O2_COLOR } from '../theme.js';
3
+ import { MediaConfBar, OxygenConfArc, IntervalBar, SourceBadge } from './Primitives.jsx';
4
 
5
  const TARGETS = [
6
  { key: 'T', label: 'Temperature optimum', metric: 'MAE', value: '3.28', unit: '°C', color: tempColor(45),
 
9
  { key: 'pH', label: 'pH optimum', metric: 'MAE', value: '0.52', unit: '', color: pHColor(7),
10
  verdict: 'Marginal — distinguishes acidic / neutral / alkaline, not finer.',
11
  detail: 'Trained on 4,652 BacDive strains. Quantile regression for 80% prediction interval.' },
12
+ { key: 'O2', label: 'Oxygen requirement', metric: 'F1', value: '0.94', unit: '', color: O2_COLOR,
13
+ verdict: 'Strong on fold 0 with LoRA; still needs folds 1-4 before publication-grade validation.',
14
+ detail: 'Hybrid oxygen uses LoRA ESM-2 fold 0 when available, with tabular prediction as the deploy fallback.' },
15
  { key: 'salt', label: 'Salt tolerance', metric: 'MAE', value: '2.51', unit: '%', color: saltColor(3),
16
  verdict: 'Decent — separates freshwater / marine / halotolerant.',
17
  detail: 'Trained on 4,793 BacDive strains.' },
 
23
  <div style={{ marginBottom: 24, padding: '14px 16px', background: '#ede4cd', border: `1px solid ${THEME.rule}` }}>
24
  <div style={{ font: `500 11px ${THEME.mono}`, color: THEME.accent, letterSpacing: '0.05em', textTransform: 'uppercase', marginBottom: 4 }}>The verdict</div>
25
  <div style={{ font: `400 14px ${THEME.serif}`, color: THEME.ink, fontStyle: 'italic' }}>
26
+ Hybrid v2 keeps tabular models for temperature, pH, salt, and media; oxygen uses LoRA when the checkpoint-backed predictions are available.
27
  </div>
28
  </div>
29
 
 
32
  <div key={t.key} style={{ border: `1px solid ${THEME.rule}`, padding: '16px 18px', background: THEME.paper }}>
33
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
34
  <span style={{ font: `500 12px ${THEME.font}`, color: THEME.ink }}>{t.label}</span>
35
+ <span style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkFaint }}>{t.key === 'O2' ? 'LoRA fold 0' : '5-fold GroupKFold by family'}</span>
36
  </div>
37
  <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
38
  <span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase' }}>{t.metric}</span>
 
58
  </div>
59
 
60
  <div style={{ marginTop: 24, font: `400 12px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.6 }}>
61
+ Tabular phenotypes were trained from <span style={{ color: THEME.ink, fontWeight: 500 }}>46,029</span> BacDive-derived strain rows;
62
+ LoRA oxygen fold 0 used <span style={{ color: THEME.ink, fontWeight: 500 }}>32,375</span> training rows and <span style={{ color: THEME.ink, fontWeight: 500 }}>8,094</span> validation rows.
63
+ The uncultured catalog is <span style={{ color: THEME.ink, fontWeight: 500 }}>5,000</span> held-out
64
  GTDB genomes scored against <span style={{ color: THEME.ink, fontWeight: 500 }}>24</span> DSMZ media.
65
  Features: 353 handcrafted genome statistics — GC, codon usage, tetranucleotide frequencies, amino-acid composition.
66
+ XGBoost classifiers handle media and tabular phenotypes; quantile regression XGBoost provides prediction intervals.
67
  </div>
68
  </div>
69
  );
 
84
  if (kind === 'oxygen') {
85
  return (
86
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '12px 14px', background: THEME.paper }}>
87
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
88
+ <OxygenConfArc value={0.72} size={36} />
89
+ <SourceBadge source="lora" />
90
+ </div>
91
  <div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, marginTop: 8 }}>Oxygen confidence</div>
92
  <div style={{ font: `400 11.5px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.5 }}>
93
+ LoRA confidence is the max softmax probability across four oxygen classes. Tabular remains the fallback when hybrid artifacts are absent.
94
  </div>
95
  </div>
96
  );
web/src/components/Catalog.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React, { useEffect, useMemo, useState } from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
- import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
4
 
5
  function ModeStrip({ mode, setMode }) {
6
  const focused = mode === 'focused';
@@ -98,7 +98,10 @@ function FeaturedCard({ m, onSelect }) {
98
  <PhenoMicro label="pH" value={m.pH.toFixed(1)} color={pHColor(m.pH)} />
99
  <PhenoMicro label="salt" value={m.salt.toFixed(1)} unit="%" color={saltColor(m.salt)} />
100
  <div>
101
- <div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, letterSpacing: '0.05em', textTransform: 'uppercase', marginBottom: 4 }}>O₂</div>
 
 
 
102
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
103
  <OxygenConfArc value={m.O2_conf} size={26} />
104
  <div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, lineHeight: 1.2 }}>{m.O2}</div>
@@ -139,6 +142,7 @@ function TableRow({ m, onSelect, isLast }) {
139
  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
140
  <OxygenConfArc value={m.O2_conf} size={20} />
141
  <span style={{ font: `400 11.5px ${THEME.font}`, color: THEME.ink }}>{m.O2}</span>
 
142
  </div>
143
  </td>
144
  <td style={{ padding: '10px 12px', font: `500 12px ${THEME.serif}`, color: saltColor(m.salt), fontVariantNumeric: 'tabular-nums' }}>{m.salt.toFixed(1)}%</td>
 
1
  import React, { useEffect, useMemo, useState } from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
+ import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
4
 
5
  function ModeStrip({ mode, setMode }) {
6
  const focused = mode === 'focused';
 
98
  <PhenoMicro label="pH" value={m.pH.toFixed(1)} color={pHColor(m.pH)} />
99
  <PhenoMicro label="salt" value={m.salt.toFixed(1)} unit="%" color={saltColor(m.salt)} />
100
  <div>
101
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
102
+ <span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, letterSpacing: '0.05em', textTransform: 'uppercase' }}>O₂</span>
103
+ <SourceBadge source={m.O2_source} compact />
104
+ </div>
105
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
106
  <OxygenConfArc value={m.O2_conf} size={26} />
107
  <div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, lineHeight: 1.2 }}>{m.O2}</div>
 
142
  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
143
  <OxygenConfArc value={m.O2_conf} size={20} />
144
  <span style={{ font: `400 11.5px ${THEME.font}`, color: THEME.ink }}>{m.O2}</span>
145
+ <SourceBadge source={m.O2_source} compact />
146
  </div>
147
  </td>
148
  <td style={{ padding: '10px 12px', font: `500 12px ${THEME.serif}`, color: saltColor(m.salt), fontVariantNumeric: 'tabular-nums' }}>{m.salt.toFixed(1)}%</td>
web/src/components/DetailDrawer.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
- import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
4
 
5
  export default function DetailDrawer({ microbe, onClose }) {
6
  if (!microbe) return null;
@@ -34,7 +34,10 @@ export default function DetailDrawer({ microbe, onClose }) {
34
  <PhenoCell label="pH" value={microbe.pH} color={pHColor(microbe.pH)} scaleMin={2} scaleMax={11} />
35
  <PhenoCell label="salt" value={microbe.salt} unit="%" color={saltColor(microbe.salt)} scaleMin={0} scaleMax={25} />
36
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '10px 12px' }}>
37
- <div style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Oxygen</div>
 
 
 
38
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
39
  <OxygenConfArc value={microbe.O2_conf} size={36} />
40
  <div style={{ font: `500 14px ${THEME.font}`, color: THEME.ink }}>{microbe.O2}</div>
 
1
  import React from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
+ import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
4
 
5
  export default function DetailDrawer({ microbe, onClose }) {
6
  if (!microbe) return null;
 
34
  <PhenoCell label="pH" value={microbe.pH} color={pHColor(microbe.pH)} scaleMin={2} scaleMax={11} />
35
  <PhenoCell label="salt" value={microbe.salt} unit="%" color={saltColor(microbe.salt)} scaleMin={0} scaleMax={25} />
36
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '10px 12px' }}>
37
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
38
+ <span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Oxygen</span>
39
+ <SourceBadge source={microbe.O2_source} compact />
40
+ </div>
41
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
42
  <OxygenConfArc value={microbe.O2_conf} size={36} />
43
  <div style={{ font: `500 14px ${THEME.font}`, color: THEME.ink }}>{microbe.O2}</div>
web/src/components/PredictBar.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React, { useState, useRef } from 'react';
2
  import { THEME } from '../theme.js';
3
- import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
4
  import { tempColor, pHColor, saltColor } from '../theme.js';
5
 
6
  const QUICK_TRY = [
@@ -198,7 +198,10 @@ function PredictResultBanner({ result, onClear }) {
198
  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
199
  <OxygenConfArc value={O2.confidence || 0} size={32} />
200
  <div>
201
- <div style={{ fontFamily: THEME.mono, fontSize: 9, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em' }}>O₂</div>
 
 
 
202
  <div style={{ fontFamily: THEME.font, fontSize: 12, color: THEME.ink }}>{O2.prediction}</div>
203
  </div>
204
  </div>
 
1
  import React, { useState, useRef } from 'react';
2
  import { THEME } from '../theme.js';
3
+ import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
4
  import { tempColor, pHColor, saltColor } from '../theme.js';
5
 
6
  const QUICK_TRY = [
 
198
  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
199
  <OxygenConfArc value={O2.confidence || 0} size={32} />
200
  <div>
201
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 2 }}>
202
+ <span style={{ fontFamily: THEME.mono, fontSize: 9, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em' }}>O₂</span>
203
+ <SourceBadge source={O2.source} compact />
204
+ </div>
205
  <div style={{ fontFamily: THEME.font, fontSize: 12, color: THEME.ink }}>{O2.prediction}</div>
206
  </div>
207
  </div>
web/src/components/Primitives.jsx CHANGED
@@ -65,3 +65,29 @@ export function MonoTag({ children, color = THEME.accent }) {
65
  }}>{children}</span>
66
  );
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }}>{children}</span>
66
  );
67
  }
68
+
69
+ export function SourceBadge({ source = 'tabular', compact = false }) {
70
+ const normalized = String(source || 'tabular').toLowerCase();
71
+ const isLora = normalized === 'lora';
72
+ const color = isLora ? O2_COLOR : THEME.inkFaint;
73
+ return (
74
+ <span style={{
75
+ display: 'inline-flex',
76
+ alignItems: 'center',
77
+ width: 'fit-content',
78
+ fontFamily: THEME.mono,
79
+ fontSize: compact ? 9 : 10,
80
+ fontWeight: 500,
81
+ letterSpacing: '0.04em',
82
+ textTransform: 'uppercase',
83
+ padding: compact ? '1px 4px' : '2px 6px',
84
+ border: `1px solid ${color}`,
85
+ color,
86
+ borderRadius: 2,
87
+ whiteSpace: 'nowrap',
88
+ lineHeight: 1.2,
89
+ }}>
90
+ {isLora ? 'LoRA' : 'tabular'}
91
+ </span>
92
+ );
93
+ }
web/src/components/TestTab.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React, { useState } from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
- import { OxygenConfArc, IntervalBar, MediaConfBar, MonoTag } from './Primitives.jsx';
4
 
5
  const PRESETS = [
6
  { id: 'ecoli', name: 'Escherichia coli K-12 MG1655', accession: 'GCF_000005845.2',
@@ -124,9 +124,12 @@ function Comparison({ preset, result }) {
124
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
125
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
126
  <span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>Oxygen requirement</span>
127
- <span style={{ font: `500 11px ${THEME.mono}`, color: O2.prediction === pub.O2 ? THEME.pos : THEME.warn }}>
128
- {O2.prediction === pub.O2 ? '✓ match' : '△ mismatch'}
129
- </span>
 
 
 
130
  </div>
131
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
132
  <div>
 
1
  import React, { useState } from 'react';
2
  import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
3
+ import { OxygenConfArc, IntervalBar, MediaConfBar, MonoTag, SourceBadge } from './Primitives.jsx';
4
 
5
  const PRESETS = [
6
  { id: 'ecoli', name: 'Escherichia coli K-12 MG1655', accession: 'GCF_000005845.2',
 
124
  <div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
125
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
126
  <span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>Oxygen requirement</span>
127
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
128
+ <SourceBadge source={O2.source} compact />
129
+ <span style={{ font: `500 11px ${THEME.mono}`, color: O2.prediction === pub.O2 ? THEME.pos : THEME.warn }}>
130
+ {O2.prediction === pub.O2 ? '✓ match' : '△ mismatch'}
131
+ </span>
132
+ </div>
133
  </div>
134
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
135
  <div>