File size: 20,267 Bytes
7c9e8d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/* ModelOps Research Lab interface | MIT code; dataset attribution: CC BY 4.0 */
(() => {
'use strict';
const E=globalThis.ModelOpsEngine;
const METHODS=[['nb','Multinomial Naive Bayes'],['word','Word TF-IDF centroid'],['char','Character TF-IDF centroid'],['hybrid','Hybrid word + character']];
const FILES=['README.md','index.html','styles.css','engine.js','app.js','train.json','iid_test.json','challenge.json','academic_rhetoric_intent.csv','challenge_predictions.csv','dataset_manifest.json','evaluation_results.json','LICENSE','DATA_LICENSE.md','DATA_CARD.md','ORIGINAL_DATASET_CARD.md','MODEL_CARD.md','METHODS.md','LIMITATIONS.md','SECURITY.md','REPRODUCE.md','DEPLOYMENT_ES.md','CHANGELOG.md','QA_REPORT.md','test_engine.js','benchmark_cli.js','test_browser.py','preflight.py'];
const state={train:[],iid:[],challenge:[],models:null,preds:{},metrics:{},history:[],experiment:null,ready:false};
const $=id=>document.getElementById(id);
const fmt=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:1}).format(n);
const pct=n=>`${(n*100).toFixed(1)}%`;
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const metricName={accuracy:'Accuracy',macroF1:'Macro F1'};
const getPred=(split,method)=>state.preds[split]?.[method]||[];
const getMetric=(split,method)=>state.metrics[split]?.[method];
const status=(message,error=false)=>{$('status').innerHTML=(error?'⚠ ':'<span class="pulse"></span> ')+esc(message);$('status').className=error?'bad':'';};
function loadHistory(){try{const raw=JSON.parse(localStorage.getItem('modelops-v2-history')||'[]');state.history=Array.isArray(raw)?raw.slice(0,15):[];}catch{state.history=[];}}
function saveHistory(){try{localStorage.setItem('modelops-v2-history',JSON.stringify(state.history.slice(0,15)));}catch{status('Local storage unavailable. Runs can still be downloaded.',true);}}
function populateModels(){for(const id of ['confModel','sliceModel','errorModel','sampleModel'])$(id).innerHTML=METHODS.map(([k,name])=>`<option value="${k}">${esc(name)}</option>`).join('');$('confModel').value='hybrid';$('sliceModel').value='hybrid';$('errorModel').value='hybrid';$('sampleModel').value='hybrid';}
function setTab(name){document.querySelectorAll('.tab').forEach(b=>{const active=b.dataset.tab===name;b.classList.toggle('active',active);if(active)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');});document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===name));if(name==='errors')renderErrors();if(name==='audit')renderAudit();if(name==='exports')renderExports();}
function table(columns,rows){return `<table class="table"><thead><tr>${columns.map(c=>`<th${c.right?' class="number"':''}>${esc(c.title)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map((v,i)=>`<td${columns[i]?.right?' class="number"':''}>${v}</td>`).join('')}</tr>`).join('')}</tbody></table>`;}
function bar(name,value,alt=false){const n=Math.max(0,Math.min(1,value));return `<div class="bar-row"><div class="bar-name">${esc(name)}</div><div class="bar-track"><div class="bar-fill ${alt?'alt':''}" style="width:${(n*100).toFixed(2)}%"></div></div><div class="bar-value">${pct(value)}</div></div>`;}
function renderOverview(){if(!state.ready)return;const split=$('overviewSplit').value,metric=$('metricSelect').value;const entries=METHODS.map(([id,name])=>({id,name,stats:getMetric(split,id)})).sort((a,b)=>b.stats.accuracy-a.stats.accuracy);const best=entries[0], other=getMetric(split==='iid'?'challenge':'iid',best.id);const total=split==='iid'?130:54;
 $('kpis').innerHTML=[['Evaluation rows',String(total),'Fixed holdout'],['Baselines','4','Trained in browser'],['Best Accuracy',pct(best.stats.accuracy),best.name],['Best Macro F1',pct(Math.max(...entries.map(e=>e.stats.macroF1))),'Six rhetorical labels']].map(([a,b,c])=>`<div class="kpi"><small>${esc(a)}</small><strong>${esc(b)}</strong><em>${esc(c)}</em></div>`).join('');
 const ranked=[...entries].sort((a,b)=>b.stats[metric]-a.stats[metric]);$('leaderboard').innerHTML=ranked.map((e,i)=>bar(e.name,e.stats[metric],i%2===1)).join('');
 const gap=getMetric('iid',best.id).accuracy-getMetric('challenge',best.id).accuracy;
 $('generalization').innerHTML=`<div class="insight-number ${gap>=.15?'gap-warn':'good'}">${(gap*100).toFixed(1)} percentage points</div><p>IID-to-challenge Accuracy gap for <strong>${esc(best.name)}</strong>.</p>${bar('Controlled IID',getMetric('iid',best.id).accuracy)}${bar('Novel challenge',getMetric('challenge',best.id).accuracy,true)}<p class="fine">The IID set and novel challenge differ by construction. This difference cannot be interpreted as a population-level causal or fairness estimate.</p>`;
 const model=$('confModel').value;const conf=getMetric(split,model).confusion;const max=Math.max(1,...E.LABELS.map(a=>Math.max(...E.LABELS.map(b=>conf[a][b]))));
 $('confusion').innerHTML=`<table class="table matrix"><thead><tr><th>True ↓ / Pred →</th>${E.LABELS.map(x=>`<th>${esc(x.slice(0,5))}</th>`).join('')}</tr></thead><tbody>${E.LABELS.map(a=>`<tr><th>${esc(a)}</th>${E.LABELS.map(b=>`<td class="matrixcell" style="--strength:${(conf[a][b]/max).toFixed(3)}" title="True ${a}, predicted ${b}: ${conf[a][b]}">${conf[a][b]}</td>`).join('')}</tr>`).join('')}</tbody></table><p class="fine">Counts of the fixed ${esc(split)} evaluation split. Matrix rows are true classes, columns are model predictions.</p>`;
 const parts=[['842 training examples',842,'train'],['130 IID test',130,'iid'],['54 novel-pattern',54,'challenge']];$('partitionBars').innerHTML=`<div class="split-bar">${parts.map(([label,n,k])=>`<div class="split-part split-${k}" style="width:${n/1026*100}%">${n>=100?n:''}</div>`).join('')}</div><div class="legend">${parts.map(([label,n,k])=>`<span><i style="background:${{train:'#68e0c6',iid:'#86bbff',challenge:'#b8a5ff'}[k]}"></i>${esc(label)}</span>`).join('')}</div><div class="test-detail"><strong>Data provenance</strong><p>Original synthetic-curated dataset: Academic Rhetorical Intent Mini v1.1. Fixed partitions are bundled in the Space. Dataset licensed CC BY 4.0.</p></div>`;
}
function renderExperiments(){const e=state.experiment;if(e){$('experimentOutput').innerHTML=`<div class="cardhead"><div><div class="overline">LATEST REPRODUCIBLE RUN</div><h3>${esc(e.cohort)} · seed ${e.seed} · ${e.reps} resamples</h3></div><span class="pill">Completed</span></div><div class="h-scroll">${table([{title:'Baseline'},{title:'Accuracy',right:true},{title:'Macro F1',right:true},{title:'Sample 95% interval',right:true}],[...e.methods].sort((a,b)=>b.accuracy-a.accuracy).map(m=>[esc(m.name),pct(m.accuracy),pct(m.macroF1),pct(m.ci.low)+' to '+pct(m.ci.high)]))}</div><p class="fine">This confidence interval is a simple nonparametric bootstrap across labeled evaluation rows and does not account for training-data variability, domain selection or label noise.</p>`;}
 $('historyList').innerHTML=state.history.length?table([{title:'Saved run'},{title:'Dataset'},{title:'Best method'},{title:'Best accuracy',right:true}],state.history.map(r=>[esc(r.timestamp.replace('T',' ').slice(0,16)),esc(r.cohort),esc(r.bestName),pct(r.bestAccuracy)])):'<p class="muted">No experiments saved yet. Run the four baselines above to add one.</p>';
 $('lastRun').textContent=state.history.length?'Last saved: '+state.history[0].timestamp.replace('T',' ').slice(0,16):'No saved experiment yet';
}
function runExperiment(){if(!state.ready)return;const split=$('runSplit').value,seed=Number($('runSeed').value),reps=Number($('runReps').value);if(!Number.isSafeInteger(seed)||seed<1||seed>999999999){status('Seed must be a whole number from 1 to 999999999.',true);return;}const btn=$('runButton');btn.disabled=true;btn.textContent='Running…';try{const methods=METHODS.map(([id,name])=>{const stats=getMetric(split,id),ci=E.bootstrapAccuracy(getPred(split,id),seed,reps);return {id,name,accuracy:stats.accuracy,macroF1:stats.macroF1,ci};});const best=[...methods].sort((a,b)=>b.accuracy-a.accuracy)[0];const run={timestamp:new Date().toISOString(),version:E.version(),dataVersion:'academic-rhetorical-intent-mini-v1.1',cohort:split,seed,reps,methods,bestName:best.name,bestAccuracy:best.accuracy};state.experiment=run;state.history.unshift({timestamp:run.timestamp,cohort:split,bestName:best.name,bestAccuracy:best.accuracy,seed,reps});state.history=state.history.slice(0,15);saveHistory();renderExperiments();status('Experiment complete. Four reproducible baselines evaluated.');}catch(e){status('Experiment failed: '+e.message,true);}finally{btn.disabled=false;btn.textContent='Run four baselines';}}
function renderSlices(){if(!state.ready)return;const method=$('sliceModel').value,split=$('sliceSplit').value,preds=getPred(split,method);const langs=E.summarizeSlices(preds,'language'),domains=E.summarizeSlices(preds,'domain'),base=getMetric(split,method);
 const sliceTable=items=>table([{title:'Slice'},{title:'n',right:true},{title:'Accuracy',right:true},{title:'Macro F1',right:true}],items.map(g=>[esc(g.name),g.n,pct(g.accuracy),pct(g.macroF1)]));$('languageSlices').innerHTML=sliceTable(langs);$('domainSlices').innerHTML=sliceTable(domains);$('labelSlices').innerHTML=table([{title:'Rhetorical function'},{title:'Support',right:true},{title:'Precision',right:true},{title:'Recall',right:true},{title:'F1',right:true}],E.LABELS.map(l=>[esc(l),base.byLabel[l].support,pct(base.byLabel[l].precision),pct(base.byLabel[l].recall),pct(base.byLabel[l].f1)]));}
function renderErrors(){if(!state.ready)return;const method=$('errorModel').value,split=$('errorSplit').value,q=String($('errorSearch').value||'').toLowerCase();const preds=getPred(split,method);const fails=preds.filter(r=>r.pred!==r.truth);const filtered=fails.filter(r=>(r.text+' '+r.truth+' '+r.pred+' '+r.language).toLowerCase().includes(q));$('errorsList').innerHTML=`<p class="fine"><strong>${fails.length} errors in ${preds.length} examples.</strong> Showing ${Math.min(filtered.length,20)} of ${filtered.length} matching errors. Full predictions are available under Export & governance.</p>`+(filtered.length?filtered.slice(0,20).map(r=>`<details class="test-detail"><summary>${esc(r.id)} <span class="badge badge-error">${esc(r.truth)} → ${esc(r.pred)}</span> <span class="badge">${esc(r.language)}</span></summary><p>${esc(r.text)}</p><small>Domain: ${esc(r.domain)} · Original annotation: ${esc(r.truth)} · Predicted: ${esc(r.pred)}</small></details>`).join(''):'<p class="muted">No errors match this search.</p>');}
function classify(){if(!state.ready)return;const text=$('sampleText').value.trim();if(!text){$('sampleOutput').innerHTML='<p class="bad">Enter a sentence to classify.</p>';return;}const method=$('sampleModel').value,p=E.predict(state.models,text,method);const ranked=E.LABELS.map((label,i)=>({label,score:p.scores[i]})).sort((a,b)=>b.score-a.score);const raw=ranked[0].score;$('sampleOutput').innerHTML=`<div class="report"><div class="overline">PREDICTED RHETORICAL FUNCTION</div><h3>${esc(p.label)}</h3><p>Model: ${esc(METHODS.find(m=>m[0]===method)?.[1])}</p><p class="fine">Uncalibrated score margin: ${p.margin.toFixed(3)}. Higher margins are not guaranteed to mean higher correctness.</p>${ranked.map((m,i)=>bar(m.label,method==='nb'?m.score:Math.max(0,m.score),i%2===1)).join('')}</div>`;}
function renderShift(){if(!state.ready)return;const rows=METHODS.map(([id,name])=>{const a=getMetric('iid',id),b=getMetric('challenge',id);return [esc(name),pct(a.accuracy),pct(b.accuracy),`<strong class="${a.accuracy-b.accuracy>.2?'gap-warn':''}">${((a.accuracy-b.accuracy)*100).toFixed(1)} pp</strong>`,pct(b.macroF1)];});$('shiftGrid').innerHTML=table([{title:'Baseline'},{title:'Controlled IID',right:true},{title:'Novel-pattern',right:true},{title:'Accuracy gap',right:true},{title:'Challenge Macro F1',right:true}],rows)+'<p class="fine">The 54-row challenge set is fixed and was not used in training or hyperparameter optimization. The four baselines share the same training data and held-out examples.</p>';}
function countBy(rows,k){const o={};for(const r of rows)o[String(r[k]||'unknown')]=(o[String(r[k]||'unknown')]||0)+1;return o;}
function renderAudit(){if(!state.ready)return;const a1=E.audit(state.train,state.iid),a2=E.audit(state.train,state.challenge);const labels=countBy([...state.train,...state.iid,...state.challenge],'label');const langs=countBy([...state.train,...state.iid,...state.challenge],'language');const domain=countBy([...state.train,...state.iid,...state.challenge],'domain');$('auditSummary').innerHTML=[['Training',842,'fixed source split'],['IID',130,'controlled holdout'],['Challenge',54,'unseen patterns'],['Label classes',6,'balanced overall']].map(([name,n,foot])=>`<div class="kpi"><small>${name}</small><strong>${n}</strong><em>${foot}</em></div>`).join('');$('datasetComposition').innerHTML=`<h4>Languages</h4>${Object.entries(langs).map(([k,v])=>bar(k,v/1026)).join('')}<h4>Classes</h4>${Object.entries(labels).map(([k,v])=>bar(k,v/1026)).join('')}<h4>Domains</h4>${table([{title:'Domain'},{title:'Rows',right:true}],Object.entries(domain).sort((a,b)=>b[1]-a[1]).map(([k,v])=>[esc(k),v]))}</h4>`;
 $('leakageChecks').innerHTML=[['IID exact-text overlap',a1.exactTextOverlap],['Challenge exact-text overlap',a2.exactTextOverlap],['IID duplicate IDs',a1.duplicateIds],['Challenge duplicate IDs',a2.duplicateIds],['Invalid IID labels/text',a1.invalidRows],['Invalid challenge labels/text',a2.invalidRows]].map(([name,val])=>`<div class="test-detail">${val===0?'<span class="badge badge-success">PASS</span>':'<span class="badge badge-error">REVIEW</span>'} <strong>${esc(name)}</strong> <span style="float:right">${val}</span></div>`).join('')+'<p class="fine">This is an exact-text / ID audit only. It does not detect paraphrase-template overlap, semantic leakage or mislabeling.</p>';}
function renderExports(){$('fileInventory').innerHTML=FILES.map(n=>`<span>▤ ${esc(n)}</span>`).join('');}
function download(content,name,mime){const blob=new Blob([content],{type:mime});const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=name;link.style.display='none';document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1200);status('Downloaded '+name+'.');}
function exportMetrics(){const payload={project:'ModelOps Research Lab',version:E.version(),dataset:'Academic Rhetorical Intent Mini v1.1',source_type:'synthetic_curated',train_rows:842,test_rows:130,challenge_rows:54,generated_utc:new Date().toISOString(),methods:Object.fromEntries(METHODS.map(([id,name])=>[id,{name,iid:{accuracy:getMetric('iid',id).accuracy,macroF1:getMetric('iid',id).macroF1},challenge:{accuracy:getMetric('challenge',id).accuracy,macroF1:getMetric('challenge',id).macroF1}}])),method_note:'Evaluation metrics on fixed original synthetic-curated partitions. Not external performance evidence.',latest_experiment:state.experiment};download(JSON.stringify(payload,null,2),'modelops_metrics.json','application/json');}
function exportPredictions(){const split=$('overviewSplit').value;const rows=[];for(const [id,name] of METHODS){for(const p of getPred(split,id))rows.push({method:name,split,id:p.id,language:p.language,domain:p.domain,true_label:p.truth,predicted_label:p.pred,correct:p.truth===p.pred,text:p.text});}download(E.toCSV(rows,['method','split','id','language','domain','true_label','predicted_label','correct','text']),'modelops_predictions_'+split+'.csv','text/csv;charset=utf-8');}
function exportReport(){const now=new Date().toISOString();let s=`# ModelOps Research Lab: Evaluation report\n\nGenerated UTC: ${now}\n\n## Research artifact\n- Dataset: Academic Rhetorical Intent Mini v1.1 (synthetic-curated), CC BY 4.0.\n- Code license: MIT.\n- Train: 842, controlled IID: 130, fixed challenge: 54.\n- Models are trained and evaluated locally in a browser.\n\n## Baseline comparisons\n\n| Model | IID accuracy | IID Macro F1 | Challenge accuracy | Challenge Macro F1 |\n|---|---:|---:|---:|---:|\n`;for(const [id,name] of METHODS)s+=`| ${name} | ${pct(getMetric('iid',id).accuracy)} | ${pct(getMetric('iid',id).macroF1)} | ${pct(getMetric('challenge',id).accuracy)} | ${pct(getMetric('challenge',id).macroF1)} |\n`;s+='\n## Methodology\nFour deterministic lexical baselines: word Multinomial Naive Bayes (Laplace smoothing), word TF-IDF class centroids, character trigram TF-IDF centroids, and a 55/45 weighted word/character hybrid. The split is fixed. Accuracy and macro F1 are computed against held-out labels. Any bootstrap interval describes synthetic sample variation only.\n\n## Limitations\nThis dataset consists of synthetic-curated academic-style sentences, not representative scholarly submissions. High IID metrics can reflect recurring templates. The fixed challenge is harder but not independent real-world validation. Training or hyperparameter selection was not performed on the challenge set. Do not use the application for grading, admission, employment or other consequential individual assessments.\n\n## External model evidence\nA distinct archived Python model from the Academic Rhetoric Classifier project reported 35.7% accuracy on a 28-sentence biomedical development smoke test. This is neither a production benchmark nor a direct comparison to the four browser baselines.\n';if(state.experiment)s+=`\n## Latest recorded experiment\nSplit: ${state.experiment.cohort}\nSeed: ${state.experiment.seed}\nBootstrap repetitions: ${state.experiment.reps}\n`;download(s,'modelops_research_report.md','text/markdown;charset=utf-8');}
async function loadData(){if(!E)throw Error('Evaluation engine not loaded');const files=['train.json','iid_test.json','challenge.json'];const response=await Promise.all(files.map(async file=>{const r=await fetch('./'+file,{cache:'no-store'});if(!r.ok)throw Error(file+' returned '+r.status);return r.json();}));[state.train,state.iid,state.challenge]=response;if(state.train.length!==842||state.iid.length!==130||state.challenge.length!==54)throw Error('Dataset partition size mismatch');for(const rows of response)for(const row of rows)if(!E.LABELS.includes(row.label)||!row.id||!row.text)throw Error('Invalid source row');state.models=E.train(state.train);for(const split of ['iid','challenge']){state.preds[split]={};state.metrics[split]={};for(const [id]of METHODS){state.preds[split][id]=E.evaluate(state.models,state[split],id);state.metrics[split][id]=E.evaluation(state.preds[split][id]);}}state.ready=true;}
function wire(){document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',()=>setTab(btn.dataset.tab)));for(const id of ['overviewSplit','metricSelect','confModel'])$(id).addEventListener('change',renderOverview);for(const id of ['sliceModel','sliceSplit'])$(id).addEventListener('change',renderSlices);for(const id of ['errorModel','errorSplit','errorSearch'])$(id).addEventListener(id==='errorSearch'?'input':'change',renderErrors);$('runButton').addEventListener('click',runExperiment);$('classifyButton').addEventListener('click',classify);$('downloadPred').addEventListener('click',exportPredictions);$('downloadMetrics').addEventListener('click',exportMetrics);$('downloadReport').addEventListener('click',exportReport);$('downloadRuns').addEventListener('click',()=>download(JSON.stringify({project:'ModelOps Research Lab',version:E.version(),runs:state.history},null,2),'modelops_run_history.json','application/json'));$('clearHistory').addEventListener('click',()=>{if(!confirm('Clear locally saved experiment summaries? This does not change project source files.'))return;state.history=[];saveHistory();renderExperiments();status('Local experiment history cleared.');});}
async function boot(){loadHistory();populateModels();wire();try{await loadData();renderOverview();renderSlices();renderErrors();renderShift();renderAudit();renderExperiments();renderExports();status('Ready. Four models trained locally on 842 examples; both holdouts evaluated.');}catch(e){status('Could not initialize: '+e.message+'. Check that all release files were uploaded together.',true);console.error(e);}}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot);else boot();
})();