local help = [[
ezr3.lua: multi-goal trees, XAI, active learning, optimization.
(c) 2026 Tim Menzies <timm@ieee.org>, MIT license.
A holdout score rig, and the stats
that police it. Demos live next door in ezr-eg.lua; the
batteries below, in lib.lua.
usage:
ezr [-h] [--key=val ..] [--demo ..]
or, adding demos from your own script:
local ezr = require"ezr-eg"
ezr.eg["--myDemo"] = function() print(ezr.the.seed) end
ezr.go(ezr.eg)
inference options:
budget=50 acquire: max labels
cap=1024 holdout: max rows kept
check=5 holdout: test rows labelled
few=128 sample size for cheap guesses
keepf=0.66 acquire: pool kept per cull
leaf=3 tree: min rows in one leaf
maxd=4 tree: max depth
more=4 acquire: labels per round
p=2 minkowski coefficient
stop=32 min rows before a split halts]]
local abs,exp,log,sqrt = math.abs,math.exp,math.log,math.sqrt
local max,min,floor = math.max,math.min,math.floor
local huge = math.huge
local TINY = 1e-32utf8 by byte, so no wide glyph sits in this source
local UP = string.char(0xE2,0x96,0xB2) -- U+25B2
local DOWN = string.char(0xE2,0x96,0xBC) -- U+25BCfind lib.lua beside this file, whatever the cwd
package.path = (arg and arg[0] or ""):gsub("[^/]*$","")
.. "?.lua;" .. package.pathall defs below land here; reads fall through to lib (and, through lib, to _G)
local _ENV = setmetatable({}, {__index = require"ezr-lib"})
if setfenv then setfenv(1, _ENV) end
the = the:also(help)
NUM, SYM, COLS, TBL, NODE, TREE = {},{},{},{},{},{}Every column is a NUM or a SYM, picked by the first letter
of its name, and both answer the same five questions: add,
mid, div, norm, dist. Nothing downstream asks which kind it
is holding. The signature above each function is a
reading aid, not a check; note that the names after the
wide gap in an argument list are LOCALS, not arguments,
so they never appear in a type. Read col as “a NUM or
a SYM”, row as “a list of cells, one per column”,
list as a table used as an array, and dict as one
used as a map.
Col(name:str, at:int) -> col
Column kind from the first letter: lowercase makes a SYM,
uppercase a NUM.
function Col(name,at)
return (name:find"^%l" and Sym or Num)(name,at) endNum(name?:str, at?:int) -> NUM
Summary of a numeric column. A trailing “-” in the name
means the goal is to minimize it.
function Num(name,at)
name = name or ""
return new(NUM, {at=at or 1, name=name, n=0, mu=0, m2=0,
heaven = name:find"-$" and 0 or 1}) endSym(name?:str, at?:int) -> SYM
Summary of a symbolic column: just the counts.
function Sym(name,at)
return new(SYM, {at=at or 1, name=name or "", n=0, has={}}) endSYM:add(v:any, inc?:int) -> any
Update the symbol counts. inc can be -1, which forgets v.
function SYM.add(i,v,inc)
if v == "?" then return v end
inc = inc or 1
i.n = i.n + inc
i.has[v] = inc + (i.has[v] or 0)
if i.has[v] <= 0 then i.has[v] = nil end
return v endNUM:add(v:num, inc?:int) -> num
One-pass update of mu and m2, forwards (inc=1) or in
reverse (inc=-1).
function NUM.add(i,v,inc, d)
if v == "?" then return v end
inc = inc or 1
i.n = i.n + inc
d = v - i.mu
i.mu = i.mu + inc * d / i.n
i.m2 = i.m2 + inc * d * (v - i.mu); return v endSYM:mid() -> any
Center: the mode.
function SYM.mid(i, hi,out)
hi = -1
for k, n in pairs(i.has) do
if n > hi then hi, out = n, k end end
return out endNUM:mid() -> num
Center: the mean.
function NUM.mid(i) return i.mu endSYM:div() -> num
Diversity: entropy of the counts. One-arg log only: the
two-arg form is LuaJIT and 5.2+, and 5.1 drops the base.
function SYM.div(i)
return sum(i.has, function(n, p)
p = n / i.n
return -p * log(p) / log(2) end) endNUM:div() -> num
Diversity: standard deviation.
function NUM.div(i)
return i.n < 2 and 0 or sqrt(max(i.m2,0) / (i.n-1)) endSYM:norm(v:any) -> any
Syms have no cdf, so v comes back untouched.
function SYM.norm(i,v) return v endNUM:norm(v:num) -> num
v’s cdf, via a logistic; 0..1.
function NUM.norm(i,v, z)
if v == "?" then return v end
z = (v - i.mu) / (i:div() + TINY)
return 1 / (1 + exp(-1.702 * max(-3, min(3, z)))) endDoes a cell fall on the “yes” side of a cut? An unknown cell says yes to everything, so no row is ever lost.
SYM:holds(x:any, v:any) -> bool
Symbols split by equality.
function SYM.holds(i,x,v) return x == "?" or x == v endNUM:holds(x:num, v:num) -> bool
Numbers split at a threshold.
function NUM.holds(i,x,v) return x == "?" or x <= v endA TBL is rows plus the column summaries those rows built. Row 1 of any source is the header, and the header alone decides each column’s kind and role.
Tbl(src:list|fun) -> TBL
Fold rows into fresh columns; row 1 is the header.
function Tbl(src)
src = iter(src)
return adds(src, new(TBL, {rows={}, mid=nil,
cols=Cols(src())})) endCols(names:list) -> COLS
Sort the header names into their roles: a trailing “!” is
the class, “+” or “-” a goal, “X” is ignored, the rest are
the x (independent) columns.
function Cols(names, all,x,y,klass)
all, x, y = {}, {}, {}
for at, s in ipairs(names) do
all[at] = Col(s, at)
if s:find"!$" then klass = all[at]
elseif s:find"[+-]$" then y[#y+1] = all[at]
elseif s:sub(-1) ~= "X" then x[#x+1] = all[at] end end
return new(COLS, {names=names,all=all,x=x,y=y,klass=klass}) endCOLS:add(row:row, inc?:int) -> row
Fold a row into every column.
function COLS.add(i,row,inc)
for _, c in ipairs(i.all) do c:add(row[c.at], inc) end
return row endTBL:add(row:row) -> row
Keep the row; update the summaries.
function TBL.add(i,row)
i.rows[#i.rows+1] = i.cols:add(row)
i.mid = nil
return row endTBL:clone(rows?:list) -> TBL
Same header, fresh summaries. Clones of a live model stay
live, so labels still arrive on demand.
function TBL.clone(i,rows, u)
u = adds(rows, Tbl{i.cols.names})
u.model = i.model
return u endTBL:mids() -> row
Centroid of this table: every column’s middle, cached.
function TBL.mids(i)
i.mid = i.mid or map(i.cols.all, "mid")
return i.mid endSummaries subtract as well as add, so a row can leave a
table as cheaply as it arrived. That is what lets one pass
over sorted data score every possible split (see
discretize) without rebuilding anything.
NUM - NUM -> NUM
tot - v: a new NUM holding what is left.
function NUM.__sub(i,j, n,d)
n = i.n - j.n
if n < 1 then return Num(i.name, i.at) end
d = j.mu - i.mu
return new(NUM, {name=i.name, at=i.at, heaven=i.heaven,
n=n, mu=(i.n*i.mu - j.n*j.mu) / n,
m2=max(0, i.m2 - j.m2
- d*d*i.n*j.n/n)}) endSYM - SYM -> SYM
tot - v: a new SYM holding what is left of the counts.
function SYM.__sub(i,j, out,n)
out = Sym(i.name, i.at)
for k,v in pairs(i.has) do
n = v - (j.has[k] or 0)
if n > 0 then out.has[k] = n; out.n = out.n + n end end
return out endNUM:reset() – back to empty.
function NUM.reset(i) i.n, i.mu, i.m2 = 0, 0, 0 endSYM:reset() – back to empty.
function SYM.reset(i) i.n, i.has = 0, {} endTBL:sub(row:row) -> row
Forget a row. All resets happen here, so an emptied table
ends up with genuinely fresh columns, not drifted ones.
function TBL.sub(i,row)
i.cols:add(row, -1)
i.mid = nil
for j,r in ipairs(i.rows) do
if r == row then table.remove(i.rows, j); break end end
if #i.rows == 0 then map(i.cols.all, "reset") end
return row endadds(src?:list|fun, i?:col|TBL) -> col|TBL
Fold a list or an iterator into a summary; a Num by
default.
function adds(src,i)
i = i or Num()
for v in iter(src or {}) do i:add(v) end
return i endTwo rows, one number. Gaps per column are 0..1, and
minkowski folds them into one 0..1 distance: distx over
the independent columns, disty from the goals to heaven
(0 = best). disty is also the seam where an external
model gets asked for a label.
SYM:dist(a:any, b:any) -> num
Gap between two symbols; 0..1.
function SYM.dist(i,a,b)
if a == "?" and b == "?" then return 1 end
return a ~= b and 1 or 0 endNUM:dist(a:num, b:num) -> num
Gap between two numbers; 0..1. An unknown cell is pushed
to whichever end is further away.
function NUM.dist(i,a,b)
if a == "?" and b == "?" then return 1 end
a, b = i:norm(a), i:norm(b)
if a == "?" then a = b > 0.5 and 0 or 1 end
if b == "?" then b = a > 0.5 and 0 or 1 end
return abs(a - b) endminkowski(cols:list, f:fun) -> num
p-norm mean of f(col), with p from the.p.
function minkowski(cols,f, d,n)
d, n = 0, TINY
for _, c in ipairs(cols) do n, d = n+1, d + f(c) ^ the.p end
return (d / n) ^ (1 / the.p) endTBL:distx(row1:row, row2:row) -> num
Gap over the x columns; 0..1.
function TBL.distx(i,row1,row2)
return minkowski(i.cols.x, function(c)
return c:dist(row1[c.at], row2[c.at]) end) endTBL:label(row:row) -> row
Ask i.model for this row’s goals, then fold them in.
function TBL.label(i,row, f)
f = i.model(map(i.cols.x,
function(c) return row[c.at] end), #i.cols.y)
for j, y in ipairs(i.cols.y) do
row[y.at] = y:add(f[j]) end
return row endTBL:disty(row:row) -> num
Gap to heaven; 0 = best. Rows born “?” get labelled on
demand, right here.
function TBL.disty(i,row)
if i.model and row[i.cols.y[1].at] == "?" then
i:label(row) end
return minkowski(i.cols.y, function(y)
return abs(y:norm(row[y.at]) - y.heaven) end) endTBL:Y() -> fun
disty as a first-class key function, for keysort and map.
function TBL.Y(i)
return function(r) return i:disty(r) end endRecursive halving on two far-apart poles. One projection per split, no centroids, no matrix: cheap enough to run on a pool nobody has labelled yet.
TBL:poles(rows:list, lo?:row, hi?:row) -> fun, row, row
Find two far poles and return the projector that ranks any
row between them, best pole first.
function TBL.poles(i,rows,lo,hi, far,c)
far = function(r, t)
t = keysort(rows, function(z) return i:distx(z, r) end)
return t[#t] end
lo = lo or far(rows[rand(#rows)])
hi = hi or far(lo)
if i:disty(lo) > i:disty(hi) then lo, hi = hi, lo end
c = i:distx(lo, hi) + TINY
return function(r) return (i:distx(lo,r)^2 + c*c
- i:distx(hi,r)^2) / (2*c) end,
lo, hi endTBL:halve(rows?:list) -> row, row, list, list
Split on the far poles, best half first.
function TBL.halve(i,rows, fun,a,b,n)
rows = rows or i.rows
fun, a, b = i:poles(some(rows, the.few))
rows = keysort(rows, fun)
n = floor(#rows / 2)
return a, b, slice(rows, 1, n), slice(rows, n + 1) endNode(tbl:TBL, rows?:list) -> NODE
Tree of halves, halving until a node is too small to
bother splitting.
function Node(tbl,rows, recurse)
function recurse(rows, node,a,b,lo,hi)
node = new(NODE, {here=tbl:clone(rows),
a=nil, b=nil, lo=nil, hi=nil})
if #rows >= 2 * the.stop then
a, b, lo, hi = tbl:halve(rows)
node.a, node.b = a, b
if #lo > 0 and #hi > 0 then
node.lo, node.hi = recurse(lo), recurse(hi) end end
return node
end -- recurse
return recurse(rows or tbl.rows) endNODE:leaf(row:row) -> NODE
Walk a row down to its leaf, going to whichever pole is
nearer at each level.
function NODE.leaf(i,row, t)
while i.lo do
t = i.here
i = t:distx(row, i.a) <= t:distx(row, i.b)
and i.lo or i.hi end
return i endLabel few rows, cull the pool toward the good pole, loop. lab is a plain list of labelled rows; acquire rebuilds its private seen set (keyed by row ref) on each entry. When a pool dries with budget left, acquirer reshuffles and goes again, anchored at the best and worst labels seen so far.
TBL:acquire(rows:list, cap:int, lab:list, lo?:row,
hi?:row) -> list
One sweep: spend up to the.more labels per round, then
keep the the.keepf fraction of the pool nearest the good
pole. Stops when the budget is gone or the pool is small.
function TBL.acquire(i,rows,cap,lab,lo,hi,
seen,more,new)
seen = {}
for _,r in ipairs(lab) do seen[r] = true end
while #rows >= 2*the.leaf do
more, new = min(the.more, cap - #lab), {}
for _,r in ipairs(rows) do -- new = labels in this pool
if seen[r] then push(new, r)
elseif more > 0 then
more, seen[r] = more - 1, true
push(new, push(lab, r)) end end
if #lab >= cap then return lab end -- budget spent
rows = slice(keysort(rows, (i:poles(new, lo, hi))),
1, max(1, floor(the.keepf * #rows))) end
return lab endTBL:acquirer(cap:int) -> list
Sweep until the budget is spent or no sweep makes
progress. Returns the labelled rows, best first.
function TBL.acquirer(i,cap, lab,lo,hi,t,b4)
lab = {}
while true do
b4 = #lab
lab = i:acquire(shuffle(i.rows), cap, lab, lo, hi)
if #lab >= cap or #lab >= #i.rows or
#lab == b4 then break end -- full, or no progress
t = keysort(lab, i:Y())
lo, hi = t[1], t[#t] end -- best+worst seen
return keysort(lab, i:Y()) endFind good cuts: places where splitting the x values most
purifies some y summary. All candidates feed one least
reducer; no cut lists are ever built.
val(a:col, b:col) -> num
Mean diversity of two summaries, weighted by their sizes.
function val(a,b)
return (a:div()*a.n + b:div()*b.n) / (a.n + b.n + TINY) endbig(lo:int, n:int) -> bool
True when both sides of a cut hold at least the.leaf.
function big(lo,n)
return the.leaf <= lo and lo <= n - the.leaf endSYM:cuts(xy:list, tot:col, acc:fun, best:fun)
One cut per key; each is offered to best.
function SYM.cuts(c,xy,tot,acc,best, d,b)
d = {}
for _,p in ipairs(xy) do
b = d[p[1]] or acc()
b:add(p[2]); d[p[1]] = b end
if #keys(d) > 1 then
for k,v in pairs(d) do
if big(v.n, #xy) then
best{val(v, tot - v), c.at, k} end end end endNUM:cuts(xy:list, tot:col, acc:fun, best:fun)
Cuts between each distinct, sorted x. One pass: the left
summary grows, the right is tot minus it.
function NUM.cuts(c,xy,tot,acc,best, here)
table.sort(xy, function(a,b) return a[1] < b[1] end)
here = acc()
for j,p in ipairs(xy) do
here:add(p[2])
if j < #xy and p[1] ~= xy[j+1][1] and big(j, #xy) then
best{val(here, tot - here),c.at,p[1]} end end endTBL:cuts(rows:list, c:col, Y:fun, acc:fun, best:fun)
Column c feeds its splits to best, skipping “?” cells.
function TBL.cuts(i,rows,c,Y,acc,best, xy,tot)
xy = {}
for _,r in ipairs(rows) do
if r[c.at] ~= "?" then push(xy, {r[c.at], Y(r)}) end end
tot = adds(map(xy, 2), acc())
c:cuts(xy, tot, acc, best) endTBL:bestcut(rows:list, Y:fun, acc:fun,
best:fun) -> list?
Champion cut over all x columns, as {val, at, v}.
function TBL.bestcut(i,rows,Y,acc,best)
for _,c in ipairs(i.cols.x) do i:cuts(rows,c,Y,acc,best) end
return best() endTBL:divide(rows:list, c:col, v:any) -> list, list
Rows that hold c<=v (or c==v), and rows that do not.
function TBL.divide(i,rows,c,v, yes,no)
yes, no = {}, {}
for _,r in ipairs(rows) do
push(c:holds(r[c.at], v) and yes or no, r) end
return yes, no endRecursive best-cut trees over the discretizer above, plus walk/sides: visit every pruning of a grown tree.
Tree(tbl:TBL, rows:list, Y?:fun, acc?:fun) -> TREE
Grow to the.maxd, splitting on the best cut each time.
Each node carries its rows, its loss, and the best loss
anywhere below it.
function Tree(tbl,rows,Y,acc, recurse)
function recurse(rows,lvl, ys,t,b,c,yes,no)
ys = adds(map(rows, Y), acc())
t = new(TREE, {at=nil, v=nil, mu=ys:mid(), leafs=1,
here = tbl:clone(rows),
loss = ys.has and ys:div() or ys:mid()})
t.val = t.loss
if #rows >= 2*the.leaf and lvl < the.maxd then
b = tbl:bestcut(rows, Y, acc, least())
if b then
c = tbl.cols.all[b[2]]
yes, no = tbl:divide(rows, c, b[3])
if #yes > 0 and #no > 0 then
t.at, t.v = b[2], b[3]
t.yes = recurse(yes, lvl+1)
t.no = recurse(no, lvl+1)
t.val = min(t.yes.val, t.no.val)
t.leafs = t.yes.leafs + t.no.leafs end end end
return t
end -- recurse
Y = Y or tbl:Y()
acc = acc or Num
return recurse(rows, 0) endTREE:leafed() -> TREE
x, collapsed to one leaf.
function TREE.leafed(x)
return new(TREE, {at=nil, mu=x.mu, loss=x.loss,
val=x.loss, leafs=1, here=x.here}) endTREE:walk(fun:fun)
Call fun on every pruning of tree t.
function TREE.walk(t,fun)
if t.at == nil then return fun(t) end
t.yes:sides(function(yes)
t.no:sides(function(no)
fun(new(TREE, {at=t.at, v=t.v, here=t.here,
yes=yes, no=no,
val=min(yes.val, no.val),
leafs=yes.leafs + no.leafs})) end) end) endTREE:sides(fun:fun)
t as one leaf, then all of t’s prunings.
function TREE.sides(t,fun)
fun(t:leafed())
if t.at ~= nil then t:walk(fun) end endTREE:leaf(tbl:TBL, row:row) -> num
Walk a row to its leaf; the leaf’s mean is the guess.
function TREE.leaf(t,tbl,row, c)
while t.at do
c = tbl.cols.all[t.at]
t = c:holds(row[t.at], t.v) and t.yes or t.no end
return t.mu endOne row per node: n, d2h, then each goal’s mean under its own header column; tree structure trails on the right.
TREE:leaves(fun:fun)
Call fun on every leaf below t.
function TREE.leaves(t,fun)
if t.at then t.yes:leaves(fun)
t.no:leaves(fun)
else fun(t) end endTREE:gstr() -> str
This node’s goal means, as aligned columns.
function TREE.gstr(t)
return table.concat(map(t.here.cols.y, function(g)
return ("%9s"):format(show(g:mid())) end)) endTREE:show(tbl:TBL)
Print the whole tree. The best and worst leaves are marked
UP and DOWN. Siblings print lower mu first, so the best
branch is always the one above.
function TREE.show(t,tbl, lo,hi,recurse)
function recurse(t,pre,txt, c,say,m,a,b)
m = (t.at == nil and t.mu == lo and UP) or -- best leaf
(t.at == nil and t.mu == hi and DOWN) or " " -- worst
print(("%s%4d %5.2f%s %s"):format(
m, #t.here.rows, t.mu, t:gstr(), pre .. txt))
if t.at then -- structure right
c = tbl.cols.all[t.at]
say = function(op)
return c.name .. op .. show(t.v) end
pre = pre .. (txt == "" and "" or "| ")
a = {t.yes, c.has and " == " or " <= "}
b = {t.no, c.has and " ~= " or " > "}
if b[1].mu < a[1].mu then a, b = b, a end -- best first
recurse(a[1], pre, say(a[2]))
recurse(b[1], pre, say(b[2]))
end
end -- recurse
lo, hi = huge, -huge -- leaf extremes,
t:leaves(function(l) -- then a header
lo, hi = min(lo, l.mu), max(hi, l.mu) end)
print((" %4s %5s"):format("n", "d2h") ..
table.concat(map(t.here.cols.y, function(g)
return ("%9s"):format(g.name) end)))
recurse(t, "", "") endHow good was that answer, honestly? wins grades a row
against an oracle pass over the whole pool, and holdout
makes the method earn its grade on rows it never saw.
TBL:wins(rows?:list) -> fun
Grader: row -> percent of the gap to the pool best that
was closed. Closed to [-100,100]; 100 = the pool best,
0 = no better than the median row.
function TBL.wins(i,rows, ys,lo,b4)
ys = sorted(map(rows or i.rows, i:Y()))
lo, b4 = ys[1], ys[floor(#ys/2)+1]
return function(r)
return max(-100, min(100,
100*(1 - (i:disty(r)-lo) / (b4-lo+TINY)))) end endTBL:holdout(how?:fun) -> row
Label the train half via how, grow a tree, use it to
rank the unseen test half, then label the best
the.check of that ranking and return the winner.
function TBL.holdout(i,how, rows,n,train,test,lab,t,top)
how = how or function(t2,cap) return t2:acquirer(cap) end
rows = shuffle(i.rows)
n = floor(#rows/2)
train= slice(rows, 1, n)
test = slice(rows, n+1)
lab = how(i:clone(train), the.budget - the.check)
assert(#lab + the.check <= the.budget) -- spend, counted
t = Tree(i, lab)
top = slice(keysort(test, function(r) return t:leaf(i, r) end),
1, the.check)
return keysort(top, i:Y())[1] endIs that gap real, or is it noise? Three cheap tests, each
asking a different question, and same only says yes when
all three agree. ranks then turns a bag of treatments
into ranks, where ties share a number.
cohen(xs:list, ys:list) -> num
Mean gap, in pooled-sd units.
function cohen(xs,ys, x,y,n,m,sd)
x, y = adds(xs), adds(ys)
n, m = x.n, y.n
sd = sqrt(((n-1)*x:div()^2 + (m-1)*y:div()^2)/(n+m-2))
return abs(x.mu - y.mu) / (sd + TINY) endks(xs:list, ys:list) -> num
Max cdf gap, in critical units. Walks both cdfs, one
distinct value at a time. Sorted lists in.
function ks(xs,ys, nx,ny,d,p,q,v)
nx, ny = #xs, #ys
d, p, q = 0, 0, 0
while p < nx and q < ny do
v = min(xs[p+1], ys[q+1])
while p < nx and xs[p+1] == v do p = p + 1 end
while q < ny and ys[q+1] == v do q = q + 1 end
d = max(d, abs(p / nx - q / ny)) end
return d / ((nx + ny) / (nx * ny)) ^ 0.5 endcliffs(xs:list, ys:list) -> num
Rank imbalance; 0..1. As x ascends, j and k (the count of
ys below and at-or-below x) only ever advance. Sorted
lists in.
function cliffs(xs,ys, gt,lt,j,k)
gt, lt, j, k = 0, 0, 0, 0
for _, x in ipairs(xs) do
while j < #ys and ys[j+1] < x do j = j + 1; k = j end
while k < #ys and ys[k+1] == x do k = k + 1 end
gt = gt + j; lt = lt + #ys - k end
return abs(gt - lt) / (#xs * #ys) endsame(xsort:list, ysort:list, Cohen?:num, Ks?:num,
Cliffs?:num) -> bool
All three tests must agree before two samples count as the
same. and is lazy, so the cheap test runs first. Sorted
lists in.
function same(xsort,ysort,Cohen,Ks,Cliffs)
return cohen( xsort,ysort) <= (Cohen or .35)
and cliffs(xsort,ysort) <= (Cliffs or .195)
and ks( xsort,ysort) <= (Ks or 1.36) endranks(d:dict, big?:bool) -> {winners:list, ranks:dict}
Rank the treatments in d (name -> list of results) by
their medians, best first. A treatment that is same as
the one above it shares its rank. Returns the rank-0
winners and the whole rank table.
function ranks(d,big, mid,dd,sign,out,win,rank,best)
mid = function(t) return t[floor(#t / 2) + 1] end
dd = {}; for k,v in pairs(d) do dd[k] = sorted(v) end
sign = big and -1 or 1
out, win, rank, best = {}, {}, -1, nil
for _, k in ipairs(keysort(keys(dd),
function(k) return sign * mid(dd[k]) end)) do
if best == nil or not same(dd[best], dd[k]) then
rank, best = rank + 1, k end
if rank == 0 then win[1+#win] = k end
out[k] = rank end
return {winners=win, ranks=out} endreturn _ENV