Babu09 commited on
Commit
2b23c61
·
verified ·
1 Parent(s): 4c244a8

Deploy MEIDNet Studio

Browse files
meidnet/generate.py CHANGED
@@ -502,9 +502,13 @@ class Designer:
502
  if v < lo - 0.05 * span or v > hi + 0.05 * span:
503
  flags.append(f"predicted {prop} {v:.2f} is outside the training range [{lo:.2f}, {hi:.2f}] "
504
  "- the model is extrapolating")
 
 
 
505
  nz = float(np.linalg.norm(z))
506
- if nz > 1.5:
507
- flags.append(f"latent length {nz:.1f} (training latents have length 1) - predictions are less reliable")
 
508
  return flags
509
 
510
 
 
502
  if v < lo - 0.05 * span or v > hi + 0.05 * span:
503
  flags.append(f"predicted {prop} {v:.2f} is outside the training range [{lo:.2f}, {hi:.2f}] "
504
  "- the model is extrapolating")
505
+ # The search always lets latents grow past the unit length the model was trained on (typically 3-5), so a
506
+ # per-candidate warning would fire on every candidate; the reports state it once per run instead
507
+ # (latent_norm stays in candidates.csv). Only a latent pinned at the clip bound is singled out.
508
  nz = float(np.linalg.norm(z))
509
+ if float(np.abs(z).max()) >= self.g.z_clip - 1e-6:
510
+ flags.append(f"latent hit the search limit (z_clip {self.g.z_clip:g}, length {nz:.1f}) "
511
+ "- predictions are less reliable")
512
  return flags
513
 
514
 
meidnet/report.py CHANGED
@@ -194,7 +194,9 @@ def _predictions(lm, dataset):
194
 
195
 
196
  def quality_word(mae, std):
197
- r = mae / std if std > 0 else 0
 
 
198
  if r < 0.25:
199
  return "good", r
200
  if r < 0.5:
@@ -218,13 +220,22 @@ def training_report(cfg, lm, history, train_set, val_set, path) -> str:
218
  word, ratio = quality_word(mae, std)
219
  words.append(word)
220
  unit = stats.units[j]
 
 
 
221
  rows.append(f"<tr><td>{esc(stats.labels[j])}</td><td>{mae:.3g} {esc(unit)}</td><td>{std:.3g} {esc(unit)}</td>"
222
- f"<td>{r2:.2f}</td><td class='{'pass' if word == 'good' else ('flag' if word == 'fair' else 'fail')}'>{word}</td></tr>")
223
  parity.append(f"<div><h3>{esc(stats.labels[j])}</h3>" + svg.scatter(
224
- Y[:, j], P[:, j], f"Predicted vs true {stats.labels[j]}", f"true [{unit}]", f"predicted [{unit}]",
225
  diagonal=True) + "</div>")
226
  last_val = history["val"][-1] if history.get("val") else None
227
- if all(w == "good" for w in words):
 
 
 
 
 
 
228
  v = verdict("good", "The model predicts every property well from structure alone.",
229
  "<p>Typical errors are under a quarter of the natural spread of each property.</p>")
230
  elif "weak" in words:
@@ -357,8 +368,9 @@ def generation_report(cfg, lm, family, res, path) -> str:
357
  "different variant, or relax that rule in <code>generation.overrides</code>.</p>")
358
  elif flagged:
359
  v = verdict("warn", f"{len(saved)} candidate(s) saved; {len(flagged)} rely on extrapolated predictions.",
360
- "<p>All saved candidates obey every rule applied in this run. Some predicted property values lie outside "
361
- "what the model saw in training — treat those numbers with caution.</p>")
 
362
  else:
363
  v = verdict("good", f"{len(saved)} candidate(s) saved for {len(res.targets)} target(s).",
364
  f"<p>Each one passed all {n_rules} rules of the {esc(family.title)} family.</p>")
@@ -366,6 +378,12 @@ def generation_report(cfg, lm, family, res, path) -> str:
366
  "for ones that decode into chemically sensible crystals, and kept the candidates whose predicted "
367
  "properties are closest to the target. <b>Predicted values come from the model and must be confirmed by "
368
  "calculation or experiment.</b></p>")
 
 
 
 
 
 
369
  off = ""
370
  if g.disabled_rules: # say plainly which of the family's rules this run did not apply
371
  off = (f"<p class='flag'>⚠ Switched off for this run (<code>disabled_rules</code>): "
 
194
 
195
 
196
  def quality_word(mae, std):
197
+ if not std > 1e-12: # every value the same: an error cannot be compared with a spread
198
+ return "not judged", None
199
+ r = mae / std
200
  if r < 0.25:
201
  return "good", r
202
  if r < 0.5:
 
220
  word, ratio = quality_word(mae, std)
221
  words.append(word)
222
  unit = stats.units[j]
223
+ u = f" [{unit}]" if unit else ""
224
+ css = {"good": "pass", "fair": "flag", "weak": "fail"}.get(word, "muted")
225
+ shown = word if word != "not judged" else f"not judged (all {which.split()[0]} values are equal)"
226
  rows.append(f"<tr><td>{esc(stats.labels[j])}</td><td>{mae:.3g} {esc(unit)}</td><td>{std:.3g} {esc(unit)}</td>"
227
+ f"<td>{'–' if r2 != r2 else f'{r2:.2f}'}</td><td class='{css}'>{esc(shown)}</td></tr>")
228
  parity.append(f"<div><h3>{esc(stats.labels[j])}</h3>" + svg.scatter(
229
+ Y[:, j], P[:, j], f"Predicted vs true {stats.labels[j]}", f"true{u}", f"predicted{u}",
230
  diagonal=True) + "</div>")
231
  last_val = history["val"][-1] if history.get("val") else None
232
+ judged = [w for w in words if w != "not judged"]
233
+ if len(judged) < len(words):
234
+ v = verdict("warn", "Some properties cannot be judged on this validation set.",
235
+ "<p>Every validation material has the same value for at least one property, so its error cannot "
236
+ "be compared with a spread. Use more data (or a larger <code>val_fraction</code>) to judge the "
237
+ "model.</p>")
238
+ elif all(w == "good" for w in words):
239
  v = verdict("good", "The model predicts every property well from structure alone.",
240
  "<p>Typical errors are under a quarter of the natural spread of each property.</p>")
241
  elif "weak" in words:
 
368
  "different variant, or relax that rule in <code>generation.overrides</code>.</p>")
369
  elif flagged:
370
  v = verdict("warn", f"{len(saved)} candidate(s) saved; {len(flagged)} rely on extrapolated predictions.",
371
+ "<p>All saved candidates obey every rule applied in this run. For the flagged ones (⚠ on the card) a "
372
+ "predicted property value lies outside what the model saw in training — treat those numbers with "
373
+ "caution.</p>")
374
  else:
375
  v = verdict("good", f"{len(saved)} candidate(s) saved for {len(res.targets)} target(s).",
376
  f"<p>Each one passed all {n_rules} rules of the {esc(family.title)} family.</p>")
 
378
  "for ones that decode into chemically sensible crystals, and kept the candidates whose predicted "
379
  "properties are closest to the target. <b>Predicted values come from the model and must be confirmed by "
380
  "calculation or experiment.</b></p>")
