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).