Skip to main content

steeldb/agent/
workflow.rs

1//! The bitmap-workflow DSL. Needle emits a compact workflow in one shot — WHICH constraints to apply to
2//! the SPLADE-expanded candidate rows, and WHICH bitmap program to run — and it compiles here to IKL
3//! set-algebra over the roaring index. This is the reasoning contract SteelDB drives on:
4//!
5//!   query → SPLADE/entity-link expansion (candidate rows = `scope`)
6//!         → Needle DSL {constraints, program, args}
7//!         → compile: refined = scope ∩ constraint₁ ∩ … (roaring AND)
8//!         → bitmap program over `refined`
9//!         → deterministic template (crate::agent::synth)
10//!
11//! Constraints are IKL atoms/expressions (`powertrain/electric`, `(num range_km gt 500)`, `(not …)`),
12//! AND-ed together and with the candidate scope. The compiled anchor is a plain IKL string the existing
13//! programs already accept, so the DSL "compiles to the bitmap program" with no new engine.
14
15use crate::db::Corpus;
16use serde_json::{json, Value};
17
18/// Compile the candidate `scope` (tokens from the query's SPLADE/entity-link expansion) plus the DSL
19/// `constraints` into a single IKL anchor: `(and (or scope…) constraint₁ …)`. Empty → whole corpus.
20pub fn compile_anchor(scope: &[String], constraints: &[String]) -> String {
21    let mut parts: Vec<String> = Vec::new();
22    let scope: Vec<&String> = scope.iter().filter(|s| !s.trim().is_empty()).collect();
23    match scope.len() {
24        0 => {}
25        1 => parts.push(scope[0].clone()),
26        _ => parts.push(format!("(or {})", scope.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "))),
27    }
28    for c in constraints {
29        let c = c.trim();
30        if !c.is_empty() {
31            parts.push(c.to_string());
32        }
33    }
34    match parts.len() {
35        0 => "*".to_string(),
36        1 => parts.remove(0),
37        _ => format!("(and {})", parts.join(" ")),
38    }
39}
40
41fn str_field<'a>(v: &'a Value, k: &str) -> &'a str {
42    v.get(k).and_then(|x| x.as_str()).unwrap_or("")
43}
44
45/// Normalise to lowercase alphanumerics — for tolerant matching of model-supplied names to real ones.
46fn norm(s: &str) -> String {
47    s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
48}
49
50/// Resolve a model-supplied facet name to a REAL corpus facet (exact, else case/punctuation-insensitive).
51/// This guards against the model putting a program name or a mis-cased/rephrased word in a facet slot.
52fn resolve_facet(facets: &[String], name: &str) -> Option<String> {
53    if name.is_empty() {
54        return None;
55    }
56    if facets.iter().any(|f| f == name) {
57        return Some(name.to_string());
58    }
59    let n = norm(name);
60    facets.iter().find(|f| norm(f) == n).cloned()
61}
62
63/// Guard a constraint against the real extracted tokens. Passes IKL expressions `(…)` through; keeps a
64/// token that exists; otherwise resolves `facet/value` to the actual token by matching the value under
65/// the resolved facet. Returns a clear error (fed back to the model) when it can't be grounded.
66fn resolve_constraint(corpus: &Corpus, facets: &[String], c: &str) -> Result<String, String> {
67    let c = c.trim();
68    if c.is_empty() {
69        return Err("empty constraint".into());
70    }
71    if c.starts_with('(') {
72        return Ok(c.to_string()); // IKL expression (e.g. numeric predicate) — trust as-is
73    }
74    if corpus.has_token(c) {
75        return Ok(c.to_string());
76    }
77    if let Some((f, v)) = c.split_once('/') {
78        let rf = resolve_facet(facets, f).ok_or_else(|| format!("constraint facet '{f}' is not in the corpus"))?;
79        let nv = norm(v);
80        for (tok, _) in corpus.facet_tokens(&rf, 100_000) {
81            let leaf = tok.split_once('/').map(|x| x.1).unwrap_or(&tok);
82            if norm(leaf) == nv {
83                return Ok(tok);
84            }
85        }
86        Err(format!("no value '{v}' under facet '{rf}'"))
87    } else {
88        Err(format!("constraint '{c}' must be a facet/value token"))
89    }
90}
91
92/// Error payload that hands the model the real schema so it can retry with valid names.
93fn bad_facet(field: &str, got: &str, facets: &[String]) -> (String, bool) {
94    (json!({ "error": format!("{field} '{got}' is not a corpus facet"), "valid_facets": facets }).to_string(), true)
95}
96
97fn usize_field(v: &Value, k: &str, default: usize) -> usize {
98    v.get(k).and_then(|x| x.as_u64()).map(|n| n as usize).unwrap_or(default)
99}
100
101/// Execute a workflow DSL against the corpus. `scope` = candidate tokens from query expansion (may be
102/// empty → whole corpus). Returns `(result_json, is_error)`; the result carries a `"program"` key so
103/// `crate::agent::synth` renders it deterministically.
104pub fn execute(corpus: &Corpus, dsl: &Value, scope: &[String]) -> (String, bool) {
105    let raw_constraints: Vec<String> = dsl
106        .get("constraints")
107        .and_then(|c| c.as_array())
108        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
109        .unwrap_or_default();
110    let program = str_field(dsl, "program");
111    let k = usize_field(dsl, "k", 8);
112
113    // GUARD: validate/resolve all placeholders against the real extracted schema before running. Facet
114    // slots must be real facet names; constraints must ground to real tokens. Failures return the schema
115    // so the model can retry — this is what stops a program name (e.g. "narrow") landing in a facet slot.
116    let facets = corpus.facet_names();
117    let constraints: Vec<String> = {
118        let mut out = Vec::with_capacity(raw_constraints.len());
119        for c in &raw_constraints {
120            match resolve_constraint(corpus, &facets, c) {
121                Ok(t) => out.push(t),
122                Err(e) => return (json!({ "error": format!("constraint {e}"), "valid_facets": facets }).to_string(), true),
123            }
124        }
125        out
126    };
127    let anchor = compile_anchor(scope, &constraints);
128
129    match program {
130        "crosstab" => {
131            let (fa, fb) = (str_field(dsl, "facet_a"), str_field(dsl, "facet_b"));
132            let Some(fa) = resolve_facet(&facets, fa) else { return bad_facet("facet_a", fa, &facets) };
133            let Some(fb) = resolve_facet(&facets, fb) else { return bad_facet("facet_b", fb, &facets) };
134            (with_dsl(corpus.crosstab(&anchor, &fa, &fb, k), &anchor, &constraints), false)
135        }
136        "breakdown" => {
137            let facet = str_field(dsl, "facet");
138            let Some(facet) = resolve_facet(&facets, facet) else { return bad_facet("facet", facet, &facets) };
139            (with_dsl(corpus.breakdown(&anchor, &facet, k.max(15)), &anchor, &constraints), false)
140        }
141        "rank" => {
142            let facet = str_field(dsl, "facet");
143            let Some(facet) = resolve_facet(&facets, facet) else { return bad_facet("facet", facet, &facets) };
144            // rank is global salience (not scoped); constraints don't apply.
145            (with_dsl(corpus.rank(&facet, k), &anchor, &constraints), false)
146        }
147        "cooccurs" => {
148            let token = str_field(dsl, "token");
149            if !corpus.has_token(token) {
150                return (json!({ "error": format!("cooccurs token '{token}' is not in the corpus"), "valid_facets": facets }).to_string(), true);
151            }
152            (with_dsl(corpus.cooccurs(token, k), &anchor, &constraints), false)
153        }
154        "s_path" => {
155            let a = match resolve_constraint(corpus, &facets, str_field(dsl, "a")) {
156                Ok(t) => t,
157                Err(e) => return (json!({ "error": format!("s_path a: {e}") }).to_string(), true),
158            };
159            let b = match resolve_constraint(corpus, &facets, str_field(dsl, "b")) {
160                Ok(t) => t,
161                Err(e) => return (json!({ "error": format!("s_path b: {e}") }).to_string(), true),
162            };
163            let s = usize_field(dsl, "s", 1);
164            (with_dsl(corpus.s_path(&a, &b, s), &anchor, &constraints), false)
165        }
166        "s_clusters" => {
167            let s = usize_field(dsl, "s", 2);
168            (with_dsl(corpus.s_clusters(s, k.max(6)), &anchor, &constraints), false)
169        }
170        "narrow" => {
171            // Pure refinement: return the constrained set (count + sample) with no aggregation.
172            (with_dsl(corpus.narrow(scope, &constraints), &anchor, &constraints), false)
173        }
174        // No explicit program → sniff the constrained set and auto-route to the high-signal programs.
175        "" | "profile" | "auto" => (with_dsl(crate::agent::profile::auto_profile(corpus, &anchor), &anchor, &constraints), false),
176        other => (
177            json!({ "error": format!("unknown program `{other}`"), "valid_programs": ["crosstab", "breakdown", "rank", "cooccurs", "s_path", "s_clusters", "narrow", "profile"] }).to_string(),
178            true,
179        ),
180    }
181}
182
183/// Attach the compiled anchor + constraints to the program result for transparency (the trace/UI shows
184/// exactly which bitmap the program ran over).
185fn with_dsl(mut result: Value, anchor: &str, constraints: &[String]) -> String {
186    if let Value::Object(m) = &mut result {
187        m.insert("compiled_anchor".into(), json!(anchor));
188        m.insert("constraints".into(), json!(constraints));
189    }
190    result.to_string()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn anchor_compiles_scope_and_constraints() {
199        assert_eq!(compile_anchor(&[], &[]), "*");
200        assert_eq!(compile_anchor(&[], &["powertrain/electric".into()]), "powertrain/electric");
201        assert_eq!(
202            compile_anchor(&["ent/x".into(), "ent/y".into()], &["(num range_km gt 500)".into()]),
203            "(and (or ent/x ent/y) (num range_km gt 500))"
204        );
205        assert_eq!(
206            compile_anchor(&[], &["powertrain/electric".into(), "body/suv".into()]),
207            "(and powertrain/electric body/suv)"
208        );
209    }
210}