381
+ if saved:
382
+ norms = [c.latent_norm for c in saved]
383
+ span = f"{min(norms):.1f}" if max(norms) - min(norms) < 0.05 else f"{min(norms):.1f}–{max(norms):.1f}"
384
+ intro += (f"<p class='muted'>The model was trained on latents of length 1; the search moves them further out "
385
+ f"(here length {span}), so every predicted value below is the model's extrapolation along that "
386
+ f"direction. This is how MEIDNet searches, not a fault of one candidate.</p>")
387
  off = ""
388
  if g.disabled_rules: # say plainly which of the family's rules this run did not apply
389
  off = (f"<p class='flag'>⚠ Switched off for this run (<code>disabled_rules</code>): "
meidnet/studio/server.py CHANGED
@@ -311,7 +311,7 @@ class Studio:
311
  "has_model": bool(s and s.lm),
312
  "using_own": self._own(s), "columns": s.columns if s else [], "n_rows": s.n_rows if s else 0,
313
  "n_cifs": s.n_cifs if s else 0, "checked": bool(s and s.check), "busy": bool(s and s.busy),
314
- "source": os.path.basename(s.table_path) if (s and s.table_path) else None},
315
  }
316
 
317
  # ── data: published / project / uploaded ────────────────────────────────
@@ -359,7 +359,8 @@ class Studio:
359
  vals = np.array([r.properties[i] for r in recs]) if recs else np.array([])
360
  out["charts"][p.column] = svg.histogram(vals, p.display, p.unit or p.display)
361
  if len(vals):
362
- out["stats"][p.column] = {"min": float(vals.min()), "median": float(np.median(vals)), "max": float(vals.max())}
 
363
  if rep.elements:
364
  out["charts"]["elements"] = svg.hbars(list(rep.elements.items()), "Most common elements", max_items=16)
365
  if rep.skipped:
@@ -540,7 +541,8 @@ class Studio:
540
  raw = {"name": "your_data", "output_dir": s.dir, "family": family, "data": data,
541
  "training": {"epochs": 20, "batch_size": 16, "device": "cpu"}, "generation": gen}
542
  cfg = config_from_dict(raw, base_dir=s.data_dir)
543
- summary, info = self._check_summary(cfg, source=os.path.basename(s.table_path), keep_structures=True)
 
544
  s.check_info = info
545
  s.chem_cache = {}
546
  # targets default to the median of each property so the first search is sensible
@@ -824,7 +826,10 @@ class Studio:
824
  if self.public:
825
  raw["plugins"] = []
826
  cfg = config_from_dict(raw, base_dir=base._base_dir)
827
- except Exception as e:
 
 
 
828
  return {"ok": False, "error": str(e)}
829
  with self.jobs_lock:
830
  refused = self._slot_error(s)
@@ -1000,7 +1005,7 @@ class Studio:
1000
  return {"available": False, "note": "no usable structures in this table - see the Data block"}
1001
  if "data" not in s.chem_cache:
1002
  s.chem_cache["data"] = cs.records_dataset(s.check_info["records"], s.cfg.data.properties,
1003
- os.path.basename(s.table_path or "table"), cap)
1004
  return s.chem_cache["data"]
1005
  if self.cfg.data is not None: # the project's own data ...
1006
  if "data" not in self.chem_cache:
 
311
  "has_model": bool(s and s.lm),
312
  "using_own": self._own(s), "columns": s.columns if s else [], "n_rows": s.n_rows if s else 0,
313
  "n_cifs": s.n_cifs if s else 0, "checked": bool(s and s.check), "busy": bool(s and s.busy),
314
+ "source": (s.table_name or os.path.basename(s.table_path)) if (s and s.table_path) else None},
315
  }
316
 
317
  # ── data: published / project / uploaded ────────────────────────────────
 
359
  vals = np.array([r.properties[i] for r in recs]) if recs else np.array([])
360
  out["charts"][p.column] = svg.histogram(vals, p.display, p.unit or p.display)
361
  if len(vals):
362
+ out["stats"][p.column] = {"min": float(vals.min()), "median": float(np.median(vals)), "max": float(vals.max()),
363
+ "std": float(vals.std())}
364
  if rep.elements:
365
  out["charts"]["elements"] = svg.hbars(list(rep.elements.items()), "Most common elements", max_items=16)
366
  if rep.skipped:
 
541
  raw = {"name": "your_data", "output_dir": s.dir, "family": family, "data": data,
542
  "training": {"epochs": 20, "batch_size": 16, "device": "cpu"}, "generation": gen}
543
  cfg = config_from_dict(raw, base_dir=s.data_dir)
544
+ summary, info = self._check_summary(cfg, source=s.table_name or os.path.basename(s.table_path),
545
+ keep_structures=True)
546
  s.check_info = info
547
  s.chem_cache = {}
548
  # targets default to the median of each property so the first search is sensible
 
826
  if self.public:
827
  raw["plugins"] = []
828
  cfg = config_from_dict(raw, base_dir=base._base_dir)
829
+ from meidnet.pipeline import family_for
830
+ # refuse at once (not seconds later from the job) a target or window the model cannot predict
831
+ self._check_against_model(family_for(cfg, need_variant=True), cfg.generation, self._model_for(s))
832
+ except (Exception, SystemExit) as e: # family_for reports family problems as SystemExit
833
  return {"ok": False, "error": str(e)}
834
  with self.jobs_lock:
835
  refused = self._slot_error(s)
 
1005
  return {"available": False, "note": "no usable structures in this table - see the Data block"}
1006
  if "data" not in s.chem_cache:
1007
  s.chem_cache["data"] = cs.records_dataset(s.check_info["records"], s.cfg.data.properties,
1008
+ s.table_name or os.path.basename(s.table_path or "table"), cap)
1009
  return s.chem_cache["data"]
1010
  if self.cfg.data is not None: # the project's own data ...
1011
  if "data" not in self.chem_cache:
meidnet/studio/studio.html CHANGED
@@ -105,6 +105,9 @@ pre{background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:
105
  code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;background:var(--bg);padding:1px 4px;border-radius:4px}
106
  .behind{margin-top:12px}.behind .tabs{display:flex;gap:6px;margin:6px 0}.behind .tabs button.on{background:var(--accent);border-color:var(--accent);color:#fff}
107
  .prog{height:9px;background:var(--line);border-radius:5px;overflow:hidden;margin:6px 0}.prog i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s}
 
 
 
108
  .cand{border:1px solid var(--line);border-radius:12px;padding:12px;margin:8px 0;display:grid;grid-template-columns:150px 1fr;gap:12px}
109
  .cand h3{margin:0 0 4px;color:var(--ink);font-size:18px}.cand .meta{grid-column:1/-1}@media(max-width:600px){.cand{grid-template-columns:1fr}}
110
  ul.chk{list-style:none;margin:6px 0;padding:0}ul.chk li{padding:1px 0;font-size:13px}
