/* ModelOps Research Lab | zero-dependency reproducible text-classification baselines. MIT. */ (function(root, factory){const obj=factory();if(typeof module==='object'&&module.exports)module.exports=obj;root.ModelOpsEngine=obj;})(typeof globalThis!=='undefined'?globalThis:this,function(){ 'use strict'; const LABELS=['background','objective','method','result','limitation','implication']; function clean(s){return String(s||'').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'');} function tokens(s){return (clean(s).match(/[a-z0-9]+/g)||[]).filter(t=>t.length>1);} function features(s,kind){let items;if(kind==='char'){const t=' '+clean(s).replace(/[^a-z0-9 ]/g,' ').replace(/\s+/g,' ').trim()+' ';items=[];for(let i=0;i1)items.push(x);}}else items=tokens(s); const out=new Map();for(const x of items)out.set(x,(out.get(x)||0)+1);return out;} function cosine(a,b){let d=0;const sm=a.size[x,new Map()]));const n=Object.fromEntries(LABELS.map(x=>[x,0]));for(let i=0;icosine(norm,model.profiles[label]));} function trainNB(rows,alpha=1){const word=new Map(),tot=new Map(),prior=new Map(),vocab=new Set();for(const label of LABELS){word.set(label,new Map());tot.set(label,0);prior.set(label,0);}for(const r of rows){prior.set(r.label,prior.get(r.label)+1);const map=word.get(r.label);for(const [token,n] of features(r.text,'word')){map.set(token,(map.get(token)||0)+n);tot.set(r.label,tot.get(r.label)+n);vocab.add(token);}}return {type:'Multinomial Naive Bayes',word,tot,prior,vocab,alpha,n:rows.length};} function scoreNB(model,s){const f=features(s,'word');const scores=LABELS.map(label=>{let v=Math.log((model.prior.get(label)+1)/(model.n+LABELS.length));const map=model.word.get(label);const denom=model.tot.get(label)+model.alpha*(model.vocab.size+1);for(const [token,n] of f){if(!model.vocab.has(token))continue;v+=n*Math.log(((map.get(token)||0)+model.alpha)/denom);}return v;});const max=Math.max(...scores);const exp=scores.map(x=>Math.exp(x-max)),z=exp.reduce((a,b)=>a+b,0);return exp.map(x=>x/z);} function argmax(s){let m=0;for(let j=1;js[m])m=j;return m;} function train(rows){if(!Array.isArray(rows)||rows.length===0)throw Error('Training data is empty');for(const r of rows)if(!LABELS.includes(r.label)||!String(r.text||'').trim())throw Error('Training row has invalid label or empty text');return {nb:trainNB(rows),word:trainCentroid(rows,'word'),char:trainCentroid(rows,'char'),trainedRows:rows.length};} function predict(bundle,text,method){const word=scoreCentroid(bundle.word,text),char=scoreCentroid(bundle.char,text);let scores;if(method==='nb')scores=scoreNB(bundle.nb,text);else if(method==='word')scores=word;else if(method==='char')scores=char;else if(method==='hybrid')scores=word.map((v,i)=>.55*v+.45*char[i]);else throw Error('Invalid model '+method);const index=argmax(scores);return {label:LABELS[index],scores,margin:[...scores].sort((a,b)=>b-a)[0]-[...scores].sort((a,b)=>b-a)[1]};} function safeDivide(a,b){return b?a/b:0;} function evaluation(predictions){const n=predictions.length;if(!n)throw Error('No rows to evaluate');let correct=0,macro=0,withSupport=0;const confusion=Object.fromEntries(LABELS.map(a=>[a,Object.fromEntries(LABELS.map(b=>[b,0]))]));for(const r of predictions){if(!LABELS.includes(r.truth)||!LABELS.includes(r.pred))throw Error('Invalid evaluation label');confusion[r.truth][r.pred]++;if(r.pred===r.truth)correct++;}const byLabel={};for(const l of LABELS){const tp=confusion[l][l],fn=LABELS.reduce((s,b)=>s+confusion[l][b],0)-tp,fp=LABELS.reduce((s,a)=>s+confusion[a][l],0)-tp,p=safeDivide(tp,tp+fp),recall=safeDivide(tp,tp+fn),f1=safeDivide(2*p*recall,p+recall);byLabel[l]={precision:p,recall,f1,support:tp+fn};if(tp+fn>0){macro+=f1;withSupport++;}}return {n,correct,accuracy:correct/n,macroF1:macro/withSupport,byLabel,confusion};} function evaluate(bundle,rows,method){return rows.map(r=>({id:r.id,language:r.language,domain:r.domain,truth:r.label,text:r.text,pred:predict(bundle,r.text,method).label}));} function summarizeSlices(predictions,key){const groups={};for(const p of predictions){const k=String(p[key]||'unknown');(groups[k]??=[]).push(p);}return Object.entries(groups).sort((a,b)=>a[0].localeCompare(b[0])).map(([name,records])=>({name,...evaluation(records)}));} function seeded(seed){let t=(Number(seed)||1)>>>0;return function(){t+=0x6D2B79F5;let x=t;x=Math.imul(x^(x>>>15),x|1);x^=x+Math.imul(x^(x>>>7),x|61);return((x^(x>>>14))>>>0)/4294967296;};} function bootstrapAccuracy(predictions,seed=1234,reps=300){if(!predictions.length)throw Error('No predictions');if(reps<10||reps>5000)throw Error('Invalid repetitions');const rnd=seeded(seed),out=[];for(let i=0;ia-b);return {low:out[Math.floor(.025*(reps-1))],high:out[Math.ceil(.975*(reps-1))],reps};} function audit(trainRows,heldoutRows){const seen=new Set(trainRows.map(x=>clean(x.text).replace(/\s+/g,' ').trim()));const idSeen=new Set(trainRows.map(x=>x.id));let overlap=0,dupIds=0,empty=0;for(const r of heldoutRows){if(seen.has(clean(r.text).replace(/\s+/g,' ').trim()))overlap++;if(idSeen.has(r.id))dupIds++;if(!r.text||!LABELS.includes(r.label))empty++;}return {train:trainRows.length,evaluation:heldoutRows.length,exactTextOverlap:overlap,duplicateIds:dupIds,invalidRows:empty,clean:overlap===0&&dupIds===0&&empty===0};} function compareRuns(a,b){const x=new Map(a.map(p=>[p.id,p]));let changed=0,bWins=0,aWins=0;for(const row of b){const old=x.get(row.id);if(!old)continue;if(old.pred!==row.pred)changed++;if(old.pred!==old.truth&&row.pred===row.truth)bWins++;if(old.pred===old.truth&&row.pred!==row.truth)aWins++;}return {changed,bWins,aWins};} function csvCell(x){const s=String(x??'');return /[",\r\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;} function toCSV(rows,fields){return [fields.join(','),...rows.map(r=>fields.map(k=>csvCell(r[k])).join(','))].join('\r\n');} function version(){return '2.0.0';} return {LABELS,features,train,predict,evaluation,evaluate,summarizeSlices,bootstrapAccuracy,audit,compareRuns,toCSV,version,seeded}; });