impactlab-latam / engine.js
Hugdrigo's picture
Upload 22 files
b9cdea1 verified
Raw History Blame Contribute Delete
11 kB
/* ImpactLab LATAM — pure, documented analytical functions. MIT license.
Exploratory estimates are not causal identifications. */
(function(root,factory){const lib=factory();if(typeof module==='object'&&module.exports)module.exports=lib;else root.ImpactEngine=lib;})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const finite=x=>typeof x==='number'&&Number.isFinite(x);
const avg=a=>{const v=a.filter(finite);return v.length?v.reduce((s,x)=>s+x,0)/v.length:null;};
const sum=a=>a.reduce((s,x)=>s+(finite(x)?x:0),0);
const sd=a=>{const m=avg(a);return m===null?null:Math.sqrt(avg(a.map(x=>(x-m)**2)));};
const round=(x,d=4)=>finite(x)?Number(x.toFixed(d)):null;
const clamp=(v,min,max)=>Math.min(max,Math.max(min,v));
const safeYear=y=>Number.isInteger(Number(y))?Number(y):null;
const encodeCSV=v=>{const str=String(v??'');const safe=typeof v==='string'&&/^[\s]*[=+@-]/.test(str)?"'"+str:str;return '"'+safe.replace(/"/g,'""')+'"';};
const byYear=(rows,country,indicator,start,end)=>new Map(rows.filter(r=>r.country===country&&r.indicator===indicator&&r.year>=start&&r.year<=end).map(r=>[r.year,finite(r.value)?r.value:null]));
const timeline=(rows,opts)=>{
const {country,controls,indicator,start,end}=opts;
const t=byYear(rows,country,indicator,start,end);
const donors=(controls||[]).filter(c=>c!==country).map(c=>({country:c,values:byYear(rows,c,indicator,start,end)}));
return Array.from({length:end-start+1},(_,i)=>{
const year=start+i;const tv=t.get(year)??null;const cv=donors.map(d=>d.values.get(year)??null).filter(finite);
return {year,treated:tv,controls:cv.length===donors.length&&cv.length?avg(cv):null,availableControls:cv.length};
});
};
const checkOptions=opts=>{
if(!opts||!opts.country||!opts.indicator)throw Error('Select an outcome and a treated country.');
if(!Array.isArray(opts.controls)||!opts.controls.length)throw Error('Select at least one comparison country.');
if(opts.controls.includes(opts.country))throw Error('The treated country cannot also be a control.');
if(!(Number.isInteger(opts.start)&&Number.isInteger(opts.end)&&Number.isInteger(opts.breakYear)))throw Error('Years must be integers.');
if(opts.end-opts.start<7)throw Error('Use at least eight calendar years.');
if(opts.breakYear-opts.start<3||opts.end-opts.breakYear+1<3)throw Error('Need at least three years before and after the break.');
};
const estimateDid=(rows,opts)=>{
checkOptions(opts);
const points=timeline(rows,opts);
const paired=points.filter(p=>finite(p.treated)&&finite(p.controls));
const pre=paired.filter(p=>p.year<opts.breakYear),post=paired.filter(p=>p.year>=opts.breakYear);
if(pre.length<3||post.length<3) return {ok:false,error:`Not enough paired observations: ${pre.length} pre / ${post.length} post; three per period required.`,points,preYears:pre.length,postYears:post.length};
const tpre=avg(pre.map(p=>p.treated)),tpost=avg(post.map(p=>p.treated));
const cpre=avg(pre.map(p=>p.controls)),cpost=avg(post.map(p=>p.controls));
const effect=(tpost-tpre)-(cpost-cpre);
return {ok:true,effect,treatedChange:tpost-tpre,controlChange:cpost-cpre,treatedPre:tpre,treatedPost:tpost,controlPre:cpre,controlPost:cpost,preYears:pre.length,postYears:post.length,points,paired,pre,post};
};
const trendSlope=arr=>{
const a=arr.filter(p=>finite(p.value));if(a.length<3)return null;
const mx=avg(a.map(p=>p.year)),my=avg(a.map(p=>p.value));
const denom=sum(a.map(p=>(p.year-mx)**2));
return denom>0?sum(a.map(p=>(p.year-mx)*(p.value-my)))/denom:null;
};
const pretrend=did=>{
if(!did.ok)return {ok:false};
const t=trendSlope(did.pre.map(p=>({year:p.year,value:p.treated})));
const c=trendSlope(did.pre.map(p=>({year:p.year,value:p.controls})));
return {ok:finite(t)&&finite(c),treatedSlope:t,controlSlope:c,slopeDifference:finite(t)&&finite(c)?t-c:null,preDifferences:did.pre.map(p=>({year:p.year,gap:p.treated-p.controls}))};
};
const seeded=(seed=24317)=>{let a=(Number(seed)||24317)>>>0;return ()=>{a=(a*1664525+1013904223)>>>0;return a/4294967296;};};
const bootstrapDid=(did,n=400,seed=24317)=>{
if(!did.ok)return {ok:false,interval:null,draws:[]};
const rnd=seeded(seed),out=[];
for(let k=0;k<n;k++){
const sample=a=>Array.from({length:a.length},()=>a[Math.floor(rnd()*a.length)]);
const pre=sample(did.pre),post=sample(did.post);
out.push((avg(post.map(p=>p.treated-p.controls)))-(avg(pre.map(p=>p.treated-p.controls))));
}
const s=out.sort((a,b)=>a-b),q=p=>{const pos=(s.length-1)*p,lo=Math.floor(pos),hi=Math.ceil(pos);return s[lo]+(s[hi]-s[lo])*(pos-lo);};
return {ok:true,draws:out,interval:[q(.025),q(.975)],n,method:'Year-resampling descriptive interval; not causal inference uncertainty.'};
};
function projectSimplex(v){
const u=[...v].sort((a,b)=>b-a);let sumU=0,rho=-1;
for(let i=0;i<u.length;i++){sumU+=u[i];if(u[i]+(1-sumU)/(i+1)>0)rho=i;}
if(rho===-1)return Array(v.length).fill(1/v.length);
const theta=(sum(u.slice(0,rho+1))-1)/(rho+1);
return v.map(x=>Math.max(0,x-theta));
}
function fitSimplex(X,y,lambda=.001,iter=1000){
// minimize average squared residual in outcome units scaled by y RMS
const n=X.length,m=X[0].length;let w=Array(m).fill(1/m);
const scale=Math.max(1,Math.sqrt(avg(y.map(v=>v*v)))||1);
const xs=X.map(row=>row.map(v=>v/scale)),ys=y.map(v=>v/scale);
const L=(2/n)*sum(xs.map(row=>sum(row.map(v=>v*v))))+2*lambda;
const step=1/Math.max(.001,L);
for(let k=0;k<iter;k++){
const grad=Array(m).fill(0);
for(let i=0;i<n;i++){const err=sum(xs[i].map((v,j)=>v*w[j]))-ys[i];for(let j=0;j<m;j++)grad[j]+=2*err*xs[i][j]/n;}
for(let j=0;j<m;j++)grad[j]+=2*lambda*w[j];
const next=projectSimplex(w.map((v,j)=>v-step*grad[j]));
if(sum(next.map((v,j)=>Math.abs(v-w[j])))<1e-10){w=next;break;}w=next;
}
return w;
}
const syntheticControl=(rows,opts)=>{
checkOptions(opts);
if(opts.controls.length<2)return {ok:false,error:'Synthetic control requires at least two distinct donor countries.'};
const years=Array.from({length:opts.end-opts.start+1},(_,i)=>opts.start+i);
const mapped=[opts.country,...opts.controls].map(c=>byYear(rows,c,opts.indicator,opts.start,opts.end));
const complete=years.filter(year=>mapped.every(m=>finite(m.get(year))));
const pre=complete.filter(y=>y<opts.breakYear),post=complete.filter(y=>y>=opts.breakYear);
if(pre.length<5||post.length<2)return {ok:false,error:`Need at least five shared pre-break and two post-break years (${pre.length} pre / ${post.length} post available).`,preYears:pre.length,postYears:post.length};
const X=pre.map(y=>mapped.slice(1).map(m=>m.get(y))),y=pre.map(y=>mapped[0].get(y));
const w=fitSimplex(X,y);
const points=complete.map(year=>{
const treated=mapped[0].get(year),synthetic=sum(mapped.slice(1).map((m,j)=>m.get(year)*w[j]));
return {year,treated,synthetic,gap:treated-synthetic};
});
const prePts=points.filter(p=>p.year<opts.breakYear),postPts=points.filter(p=>p.year>=opts.breakYear);
const rmse=a=>Math.sqrt(avg(a.map(p=>p.gap*p.gap)));
const preRmse=rmse(prePts),postRmse=rmse(postPts);
return {ok:true,weights:opts.controls.map((country,i)=>({country,weight:w[i]})),points,preYears:pre.length,postYears:post.length,preRmse,postRmse,postMeanGap:avg(postPts.map(p=>p.gap)),postCumulativeGap:sum(postPts.map(p=>p.gap)),fitRatio:preRmse>1e-10?postRmse/preRmse:null};
};
const unitPlacebos=(rows,opts)=>{
const candidates=[opts.country,...opts.controls],results=[];
for(const fake of candidates){const donor=candidates.filter(c=>c!==fake);if(donor.length<2)continue;
try{const r=syntheticControl(rows,{...opts,country:fake,controls:donor});if(r.ok)results.push({country:fake,treated:fake===opts.country,postMeanGap:r.postMeanGap,preRmse:r.preRmse,fitRatio:r.fitRatio});}catch(e){}
}
return results;
};
const timePlacebos=(rows,opts)=>{
const years=[];for(let t=opts.start+3;t<=opts.breakYear-3;t++){
try{const r=estimateDid(rows,{...opts,breakYear:t,end:opts.breakYear-1});if(r.ok)years.push({year:t,effect:r.effect});}catch(e){}
}return years;
};
const coverage=(rows,opts)=>{
const relevant=rows.filter(r=>r.indicator===opts.indicator&&[opts.country,...opts.controls].includes(r.country)&&r.year>=opts.start&&r.year<=opts.end);
return [opts.country,...opts.controls].map(country=>{
const arr=relevant.filter(r=>r.country===country&&finite(r.value));const years=arr.map(r=>r.year);
return {country,present:arr.length,expected:opts.end-opts.start+1,latest:years.length?Math.max(...years):null,missing:opts.end-opts.start+1-arr.length};
});
};
function parseWorldBank(payload,indicator){
if(!Array.isArray(payload)||!Array.isArray(payload[1]))throw Error('World Bank API response has no observation array.');
if(payload[0]&&payload[0].message)throw Error('World Bank returned an error.');
return payload[1].map(r=>({country:r.countryiso3code||r.country?.id||'',year:safeYear(r.date),indicator,value:finite(r.value)?r.value:null,source:'worldbank'})).filter(r=>r.country&&r.year!==null);
}
function validateRows(input){
if(!Array.isArray(input))throw Error('Rows must be an array.');
const out=[],seen=new Set();for(const r of input){
if(!r||!r.country||!r.indicator||!Number.isInteger(Number(r.year)))continue;
const country=String(r.country).slice(0,20),indicator=String(r.indicator).slice(0,60),year=Number(r.year);
if(year<1900||year>2100)continue;const value=r.value===null||r.value===''?null:Number(r.value);
if(value!==null&&!finite(value))continue;
const id=`${country}|${indicator}|${year}`;if(seen.has(id))continue;seen.add(id);
out.push({country,indicator,year,value,source:String(r.source||'user').slice(0,60)});
}return out;
}
function toCSV(rows,keys){return [keys.map(encodeCSV).join(','),...rows.map(r=>keys.map(k=>encodeCSV(r[k])).join(','))].join('\r\n')+'\r\n';}
const indicators={
'SP.DYN.LE00.IN':{name:'Life expectancy at birth',unit:'years',decimals:2,domain:'Health'},
'NY.GDP.PCAP.KD.ZG':{name:'GDP per capita growth',unit:'annual %',decimals:2,domain:'Economy'},
'SL.UEM.TOTL.ZS':{name:'Unemployment (modeled ILO)',unit:'% of labor force',decimals:2,domain:'Labor'},
'SE.SEC.ENRR':{name:'School enrollment, secondary (gross)',unit:'%',decimals:2,domain:'Education'},
'IT.NET.USER.ZS':{name:'Individuals using the Internet',unit:'% of population',decimals:2,domain:'Digital'},
'SH.XPD.CHEX.GD.ZS':{name:'Current health expenditure',unit:'% of GDP',decimals:2,domain:'Health'},
'SE.XPD.TOTL.GD.ZS':{name:'Government expenditure on education',unit:'% of GDP',decimals:2,domain:'Education'},
'SP.POP.TOTL':{name:'Population, total',unit:'people',decimals:0,domain:'Demography'}
};
const countries={ARG:'Argentina',BOL:'Bolivia',BRA:'Brazil',CHL:'Chile',COL:'Colombia',CRI:'Costa Rica',DOM:'Dominican Republic',ECU:'Ecuador',GTM:'Guatemala',MEX:'Mexico',PAN:'Panama',PER:'Peru',PRY:'Paraguay',URY:'Uruguay',SLV:'El Salvador'};
return {finite,avg,sum,sd,round,clamp,timeline,checkOptions,estimateDid,pretrend,bootstrapDid,projectSimplex,fitSimplex,syntheticControl,unitPlacebos,timePlacebos,coverage,parseWorldBank,validateRows,toCSV,indicators,countries};
});