@@ -155,7 +158,7 @@ body>div:empty:not([id]):not([class]){position:absolute;top:0;left:0;width:0;hei
155
  <div class="chain" id="chain"><span class="lead">Change anything above or in a block — what it does to the blocks after it appears here.</span></div>
156
  <details id="flowlog"><summary>Flow log (0)</summary><ol id="flowlog-list"></ol></details>
157
  <div class="cols">
158
- <div id="panel"></div>
159
  <aside>
160
  <div class="card" id="live"></div>
161
  <div class="card small" id="legend"></div>
@@ -176,7 +179,7 @@ body>div:empty:not([id]):not([class]){position:absolute;top:0;left:0;width:0;hei
176
  <div class="row" style="margin-top:8px"><button class="primary" onclick="applyYaml()">Apply</button><button onclick="validateYaml()">Validate only</button><button onclick="downloadYaml()">⬇ Download meidnet.yaml</button><button onclick="openYaml()">Reset</button></div>
177
  <div id="yaml-msg"></div>
178
  </div>
179
- <div class="modal" id="modal" onclick="if(event.target===this)closeModal()"><div class="card" id="modal-body"></div></div>
180
  <script>
181
  /* ═══════════════════════════ state & constants ═══════════════════════════ */
182
  const STATIC = window.MEIDNET_STATIC || null;
@@ -218,7 +221,8 @@ const esc = s => String(s??'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;'
218
  /* a value as a JS string literal inside an onclick="…" attribute: safe for names such as "Young's modulus" */
219
  const jsq = s => esc(JSON.stringify(String(s??'')));
220
  const fmt = v => { if(v===null||v===undefined||Number.isNaN(+v)) return '–'; const x=+v;
221
- if(Math.abs(x)>=1000||(Math.abs(x)<0.01&&x!==0)) return x.toPrecision(2);
 
222
  let s=x.toPrecision(3); if(s.includes('.')&&!/e/i.test(s)) s=s.replace(/0+$/,'').replace(/\.$/,''); // 1.50→1.5, 100 stays 100
223
  return s; };
224
  const $ = id => document.getElementById(id);
@@ -363,6 +367,12 @@ function ruleTitle(n, params){ if(n==='property_window') return 'Predicted '+lab
363
  function ruleText(n){ if(n==='property_window') return 'The predicted value must lie inside the window.'; const r=(S.fam.space.rules||[]).find(x=>x.name===n); return r? r.text : ''; }
364
  function label(p){ const m=S.state.model.properties.find(x=>x.column===p); return m? m.label : p; }
365
  function unit(p){ const m=S.state.model.properties.find(x=>x.column===p); return m&&m.unit ? ' '+m.unit : ''; }
 
 
 
 
 
 
366
  /* a search result's rule, in words: property windows name their property (the result says "predicted dir_gap = …") */
367
  function resultTitle(r){ if(r.name==='symmetry_refinement') return 'Symmetry analysis';
368
  if(/^property_window/.test(r.name)){ const m=/predicted (.+?) =/.exec(r.detail||''); return 'Predicted '+(m?label(m[1]):'property')+' window'; }
@@ -412,7 +422,9 @@ function statusAll(){
412
  if(S.fam) status('family', `${S.fam.variant||S.fam.name}`, `${nfmt(ev?ev.rows.length:S.fam.space.rows.length)} compositions`);
413
  nodeState('family', ev&&ev.rows.length ? 'ok' : 'warn');
414
  if(ev){ status('rules', `${activeRules().length} rules`, `${nfmt(ev.alive.length)} pass all`); nodeState('rules', ev.alive.length ? 'ok' : 'warn'); }
415
- status('targets', S.ui.objectives.map(o=>`${o.property} ${fmt(+S.ui.target[o.property])}`).join(', '), ev&&ev.ranked[0]?`best: ${ev.ranked[0][1].f}`:'no match');
 
 
416
  nodeState('targets', outOfRange().length ? 'warn' : (ev&&ev.ranked.length ? 'ok' : 'warn'));
417
  const st=S.search.status; if(!st) { status('search','idle',''); nodeState('search','idle'); }
418
  else if(!st.running){ status('search', st.error?'failed':'finished', ''); nodeState('search', st.error?'error':'ok'); }
@@ -448,8 +460,8 @@ function drawPipe(){
448
  <text class="s" id="st-${n.id}" x="${x+12}" y="${y+56}">…</text><text class="s" id="st2-${n.id}" x="${x+12}" y="${y+73}"></text></g>`; });
449
  $('pipe').innerHTML=s;
450
  }
451
- function status(id, a, b){ const fit=s=>{ s=String(s||''); return s.length>21 ? s.slice(0,20)+'…' : s; }; // ~21 characters fit a 130-px node at 11 px
452
- const e=$('st-'+id); if(!e) return; e.textContent=fit(a); e.parentNode.querySelector('title').textContent = blockName(id)+' — '+String(a||'')+(b?' · '+b:'');
453
  const e2=$('st2-'+id); if(e2) e2.textContent=fit(b); }
454
  function nodeState(id, st){ const b=$('bd-'+id); if(b) b.setAttribute('class','badge '+(st||'idle')); }
455
  function flowFrom(id){
@@ -501,7 +513,7 @@ function scatter(rows, px, py, target, highlight){
501
  for(const [x,y,r] of pts){ if(!r._ok) s+=`<circle class="ptx" cx="${X(x)}" cy="${Y(y)}" r="2.4"><title>${esc(r.f)} (rejected)</title></circle>`; }
502
  for(const [x,y,r] of pts){ if(r._ok&&!hs.has(r.f)) s+=`<circle class="pt" cx="${X(x)}" cy="${Y(y)}" r="3" onclick="inspect(${jsq(r.f)})" style="cursor:pointer"><title>${esc(r.f)}: ${fmt(x)}, ${fmt(y)}</title></circle>`; }
503
  for(const [x,y,r] of pts){ if(hs.has(r.f)) s+=`<path class="star" d="M${X(x)},${Y(y)-8} l2.3,5.5 5.7,0 -4.6,3.5 2,5.5 -5.4,-3.4 -5.4,3.4 2,-5.5 -4.6,-3.5 5.7,0z" onclick="inspect(${jsq(r.f)})" style="cursor:pointer"><title>${esc(r.f)} (found by search)</title></path>`; }
504
- s+=`<text class="lab" x="${(ml+W-mr)/2}" y="${H-4}" text-anchor="middle">predicted ${esc(label(px))}${esc(unit(px))}</text><text class="lab" transform="translate(12,${(mt+H-mb)/2}) rotate(-90)" text-anchor="middle">predicted ${esc(label(py))}${esc(unit(py))}</text>`;
505
  return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img"><title>Predicted properties of every composition; dashed lines = target</title>${s}</svg>`;
506
  }
507
  function gauge(p, value, target){
@@ -761,8 +773,12 @@ function trainBox(){
761
  if(st){ const p=st.progress; const frac=p.epochs?p.epoch/p.epochs:0;
762
  b+=`<h4>${st.running?'Training':(st.error?'Failed':'Finished')}</h4><p class="small">epoch ${p.epoch}/${p.epochs}${p.loss!=null?' · loss '+fmt(p.loss):''} · ${st.seconds.toFixed(0)} s</p><div class="prog"><i style="width:${(100*frac).toFixed(1)}%"></i></div>`;
763
  if(st.error) b+=`<div class="verdict bad">${esc(st.error)}</div>`;
764
- if(p.curve&&p.curve.length>1) b+=`<div class="loss"><div>${lineChart({'total loss':p.curve.map(c=>[c[0],c[1]])},{log:true,xlabel:'epoch',title:'loss'})}</div><div>${lineChart({'alignment cos(z_c,z_p)':p.curve.map(c=>[c[0],c[2]])},{xlabel:'epoch',title:'alignment'})}</div></div>`;
765
- if(p.val&&p.val.mae){ b+=`<table><tr><th>validation (epoch ${p.val.epoch})</th>${Object.keys(p.val.mae).map(k=>`<th>${esc(k)}</th>`).join('')}<th>retrieval@1</th></tr><tr><td>MAE</td>${Object.values(p.val.mae).map(v=>`<td>${fmt(v)}</td>`).join('')}<td>${(100*p.val.retrieval_top1).toFixed(0)}%</td></tr></table>`; }
 
 
 
 
766
  if(st.done&&!st.error) b+=`<div class="verdict good">✅ Model ready — the blocks below now use <b>your</b> model and properties.${st.report?` <a href="/files/${esc(st.report)}" target="_blank">training report</a> · <a href="/files/${esc(st.report.replace('training_report.html','model.pt'))}" download="model.pt">download model.pt</a>`:''}</div>`; }
767
  return b;
768
  }
@@ -844,11 +860,11 @@ function panelTargets(){
844
  const ev=S.ev||evaluateSpace(); const props=S.state.model.properties; let b=`<p class="muted small">Each objective pulls the search; weights say how much. The chart and table update as you drag.</p>`;
845
  S.ui.objectives.forEach((o,i)=>{ const r=S.state.ranges[o.property]||[0,1]; const span=(r[1]-r[0])||1; const t=+S.ui.target[o.property];
846
  const vals=ev.rows.map(x=>x.p[o.property]); const okm=ev.rows.map(x=>x._ok);
847
- b+=`<div class="rule"><div class="hd"><b>${esc(label(o.property))}${esc(unit(o.property))}</b>${S.ui.objectives.length>1?`<button onclick="removeObjective(${i})">remove</button>`:''}</div>
848
  <div class="params"><div class="param"><label>target</label><input type="number" step="0.01" value="${t}" onchange="setTarget(${jsq(o.property)},this.value)"><br><input type="range" min="${(r[0]-.2*span).toFixed(3)}" max="${(r[1]+.2*span).toFixed(3)}" step="${(span/200).toFixed(4)}" value="${t}" oninput="setTarget(${jsq(o.property)},this.value,true)" onchange="render()" style="width:230px"></div>
849
  <div class="param"><label>how to compare</label><select onchange="S.ui.objectives[${i}].loss=this.value;S.ui.objectives[${i}].select_loss=this.value;recompute('targets',${jsq('Comparison for '+label(o.property)+': ')}+this.value);render()">${['l2','l1','at_most','at_least'].map(k=>`<option value="${k}" ${k===o.loss?'selected':''}>${{l2:'as close as possible (squared)',l1:'as close as possible (absolute)',at_most:'at most the target',at_least:'at least the target'}[k]}</option>`).join('')}</select></div>
850
  <div class="param"><label>weight in search</label><input type="number" step="100" value="${o.weight}" onchange="S.ui.objectives[${i}].weight=+this.value"></div><div class="param"><label>weight in ranking</label><input type="number" step="0.1" value="${o.select_weight}" onchange="S.ui.objectives[${i}].select_weight=+this.value;recompute('targets','Ranking weight changed');render()"></div></div>
851
- ${hist(vals, 'predicted '+label(o.property), 'predicted '+label(o.property)+unit(o.property)+' (grey = rejected by rules)', {marks:{target:t}, okMask:okm, bins:30})}
852
  ${(t<r[0]||t>r[1])?`<p class="flag small">⚠ ${fmt(t)} is outside the training range ${fmt(r[0])}–${fmt(r[1])}: the model would be extrapolating.</p>`:''}</div>`; });
853
  const more=(S.ui.extraTargets||[]).length;
854
  if(more) b+=`<p class="small muted">The search also runs ${more} more target${more>1?'s':''} (${S.ui.extraTargets.map(t=>esc(Object.entries(t).map(([k,v])=>label(k)+' '+fmt(v)).join(', '))).join(' · ')}); change ${more>1?'them':'it'} in <a href="#" onclick="openYaml();return false">✎ Edit as text</a>.</p>`;
@@ -871,7 +887,7 @@ function panelSearch(){
871
  if(STATIC) b+=`<div class="verdict warn">The static demo cannot run a search. Use the hosted Studio, or <code>pip install meidnet</code> then <code>meidnet studio</code>.</div>`;
872
  b+=`<div class="row"><button class="primary" ${S.search.running||STATIC?'disabled':''} onclick="startSearch()">▶ Run search</button><button class="danger" ${S.search.running?'':'disabled'} onclick="stopSearch()">■ Stop</button><button onclick="downloadYaml()">⬇ Export meidnet.yaml</button></div>`;
873
  if(st){ const p=st.progress; const frac=p.steps?((p.step+1)/p.steps):0;
874
- b+=`<h3>${st.running?'Running':(st.error?'Failed':'Finished')}</h3><p class="small">target ${p.target}/${p.targets} · round ${p.round}/${p.rounds} · step ${p.step}/${p.steps}${p.loss!==null&&p.loss!==undefined?' · loss '+fmt(p.loss):''} · ${st.seconds.toFixed(0)} s</p><div class="prog"><i style="width:${(100*frac).toFixed(1)}%"></i></div>`;
875
  if(st.error) b+=`<pre>${esc(st.error)}</pre>`;
876
  b+=`<details ${(S.logOpen??st.running)?'open':''} ontoggle="S.logOpen=this.open"><summary>log</summary><pre id="log">${esc(st.log.join('\n'))}</pre></details>`;
877
  if(st.report) b+=`<p><a href="/files/${esc(st.report)}" target="_blank">open the full generation report</a></p>`; }
@@ -901,7 +917,7 @@ function panelCandidates(){
901
  function renderLive(){
902
  const ev=S.ev; const L=$('live'); if(!ev || !S.fam){ L.innerHTML='<h2 style="font-size:17px">Live effects</h2><p class="muted small">…</p>'; return; } const top=ev.ranked;
903
  let b=`<h2 style="font-size:17px">Live effects</h2><div class="kpi side"><div><b>${nfmt(ev.rows.length)}</b><span><i style="background:var(--c-family)"></i>compositions</span></div><div><b>${nfmt(ev.alive.length)}</b><span><i style="background:var(--c-rules)"></i>pass all rules</span></div><div><b>${foundCands().length}</b><span><i style="background:var(--c-candidates)"></i>found by search</span></div></div>`;
904
- if(top.length){ b+=`<h4 style="color:var(--c-targets)">Predicted closest to target</h4><table><tr><th>#</th><th>composition</th>${S.ui.objectives.map(o=>`<th>${esc(label(o.property))}${esc(unit(o.property))}</th>`).join('')}</tr>`;
905
  top.slice(0,8).forEach(([s,r],i)=>{ b+=`<tr class="clk" onclick="inspect(${jsq(r.f)})"><td>${i+1}</td><td><b>${esc(r.f)}</b></td>${S.ui.objectives.map(o=>`<td>${fmt(r.p[o.property])}</td>`).join('')}</tr>`; }); b+='</table><p class="small muted">encoder prediction on the ideal prototype · click a row for the checklist and 3D cell</p>'; }
906
  else if(S.fam.space.columns.length) b+=`<p class="muted small">No composition passes all rules.</p>`;
907
  if(S.fam.space.sampled) b+=`<p class="flag small">⚠ family too large to enumerate fully; a random sample of ${nfmt(S.fam.space.rows.length)} of ${nfmt(S.fam.space.total)} compositions is shown.</p>`;
@@ -956,7 +972,9 @@ function drawerGeneration(){ const p=generationPayload(); const base=S.state.con
956
  return out; }
957
  async function startSearch(){ try{ const fam=S.fam; const r=await api('/api/search/start',{generation:generationPayload()});
958
  if(!r.ok){ renderChain([{block:'search', text:(r.busy?'server busy — ':'')+r.error}]); return; }
959
- S.search.fam=fam; S.search.running=true; S.logOpen=undefined; status('search','running'); nodeState('search','running'); flowFrom('targets'); poll(); render(); }catch(e){ renderChain([{block:'search', text:e.message}]); } }
 
 
960
  async function stopSearch(){ try{ await api('/api/search/stop',{}); }catch(e){} }
961
  function typing(){ const a=document.activeElement; return !!a && $('panel').contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName); }
962
  function lastLine(err){ return String(err||'').trim().split('\n').pop(); }
@@ -1113,13 +1131,18 @@ function inspect(formula){ const r=S.fam.space.rows.find(x=>x.f===formula); if(!
1113
  if(ru.name==='bond_window') w=`allowed ${esc(ru.params.low)}–${esc(ru.params.high)}`; else if(ru.params.min!==undefined||ru.params.max!==undefined) w=`allowed ${esc(ru.params.min??'…')}–${esc(ru.params.max??'…')}`;
1114
  b+=`<li><span class="${ok?'pass':'fail'}">${ok?'✓':'✗'}</span> ${esc(ruleTitle(k||ru.name, ru.params))}${v!==undefined&&v!==null&&ru.name!=='charge_neutrality'?' = <b>'+fmt(v)+'</b>':''}${ru.name==='property_window'?' = <b>'+fmt(r.p[ru.params.property])+'</b>':''} <span class="muted small">${w}</span><div class="small muted beginner-only">${esc(ruleText(k||ru.name))}</div></li>`; }
1115
  b+='</ul><p class="small muted">Descriptor values are computed by the same Python functions the search uses.</p><button onclick="closeModal()">close</button>';
1116
- $('modal-body').innerHTML=b; $('modal').classList.add('open'); mountAll3d(); }
1117
- function closeModal(){ $('modal').classList.remove('open'); }
 
 
 
1118
  document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeModal(); closeYaml(); }
1119
  const t=e.target.closest && e.target.closest('[data-show]'); // blocks and chain steps work from the keyboard too
1120
  if(t && (e.key==='Enter'||e.key===' ')){ e.preventDefault(); show(t.dataset.show); } });
1121
  window.addEventListener('resize', ()=>{ clearTimeout(window._rz); window._rz=setTimeout(mountAll3d, 200); });
1122
- boot().catch(e=>{ renderChain([{block:'data', text:'Could not start: '+e.message}]); console.error(e); });
 
 
1123
  </script>
1124
  </body>
1125
  </html>
 
105
  code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;background:var(--bg);padding:1px 4px;border-radius:4px}
106
  .behind{margin-top:12px}.behind .tabs{display:flex;gap:6px;margin:6px 0}.behind .tabs button.on{background:var(--accent);border-color:var(--accent);color:#fff}
107
  .prog{height:9px;background:var(--line);border-radius:5px;overflow:hidden;margin:6px 0}.prog i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s}
108
+ .prog i.indeterminate{width:35%;background:var(--c-family);animation:slide 1.4s ease-in-out infinite}
109
+ @keyframes slide{0%{margin-left:-35%}100%{margin-left:100%}}
110
+ @media (prefers-reduced-motion: reduce){.prog i.indeterminate{animation:none;width:100%;opacity:.5}}
111
  .cand{border:1px solid var(--line);border-radius:12px;padding:12px;margin:8px 0;display:grid;grid-template-columns:150px 1fr;gap:12px}
112
  .cand h3{margin:0 0 4px;color:var(--ink);font-size:18px}.cand .meta{grid-column:1/-1}@media(max-width:600px){.cand{grid-template-columns:1fr}}
113
  ul.chk{list-style:none;margin:6px 0;padding:0}ul.chk li{padding:1px 0;font-size:13px}
 
158
  <div class="chain" id="chain"><span class="lead">Change anything above or in a block — what it does to the blocks after it appears here.</span></div>
159
  <details id="flowlog"><summary>Flow log (0)</summary><ol id="flowlog-list"></ol></details>
160
  <div class="cols">
161
+ <div id="panel"><div class="card loading-card"><h2>Loading MEIDNet Studio…</h2><p class="muted">Fetching the model and every composition of the perovskite family. This takes a few seconds; on the hosted Studio the very first visit can take up to a minute while the server wakes up.</p><div class="prog"><i class="indeterminate"></i></div></div></div>
162
  <aside>
163
  <div class="card" id="live"></div>
164
  <div class="card small" id="legend"></div>
 
179
  <div class="row" style="margin-top:8px"><button class="primary" onclick="applyYaml()">Apply</button><button onclick="validateYaml()">Validate only</button><button onclick="downloadYaml()">⬇ Download meidnet.yaml</button><button onclick="openYaml()">Reset</button></div>
180
  <div id="yaml-msg"></div>
181
  </div>
182
+ <div class="modal" id="modal" onclick="if(event.target===this)closeModal()"><div class="card" id="modal-body" role="dialog" aria-modal="true" aria-label="Composition details"></div></div>
183
  <script>
184
  /* ═══════════════════════════ state & constants ═══════════════════════════ */
185
  const STATIC = window.MEIDNET_STATIC || null;
 
221
  /* a value as a JS string literal inside an onclick="…" attribute: safe for names such as "Young's modulus" */
222
  const jsq = s => esc(JSON.stringify(String(s??'')));
223
  const fmt = v => { if(v===null||v===undefined||Number.isNaN(+v)) return '–'; const x=+v;
224
+ if(Math.abs(x)>=1e6||(Math.abs(x)<0.01&&x!==0)) return x.toPrecision(2);
225
+ if(Math.abs(x)>=1000) return Math.round(x).toLocaleString('en-US'); // 1,500 rather than 1.5e+3
226
  let s=x.toPrecision(3); if(s.includes('.')&&!/e/i.test(s)) s=s.replace(/0+$/,'').replace(/\.$/,''); // 1.50→1.5, 100 stays 100
227
  return s; };
228
  const $ = id => document.getElementById(id);
 
367
  function ruleText(n){ if(n==='property_window') return 'The predicted value must lie inside the window.'; const r=(S.fam.space.rules||[]).find(x=>x.name===n); return r? r.text : ''; }
368
  function label(p){ const m=S.state.model.properties.find(x=>x.column===p); return m? m.label : p; }
369
  function unit(p){ const m=S.state.model.properties.find(x=>x.column===p); return m&&m.unit ? ' '+m.unit : ''; }
370
+ /* the unit in brackets after a property name: "Direct band gap (eV)" */
371
+ function unitP(p){ const u=unit(p).trim(); return u ? ' ('+u+')' : ''; }
372
+ /* a validation error in words, as the training report judges it (error / spread of the property in the data) */
373
+ function quality(col, mae){ const st=((S.data||{}).stats||{})[col]; const sd=st&&st.std;
374
+ if(!(sd>1e-12)) return {word:'—', css:'muted'}; const r=mae/sd;
375
+ return r<0.25 ? {word:'good', css:'pass'} : r<0.5 ? {word:'fair', css:'flag'} : {word:'weak', css:'fail'}; }
376
  /* a search result's rule, in words: property windows name their property (the result says "predicted dir_gap = …") */
377
  function resultTitle(r){ if(r.name==='symmetry_refinement') return 'Symmetry analysis';
378
  if(/^property_window/.test(r.name)){ const m=/predicted (.+?) =/.exec(r.detail||''); return 'Predicted '+(m?label(m[1]):'property')+' window'; }
 
422
  if(S.fam) status('family', `${S.fam.variant||S.fam.name}`, `${nfmt(ev?ev.rows.length:S.fam.space.rows.length)} compositions`);
423
  nodeState('family', ev&&ev.rows.length ? 'ok' : 'warn');
424
  if(ev){ status('rules', `${activeRules().length} rules`, `${nfmt(ev.alive.length)} pass all`); nodeState('rules', ev.alive.length ? 'ok' : 'warn'); }
425
+ { const full=S.ui.objectives.map(o=>`${label(o.property)} ${fmt(+S.ui.target[o.property])}${unit(o.property)}`).join(', ');
426
+ const short=S.ui.objectives.map(o=>`${o.property} ${fmt(+S.ui.target[o.property])}`).join(', '); // the node fits ~21 characters; the tooltip has the words
427
+ status('targets', full.length<=21 ? full : short, ev&&ev.ranked[0]?`best: ${ev.ranked[0][1].f}`:'no match', full); }
428
  nodeState('targets', outOfRange().length ? 'warn' : (ev&&ev.ranked.length ? 'ok' : 'warn'));
429
  const st=S.search.status; if(!st) { status('search','idle',''); nodeState('search','idle'); }
430
  else if(!st.running){ status('search', st.error?'failed':'finished', ''); nodeState('search', st.error?'error':'ok'); }
 
460
  <text class="s" id="st-${n.id}" x="${x+12}" y="${y+56}">…</text><text class="s" id="st2-${n.id}" x="${x+12}" y="${y+73}"></text></g>`; });
461
  $('pipe').innerHTML=s;
462
  }
463
+ function status(id, a, b, tip){ const fit=s=>{ s=String(s||''); return s.length>21 ? s.slice(0,20)+'…' : s; }; // ~21 characters fit a 130-px node at 11 px
464
+ const e=$('st-'+id); if(!e) return; e.textContent=fit(a); e.parentNode.querySelector('title').textContent = blockName(id)+' — '+String(tip||a||'')+(b?' · '+b:'');
465
  const e2=$('st2-'+id); if(e2) e2.textContent=fit(b); }
466
  function nodeState(id, st){ const b=$('bd-'+id); if(b) b.setAttribute('class','badge '+(st||'idle')); }
467
  function flowFrom(id){
 
513
  for(const [x,y,r] of pts){ if(!r._ok) s+=`<circle class="ptx" cx="${X(x)}" cy="${Y(y)}" r="2.4"><title>${esc(r.f)} (rejected)</title></circle>`; }
514
  for(const [x,y,r] of pts){ if(r._ok&&!hs.has(r.f)) s+=`<circle class="pt" cx="${X(x)}" cy="${Y(y)}" r="3" onclick="inspect(${jsq(r.f)})" style="cursor:pointer"><title>${esc(r.f)}: ${fmt(x)}, ${fmt(y)}</title></circle>`; }
515
  for(const [x,y,r] of pts){ if(hs.has(r.f)) s+=`<path class="star" d="M${X(x)},${Y(y)-8} l2.3,5.5 5.7,0 -4.6,3.5 2,5.5 -5.4,-3.4 -5.4,3.4 2,-5.5 -4.6,-3.5 5.7,0z" onclick="inspect(${jsq(r.f)})" style="cursor:pointer"><title>${esc(r.f)} (found by search)</title></path>`; }
516
+ s+=`<text class="lab" x="${(ml+W-mr)/2}" y="${H-4}" text-anchor="middle">predicted ${esc(label(px))}${esc(unitP(px))}</text><text class="lab" transform="translate(12,${(mt+H-mb)/2}) rotate(-90)" text-anchor="middle">predicted ${esc(label(py))}${esc(unitP(py))}</text>`;
517
  return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img"><title>Predicted properties of every composition; dashed lines = target</title>${s}</svg>`;
518
  }
519
  function gauge(p, value, target){
 
773
  if(st){ const p=st.progress; const frac=p.epochs?p.epoch/p.epochs:0;
774
  b+=`<h4>${st.running?'Training':(st.error?'Failed':'Finished')}</h4><p class="small">epoch ${p.epoch}/${p.epochs}${p.loss!=null?' · loss '+fmt(p.loss):''} · ${st.seconds.toFixed(0)} s</p><div class="prog"><i style="width:${(100*frac).toFixed(1)}%"></i></div>`;
775
  if(st.error) b+=`<div class="verdict bad">${esc(st.error)}</div>`;
776
+ if(p.curve&&p.curve.length>1) b+=`<div class="loss"><div>${lineChart({'training loss':p.curve.map(c=>[c[0],c[1]])},{log:true,xlabel:'epoch',title:'training loss (lower is better)'})}</div><div>${lineChart({'structure–property alignment':p.curve.map(c=>[c[0],c[2]])},{xlabel:'epoch',title:'alignment (higher is better)'})}</div></div>`;
777
+ if(p.val&&p.val.mae){ const q=Object.entries(p.val.mae).map(([k,v])=>quality(k,v));
778
+ b+=`<table><tr><th>held-out materials (epoch ${p.val.epoch})</th>${Object.keys(p.val.mae).map(k=>`<th>${esc(label(k)||k)}</th>`).join('')}<th title="how often a material's own properties are the closest match to its structure in the latent space">matched correctly</th></tr>
779
+ <tr><td>typical error (MAE)</td>${Object.entries(p.val.mae).map(([k,v])=>`<td>${fmt(v)}${esc(unit(k))}</td>`).join('')}<td>${(100*p.val.retrieval_top1).toFixed(0)}%</td></tr>
780
+ <tr><td>quality</td>${q.map(x=>`<td class="${x.css}">${x.word}</td>`).join('')}<td></td></tr></table>
781
+ <p class="small muted beginner-only">The typical error is compared with how much each property varies in your data: under 25% of that spread is <b>good</b>, under 50% <b>fair</b>, otherwise <b>weak</b>. "Matched correctly" is how often a material's structure and its properties land closest to each other in the model's hidden space (higher is better). A few epochs give a rough model; more epochs or more data usually help.</p>`; }
782
  if(st.done&&!st.error) b+=`<div class="verdict good">✅ Model ready — the blocks below now use <b>your</b> model and properties.${st.report?` <a href="/files/${esc(st.report)}" target="_blank">training report</a> · <a href="/files/${esc(st.report.replace('training_report.html','model.pt'))}" download="model.pt">download model.pt</a>`:''}</div>`; }
783
  return b;
784
  }
 
860
  const ev=S.ev||evaluateSpace(); const props=S.state.model.properties; let b=`<p class="muted small">Each objective pulls the search; weights say how much. The chart and table update as you drag.</p>`;
861
  S.ui.objectives.forEach((o,i)=>{ const r=S.state.ranges[o.property]||[0,1]; const span=(r[1]-r[0])||1; const t=+S.ui.target[o.property];
862
  const vals=ev.rows.map(x=>x.p[o.property]); const okm=ev.rows.map(x=>x._ok);
863
+ b+=`<div class="rule"><div class="hd"><b>${esc(label(o.property))}${esc(unitP(o.property))}</b>${S.ui.objectives.length>1?`<button onclick="removeObjective(${i})">remove</button>`:''}</div>
864
  <div class="params"><div class="param"><label>target</label><input type="number" step="0.01" value="${t}" onchange="setTarget(${jsq(o.property)},this.value)"><br><input type="range" min="${(r[0]-.2*span).toFixed(3)}" max="${(r[1]+.2*span).toFixed(3)}" step="${(span/200).toFixed(4)}" value="${t}" oninput="setTarget(${jsq(o.property)},this.value,true)" onchange="render()" style="width:230px"></div>
865
  <div class="param"><label>how to compare</label><select onchange="S.ui.objectives[${i}].loss=this.value;S.ui.objectives[${i}].select_loss=this.value;recompute('targets',${jsq('Comparison for '+label(o.property)+': ')}+this.value);render()">${['l2','l1','at_most','at_least'].map(k=>`<option value="${k}" ${k===o.loss?'selected':''}>${{l2:'as close as possible (squared)',l1:'as close as possible (absolute)',at_most:'at most the target',at_least:'at least the target'}[k]}</option>`).join('')}</select></div>
866
  <div class="param"><label>weight in search</label><input type="number" step="100" value="${o.weight}" onchange="S.ui.objectives[${i}].weight=+this.value"></div><div class="param"><label>weight in ranking</label><input type="number" step="0.1" value="${o.select_weight}" onchange="S.ui.objectives[${i}].select_weight=+this.value;recompute('targets','Ranking weight changed');render()"></div></div>
867
+ ${hist(vals, 'predicted '+label(o.property), 'predicted '+label(o.property)+unitP(o.property)+' · grey = rejected by the rules', {marks:{target:t}, okMask:okm, bins:30})}
868
  ${(t<r[0]||t>r[1])?`<p class="flag small">⚠ ${fmt(t)} is outside the training range ${fmt(r[0])}–${fmt(r[1])}: the model would be extrapolating.</p>`:''}</div>`; });
869
  const more=(S.ui.extraTargets||[]).length;
870
  if(more) b+=`<p class="small muted">The search also runs ${more} more target${more>1?'s':''} (${S.ui.extraTargets.map(t=>esc(Object.entries(t).map(([k,v])=>label(k)+' '+fmt(v)).join(', '))).join(' · ')}); change ${more>1?'them':'it'} in <a href="#" onclick="openYaml();return false">✎ Edit as text</a>.</p>`;
 
887
  if(STATIC) b+=`<div class="verdict warn">The static demo cannot run a search. Use the hosted Studio, or <code>pip install meidnet</code> then <code>meidnet studio</code>.</div>`;
888
  b+=`<div class="row"><button class="primary" ${S.search.running||STATIC?'disabled':''} onclick="startSearch()">▶ Run search</button><button class="danger" ${S.search.running?'':'disabled'} onclick="stopSearch()">■ Stop</button><button onclick="downloadYaml()">⬇ Export meidnet.yaml</button></div>`;
889
  if(st){ const p=st.progress; const frac=p.steps?((p.step+1)/p.steps):0;
890
+ b+=`<h3>${st.running?'Running':(st.error?'Failed':'Finished')}</h3><p class="small">target ${p.target}/${p.targets} · round ${p.round}/${p.rounds} · step ${p.step}/${p.steps}${p.loss!==null&&p.loss!==undefined?` · <span class="muted" title="the quantity the search minimises: lower is better; its size depends on the weights, so compare it only within one search">loss ${fmt(p.loss)}</span>`:''} · ${st.seconds.toFixed(0)} s</p><div class="prog"><i style="width:${(100*frac).toFixed(1)}%"></i></div>`;
891
  if(st.error) b+=`<pre>${esc(st.error)}</pre>`;
892
  b+=`<details ${(S.logOpen??st.running)?'open':''} ontoggle="S.logOpen=this.open"><summary>log</summary><pre id="log">${esc(st.log.join('\n'))}</pre></details>`;
893
  if(st.report) b+=`<p><a href="/files/${esc(st.report)}" target="_blank">open the full generation report</a></p>`; }
 
917
  function renderLive(){
918
  const ev=S.ev; const L=$('live'); if(!ev || !S.fam){ L.innerHTML='<h2 style="font-size:17px">Live effects</h2><p class="muted small">…</p>'; return; } const top=ev.ranked;
919
  let b=`<h2 style="font-size:17px">Live effects</h2><div class="kpi side"><div><b>${nfmt(ev.rows.length)}</b><span><i style="background:var(--c-family)"></i>compositions</span></div><div><b>${nfmt(ev.alive.length)}</b><span><i style="background:var(--c-rules)"></i>pass all rules</span></div><div><b>${foundCands().length}</b><span><i style="background:var(--c-candidates)"></i>found by search</span></div></div>`;
920
+ if(top.length){ b+=`<h4 style="color:var(--c-targets)">Predicted closest to target</h4><table><tr><th>#</th><th>composition</th>${S.ui.objectives.map(o=>`<th>${esc(label(o.property))}${esc(unitP(o.property))}</th>`).join('')}</tr>`;
921
  top.slice(0,8).forEach(([s,r],i)=>{ b+=`<tr class="clk" onclick="inspect(${jsq(r.f)})"><td>${i+1}</td><td><b>${esc(r.f)}</b></td>${S.ui.objectives.map(o=>`<td>${fmt(r.p[o.property])}</td>`).join('')}</tr>`; }); b+='</table><p class="small muted">encoder prediction on the ideal prototype · click a row for the checklist and 3D cell</p>'; }
922
  else if(S.fam.space.columns.length) b+=`<p class="muted small">No composition passes all rules.</p>`;
923
  if(S.fam.space.sampled) b+=`<p class="flag small">⚠ family too large to enumerate fully; a random sample of ${nfmt(S.fam.space.rows.length)} of ${nfmt(S.fam.space.total)} compositions is shown.</p>`;
 
972
  return out; }
973
  async function startSearch(){ try{ const fam=S.fam; const r=await api('/api/search/start',{generation:generationPayload()});
974
  if(!r.ok){ renderChain([{block:'search', text:(r.busy?'server busy — ':'')+r.error}]); return; }
975
+ S.search.fam=fam; S.search.running=true; S.logOpen=undefined; status('search','running'); nodeState('search','running'); flowFrom('targets');
976
+ const s=S.ui.search; renderChain([{block:'search', text:`started: up to ${s.rounds} round(s) of ${s.steps} steps with ${s.population} latents per target`},{block:'candidates', text:`each candidate appears here as soon as it is found (aim: ${s.per_target} per target)`}]);
977
+ poll(); render(); }catch(e){ renderChain([{block:'search', text:e.message}]); } }
978
  async function stopSearch(){ try{ await api('/api/search/stop',{}); }catch(e){} }
979
  function typing(){ const a=document.activeElement; return !!a && $('panel').contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName); }
980
  function lastLine(err){ return String(err||'').trim().split('\n').pop(); }
 
1131
  if(ru.name==='bond_window') w=`allowed ${esc(ru.params.low)}–${esc(ru.params.high)}`; else if(ru.params.min!==undefined||ru.params.max!==undefined) w=`allowed ${esc(ru.params.min??'…')}–${esc(ru.params.max??'…')}`;
1132
  b+=`<li><span class="${ok?'pass':'fail'}">${ok?'✓':'✗'}</span> ${esc(ruleTitle(k||ru.name, ru.params))}${v!==undefined&&v!==null&&ru.name!=='charge_neutrality'?' = <b>'+fmt(v)+'</b>':''}${ru.name==='property_window'?' = <b>'+fmt(r.p[ru.params.property])+'</b>':''} <span class="muted small">${w}</span><div class="small muted beginner-only">${esc(ruleText(k||ru.name))}</div></li>`; }
1133
  b+='</ul><p class="small muted">Descriptor values are computed by the same Python functions the search uses.</p><button onclick="closeModal()">close</button>';
1134
+ $('modal-body').innerHTML=b; S.modalReturn=document.activeElement; $('modal').classList.add('open'); mountAll3d();
1135
+ const first=$('modal-body').querySelector('button'); if(first) first.focus(); // keyboard users land in the window
1136
+ }
1137
+ function closeModal(){ const was=$('modal').classList.contains('open'); $('modal').classList.remove('open');
1138
+ if(was && S.modalReturn && document.body.contains(S.modalReturn)) S.modalReturn.focus(); }
1139
  document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeModal(); closeYaml(); }
1140
  const t=e.target.closest && e.target.closest('[data-show]'); // blocks and chain steps work from the keyboard too
1141
  if(t && (e.key==='Enter'||e.key===' ')){ e.preventDefault(); show(t.dataset.show); } });
