diff --git a/docs/SIGMA-FLOW.md b/docs/SIGMA-FLOW.md index 8d9f40e..0a6a8f2 100644 --- a/docs/SIGMA-FLOW.md +++ b/docs/SIGMA-FLOW.md @@ -95,7 +95,7 @@ identity. Total, runs before anything executes, rejects a hostile flow before recursion: 1. **Shape.** `t[1] == "flow"`; `t[2]` is a map of string id → node record; each - node has a `kind ∈ {input, llm, output}`. + node has a `kind ∈ {input, llm, output, decision, data}` (extensions below). 2. **Single source / single sink.** Exactly one `input` node and exactly one `output` node. 3. **Arity by kind.** `input` has no `inputs`; `output` has exactly one input @@ -207,3 +207,61 @@ The reference driver passes routing to `opts.run_node`, like the node's existing policy. The host effect asks the decision model once and executes only the selected declared generation policy. It owns timeout, uncertainty/failure fallback, usage and trace handling. This does not add shell execution to the flow. + +## Typed decisions and bounded data composition + +These are append-only extensions to `sigma-flow/v1`. Existing encodings without +the new fields are byte-identical (a pre-extension golden fixture checks this). +Every new semantic option participates in normalization and identity. Node IDs +remain labels; references are only in ordered `inputs`, never inside options. + +`decision` nodes have `inputs`, `policy`, and `questions`. Questions use the typed +decision protocol (`choice`, `score`, `noul`): 1–32 named questions, instructions +of 1–2000 bytes, bounded criteria. The core admits every policy and question +before any effects. The host additionally checks the decision wire budget. The +state is the single input's typed value, or an ordered list of multiple values. +The result is a map of question IDs to validated typed answers. No generation is +implied by a decision node. + +`data` nodes have an `operation` and the following closed options. They perform +no inference, I/O, code execution or evaluation of arbitrary expressions: + +| Operation | Inputs | Options and semantics | +| --- | --- | --- | +| `project` | one object | `path`: 1–16 object keys; fail on a missing key. | +| `select` | records, mask | `field`, `equals`: retain records whose matching mask object has that string field/value; unmatched records are excluded. | +| `overlay` | base, replacements, optional removals | Unknown replacement/removal keys fail. Removals win. Missing replacements retain the original. Optional `min_string_bytes`, `max_string_bytes` and `only_shrink` constrain replacements; rejected replacements retain the original. | +| `union` | 1–32 record maps | Disjoint union; duplicate keys fail. | + +A record map has at most 128 string keys, each 1–128 bytes. String lengths are +UTF-8 byte lengths. Operations do not mutate their inputs. Ordered reconstruction +of application objects remains the caller's job; map keys carry stable item IDs. +The Lua reference exposes these pure operations as `llm_policy.flow_data.run`; +the host mirrors them over JSON values and checks conformance. Runtime nulls and +array/object distinctions must be preserved by the host's JSON representation. + +The following optional controls apply where indicated: + +- `on_error: "input"` (`decision`, `data`, `llm`): return the first input on a + failed node, retaining a failure/fallback trace. Otherwise failure stops the + flow. This is abstention, not a fabricated decision or successful model call. +- `skip_empty: true` (`decision`, `llm`): if the first input is an empty record + map or array, pass it through without calling a model. +- `output_format: "json"` (`llm`): the host requires complete, valid JSON and + forwards its typed value. Truncation, duplicate keys and tool calls fail. +- `context: "inputs"` (`llm`): only declared inputs and the node's system prompt + reach the model. Absent this field, existing conversation inheritance remains. +- `max_tokens` (`llm`): integer 1–4096. `timeout_ms` (`llm`, `decision`): integer + 1–40000. The host may impose a smaller shared request deadline. + +The graph remains finite and acyclic: no foreach, recursion, dynamic edges or +model-created nodes. A client may deterministically prepare a bounded DAG from +its input **before admission**. Every node is visited once, with bounded record +operations. A skipped node is explicit in the trace and performs no effects. + +For typed nodes, the reference `run_node` effect returns the already validated +typed value (and throws on failure); `opts.encode_data` supplies JSON encoding +for typed LLM inputs, including strings projected from records. Set +`opts.typed_input=true` for a typed scalar input (tables are inferred). The +production host owns provider response parsing, +deadlines, cancellation, telemetry and its lossless JSON representation. diff --git a/llm_policy/flow.lua b/llm_policy/flow.lua index 1c4feb6..5408c55 100644 --- a/llm_policy/flow.lua +++ b/llm_policy/flow.lua @@ -18,13 +18,14 @@ local term = require("llm_policy.term") local fields = require("llm_policy.fields") +local data = require("llm_policy.flow_data") local F = {} F.VERSION = "sigma-flow/v1" -- Admission bounds (part of the spec): reject a hostile flow before recursion. F.LIMITS = { max_nodes = 256, max_in_degree = 32 } -F.KINDS = { input = true, llm = true, output = true } +F.KINDS = { input = true, llm = true, output = true, decision = true, data = true } -- =========================================================================== -- shared helpers @@ -111,6 +112,10 @@ function F.check(flow, schema) end local nodes = flow[2] + for id in pairs(nodes) do + if type(id) ~= "string" then return nil, "invalid node id" end + end + local ids = ids_of(nodes) if #ids == 0 then return nil, "$: flow has no nodes" end if #ids > F.LIMITS.max_nodes then @@ -125,6 +130,17 @@ function F.check(flow, schema) if not F.KINDS[kind] then return nil, "node '" .. id .. "': unknown kind '" .. tostring(kind) .. "'" end + if node.inputs ~= nil then + if type(node.inputs) ~= "table" then return nil, "inputs must be an array" end + local count=0 + for k,v in pairs(node.inputs) do + count=count+1 + if type(k)~="number" or k%1~=0 or k<1 or k>#node.inputs or type(v)~="string" then return nil,"invalid input reference" end + end + if count~=#node.inputs then return nil,"sparse inputs" end + elseif kind ~= "input" then return nil,"missing inputs" end + local extra_ok, extra_err = data.check(node) + if not extra_ok then return nil, "node " .. id .. ": " .. extra_err end if node.routing ~= nil then if kind ~= "llm" then return nil, "routing is only valid on llm nodes" end local ok, err = check_routing(node.routing, schema) @@ -137,21 +153,23 @@ function F.check(flow, schema) elseif kind == "output" then n_output = n_output + 1 if nin ~= 1 then return nil, "output node '" .. id .. "' takes exactly one input" end - else -- llm + else -- model or data node if nin < 1 then return nil, "llm node '" .. id .. "' needs at least one input" end if nin > F.LIMITS.max_in_degree then return nil, "llm node '" .. id .. "' exceeds max in-degree " .. F.LIMITS.max_in_degree end - if type(node.system) ~= "string" then + if kind == "llm" and type(node.system) ~= "string" then return nil, "llm node '" .. id .. "' needs a string system prompt" end if node.template ~= nil and type(node.template) ~= "string" then return nil, "llm node '" .. id .. "' template must be a string" end - local sort, err = term.check(node.policy, schema) - if sort == nil then return nil, "llm node '" .. id .. "' policy: " .. err end - if sort ~= "Policy" then - return nil, "llm node '" .. id .. "' policy must be a Policy term, got " .. sort + if kind ~= "data" then + local sort, err = term.check(node.policy, schema) + if sort == nil then return nil, "node '" .. id .. "' policy: " .. err end + if sort ~= "Policy" then + return nil, "node '" .. id .. "' policy must be a Policy term, got " .. sort + end end end for _, pre in ipairs(node.inputs or {}) do @@ -229,8 +247,10 @@ local function content_key(node) "llm", node.system or "", term.encode(term.normalize(node.policy)), node.template or "", - }, "\0") .. (node.routing and ("\0" .. routing_key(node.routing)) or "") + }, "\0") .. (node.routing and ("\0" .. routing_key(node.routing)) or "") .. (data.options(node) ~= "" and ("\0data=" .. data.options(node)) or "") end + if node.kind == "decision" then return "decision\0" .. term.encode(term.normalize(node.policy)) .. "\0" .. data.options(node) end + if node.kind == "data" then return "data\0" .. data.options(node) end return node.kind -- input / output carry nothing else end @@ -287,6 +307,8 @@ function F.normalize(flow) end end end + if node.kind == "decision" then nn.policy = term.normalize(node.policy) end + data.copy_options(node, nn) out[newid[id]] = nn end return { "flow", out } @@ -317,6 +339,9 @@ function F.encode(flow) if node.template ~= nil then seg[#seg + 1] = "template=" .. str_enc(node.template) end if node.routing then seg[#seg + 1] = "routing=" .. routing_key(node.routing, true) end end + if node.kind == "decision" then seg[#seg + 1] = "policy=" .. term.encode(term.normalize(node.policy)) end + local options = data.options(node) + if options ~= "" then seg[#seg + 1] = "data=" .. options end if node.inputs then seg[#seg + 1] = "inputs=[" .. table.concat(node.inputs, ",") .. "]" end @@ -381,11 +406,41 @@ function F.run(flow, opts) end local assemble = opts.assemble or default_assemble - local out, trace = {}, {} - out[input_id] = opts.input or "" + local out, trace, typed = {}, {}, {} + if opts.input ~= nil then out[input_id] = opts.input else out[input_id] = "" end + typed[input_id] = opts.typed_input or type(opts.input) == "table" for _, id in ipairs(order) do local node = nodes[id] - if node.kind == "llm" then + local typed_parts = false + for _,pre in ipairs(node.inputs or {}) do if typed[pre] then typed_parts=true end end + if node.kind == "data" or node.kind == "decision" or (node.kind == "llm" and (typed_parts or data.options(node) ~= "")) then + local values = {}; for i,pre in ipairs(node.inputs) do values[i]=out[pre] end + local skipped = node.skip_empty and data.empty(values[1]) + local ok, result + if skipped then ok,result=true,values[1] + elseif node.kind == "data" then ok,result=pcall(data.run,node,values) + else + local prompt = values[1] + if node.kind == "decision" then + if #values > 1 then prompt=values end + else + local parts={} + for i,v in ipairs(values) do + local text = v + if typed[node.inputs[i]] then text=assert(opts.encode_data,"typed LLM input needs encode_data")(v) end + parts[i]={id=node.inputs[i],text=text} + end + prompt=assemble(node,parts) + end + ok,result=pcall(opts.run_node,node,prompt) + end + local fallback = not ok and node.on_error == "input" + if not ok and not fallback then error(result) end + if fallback then out[id]=values[1] else out[id]=result end + if skipped or fallback then typed[id]=typed[node.inputs[1]] + else typed[id]=node.kind ~= "llm" or node.output_format == "json" end + trace[#trace+1]={node=id,kind=node.kind,skipped=skipped or false,fallback=fallback} + elseif node.kind == "llm" then local parts = {} for i, pre in ipairs(node.inputs) do parts[i] = { id = pre, text = out[pre] or "" } end local prompt = assemble(node, parts) @@ -396,6 +451,7 @@ function F.run(flow, opts) } elseif node.kind == "output" then out[id] = out[node.inputs[1]] + typed[id] = typed[node.inputs[1]] end end return out[output_id], trace diff --git a/llm_policy/flow_data.lua b/llm_policy/flow_data.lua new file mode 100644 index 0000000..a5b2a76 --- /dev/null +++ b/llm_policy/flow_data.lua @@ -0,0 +1,141 @@ +-- Bounded structured-data extensions for Sigma flow. No application vocabulary, +-- code evaluation, dynamic topology or I/O. Model effects remain host-owned. +local D = {} +D.keys = {"operation", "path", "field", "equals", "questions", "output_format", + "skip_empty", "on_error", "context", "max_tokens", "timeout_ms", + "max_string_bytes", "min_string_bytes", "only_shrink"} + +local function integer(x, lo, hi) + return type(x) == "number" and x % 1 == 0 and x >= lo and x <= hi +end +local function text(x, n) return type(x) == "string" and #x > 0 and #x <= n end +local function count(t) local n=0; for _ in pairs(t) do n=n+1 end; return n end + +function D.check(node) + local kind = node.kind + local allowed = {kind=true, inputs=true} + if kind == "decision" then + for _, k in ipairs({"policy", "questions", "skip_empty", "on_error", "timeout_ms"}) do allowed[k]=true end + local qs = node.questions + if type(qs) ~= "table" or count(qs) < 1 or count(qs) > 32 then return nil, "decision needs 1..32 questions" end + for name, q in pairs(qs) do + if not text(name,64) or type(q)~="table" or not text(q.instructions,2000) then return nil,"invalid question" end + for k in pairs(q) do if k~="type" and k~="instructions" and k~="criteria" then return nil,"unknown question field" end end + if q.type == "choice" then + if type(q.criteria)~="table" or count(q.criteria)<1 or count(q.criteria)>32 then return nil,"invalid choice criteria" end + for k,v in pairs(q.criteria) do if not text(k,64) or not text(v,2000) then return nil,"invalid criterion" end end + elseif q.type == "score" then + if type(q.criteria)~="table" or #q.criteria<2 or #q.criteria>32 or count(q.criteria)~=#q.criteria then return nil,"invalid score criteria" end + for _,v in ipairs(q.criteria) do if not text(v,2000) then return nil,"invalid score criterion" end end + elseif q.type == "noul" then + if q.criteria ~= nil then + if type(q.criteria)~="table" then return nil,"invalid noul criteria" end + for k,v in pairs(q.criteria) do if (k~="true" and k~="false") or not text(v,2000) then return nil,"invalid noul criterion" end end + if count(q.criteria)~=2 then return nil,"invalid noul criteria" end + end + else return nil,"unknown question type" end + end + elseif kind == "data" then + allowed.operation=true; allowed.on_error=true + if node.operation == "project" then + allowed.path=true + if #node.inputs~=1 or type(node.path)~="table" or #node.path<1 or #node.path>16 or count(node.path)~=#node.path then return nil,"project needs one input and a bounded path" end + for _,k in ipairs(node.path) do if not text(k,128) then return nil,"path components must be object keys" end end + elseif node.operation == "select" then + allowed.field=true; allowed.equals=true + if #node.inputs~=2 or not text(node.field,128) or not text(node.equals,128) then return nil,"select needs records, mask, field and equals" end + elseif node.operation == "overlay" then + allowed.max_string_bytes=true; allowed.min_string_bytes=true; allowed.only_shrink=true + if #node.inputs<2 or #node.inputs>3 then return nil,"overlay needs base, replacements and optional removals" end + elseif node.operation == "union" then + if #node.inputs<1 or #node.inputs>32 then return nil,"union needs 1..32 inputs" end + else return nil,"unknown data operation" end + elseif kind == "llm" then + allowed = {output_format=true,skip_empty=true,on_error=true,context=true,max_tokens=true,timeout_ms=true} + end + for _,k in ipairs(D.keys) do + if node[k]~=nil and not allowed[k] then return nil,"field not supported by node kind: "..k end + end + if kind=="decision" or kind=="data" then + for k in pairs(node) do if not allowed[k] then return nil,"unknown node field" end end + end + if node.output_format~=nil and node.output_format~="json" then return nil,"invalid output_format" end + if node.on_error~=nil and node.on_error~="input" then return nil,"invalid on_error" end + if node.context~=nil and node.context~="inputs" then return nil,"invalid context" end + for _,k in ipairs({"skip_empty","only_shrink"}) do if node[k]~=nil and type(node[k])~="boolean" then return nil,"invalid boolean field" end end + if node.max_tokens~=nil and not integer(node.max_tokens,1,4096) then return nil,"invalid max_tokens" end + if node.timeout_ms~=nil and not integer(node.timeout_ms,1,40000) then return nil,"invalid timeout_ms" end + if node.max_string_bytes~=nil and not integer(node.max_string_bytes,1,1048576) then return nil,"invalid max_string_bytes" end + if node.min_string_bytes~=nil and not integer(node.min_string_bytes,1,node.max_string_bytes or 1048576) then return nil,"invalid min_string_bytes" end + return true +end + +-- Type-tagged, length-delimited encoding; sorted keys, ordered integer indexes. +-- It only sees admitted, bounded static records, never runtime model output. +local function enc(v) + local ty=type(v) + if ty=="string" then return "s"..#v..":"..v end + if ty=="boolean" then return v and "b1" or "b0" end + if ty=="number" then return "n"..string.format("%.0f",v)..":" end + local keys={}; for k in pairs(v) do keys[#keys+1]=k end + table.sort(keys,function(a,b) return enc(a)=node.min_string_bytes end + if accept and node.only_shrink then accept=type(replacement)=="string" and type(v)=="string" and #replacement<#v end + if accept then out[k]=replacement else out[k]=v end + end + end + return out +end +return D diff --git a/tests/run_lua.lua b/tests/run_lua.lua index 151dba0..d0a9b09 100644 --- a/tests/run_lua.lua +++ b/tests/run_lua.lua @@ -20,6 +20,7 @@ local files = { "tests/unit/ir_elaborate.lua", "tests/unit/ir_golden.lua", "tests/unit/flow_basic.lua", + "tests/unit/flow_data.lua", "tests/unit/reliability_field.lua", } diff --git a/tests/unit/flow_data.lua b/tests/unit/flow_data.lua new file mode 100644 index 0000000..6492aec --- /dev/null +++ b/tests/unit/flow_data.lua @@ -0,0 +1,114 @@ +local t=require('_assert') +local F=require('llm_policy.flow') +local D=require('llm_policy.flow_data') +local function policy() + return {'policy',{'meets_req'},{'field','context'},{'argmax'},{'id'},{'always',{action='next_candidate'}}} +end +local function graph() + return {'flow',{ + input={kind='input'}, + classify={kind='decision',policy=policy(),inputs={'input'},on_error='input',questions={ + ticket={type='choice',instructions='Choose department',criteria={sales='Sales',support='Support'}}}}, + selected={kind='data',operation='select',field='choice',equals='support',inputs={'input','classify'}}, + reply={kind='llm',policy=policy(),system='Draft replies',context='inputs',output_format='json',skip_empty=true,on_error='input',inputs={'selected'}}, + patch={kind='data',operation='overlay',inputs={'input','reply'},on_error='input'}, + output={kind='output',inputs={'patch'}}}} +end + +t.test('typed flow admits and all semantic fields affect identity',function() + local g=graph(); t.truthy(F.check(g)) + local nf=F.normalize(g); local encoded=F.encode(nf) + t.eq(F.encode(F.normalize(nf)),encoded) + g[2].selected.equals='sales' + t.truthy(F.encode(F.normalize(g))~=encoded) + g=graph(); g[2].reply.skip_empty=false + t.truthy(F.encode(F.normalize(g))~=encoded) + g=graph(); g[2].classify.questions.ticket.instructions='Different question' + t.truthy(F.encode(F.normalize(g))~=encoded) + g=graph(); g[2].patch.max_string_bytes=20 + t.truthy(F.encode(F.normalize(g))~=encoded) +end) + +t.test('typed flow rejects hostile options, references and questions before effects',function() + for _,edit in ipairs({ + function(g) g[2].selected.operation='eval' end, + function(g) g[2].selected.code='os.execute' end, + function(g) g[2].selected.inputs='input' end, + function(g) g[2].selected.field={} end, + function(g) g[2].reply.on_error='run_shell' end, + function(g) g[2].reply.skip_empty='true' end, + function(g) g[2].reply.timeout_ms=0 end, + function(g) g[2].reply.questions={} end, + function(g) g[2].classify.policy={'invalid'} end, + function(g) g[2].classify.questions.ticket.criteria={} end, + function(g) g[2].classify.questions.ticket.instructions='' end, + function(g) g[2].patch.max_string_bytes=-1 end, + function(g) g[2].patch.inputs={} end, + }) do local g=graph(); edit(g); local ok,valid=pcall(F.check,g); t.truthy(ok); t.falsy(valid) end +end) + +t.test('bounded record operations preserve values and reject unknown keys',function() + local base={a='aaaa',b='bbbb',flag=true} + t.eq(D.run({operation='project',path={'nested'}},{{nested=base}}).a,'aaaa') + t.eq(D.run({operation='select',field='choice',equals='yes'},{base,{a={choice='yes'},b={choice='no'}}}).a,'aaaa') + local out=D.run({operation='overlay'},{base,{flag=false}}) + t.eq(out.flag,false) + out=D.run({operation='overlay',only_shrink=true,max_string_bytes=3,min_string_bytes=1},{base,{a='A',b='',flag='big'},{b='remove'}}) + t.eq(out.a,'A'); t.eq(out.b,nil); t.eq(out.flag,true) + t.falsy(pcall(D.run,{operation='overlay'},{base,{invented='x'}})) + t.falsy(pcall(D.run,{operation='union'},{{a=1},{a=2}})) + t.eq(D.run({operation='union'},{{a=1},{b=2}}).b,2) + t.falsy(pcall(D.run,{operation='project',path={'missing'}},{base})) +end) + +t.test('reference flow skips generation after an empty selection',function() + local calls=0 + local out,trace=F.run(graph(),{input={ticket='Need a quote'},encode_data=function() return 'json' end, + run_node=function(node,prompt) + calls=calls+1 + t.eq(node.kind,'decision') + return {ticket={choice='sales'}} + end}) + t.eq(calls,1); t.eq(out.ticket,'Need a quote') + local skipped=false; for _,entry in ipairs(trace) do if entry.skipped then skipped=true end end + t.truthy(skipped) +end) + +t.test('reference flow performs selected generation and bounded fallback',function() + local calls=0 + local out=F.run(graph(),{input={ticket='It broke'},encode_data=function() return 'json' end, + run_node=function(node) + calls=calls+1 + if node.kind=='decision' then return {ticket={choice='support'}} end + return {ticket='Try restarting'} + end}) + t.eq(calls,2); t.eq(out.ticket,'Try restarting') + out=F.run(graph(),{input={ticket='It broke'},encode_data=function() return 'json' end, + run_node=function() error('unavailable') end}) + t.eq(out.ticket,'It broke') +end) + +t.test('legacy flow encoding stays byte-identical',function() + local g={'flow',{u={kind='input'},g={kind='llm',system='Answer.',policy=policy(),inputs={'u'}},out={kind='output',inputs={'g'}}}} + t.eq(F.encode(F.normalize(g)),[=[sigma-flow/v1:((input n0) (llm n1 system="Answer." policy=sigma-pol/v2:(policy (meets_req) (field "context") (argmax) (id) (always {"action":"next_candidate"})) inputs=[n0]) (output n2 inputs=[n1]))]=]) +end) + + +t.test('reference serializes typed strings into ordinary generation nodes',function() + local g={'flow',{ + input={kind='input'}, + value={kind='data',operation='project',path={'item'},inputs={'input'}}, + reply={kind='llm',system='',policy=policy(),inputs={'value'}}, + output={kind='output',inputs={'reply'}}}} + local result=F.run(g,{input={item='hello'},encode_data=function(value) return '"'..value..'"' end, + run_node=function(node,prompt) t.eq(prompt,'"hello"'); return 'reply' end}) + t.eq(result,'reply') +end) + + +t.test('integer-valued JSON options have one canonical numeric encoding',function() + local g=graph(); g[2].reply.max_tokens=512 + local encoded=F.encode(F.normalize(g)) + g[2].reply.max_tokens=512.0 + t.eq(F.encode(F.normalize(g)),encoded) +end)