local M = {}
local SCORER_TAG = {}
local function build(name, spec)
local score_fn
if spec.score then
if type(spec.score) ~= "function" then
error(string.format("scorer '%s': 'score' must be function, got %s", name, type(spec.score)), 3)
end
score_fn = spec.score
elseif spec.min ~= nil and spec.max ~= nil then
local lo, hi = spec.min, spec.max
if type(lo) ~= "number" or type(hi) ~= "number" then
error(string.format("scorer '%s': min/max must be numbers", name), 3)
end
if lo == hi then
error(string.format("scorer '%s': min and max must differ", name), 3)
end
local range = hi - lo
score_fn = function(v)
if type(v) ~= "number" then return 0.0 end
local normalized = (v - lo) / range
return math.max(0.0, math.min(1.0, normalized))
end
elseif spec.pass ~= nil then
local threshold = spec.pass
if type(threshold) ~= "number" then
error(string.format("scorer '%s': pass must be number, got %s", name, type(threshold)), 3)
end
score_fn = function(v)
if type(v) == "boolean" then v = v and 1.0 or 0.0 end
if type(v) ~= "number" then return 0.0 end
return v >= threshold and 1.0 or 0.0
end
elseif spec.steps then
local steps = spec.steps
if type(steps) ~= "table" or #steps < 2 then
error(string.format("scorer '%s': steps must be table with >= 2 entries", name), 3)
end
for i, step in ipairs(steps) do
if type(step) ~= "table" or type(step[1]) ~= "number" or type(step[2]) ~= "number" then
error(string.format("scorer '%s': steps[%d] must be {threshold, score}", name, i), 3)
end
end
local sorted = {}
for i, s in ipairs(steps) do sorted[i] = s end
table.sort(sorted, function(a, b) return a[1] < b[1] end)
steps = sorted
score_fn = function(v)
if type(v) ~= "number" then return 0.0 end
local result = 0.0
for _, step in ipairs(steps) do
if v >= step[1] then result = step[2] end
end
return math.max(0.0, math.min(1.0, result))
end
else
error(string.format("scorer '%s': requires 'score', 'min'+'max', 'pass', or 'steps'", name), 3)
end
return {
_tag = SCORER_TAG,
name = name,
score = score_fn,
}
end
M.default_bool = build("_bool", {
score = function(v)
if type(v) == "boolean" then return v and 1.0 or 0.0 end
if type(v) == "number" then return math.max(0.0, math.min(1.0, v)) end
return 0.0
end,
})
setmetatable(M, {
__call = function(_, name)
if type(name) ~= "string" then
error(string.format("scorer: name must be string, got %s", type(name)), 2)
end
return function(spec)
if type(spec) ~= "table" then
error(string.format("scorer '%s': spec must be a table", name), 2)
end
return build(name, spec)
end
end,
})
function M.is_scorer(v)
return type(v) == "table" and v._tag == SCORER_TAG
end
return M