1142
  window.addEventListener('resize', ()=>{ clearTimeout(window._rz); window._rz=setTimeout(mountAll3d, 200); });
1143
+ drawPipe(); // the workflow is visible at once; its numbers arrive with the data
1144
+ boot().catch(e=>{ renderChain([{block:'data', text:'Could not start: '+e.message}]); console.error(e);
1145
+ const lc=document.querySelector('.loading-card'); if(lc) lc.innerHTML=`<h2>The Studio could not start</h2><p>${esc(e.message)}</p><p class="muted">Reload the page in a minute. If it keeps failing, the server may be restarting or busy.</p>`; });
1146
  </script>
1147
  </body>
1148
  </html>
meidnet/svg.py CHANGED
@@ -42,8 +42,10 @@ def _ticks(lo, hi, n=5):
42
 
43
 
44
  def _fmt(v):
45
- if abs(v) >= 1000 or (abs(v) < 0.01 and v != 0):
46
  return f"{v:.2g}"
 
 
47
  return f"{v:.3g}"
48
 
49
 
 
42
 
43
 
44
  def _fmt(v):
45
+ if abs(v) >= 1e6 or (abs(v) < 0.01 and v != 0):
46
  return f"{v:.2g}"
47
+ if abs(v) >= 1000: # axis counts read 1,000 and 1,500 rather than 1e+03 and 1.5e+03
48
+ return f"{v:,.0f}"
49
  return f"{v:.3g}"
