policylab-ai / engine.js
Hugdrigo's picture
Upload 27 files
6ca5a77 verified
Raw History Blame Contribute Delete
5.06 kB
import {COUNTRIES,IDS,INDICATORS,DIMENSIONS,DIMENSION_CODES,finite,safeNumber,clamp} from './catalog.js';
/** Preserve actual observations, do not forward-fill or interpolate. */
export function normalize(raw){
if(!Array.isArray(raw))throw Error('Rows must be an array');const seen=new Set(),out=[];
for(const row of raw){if(!row||!COUNTRIES[row.country]||!IDS.includes(row.indicator))continue;
const year=Number(row.year),value=safeNumber(row.value);
if(!Number.isInteger(year)||year<1990||year>2026||value===null)continue;
const key=`${row.country}|${row.indicator}|${year}`;if(seen.has(key))continue;seen.add(key);
out.push({country:row.country,year,indicator:row.indicator,value,obsStatus:String(row.obsStatus||'').slice(0,30)});
}
return out.sort((a,b)=>a.year-b.year||a.country.localeCompare(b.country)||a.indicator.localeCompare(b.indicator));
}
export function latest(rows,country,indicator,start,end){let best=null;for(const r of rows){if(r.country===country&&r.indicator===indicator&&r.year>=start&&r.year<=end&&(!best||r.year>best.year))best=r;}return best;}
export function exact(rows,country,indicator,year){return rows.find(r=>r.country===country&&r.indicator===indicator&&r.year===year)||null;}
export function series(rows,country,indicator,start,end){return rows.filter(r=>r.country===country&&r.indicator===indicator&&r.year>=start&&r.year<=end).sort((a,b)=>a.year-b.year);}
export function score(value,indicator){const def=INDICATORS[indicator];if(!def||!DIMENSION_CODES.includes(indicator)||!finite(value))return null;
let n=def.transform==='log'?100*(Math.log(Math.max(1,value))-Math.log(def.lo))/(Math.log(def.hi)-Math.log(def.lo)):100*(value-def.lo)/(def.hi-def.lo);
n=clamp(n,0,100);return def.positive?n:100-n;
}
export function cleanWeights(weights){const w=DIMENSIONS.map(k=>{const n=Number(weights?.[k]);return Number.isFinite(n)&&n>=0?n:0;});const s=w.reduce((a,b)=>a+b,0);return Object.fromEntries(DIMENSIONS.map((k,i)=>[k,s?w[i]/s:1/5]));}
/** Full five dimension completeness required to publish rankings. Partial observations remain visible separately. */
export function composite(rows,countries,weights,start,end,maxLag=5){
const normW=cleanWeights(weights);return countries.map(country=>{
const parts=DIMENSIONS.map((d,i)=>{const id=DIMENSION_CODES[i],p=latest(rows,country,id,start,end);
return {dimension:d,indicator:id,year:p?.year??null,raw:p?.value??null,score:p&&end-p.year<=maxLag?score(p.value,id):null,lag:p?end-p.year:null};});
const ready=parts.every(p=>finite(p.score));const index=ready?parts.reduce((s,p)=>s+p.score*normW[p.dimension],0):null;
return {country,index,eligible:ready,covered:parts.filter(p=>finite(p.score)).length,parts,oldestYear:ready?Math.min(...parts.map(p=>p.year)):null,newestYear:ready?Math.max(...parts.map(p=>p.year)):null};
}).sort((a,b)=>(b.index??-1)-(a.index??-1));
}
export function coverage(rows,countries,indicators,start,end){const eligible=countries.length*indicators.length*(end-start+1);const actual=rows.filter(r=>countries.includes(r.country)&&indicators.includes(r.indicator)&&r.year>=start&&r.year<=end).length;return {eligible,actual,share:eligible?actual/eligible:0};}
export function missingMatrix(rows,countries,indicators,start,end){return countries.flatMap(c=>indicators.map(id=>{const arr=series(rows,c,id,start,end);return {country:c,indicator:id,available:arr.length,expected:end-start+1,latestYear:arr.length?arr[arr.length-1].year:null,missing:end-start+1-arr.length};}));}
export function paired(rows,countries,x,y,start,end){const out=[];for(const c of countries)for(let year=start;year<=end;year++){const a=exact(rows,c,x,year),b=exact(rows,c,y,year);if(a&&b)out.push({country:c,year,x:a.value,y:b.value});}return out;}
export function pearson(points){if(points.length<4)return null;const a=points.map(p=>p.x),b=points.map(p=>p.y),n=a.length,ma=a.reduce((x,y)=>x+y,0)/n,mb=b.reduce((x,y)=>x+y,0)/n;let num=0,sa=0,sb=0;for(let i=0;i<n;i++){const x=a[i]-ma,y=b[i]-mb;num+=x*y;sa+=x*x;sb+=y*y;}return sa>0&&sb>0?num/Math.sqrt(sa*sb):null;}
export function trendChange(points){if(points.length<2)return null;const first=points[0],last=points[points.length-1];if(first.year===last.year)return null;return {from:first.year,to:last.year,absolute:last.value-first.value,annualChange:(last.value-first.value)/(last.year-first.year)};}
export function giniOfScores(results){const a=results.filter(x=>finite(x.index)).map(x=>x.index).sort((x,y)=>x-y);if(a.length<2)return null;let total=a.reduce((x,y)=>x+y,0);if(total===0)return 0;let weight=0;for(let i=0;i<a.length;i++)weight+=(i+1)*a[i];return 2*weight/(a.length*total)-(a.length+1)/a.length;}
export function validateImport(data){if(data?.format!=='policylab-session-v1'||!Array.isArray(data.rows)||data.rows.length>80000)throw Error('Invalid PolicyLab session');const rows=normalize(data.rows);if(!rows.length)throw Error('No valid observations');return {rows,kind:'imported',source:'Imported session (user-provided; provenance not independently verified)',year:new Date().getFullYear()};}