/** PrismAudit analytical core. Pure functions; no network, no dependencies. MIT. */ export const VERSION = '1.0.0'; export const MAX_ROWS = 10000; export const REQUIRED_COLUMNS = ['score', 'label', 'group']; export const DEFAULT_THRESHOLD = 0.50; export function parseCSV(csv) { if (typeof csv !== 'string') throw new Error('Expected a CSV text file.'); csv = csv.replace(/^\uFEFF/, ''); const table = []; let row = [], val = '', quoted = false; for (let i = 0; i < csv.length; i++) { const c = csv[i]; if (quoted) { if (c === '"' && csv[i+1] === '"') { val += '"'; i++; } else if (c === '"') quoted = false; else val += c; } else if (c === '"') { if (val.trim() !== '') throw new Error('Unexpected quote in CSV field.'); quoted = true; } else if (c === ',') { row.push(val); val = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && csv[i+1] === '\n') i++; row.push(val); val = ''; if (row.some(v => v.trim().length)) table.push(row); row = []; if (table.length > MAX_ROWS + 1) throw new Error(`Maximum ${MAX_ROWS.toLocaleString()} data rows allowed.`); } else { val += c; } } if (quoted) throw new Error('Unclosed quoted field in CSV.'); row.push(val); if (row.some(v => v.trim().length)) table.push(row); if (table.length < 2) throw new Error('CSV must contain a header and at least one data row.'); const headers = table.shift().map(s => s.trim().toLowerCase()); if (new Set(headers).size !== headers.length) throw new Error('CSV has duplicate column names.'); for (const h of REQUIRED_COLUMNS) if (!headers.includes(h)) throw new Error(`Missing required column: ${h}.`); const indices = Object.fromEntries(headers.map((h, i) => [h, i])); if (table.length > MAX_ROWS) throw new Error(`Maximum ${MAX_ROWS.toLocaleString()} rows allowed.`); const rows = table.map((fields, i) => { if (fields.length !== headers.length) throw new Error(`Row ${i + 2}: expected ${headers.length} columns, got ${fields.length}.`); const rawScore = fields[indices.score].trim(), rawLabel = fields[indices.label].trim(); if (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(rawScore)) throw new Error(`Row ${i + 2}: score must be between 0 and 1.`); if (!/^[01]$/.test(rawLabel)) throw new Error(`Row ${i + 2}: label must be exactly 0 or 1.`); const group = fields[indices.group].trim(); if (!group || group.length > 72) throw new Error(`Row ${i + 2}: group is empty or exceeds 72 characters.`); const id = indices.id === undefined ? String(i + 1) : fields[indices.id].trim().slice(0,120); return { id, score: Number(rawScore), label: Number(rawLabel), group }; }); return validateRows(rows); } export function validateRows(rows) { if (!Array.isArray(rows) || rows.length < 1 || rows.length > MAX_ROWS) throw new Error(`Use 1 to ${MAX_ROWS.toLocaleString()} rows.`); const groupCount = new Set(); for (const [i, r] of rows.entries()) { if (!Number.isFinite(r.score) || r.score < 0 || r.score > 1) throw new Error(`Row ${i + 1}: invalid score.`); if (r.label !== 0 && r.label !== 1) throw new Error(`Row ${i + 1}: label must be 0 or 1.`); if (typeof r.group !== 'string' || !r.group.trim() || r.group.length > 72) throw new Error(`Row ${i + 1}: invalid group.`); groupCount.add(r.group); if (groupCount.size > 35) throw new Error('A maximum of 35 unique groups is supported.'); } return rows; } export const safeDivide = (a, b) => b === 0 ? null : a / b; export const round = (v, places = 4) => v === null || !Number.isFinite(v) ? null : Number(v.toFixed(places)); export function confusion(rows, threshold) { if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) throw new Error('Threshold must be between 0 and 1.'); let tp = 0, fp = 0, tn = 0, fn = 0; for (const r of rows) { const positive = r.score >= threshold; if (positive && r.label === 1) tp++; else if (positive && r.label === 0) fp++; else if (!positive && r.label === 0) tn++; else fn++; } return { tp, fp, tn, fn }; } /** Tie-aware Mann-Whitney (rank statistic) ROC AUC. Undefined if only one observed class. */ export function auc(rows) { let positives = 0, negatives = 0; for (const r of rows) r.label ? positives++ : negatives++; if (!positives || !negatives) return null; const sorted = [...rows].sort((a, b) => a.score - b.score); let rank = 1, sumPositiveRanks = 0; for (let i = 0; i < sorted.length;) { let j = i + 1; while (j < sorted.length && sorted[j].score === sorted[i].score) j++; const meanRank = (rank + (rank + j - i - 1)) / 2; for (let k = i; k < j; k++) if (sorted[k].label) sumPositiveRanks += meanRank; rank += j - i; i = j; } return (sumPositiveRanks - positives * (positives + 1) / 2) / (positives * negatives); } export function calibration(rows, bins = 10) { const slots = Array.from({length: bins}, (_, i) => ({index:i, n:0, totalScore:0, totalLabel:0})); let brierSum = 0; for (const r of rows) { const b = Math.min(bins - 1, Math.floor(r.score * bins)); slots[b].n++; slots[b].totalScore += r.score; slots[b].totalLabel += r.label; brierSum += (r.score - r.label) ** 2; } let ece = 0; const data = slots.map(({index,n,totalScore,totalLabel}) => { const confidence = safeDivide(totalScore,n), outcome = safeDivide(totalLabel,n); if (n) ece += n / rows.length * Math.abs(confidence - outcome); return {bin:index + 1, n, confidence, outcome}; }); return {bins:data, ece, brier:brierSum / rows.length}; } export function evaluate(rows, threshold = DEFAULT_THRESHOLD) { validateRows(rows); const {tp, fp, tn, fn} = confusion(rows, threshold); const n = rows.length, p = tp + fn, negative = tn + fp; const precision = safeDivide(tp, tp + fp); const recall = safeDivide(tp, p); const specificity = safeDivide(tn, negative); const f1 = precision === null || recall === null || precision + recall === 0 ? (precision === 0 || recall === 0 ? 0 : null) : 2 * precision * recall / (precision + recall); const balancedAccuracy = recall === null || specificity === null ? null : (recall + specificity) / 2; return { n, threshold, tp, fp, tn, fn, prevalence:p/n, accuracy:(tp + tn)/n, precision, recall, specificity, f1, balancedAccuracy, fpr:safeDivide(fp,negative), fnr:safeDivide(fn,p), positiveRate:(tp + fp)/n, auc:auc(rows), ...calibration(rows) }; } export function byGroup(rows, threshold) { const map = new Map(); for (const r of rows) { if (!map.has(r.group)) map.set(r.group, []); map.get(r.group).push(r); } return [...map.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([group,subset])=>({group, ...evaluate(subset, threshold)})); } export function rangeOf(values) { const a=values.filter(x=>x!==null && Number.isFinite(x)); return a.length < 2 ? null : Math.max(...a)-Math.min(...a); } export function fairness(rows, threshold) { const groups=byGroup(rows,threshold); return {groups, demographicParityGap:rangeOf(groups.map(x=>x.positiveRate)), equalOpportunityGap:rangeOf(groups.map(x=>x.recall)), falsePositiveRateGap:rangeOf(groups.map(x=>x.fpr)), minimumGroupN:Math.min(...groups.map(x=>x.n)), reliable:groups.length >= 2 && groups.every(x=>x.n>=30) && groups.every(x=>x.recall!==null && x.fpr!==null)}; } export function rocCurve(rows) { const p = rows.reduce((s,r)=>s+r.label,0), n=rows.length-p; if (!p || !n) return []; const sorted=[...rows].sort((a,b)=>b.score-a.score); const points=[{fpr:0,tpr:0,threshold:1.000001}]; let tp=0, fp=0; for(let i=0;i>>0;return ()=>{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;};} export function bootstrapInterval(rows, threshold, metric = 'f1', repeats = 240, seed = 24) { if(!['f1','accuracy','balancedAccuracy','auc','brier'].includes(metric))throw new Error('Unsupported metric.'); if (!Number.isInteger(repeats) || repeats < 50 || repeats > 500) throw new Error('Use 50 to 500 bootstrap repeats.'); validateRows(rows); const random=rng(seed), values=[]; for (let b=0;brows[Math.floor(random()*rows.length)]); const metricValue=evaluate(sample,threshold)[metric]; if(metricValue!==null)values.push(metricValue); } if(values.lengtha-b); const quantile=q=>{const at=(values.length-1)*q, lo=Math.floor(at), hi=Math.ceil(at);return values[lo]+(values[hi]-values[lo])*(at-lo);}; return {lower:quantile(.025),upper:quantile(.975),repeats,valid:values.length}; } export function csvEscape(v) {const s=String(v??'');return /[,"\r\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s;} export function toCSV(rows, columns) {return [columns.join(','), ...rows.map(r=>columns.map(c=>csvEscape(r[c])).join(','))].join('\r\n')+'\r\n';} export function syntheticRows(preset='support',count=720,seed=2026) { if(!['support','quality','education'].includes(preset)) throw new Error('Unknown demo dataset.'); const random=rng(seed), rows=[], cohorts=['North','Central','South']; const sigmoid=x=>1/(1+Math.exp(-x)); for(let i=0;i