Skip to main content

PRELUDE

Constant PRELUDE 

Source
pub const PRELUDE: &str = "--- Layer 1: Prelude Combinators\n---\n--- Higher-order functions that compose Layer 0 primitives.\n--- Loaded automatically into every session (embedded via include_str!).\n--- These extend the alc.* namespace alongside Rust-backed Layer 0 functions.\n\n--- alc.cache(prompt, opts?) -> string\n--- Memoized LLM call. Returns cached response if the same prompt+opts\n--- combination was seen before in this session. Drop-in replacement\n--- for alc.llm() when repeated identical calls are expected.\n---\n--- Cache is session-scoped (in-memory table, cleared on session end).\n--- Key is computed via alc.fingerprint(prompt + system + max_tokens).\n---\n--- opts: same as alc.llm() + cache control:\n---   opts.cache_key:  explicit cache key (overrides auto-fingerprint)\n---   opts.cache_skip: if true, bypass cache and always call LLM\n---\n--- Usage:\n---   local resp = alc.cache(\"Summarize: \" .. text)  -- first call: LLM\n---   local resp = alc.cache(\"Summarize: \" .. text)  -- second call: instant\n---\n---   local resp = alc.cache(\"Analyze\", { system = \"expert\", cache_skip = true })\ndo\n    local _cache = {} -- key -> value\n    local _order = {} -- insertion order (array of keys)\n    local _hits = 0\n    local _misses = 0\n    local _max_entries = 256\n\n    function alc.cache(prompt, opts)\n        opts = opts or {}\n        if opts.cache_skip then\n            return alc.llm(prompt, opts)\n        end\n\n        local key = opts.cache_key\n        if not key then\n            local sig = prompt\n            if opts.system then\n                sig = sig .. \"\\0\" .. opts.system\n            end\n            if opts.max_tokens then\n                sig = sig .. \"\\0\" .. tostring(opts.max_tokens)\n            end\n            key = alc.fingerprint(sig)\n        end\n\n        if _cache[key] ~= nil then\n            _hits = _hits + 1\n            alc.log(\"debug\", \"alc.cache: hit \" .. key)\n            return _cache[key]\n        end\n\n        local resp = alc.llm(prompt, opts)\n        _cache[key] = resp\n        _order[#_order + 1] = key\n        _misses = _misses + 1\n        alc.log(\"debug\", \"alc.cache: miss \" .. key)\n\n        -- Evict oldest entries when over capacity\n        while #_order > _max_entries do\n            local evict_key = table.remove(_order, 1)\n            _cache[evict_key] = nil\n        end\n\n        return resp\n    end\n\n    --- alc.cache_info() -> { entries, hits, misses, max_entries }\n    --- Return cache statistics for the current session.\n    function alc.cache_info()\n        return { entries = #_order, hits = _hits, misses = _misses, max_entries = _max_entries }\n    end\n\n    --- alc.cache_clear()\n    --- Clear all cached responses and reset counters.\n    function alc.cache_clear()\n        _cache = {}\n        _hits = 0\n        _misses = 0\n    end\nend\n\n--- alc.map(items, fn) -> results\n--- Apply fn(item, index) to each item, return array of results.\n--- fn receives (item, index) and should return a value.\n---\n--- Usage:\n---   local results = alc.map(chunks, function(chunk, i)\n---       return alc.llm(\"Summarize:\\n\" .. chunk, { max_tokens = 200 })\n---   end)\nfunction alc.map(items, fn)\n    local results = {}\n    for i, item in ipairs(items) do\n        results[i] = fn(item, i)\n    end\n    return results\nend\n\n--- alc.reduce(items, fn, init?) -> value\n--- Reduce array to single value. fn(acc, item, index) -> new_acc.\n--- If init is nil, uses items[1] as initial value.\n---\n--- Usage:\n---   local summary = alc.reduce(summaries, function(acc, s, i)\n---       return alc.llm(\n---           \"Combine these summaries:\\n1: \" .. acc .. \"\\n2: \" .. s,\n---           { max_tokens = 300 }\n---       )\n---   end)\nfunction alc.reduce(items, fn, init)\n    local acc = init\n    local start = 1\n    if acc == nil then\n        acc = items[1]\n        start = 2\n    end\n    for i = start, #items do\n        acc = fn(acc, items[i], i)\n    end\n    return acc\nend\n\n--- alc.vote(answers) -> { winner, count, total }\n--- Majority vote over an array of string answers.\n--- Groups similar answers (exact match) and returns the most frequent.\n---\n--- Usage:\n---   local result = alc.vote({\"yes\", \"yes\", \"no\", \"yes\"})\n---   -- result.winner == \"yes\", result.count == 3, result.total == 4\nfunction alc.vote(answers)\n    local counts = {}\n    local order = {}\n    for _, a in ipairs(answers) do\n        local key = tostring(a):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n        if counts[key] == nil then\n            counts[key] = 0\n            order[#order + 1] = key\n        end\n        counts[key] = counts[key] + 1\n    end\n    local winner, max_count = nil, 0\n    for _, key in ipairs(order) do\n        if counts[key] > max_count then\n            winner = key\n            max_count = counts[key]\n        end\n    end\n    return { winner = winner, count = max_count, total = #answers }\nend\n\n--- alc.filter(items, fn) -> filtered\n--- Keep items where fn(item, index) returns truthy.\n---\n--- Usage:\n---   local critical = alc.filter(findings, function(f, i)\n---       local verdict = alc.llm(\n---           \"Is this a critical issue? Answer YES or NO:\\n\" .. f,\n---           { max_tokens = 10 }\n---       )\n---       return verdict:match(\"[Yy][Ee][Ss]\")\n---   end)\nfunction alc.filter(items, fn)\n    local result = {}\n    for i, item in ipairs(items) do\n        if fn(item, i) then\n            result[#result + 1] = item\n        end\n    end\n    return result\nend\n\n--- alc.ground(claim, opts?) -> string\n--- Convenience wrapper: calls alc.llm with grounded = true.\n--- The host should ground the response in external evidence\n--- (web search, code reading, documentation, etc.).\n---\n--- Usage:\n---   local verified = alc.ground(\"rmcp is tokio-only\")\n---   local verified = alc.ground(\"claim\", { system = \"expert\" })\nfunction alc.ground(claim, opts)\n    local merged = {}\n    for k, v in pairs(opts or {}) do\n        merged[k] = v\n    end\n    merged.grounded = true\n    return alc.llm(claim, merged)\nend\n\n--- alc.specify(prompt, opts?) -> string\n--- Convenience wrapper: calls alc.llm with underspecified = true.\n--- Signals that the prompt\'s preconditions depend on intent/goal\n--- definitions outside the current context. The host decides the\n--- resolution means (user query, RAG, DB lookup, delegated agent, etc.).\n---\n--- Usage:\n---   local answer = alc.specify(\"What output format do you need?\")\n---   local answer = alc.specify(\"Which module?\", { system = \"concise\" })\nfunction alc.specify(prompt, opts)\n    local merged = {}\n    for k, v in pairs(opts or {}) do\n        merged[k] = v\n    end\n    merged.underspecified = true\n    return alc.llm(prompt, merged)\nend\n\n--- alc.parse_score(str, default?) -> number\n--- Extract the first integer from a string. Returns default (or 5) on failure.\n--- Clamps result to 1-10 range.\n---\n--- Usage:\n---   local score = alc.parse_score(llm_response)       -- default 5\n---   local score = alc.parse_score(llm_response, 3)    -- default 3\nfunction alc.parse_score(str, default)\n    default = default or 5\n    local n = tonumber(tostring(str):match(\"%d+\"))\n    if n == nil then\n        return default\n    end\n    if n < 1 then\n        return 1\n    end\n    if n > 10 then\n        return 10\n    end\n    return n\nend\n\n--- alc.parse_number(text, pattern?) -> number | nil\n--- Extract a number from LLM output.\n--- If pattern is given, uses it as a Lua pattern with a capture group.\n--- Otherwise extracts the first number (integer or decimal, optionally negative).\n---\n--- Usage:\n---   alc.parse_number(\"Found 3 subtasks\")              -- 3\n---   alc.parse_number(\"Score: 7.5/10\")                  -- 7.5\n---   alc.parse_number(response, \"(%d+)%s+subtask\")      -- 3\n---   alc.parse_number(\"no numbers here\")                -- nil\nfunction alc.parse_number(text, pattern)\n    if type(text) ~= \"string\" then\n        return nil\n    end\n    if pattern then\n        local m = text:match(pattern)\n        return tonumber(m)\n    end\n    return tonumber(text:match(\"%-?%d+%.?%d*\"))\nend\n\n--- alc.fmt(fmt, ...) -> string\n--- Safe string.format drop-in.\n--- - Integer specs (%d %i %u %o %x %X %c) with non-integer float args use\n---   half-away-from-zero rounding (1.5 -> 2, -1.5 -> -2).\n--- - NaN / +Inf / -Inf args to integer specs rewrite the spec to %s and\n---   substitute \"NaN\" / \"Inf\" / \"-Inf\".\n--- - String args to integer specs are re-coerced via tonumber.\n--- - %s + nil falls back to \"<nil>\".\n--- - All other specs (%s %f %.Nf %q %g %e %g etc) are byte-for-byte identical\n---   to string.format.\n---\n--- Edge cases: nil fmt is treated as \"\". Underflow (more specs than args)\n--- propagates the native string.format error (we do not swallow).\n---\n--- Usage:\n---   alc.fmt(\"%d\", 1.5)              -- \"2\"\n---   alc.fmt(\"revenue=$%d\", 1234.5)  -- \"revenue=$1235\"\n---   alc.fmt(\"%s\", nil)              -- \"<nil>\"\n---   alc.fmt(\"%d\", 0/0)              -- \"NaN\"\nfunction alc.fmt(fmt, ...)\n    local args = { ... }\n    local n = select(\"#\", ...)\n    local i = 0\n    local out = (fmt or \"\"):gsub(\"%%[%-%+ #0]*%d*%.?%d*[diouxXcsqfeEgGpP%%]\", function(spec)\n        if spec == \"%%\" then\n            return \"%%\"\n        end\n        i = i + 1\n        if i > n then\n            return spec\n        end\n        local v = args[i]\n        local conv = spec:sub(-1)\n        if\n            conv == \"d\"\n            or conv == \"i\"\n            or conv == \"u\"\n            or conv == \"o\"\n            or conv == \"x\"\n            or conv == \"X\"\n            or conv == \"c\"\n        then\n            if type(v) == \"string\" then\n                v = tonumber(v) or v\n            end\n            if type(v) == \"number\" then\n                if v ~= v then\n                    args[i] = \"NaN\"\n                    return spec:sub(1, -2) .. \"s\"\n                elseif v == math.huge then\n                    args[i] = \"Inf\"\n                    return spec:sub(1, -2) .. \"s\"\n                elseif v == -math.huge then\n                    args[i] = \"-Inf\"\n                    return spec:sub(1, -2) .. \"s\"\n                end\n                if v % 1 ~= 0 then\n                    args[i] = v >= 0 and math.floor(v + 0.5) or math.ceil(v - 0.5)\n                else\n                    args[i] = v\n                end\n            end\n        elseif conv == \"s\" then\n            if v == nil then\n                args[i] = \"<nil>\"\n            end\n        end\n        return spec\n    end)\n    return string.format(out, table.unpack(args, 1, n))\nend\n\n--- alc.log_fmt(level, fmt, ...) -> nil\n--- Thin wrapper: equivalent to alc.log(level, alc.fmt(fmt, ...)).\n---\n--- Usage:\n---   alc.log_fmt(\"info\", \"score=%d\", 7.5)   -- logs \"score=8\"\nfunction alc.log_fmt(level, fmt, ...)\n    return alc.log(level, alc.fmt(fmt, ...))\nend\n\n--- alc.json_extract(raw) -> table | nil\n--- Extract JSON object or array from LLM output.\n--- Handles raw JSON, markdown fences (```json ... ```), and\n--- embedded JSON within surrounding text.\n--- Returns nil if no valid JSON found.\n---\n--- Usage:\n---   local data = alc.json_extract(llm_response)\n---   if data then process(data) end\nfunction alc.json_extract(raw)\n    if type(raw) ~= \"string\" then\n        return nil\n    end\n    -- Direct parse\n    local ok, result = pcall(alc.json_decode, raw)\n    if ok and type(result) == \"table\" then\n        return result\n    end\n    -- Markdown fences\n    local stripped = raw:match(\"```json%s*(.-)%s*```\") or raw:match(\"```%s*(.-)%s*```\")\n    if stripped then\n        ok, result = pcall(alc.json_decode, stripped)\n        if ok and type(result) == \"table\" then\n            return result\n        end\n    end\n    -- Balanced brace/bracket extraction (try all matches)\n    for json_str in raw:gmatch(\"%b{}\") do\n        ok, result = pcall(alc.json_decode, json_str)\n        if ok and type(result) == \"table\" then\n            return result\n        end\n    end\n    for json_str in raw:gmatch(\"%b[]\") do\n        ok, result = pcall(alc.json_decode, json_str)\n        if ok and type(result) == \"table\" then\n            return result\n        end\n    end\n    return nil\nend\n\n--- alc.state.update(key, fn, default?) -> updated_value\n--- Read current value, apply fn, write back. Single-operation read-modify-write.\n--- If key doesn\'t exist, uses default (or nil) as initial value.\n--- fn receives current value and must return new value.\n---\n--- Usage:\n---   alc.state.update(\"counter\", function(n) return n + 1 end, 0)\n---\n---   alc.state.update(\"portfolio\", function(p)\n---       p.updated_at = alc.time()\n---       table.insert(p.arms, new_arm)\n---       return p\n---   end, { arms = {}, history = {} })\nfunction alc.state.update(key, fn, default)\n    local current = alc.state.get(key, default)\n    local updated = fn(current)\n    alc.state.set(key, updated)\n    return updated\nend\n\n--- alc.llm_safe(prompt, opts, default) -> string\n--- Call alc.llm, returning default on failure instead of raising.\n--- Logs the error at warn level. Use for optional LLM enrichment\n--- where failure should not abort the pipeline.\n---\n--- Usage:\n---   local summary = alc.llm_safe(\n---       \"Summarize: \" .. text,\n---       { max_tokens = 200 },\n---       \"(summary unavailable)\"\n---   )\nfunction alc.llm_safe(prompt, opts, default)\n    local ok, result = pcall(alc.llm, prompt, opts)\n    if ok then\n        return result\n    end\n    alc.log(\"warn\", \"alc.llm_safe: \" .. tostring(result))\n    return default\nend\n\n--- alc.llm_json(prompt, opts?) -> table|nil, string\n--- Call alc.llm and parse the response as JSON. On parse failure,\n--- retries once with a repair prompt that includes the previous output.\n--- Uses alc.json_extract (3-stage fallback) for parsing.\n---\n--- Returns (parsed_table, raw_string) on success,\n--- or (nil, raw_string) if JSON extraction fails after retry.\n---\n--- Usage:\n---   local data, raw = alc.llm_json(\"Return a JSON object with fields: name, age\")\n---   if data then\n---       print(data.name)\n---   else\n---       alc.log(\"error\", \"Failed to get JSON: \" .. raw)\n---   end\nfunction alc.llm_json(prompt, opts)\n    opts = opts or {}\n    local raw = alc.llm(prompt, opts)\n    local parsed = alc.json_extract(raw)\n    if parsed then\n        return parsed, raw\n    end\n\n    alc.log(\"warn\", \"alc.llm_json: JSON parse failed, retrying\")\n    local retry_opts = {}\n    for k, v in pairs(opts) do\n        retry_opts[k] = v\n    end\n    retry_opts.system = \"Output ONLY valid JSON. No markdown fences, no explanation.\"\n\n    raw = alc.llm(\n        \"The previous response was not valid JSON.\\n\\n\"\n            .. \"Previous output:\\n\"\n            .. raw\n            .. \"\\n\\n\"\n            .. \"Fix the JSON and return ONLY valid JSON.\\n\\n\"\n            .. \"Original request:\\n\"\n            .. prompt,\n        retry_opts\n    )\n    parsed = alc.json_extract(raw)\n    if not parsed then\n        alc.log(\"warn\", \"alc.llm_json: JSON parse failed after retry\")\n    end\n    return parsed, raw\nend\n\n--- alc.fingerprint(str) -> string\n--- Normalize text (lowercase, collapse whitespace, trim) and\n--- return 8-char hex hash (DJB2). For deduplication, not cryptography.\n---\n--- Usage:\n---   local fp = alc.fingerprint(\"  Fix the Login Bug  \")\n---   -- fp == alc.fingerprint(\"fix the login bug\")  -- true\nfunction alc.fingerprint(str)\n    local s = tostring(str):lower():gsub(\"%s+\", \" \"):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n    local hash = 5381\n    for i = 1, #s do\n        hash = ((hash * 33) + s:byte(i)) % 0x100000000\n    end\n    return string.format(\"%08x\", hash)\nend\n\n--- alc.budget_check() -> boolean\n--- Returns true if budget has remaining capacity (safe to continue).\n--- Returns true if no budget is set (no limits).\n--- Checks elapsed_ms at call time (wall-clock snapshot).\n--- Use before optional LLM calls to skip gracefully when budget is low.\n---\n--- Note: even if budget_check() returns true, a subsequent alc.llm()\n--- may still fail with \"budget_exceeded\" if another call consumed the\n--- last remaining budget between the check and the call.\n---\n--- Usage:\n---   if alc.budget_check() then\n---       local extra = alc.llm(\"Optional enrichment: \" .. data)\n---   end\nfunction alc.budget_check()\n    local r = alc.budget_remaining()\n    if r == nil then\n        return true\n    end\n    -- Use type() check: JSON null from serde becomes userdata in mlua,\n    -- not Lua nil. Comparing userdata with number would error.\n    if type(r.llm_calls) == \"number\" and r.llm_calls <= 0 then\n        return false\n    end\n    if type(r.elapsed_ms) == \"number\" and r.elapsed_ms <= 0 then\n        return false\n    end\n    return true\nend\n\n--- alc.tuning(defaults, ctx, opts?) -> table\n--- Merge tuning defaults with ctx overrides. Deep-merges dict-like\n--- nested tables; shallow-replaces arrays and scalars.\n--- Strips _schema key (reserved for Layer 2 parameter metadata).\n---\n--- Override priority: ctx values > tuning.lua defaults\n---\n--- opts.prefix: namespace key in ctx (e.g. \"biz_kernel\" reads\n---   ctx.biz_kernel.kill_threshold instead of ctx.kill_threshold)\n---\n--- Usage:\n---   local cfg = alc.tuning(require(\"my_pkg.tuning\"), ctx)\n---   -- cfg.kill_threshold uses ctx.kill_threshold if present\n---\n---   -- With prefix (namespaced):\n---   local cfg = alc.tuning(require(\"my_pkg.tuning\"), ctx, { prefix = \"my_pkg\" })\n---   -- reads from ctx.my_pkg.kill_threshold\n---\n---   -- Deep merge example:\n---   -- defaults: { exponents = { alpha = 1.0, beta = 1.0 } }\n---   -- ctx:      { exponents = { alpha = 2.0 } }\n---   -- result:   { exponents = { alpha = 2.0, beta = 1.0 } }\nfunction alc.tuning(defaults, ctx, opts)\n    if type(defaults) ~= \"table\" then\n        return defaults\n    end\n    opts = opts or {}\n    local source = ctx or {}\n    if opts.prefix then\n        local ns = source[opts.prefix]\n        if type(ns) == \"table\" then\n            source = ns\n        elseif ns ~= nil then\n            alc.log(\n                \"warn\",\n                \"alc.tuning: prefix \'\" .. opts.prefix .. \"\' exists but is not a table, ignoring\"\n            )\n            source = {}\n        end\n    end\n    local result = {}\n    for k, v in pairs(defaults) do\n        if k == \"_schema\" then\n            -- reserved for parameter metadata, skip\n        elseif source[k] ~= nil then\n            if type(v) == \"table\" and type(source[k]) == \"table\" and v[1] == nil then\n                -- deep merge dict-like tables (no integer key 1)\n                result[k] = alc.tuning(v, source[k])\n            else\n                -- shallow replace: scalars, arrays, type changes\n                result[k] = source[k]\n            end\n        else\n            result[k] = v\n        end\n    end\n    return result\nend\n\n--- alc.parallel(items, prompt_fn, opts?) -> results\n--- Batch-parallel LLM calls over an array of items. Each item is\n--- transformed into a prompt by prompt_fn, then all prompts are sent\n--- as a single alc.llm_batch() call (one round-trip instead of N).\n---\n--- prompt_fn(item, index) must return:\n---   - string: used as prompt (opts.system/max_tokens applied)\n---   - table:  used as-is for llm_batch (must have .prompt field)\n---\n--- opts:\n---   opts.system:     shared system prompt for all items\n---   opts.max_tokens: shared max_tokens for all items\n---   opts.post_fn:    post_fn(response, item, index) -> value\n---\n--- Usage:\n---   -- Before (sequential: N round-trips)\n---   local out = alc.map(chunks, function(c)\n---       return alc.llm(\"Summarize:\\n\" .. c)\n---   end)\n---\n---   -- After (parallel: 1 round-trip)\n---   local out = alc.parallel(chunks, function(c)\n---       return \"Summarize:\\n\" .. c\n---   end)\n---\n---   -- With post-processing\n---   local scores = alc.parallel(candidates, function(c)\n---       return \"Rate 1-10:\\n\" .. c\n---   end, {\n---       post_fn = function(resp) return alc.parse_score(resp) end,\n---   })\nfunction alc.parallel(items, prompt_fn, opts)\n    if type(items) ~= \"table\" or #items == 0 then\n        error(\"alc.parallel: items must be a non-empty array\", 2)\n    end\n    if type(prompt_fn) ~= \"function\" then\n        error(\"alc.parallel: prompt_fn must be a function\", 2)\n    end\n    opts = opts or {}\n\n    -- Phase 1: build batch from prompt_fn (no LLM calls)\n    local batch = {}\n    for i, item in ipairs(items) do\n        local p = prompt_fn(item, i)\n        if type(p) == \"string\" then\n            local entry = { prompt = p }\n            if opts.system then\n                entry.system = opts.system\n            end\n            if opts.max_tokens then\n                entry.max_tokens = opts.max_tokens\n            end\n            batch[i] = entry\n        elseif type(p) == \"table\" then\n            if type(p.prompt) ~= \"string\" then\n                error(\"alc.parallel: prompt_fn returned table without .prompt at index \" .. i, 2)\n            end\n            batch[i] = p\n        else\n            error(\n                \"alc.parallel: prompt_fn must return string or table, got \"\n                    .. type(p)\n                    .. \" at index \"\n                    .. i,\n                2\n            )\n        end\n    end\n\n    -- Phase 2: single batch LLM call\n    local responses = alc.llm_batch(batch)\n\n    -- Phase 3: optional post-processing\n    if opts.post_fn then\n        local results = {}\n        for i, resp in ipairs(responses) do\n            results[i] = opts.post_fn(resp, items[i], i)\n        end\n        return results\n    end\n\n    return responses\nend\n\n--- alc.pipe(strategies, ctx, opts?) -> ctx\n--- Sequential pipeline: run multiple strategies in order, passing\n--- each stage\'s result as the next stage\'s task.\n---\n--- Each strategy is loaded via require() and must have M.run(ctx).\n--- The pipeline shallow-copies ctx, then for each strategy:\n---   1. Sets ctx.task to the previous stage\'s result (stringified)\n---   2. Calls strategy.run(ctx)\n---   3. Extracts ctx.result for the next stage\n---\n--- opts.on_stage(i, name, ctx): optional callback after each stage.\n--- ctx.pipe_history: array of { strategy, result } for debugging.\n---\n--- Inter-stage data flow:\n--- Between stages, ctx.result is converted to ctx.task as a **string**:\n--- - table results: serialized via alc.json_encode() (JSON string)\n--- - all other types: converted via tostring()\n--- This means the next stage always receives ctx.task as a string.\n--- Type information is intentionally discarded \u{2014} each stage treats\n--- ctx.task as raw text (prompt material), not structured data.\n--- If a stage needs structured input, it should json_decode(ctx.task).\n---\n--- Limitations:\n--- - Strategies must be pre-installed (require() is used, not alc_advice\'s\n---   auto-install). Use alc_pkg_install or alc init beforehand.\n--- - Budget (ctx.budget) is shared across all pipeline stages. A 3-stage\n---   pipeline with max_llm_calls=10 gives ~3 calls per stage, not 10 each.\n--- - Shallow copy: nested tables in ctx are shared by reference.\n---   Stages that mutate nested ctx fields affect subsequent stages.\n---\n--- Usage:\n---   local result = alc.pipe({\"cot\", \"cove\", \"reflect\"}, ctx)\n---   -- result.pipe_history has intermediate results\n---\n---   -- With inline functions:\n---   local result = alc.pipe({\n---       \"cot\",\n---       function(c) c.result = alc.llm(\"Verify: \" .. c.task); return c end,\n---       \"reflect\",\n---   }, ctx)\nfunction alc.pipe(strategies, ctx, opts)\n    if type(strategies) ~= \"table\" or #strategies == 0 then\n        error(\"alc.pipe: strategies must be a non-empty array\", 2)\n    end\n    if type(ctx) ~= \"table\" then\n        error(\"alc.pipe: ctx must be a table\", 2)\n    end\n    opts = opts or {}\n    local on_error = opts.on_error or \"abort\"\n\n    -- Shallow-copy ctx to avoid mutating the original\n    local pipe_ctx = {}\n    for k, v in pairs(ctx) do\n        pipe_ctx[k] = v\n    end\n    pipe_ctx.pipe_history = {}\n\n    for i, entry in ipairs(strategies) do\n        local name, run_fn\n\n        if type(entry) == \"string\" then\n            name = entry\n            local ok, pkg = pcall(require, entry)\n            if not ok then\n                error(\"alc.pipe: failed to load strategy \'\" .. entry .. \"\': \" .. tostring(pkg), 2)\n            end\n            if type(pkg) ~= \"table\" or type(pkg.run) ~= \"function\" then\n                error(\"alc.pipe: strategy \'\" .. entry .. \"\' must export run(ctx)\", 2)\n            end\n            run_fn = pkg.run\n        elseif type(entry) == \"function\" then\n            name = \"(inline-\" .. i .. \")\"\n            run_fn = entry\n        else\n            error(\"alc.pipe: strategy[\" .. i .. \"] must be a string or function\", 2)\n        end\n\n        if on_error == \"abort\" then\n            -- Default: propagate error with full stack trace (backward compatible)\n            pipe_ctx = run_fn(pipe_ctx)\n\n            if type(pipe_ctx) ~= \"table\" then\n                error(\"alc.pipe: strategy \'\" .. name .. \"\' must return a table (ctx)\", 2)\n            end\n        else\n            local ok, result = pcall(run_fn, pipe_ctx)\n            if not ok then\n                -- Record error in history; pipe_ctx remains unchanged (previous value)\n                alc.log(\"warn\", \"alc.pipe: stage \'\" .. name .. \"\' failed: \" .. tostring(result))\n                pipe_ctx.pipe_history[#pipe_ctx.pipe_history + 1] = {\n                    strategy = name,\n                    error = tostring(result),\n                }\n                -- \"skip\": ctx.task unchanged, advance to next stage\n                -- \"continue\": pipe_ctx (including task) unchanged, advance to next stage\n                goto next_stage\n            end\n\n            pipe_ctx = result\n            if type(pipe_ctx) ~= \"table\" then\n                error(\"alc.pipe: strategy \'\" .. name .. \"\' must return a table (ctx)\", 2)\n            end\n        end\n\n        -- Record history (success path only)\n        local result_snapshot = pipe_ctx.result\n        pipe_ctx.pipe_history[#pipe_ctx.pipe_history + 1] = {\n            strategy = name,\n            result = result_snapshot,\n        }\n\n        -- Transfer result \u{2192} task for next stage\n        if pipe_ctx.result ~= nil and i < #strategies then\n            if type(pipe_ctx.result) == \"table\" then\n                pipe_ctx.task = alc.json_encode(pipe_ctx.result)\n            else\n                pipe_ctx.task = tostring(pipe_ctx.result)\n            end\n        end\n\n        -- Optional callback (success path only)\n        if opts.on_stage then\n            opts.on_stage(i, name, pipe_ctx)\n        end\n\n        ::next_stage::\n    end\n\n    return pipe_ctx\nend\n\n--- alc.eval(scenario, strategy, opts?) -> report\n--- Evaluate a strategy against a scenario. Thin facade over evalframe\n--- that handles scenario resolution, provider wiring, and Card emission.\n---\n--- scenario: string (name in ~/.algocline/scenarios/) or table:\n---   Simple form:  { cases = { {input=..., expected=...}, ... }, graders = {\"exact_match\"} }\n---   Full form:    evalframe-compatible spec with ef.bind / ef.case\n---\n--- strategy: string (package name, e.g. \"cot\", \"reflect\")\n---\n--- opts:\n---   strategy_opts  table   Extra opts passed to strategy.run()\n---   auto_card      bool    Emit Card on completion (default: false)\n---   card_pkg       string  Card pkg.name override\n---\n--- Returns: evalframe report table (aggregated, failures, results, summary)\ndo\n    -- Resolve grader shorthand (\"exact_match\") to evalframe grader function.\n    local function resolve_grader(ef, g)\n        if type(g) == \"function\" then\n            return g\n        end\n        if type(g) == \"string\" then\n            local grader_fn = ef.graders[g]\n            if not grader_fn then\n                error(\"alc.eval: unknown grader \'\" .. g .. \"\'\")\n            end\n            return grader_fn\n        end\n        error(\"alc.eval: grader must be a string name or function, got \" .. type(g))\n    end\n\n    -- Wrap simple {input, expected} tables as ef.case if needed.\n    local function resolve_cases(ef, raw_cases)\n        local cases = {}\n        for i, c in ipairs(raw_cases) do\n            if type(c) == \"table\" and ef.case.is_case(c) then\n                cases[i] = c\n            elseif type(c) == \"table\" and c.input then\n                cases[i] = ef.case(c)\n            else\n                error(\"alc.eval: case #\" .. i .. \" must have an \'input\' field\")\n            end\n        end\n        return cases\n    end\n\n    -- Build evalframe suite spec from scenario table.\n    local function build_suite_spec(ef, spec, provider)\n        -- Full form: spec already contains ef.bind entries as indexed elements\n        local has_bindings = false\n        for i = 1, #spec do\n            if type(spec[i]) == \"table\" and ef.bind.is_binding(spec[i]) then\n                has_bindings = true\n                break\n            end\n        end\n\n        -- scenario-side `provider` (if any) takes precedence over the auto-wired\n        -- algocline provider. This allows replay / mock providers (e.g.\n        -- ef.providers.recorded, mock.recording) to be used in alc_eval without\n        -- changing the MCP wire shape. Falls back to the auto-wired provider\n        -- when scenario does not specify one.\n        local resolved_provider = spec.provider or provider\n\n        if has_bindings then\n            -- Full evalframe-compatible spec: copy indexed bindings + cases\n            local suite_spec = { provider = resolved_provider }\n            for i = 1, #spec do\n                suite_spec[i] = spec[i]\n            end\n            suite_spec.cases = spec.cases\n            return suite_spec\n        end\n\n        -- Simple form: resolve graders \u{2192} bindings, cases \u{2192} ef.case\n        local grader_names = spec.graders or { \"exact_match\" }\n        local suite_spec = { provider = resolved_provider }\n        for i, g in ipairs(grader_names) do\n            suite_spec[i] = ef.bind({ resolve_grader(ef, g) })\n        end\n        suite_spec.cases = resolve_cases(ef, spec.cases or {})\n        return suite_spec\n    end\n\n    -- Emit Card from eval report (Two-Tier Content Policy).\n    local function emit_eval_card(strategy, scenario_name, report, opts)\n        local pkg_name = opts.card_pkg or strategy\n        local agg = report.aggregated or {}\n        local scores = agg.scores or {}\n\n        local card = alc.card.create({\n            pkg = { name = pkg_name },\n            scenario = { name = scenario_name or \"inline\" },\n            stats = {\n                pass_rate = agg.pass_rate,\n                mean_score = scores.mean,\n                n = agg.total,\n                passed = agg.passed,\n            },\n        })\n\n        -- Tier 2: per-case results as samples sidecar\n        if report.results and #report.results > 0 then\n            alc.card.write_samples(card.card_id, report.results)\n        end\n\n        return card.card_id\n    end\n\n    function alc.eval(scenario, strategy, opts)\n        if not scenario then\n            error(\"alc.eval: scenario is required\")\n        end\n        if type(scenario) ~= \"string\" and type(scenario) ~= \"table\" then\n            error(\"alc.eval: scenario must be a string or table\")\n        end\n        if not strategy or type(strategy) ~= \"string\" then\n            error(\"alc.eval: strategy must be a string package name\")\n        end\n        opts = opts or {}\n\n        -- 1. Load evalframe\n        local ok, ef = pcall(require, \"evalframe\")\n        if not ok then\n            error(\"alc.eval: evalframe not installed. Run alc_pkg_install to add it.\")\n        end\n\n        -- 2. Resolve scenario\n        local spec\n        local scenario_name\n        if type(scenario) == \"string\" then\n            scenario_name = scenario\n            local load_ok, loaded\n\n            -- 2a. Try require (packages on package.path)\n            load_ok, loaded = pcall(require, scenario)\n\n            -- 2b. Try {alc._dirs.scenarios}/{name}.lua (service layer injects\n            --     the absolute path so Lua never reads HOME directly).\n            if not load_ok then\n                local scenarios_dir = (alc and alc._dirs and alc._dirs.scenarios) or \"\"\n                local path = scenarios_dir .. \"/\" .. scenario .. \".lua\"\n                local f = io.open(path, \"r\")\n                if f then\n                    local code = f:read(\"*a\")\n                    f:close()\n                    local chunk, err = load(code, \"@\" .. path)\n                    if not chunk then\n                        error(\"alc.eval: failed to load scenario \'\" .. scenario .. \"\': \" .. err)\n                    end\n                    loaded = chunk()\n                    load_ok = true\n                end\n            end\n\n            -- 2c. Try as a direct file path (absolute or relative)\n            if not load_ok then\n                local f = io.open(scenario, \"r\")\n                if f then\n                    local code = f:read(\"*a\")\n                    f:close()\n                    local chunk, err = load(code, \"@\" .. scenario)\n                    if not chunk then\n                        error(\"alc.eval: failed to load scenario: \" .. err)\n                    end\n                    loaded = chunk()\n                    load_ok = true\n                end\n            end\n\n            if not load_ok then\n                error(\"alc.eval: could not resolve scenario \'\" .. scenario .. \"\'\")\n            end\n            spec = loaded\n        else -- type(scenario) == \"table\" (guaranteed by early validation)\n            scenario_name = scenario.name\n            spec = scenario\n        end\n\n        -- Validate resolved spec\n        if type(spec) ~= \"table\" then\n            error(\"alc.eval: scenario resolved to \" .. type(spec) .. \", expected table\")\n        end\n\n        -- 3. Build provider\n        local provider = ef.providers.algocline({\n            strategy = strategy,\n            opts = opts.strategy_opts,\n        })\n\n        -- 4. Build and run suite\n        local suite_spec = build_suite_spec(ef, spec, provider)\n        local suite_name = strategy .. \":\" .. (scenario_name or \"inline\")\n        local suite = ef.suite(suite_name)(suite_spec)\n        local report = suite:run():to_table()\n\n        -- 5. Auto-card\n        if opts.auto_card then\n            local card_id = emit_eval_card(strategy, scenario_name, report, opts)\n            report.card_id = card_id\n            alc.log(\"info\", \"alc.eval: card emitted \u{2014} \" .. card_id)\n        end\n\n        return report\n    end\nend\n";
Expand description

Layer 1 prelude (also used by fork to setup child VMs).