50
 
51
 
site/search/search_index.json CHANGED
The diff for this file is too large to render. See raw diff
 
site/sitemap.xml.gz CHANGED
Binary files a/site/sitemap.xml.gz and b/site/sitemap.xml.gz differ
 
site/studio.html CHANGED
The diff for this file is too large to render. See raw diff
 
site/understand/interpretability.html CHANGED
@@ -1703,8 +1703,11 @@
1703
  <li><strong>The checklist</strong> — every rule's measured value and allowed window. These are deterministic chemistry
1704
  (charges, radii, distances), not model outputs.</li>
1705
  <li><strong>The gauges</strong> — where the prediction sits relative to the target and to the training range.</li>
1706
- <li><strong>Warnings</strong> — <em>predicted value outside the training range</em> (extrapolation) and <em>latent far from training
1707
- latents</em> (the search pushed into a region the decoders never saw; predictions there are less reliable).</li>
 
 
 
1708
  <li><strong>Encoder vs search</strong> — in the Studio, compare the search's decoded prediction with the structure encoder's
1709
  prediction for the same composition. Large disagreement is a red flag.</li>
1710
  </ul>
 
1703
  <li><strong>The checklist</strong> — every rule's measured value and allowed window. These are deterministic chemistry
1704
  (charges, radii, distances), not model outputs.</li>
