<p class="hint">Computation here is one rule: when <code>(λx. body) arg</code> appears, replace every <code>x</code> in <code>body</code> with <code>arg</code>. Pick an example, then <b>Step</b> one reduction at a time or <b>Run</b> to the end.</p>
<div class="picker">
<button class="ex" data-k="add">ADD 2 3</button>
<button class="ex" data-k="mul">MUL 2 3</button>
<button class="ex" data-k="id">(λx.x) y</button>
<button class="ex" data-k="k">(λx.λy.x) a b</button>
</div>
<div class="expr" id="expr"></div>
<div class="meta" id="meta">Choose an example above.</div>
<div class="btns">
<button id="step" type="button">Step ▸</button>
<button id="run" type="button">Run ⏩</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
code { background: #eef2f6; padding: 1px 4px; border-radius: 4px; font-family: ui-monospace, monospace; }
.picker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .7rem; }
.ex { font: 600 13px ui-monospace, monospace; padding: .35rem .7rem; border: 1px solid #cdd9e3;
background: #fff; color: #1d3557; border-radius: 7px; cursor: pointer; }
.ex.active { background: #1d3557; color: #fff; border-color: #1d3557; }
.expr { font: 600 17px ui-monospace, monospace; color: #1d3557; background: #f5f8fb;
border: 1px solid #dde6ee; border-radius: 8px; padding: .8rem .9rem; min-height: 3.2em;
line-height: 1.5; word-break: break-word; }
.expr .redex { background: #ffe08a; border-radius: 4px; padding: 0 2px; }
.meta { font-size: .92rem; font-weight: 600; margin: .55rem 0; min-height: 1.3em; color: #555; }
.meta.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// A tiny lambda-calculus engine. Terms are tagged objects:
// {t:'var', name} a variable
// {t:'lam', param, body} λparam. body
// {t:'app', fn, arg} (fn arg)
// Computation = normal-order beta reduction: find the LEFTMOST-outermost redex (λx.B) A and substitute. This is the whole engine — numbers, ADD and MUL below are *defined as functions*, nothing arithmetic is built in.
function V(name){ return {t:'var',name}; }
function L(param,body){ return {t:'lam',param,body}; }
function A(fn,arg){ return {t:'app',fn,arg}; }
// Church numeral n = λf.λx. f (f ... (f x)) with n applications of f.
function church(n){ let body=V('x'); for(let i=0;i<n;i++) body=A(V('f'),body); return L('f',L('x',body)); }
// ADD = λm.λn.λf.λx. m f (n f x) ; MUL = λm.λn.λf. m (n f)
const ADD = L('m',L('n',L('f',L('x', A(A(V('m'),V('f')), A(A(V('n'),V('f')),V('x')))))));
const MUL = L('m',L('n',L('f', A(V('m'), A(V('n'),V('f'))))));
const EXAMPLES = {
add: A(A(ADD, church(2)), church(3)),
mul: A(A(MUL, church(2)), church(3)),
id: A(L('x',V('x')), V('y')),
k: A(A(L('x',L('y',V('x'))), V('a')), V('b')),
};
// --- free variables, fresh names, capture-avoiding substitution ---
function freeVars(t, acc=new Set()){
if(t.t==='var') acc.add(t.name);
else if(t.t==='lam'){ const inner=freeVars(t.body); inner.forEach(v=>{ if(v!==t.param) acc.add(v); }); }
else { freeVars(t.fn,acc); freeVars(t.arg,acc); }
return acc;
}
let counter=0;
function fresh(base, avoid){ let n; do { n = base.replace(/\d+$/,'') + (++counter); } while(avoid.has(n)); return n; }
// substitute value `s` for variable `x` inside `t`
function subst(t, x, s){
if(t.t==='var') return t.name===x ? s : t;
if(t.t==='app') return A(subst(t.fn,x,s), subst(t.arg,x,s));
// lambda
if(t.param===x) return t; // x is shadowed; leave inner body
const sFree = freeVars(s);
if(sFree.has(t.param)){ // would capture -> alpha-rename
const nn = fresh(t.param, new Set([...sFree, ...freeVars(t.body)]));
const renamed = subst(t.body, t.param, V(nn));
return L(nn, subst(renamed, x, s));
}
return L(t.param, subst(t.body, x, s));
}
// Find + perform ONE normal-order (leftmost-outermost) reduction.
// Returns {term, did} where did is false if already in normal form.
function stepOnce(t){
if(t.t==='app'){
if(t.fn.t==='lam') return { term: subst(t.fn.body, t.fn.param, t.arg), did:true };
const l = stepOnce(t.fn); if(l.did) return { term: A(l.term, t.arg), did:true };
const r = stepOnce(t.arg); if(r.did) return { term: A(t.fn, r.term), did:true };
return { term:t, did:false };
}
if(t.t==='lam'){ const b=stepOnce(t.body); return b.did ? { term:L(t.param,b.term), did:true } : { term:t, did:false }; }
return { term:t, did:false };
}
// pretty-print, dropping needless parens
function show(t, parent=null){
if(t.t==='var') return t.name;
if(t.t==='lam'){ const s='λ'+t.param+'. '+show(t.body); return parent==='appfn'||parent==='apparg' ? '('+s+')' : s; }
const fn=show(t.fn,'appfn'), arg=show(t.arg,'apparg');
const s=fn+' '+arg; return parent==='apparg' ? '('+s+')' : s;
}
let current=null, key=null;
const exprEl=document.getElementById('expr'), metaEl=document.getElementById('meta');
function setMeta(txt,cls){ metaEl.textContent=txt; metaEl.className='meta '+(cls||''); }
function inNormalForm(t){ return !stepOnce(t).did; }
function render(){
exprEl.textContent = current ? show(current) : '';
const done = current && inNormalForm(current);
document.getElementById('step').disabled = !current || done;
document.getElementById('run').disabled = !current || done;
}
function load(k){
key=k; counter=0; current=EXAMPLES[k];
document.querySelectorAll('.ex').forEach(b=>b.classList.toggle('active', b.dataset.k===k));
setMeta('Ready — press Step or Run.',''); render();
}
document.querySelectorAll('.ex').forEach(b=> b.addEventListener('click',()=>load(b.dataset.k)) );
document.getElementById('step').addEventListener('click',()=>{
const r=stepOnce(current);
if(r.did){ current=r.term;
if(inNormalForm(current)) setMeta('Normal form reached — no more reductions.','ok');
else setMeta('One beta-reduction applied.','');
} else setMeta('Already in normal form.','ok');
render();
});
document.getElementById('run').addEventListener('click',()=>{
let n=0; while(!inNormalForm(current) && n<2000){ current=stepOnce(current).term; n++; }
setMeta(fmt('Reduced to normal form in %n% steps.', { n: n }),'ok'); render();
console.log('Result:', show(current));
});
document.getElementById('reset').addEventListener('click',()=>{ if(key) load(key); });
load('add');
<p class="hint">Computation here is one rule: when <code>(λx. body) arg</code> appears, replace every <code>x</code> in <code>body</code> with <code>arg</code>. Pick an example, then <b>Step</b> one reduction at a time or <b>Run</b> to the end.</p>
<div class="picker">
<button class="ex" data-k="add">ADD 2 3</button>
<button class="ex" data-k="mul">MUL 2 3</button>
<button class="ex" data-k="id">(λx.x) y</button>
<button class="ex" data-k="k">(λx.λy.x) a b</button>
</div>
<div class="expr" id="expr"></div>
<div class="meta" id="meta">Choose an example above.</div>
<div class="btns">
<button id="step" type="button">Step ▸</button>
<button id="run" type="button">Run ⏩</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
code { background: #eef2f6; padding: 1px 4px; border-radius: 4px; font-family: ui-monospace, monospace; }
.picker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .7rem; }
.ex { font: 600 13px ui-monospace, monospace; padding: .35rem .7rem; border: 1px solid #cdd9e3;
background: #fff; color: #1d3557; border-radius: 7px; cursor: pointer; }
.ex.active { background: #1d3557; color: #fff; border-color: #1d3557; }
.expr { font: 600 17px ui-monospace, monospace; color: #1d3557; background: #f5f8fb;
border: 1px solid #dde6ee; border-radius: 8px; padding: .8rem .9rem; min-height: 3.2em;
line-height: 1.5; word-break: break-word; }
.expr .redex { background: #ffe08a; border-radius: 4px; padding: 0 2px; }
.meta { font-size: .92rem; font-weight: 600; margin: .55rem 0; min-height: 1.3em; color: #555; }
.meta.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// A tiny lambda-calculus engine. Terms are tagged objects:
// {t:'var', name} a variable
// {t:'lam', param, body} λparam. body
// {t:'app', fn, arg} (fn arg)
// Computation = normal-order beta reduction: find the LEFTMOST-outermost redex (λx.B) A and substitute. This is the whole engine — numbers, ADD and MUL below are *defined as functions*, nothing arithmetic is built in.
function V(name){ return {t:'var',name}; }
function L(param,body){ return {t:'lam',param,body}; }
function A(fn,arg){ return {t:'app',fn,arg}; }
// Church numeral n = λf.λx. f (f ... (f x)) with n applications of f.
function church(n){ let body=V('x'); for(let i=0;i<n;i++) body=A(V('f'),body); return L('f',L('x',body)); }
// ADD = λm.λn.λf.λx. m f (n f x) ; MUL = λm.λn.λf. m (n f)
const ADD = L('m',L('n',L('f',L('x', A(A(V('m'),V('f')), A(A(V('n'),V('f')),V('x')))))));
const MUL = L('m',L('n',L('f', A(V('m'), A(V('n'),V('f'))))));
const EXAMPLES = {
add: A(A(ADD, church(2)), church(3)),
mul: A(A(MUL, church(2)), church(3)),
id: A(L('x',V('x')), V('y')),
k: A(A(L('x',L('y',V('x'))), V('a')), V('b')),
};
// --- free variables, fresh names, capture-avoiding substitution ---
function freeVars(t, acc=new Set()){
if(t.t==='var') acc.add(t.name);
else if(t.t==='lam'){ const inner=freeVars(t.body); inner.forEach(v=>{ if(v!==t.param) acc.add(v); }); }
else { freeVars(t.fn,acc); freeVars(t.arg,acc); }
return acc;
}
let counter=0;
function fresh(base, avoid){ let n; do { n = base.replace(/\d+$/,'') + (++counter); } while(avoid.has(n)); return n; }
// substitute value `s` for variable `x` inside `t`
function subst(t, x, s){
if(t.t==='var') return t.name===x ? s : t;
if(t.t==='app') return A(subst(t.fn,x,s), subst(t.arg,x,s));
// lambda
if(t.param===x) return t; // x is shadowed; leave inner body
const sFree = freeVars(s);
if(sFree.has(t.param)){ // would capture -> alpha-rename
const nn = fresh(t.param, new Set([...sFree, ...freeVars(t.body)]));
const renamed = subst(t.body, t.param, V(nn));
return L(nn, subst(renamed, x, s));
}
return L(t.param, subst(t.body, x, s));
}
// Find + perform ONE normal-order (leftmost-outermost) reduction.
// Returns {term, did} where did is false if already in normal form.
function stepOnce(t){
if(t.t==='app'){
if(t.fn.t==='lam') return { term: subst(t.fn.body, t.fn.param, t.arg), did:true };
const l = stepOnce(t.fn); if(l.did) return { term: A(l.term, t.arg), did:true };
const r = stepOnce(t.arg); if(r.did) return { term: A(t.fn, r.term), did:true };
return { term:t, did:false };
}
if(t.t==='lam'){ const b=stepOnce(t.body); return b.did ? { term:L(t.param,b.term), did:true } : { term:t, did:false }; }
return { term:t, did:false };
}
// pretty-print, dropping needless parens
function show(t, parent=null){
if(t.t==='var') return t.name;
if(t.t==='lam'){ const s='λ'+t.param+'. '+show(t.body); return parent==='appfn'||parent==='apparg' ? '('+s+')' : s; }
const fn=show(t.fn,'appfn'), arg=show(t.arg,'apparg');
const s=fn+' '+arg; return parent==='apparg' ? '('+s+')' : s;
}
let current=null, key=null;
const exprEl=document.getElementById('expr'), metaEl=document.getElementById('meta');
function setMeta(txt,cls){ metaEl.textContent=txt; metaEl.className='meta '+(cls||''); }
function inNormalForm(t){ return !stepOnce(t).did; }
function render(){
exprEl.textContent = current ? show(current) : '';
const done = current && inNormalForm(current);
document.getElementById('step').disabled = !current || done;
document.getElementById('run').disabled = !current || done;
}
function load(k){
key=k; counter=0; current=EXAMPLES[k];
document.querySelectorAll('.ex').forEach(b=>b.classList.toggle('active', b.dataset.k===k));
setMeta('Ready — press Step or Run.',''); render();
}
document.querySelectorAll('.ex').forEach(b=> b.addEventListener('click',()=>load(b.dataset.k)) );
document.getElementById('step').addEventListener('click',()=>{
const r=stepOnce(current);
if(r.did){ current=r.term;
if(inNormalForm(current)) setMeta('Normal form reached — no more reductions.','ok');
else setMeta('One beta-reduction applied.','');
} else setMeta('Already in normal form.','ok');
render();
});
document.getElementById('run').addEventListener('click',()=>{
let n=0; while(!inNormalForm(current) && n<2000){ current=stepOnce(current).term; n++; }
setMeta(fmt('Reduced to normal form in %n% steps.', { n: n }),'ok'); render();
console.log('Result:', show(current));
});
document.getElementById('reset').addEventListener('click',()=>{ if(key) load(key); });
load('add');
Comments
Loading comments...