1705
  <li><strong>The gauges</strong> — where the prediction sits relative to the target and to the training range.</li>
1706
+ <li><strong>Warnings</strong> — <em>predicted value outside the training range</em> (extrapolation) and <em>latent hit the search limit</em>
1707
+ (the search pushed a latent to the <code>z_clip</code> bound; predictions there are less reliable). The model is trained
1708
+ on latents of length 1 and every search moves them further out (typically to length 3–5), so the report says
1709
+ this once for the whole run rather than on every card; each candidate's length is the <code>latent_norm</code> column of
1710
+ <code>candidates.csv</code>.</li>
1711
  <li><strong>Encoder vs search</strong> — in the Studio, compare the search's decoded prediction with the structure encoder's
1712
  prediction for the same composition. Large disagreement is a red flag.</li>
1713
  </ul>
site/use/reports.html CHANGED
@@ -1712,7 +1712,7 @@
1712
  </ul>
1713
  <h2 id="generation_reporthtml"><code>generation_report.html</code><a class="headerlink" href="#generation_reporthtml" title="Permanent link">&para;</a></h2>
1714
  <ul>
1715
- <li><strong>Verdict</strong>: how many candidates, and whether any rely on extrapolated predictions.</li>
1716
  <li>Per target: the <strong>funnel</strong> (compositions alive after each rule — the biggest drop is the rule that limits the
1717
  family most), the <strong>candidate cards</strong>, and <strong>examples of rejected compositions</strong> with the rule and the
1718
  measured value.</li>
 
1712
  </ul>
1713
  <h2 id="generation_reporthtml"><code>generation_report.html</code><a class="headerlink" href="#generation_reporthtml" title="Permanent link">&para;</a></h2>
1714
  <ul>
1715
+ <li><strong>Verdict</strong>: how many candidates, and whether any predicted value lies outside the training range.</li>
1716
  <li>Per target: the <strong>funnel</strong> (compositions alive after each rule — the biggest drop is the rule that limits the
1717
  family most), the <strong>candidate cards</strong>, and <strong>examples of rejected compositions</strong> with the rule and the
1718
  measured value.</li>
site/use/studio.html CHANGED
@@ -2056,7 +2056,10 @@ changed.</p>
2056
  (with examples), the distribution of each property and the elements present.</li>
2057
  <li><strong>Train</strong> in the Model block: choose the number of epochs and press ▶ Train. The loss and alignment curves
2058
  grow while it trains; at the end the validation errors appear and <strong>every block switches to your model and
2059
- your properties</strong> — the design space is re-scored, the targets are your properties.</li>
 
 
 
2060
  <li>Switch between <strong>your model</strong> and the <strong>published model</strong> at any time in the Model block. <em>Start over</em>
2061
  removes your upload and model from the session.</li>
2062
  </ol>
 
2056
  (with examples), the distribution of each property and the elements present.</li>
2057
  <li><strong>Train</strong> in the Model block: choose the number of epochs and press ▶ Train. The loss and alignment curves
2058
  grow while it trains; at the end the validation errors appear and <strong>every block switches to your model and
2059
+ your properties</strong> — the design space is re-scored, the targets are your properties. Each property's typical
2060
+ error (MAE) is judged in words against the spread (standard deviation) of that property in your data — <em>good</em> below a quarter of
2061
+ the spread, <em>fair</em> below half, <em>weak</em> above — the same rule the training report uses; "matched correctly" is
2062
+ how often a validation structure is matched to its own properties.</li>
2063
  <li>Switch between <strong>your model</strong> and the <strong>published model</strong> at any time in the Model block. <em>Start over</em>
2064
  removes your upload and model from the session.</li>
2065
  </ol>