use crate::db::Corpus;
use crate::linter::Linter;
use crate::vocabulary::VocabularySpace;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Construct {
Atom,
Conjunction,
Disjunction,
Negation,
Wildcard,
NumericRange,
Compound,
Epistemic,
Unanswerable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IklTraj {
pub query: String,
pub ikl: String,
pub construct: Construct,
pub matched: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives: Vec<String>,
}
pub fn ikl_tool(spec: &VocabularySpace) -> serde_json::Value {
let stems = spec.valid_prefixes();
json!({
"name": "ikl_query",
"description": format!(
"Run an IKL s-expression over the corpus. Grammar: a bare token `facet/value`, a subtree wildcard `facet/*`, `(and …)`, `(or …)`, `(not …)`, and `(num <field> <op> <value>)` with op ge|gt|le|lt|eq|ne. Facet stems: {}.",
stems.join(", ")
),
"parameters": {
"type": "object",
"properties": { "ikl": { "type": "string" } },
"required": ["ikl"]
}
})
}
pub fn verify(corpus: &Corpus, linter: &Linter, ikl: &str, min_rows: usize) -> Option<usize> {
let report = linter.lint(ikl);
if !report.ok {
return None;
}
let out = corpus.query(ikl, 1);
(out.count >= min_rows).then_some(out.count)
}
fn facet_tokens(corpus: &Corpus, facet: &str, k: usize) -> Vec<String> {
corpus.facet_tokens(facet, k).into_iter().map(|(t, _)| t).collect()
}
fn leaf(t: &str) -> &str {
t.rsplit('/').next().unwrap_or(t)
}
pub fn generate(corpus: &Corpus, _spec: &VocabularySpace, per_construct: usize) -> Vec<IklTraj> {
let linter = corpus.linter();
let stats = corpus.stats();
let facets: Vec<String> = stats
.facets
.iter()
.map(|(f, _)| f.clone())
.filter(|f| f != "src" && f != "state")
.collect();
let mut out: Vec<IklTraj> = Vec::new();
let mut push = |query: String, ikl: String, construct: Construct, alternatives: Vec<String>| {
let min = if construct == Construct::Unanswerable { 0 } else { 1 };
if construct == Construct::Unanswerable {
out.push(IklTraj { query, ikl, construct, matched: 0, alternatives });
return;
}
if let Some(n) = verify(corpus, &linter, &ikl, min) {
out.push(IklTraj { query, ikl, construct, matched: n, alternatives });
}
};
for f in facets.iter().take(per_construct.max(2)) {
for t in facet_tokens(corpus, f, 2) {
push(format!("Which records mention {}?", leaf(&t)), t.clone(), Construct::Atom, vec![]);
}
push(format!("Show everything with a {f}."), format!("{f}/*"), Construct::Wildcard, vec![]);
}
for a in facets.iter().take(3) {
for b in facets.iter().take(3) {
if a == b {
continue;
}
let (ta, tb) = (facet_tokens(corpus, a, 1), facet_tokens(corpus, b, 1));
let (Some(ta), Some(tb)) = (ta.first(), tb.first()) else { continue };
push(
format!("Which records have both {} and {}?", leaf(ta), leaf(tb)),
format!("(and {ta} {tb})"),
Construct::Conjunction,
vec![],
);
push(
format!("Show records with either {} or {}.", leaf(ta), leaf(tb)),
format!("(or {ta} {tb})"),
Construct::Disjunction,
vec![],
);
push(
format!("Which {} records are not {}?", leaf(ta), leaf(tb)),
format!("(and {ta} (not {tb}))"),
Construct::Negation,
vec![],
);
push(
format!("Any {a} that is {} but not {}?", leaf(ta), leaf(tb)),
format!("(and {a}/* {ta} (not {tb}))"),
Construct::Compound,
vec![],
);
}
}
for field in stats.numeric_fields.iter().take(3) {
for (op, word) in [("gt", "more than"), ("lt", "less than")] {
push(
format!("Which records have {field} {word} 100?"),
format!("(num {field} {op} 100)"),
Construct::NumericRange,
vec![],
);
}
if let Some(f) = facets.first() {
push(
format!("Show {f} records with {field} above 50."),
format!("(and {f}/* (num {field} gt 50))"),
Construct::Compound,
vec![],
);
}
}
if corpus.has_token("state/negated") {
for f in facets.iter().take(2) {
push(
format!("Which {f} facts are asserted, excluding anything negated?"),
format!("(and {f}/* (not state/negated))"),
Construct::Epistemic,
vec![],
);
}
}
let real_facets = linter.facet_names();
for bogus in ["gene/brca1", "platform/uav", "artifact/power_cube"] {
let bogus_facet = bogus.split('/').next().unwrap_or(bogus);
if real_facets.iter().any(|f| f == bogus_facet) {
continue; }
push(
format!("Which records involve {}?", leaf(bogus)),
String::new(),
Construct::Unanswerable,
real_facets.iter().take(6).cloned().collect(),
);
}
out
}
pub fn to_needle_jsonl(spec: &VocabularySpace, trajs: &[IklTraj]) -> String {
let tool = ikl_tool(spec);
trajs
.iter()
.filter_map(|t| {
let answers = if t.construct == Construct::Unanswerable {
json!([])
} else {
json!([{ "name": "ikl_query", "arguments": { "ikl": t.ikl } }])
};
serde_json::to_string(&json!({
"query": t.query,
"tools": [tool.clone()],
"answers": answers,
}))
.ok()
.map(|l| l + "\n")
})
.collect()
}
pub fn coverage(trajs: &[IklTraj]) -> std::collections::BTreeMap<String, usize> {
let mut m = std::collections::BTreeMap::new();
for t in trajs {
let key = serde_json::to_value(t.construct).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default();
*m.entry(key).or_insert(0) += 1;
}
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::projector::CorpusKind;
use crate::vocabulary::{EntityFacet, VocabularySpace};
fn spec() -> VocabularySpace {
VocabularySpace {
version: 1,
corpus: "t".into(),
entity_facets: vec![
EntityFacet { name: "country".into(), parent: None, description: "".into(), examples: vec![], structural: false },
EntityFacet { name: "powertrain".into(), parent: None, description: "".into(), examples: vec![], structural: false },
],
relation_facets: vec![],
gazetteer: vec![],
metrics: None,
}
}
fn corpus() -> Corpus {
let mut c = Corpus::new_incremental("t", vec!["row".into()], CorpusKind::Csv);
let rows = [
(vec!["country/japan", "powertrain/electric", "state/negated"], 600.0),
(vec!["country/japan", "powertrain/diesel"], 1100.0),
(vec!["country/usa", "powertrain/electric"], 650.0),
(vec!["country/usa", "powertrain/diesel"], 1300.0),
];
for (toks, km) in rows {
let t: Vec<String> = toks.iter().map(|s| s.to_string()).collect();
c.add_situation_num(t, vec!["r".into()], vec![("range_km".into(), km)]);
}
c
}
#[test]
fn every_generated_expression_is_engine_verified() {
let (c, s) = (corpus(), spec());
let trajs = generate(&c, &s, 3);
assert!(!trajs.is_empty());
let linter = c.linter();
for t in trajs.iter().filter(|t| t.construct != Construct::Unanswerable) {
assert!(linter.lint(&t.ikl).ok, "must lint clean: {}", t.ikl);
assert!(t.matched >= 1, "must match rows: {} → {}", t.ikl, t.matched);
assert_eq!(c.query(&t.ikl, 1).count, t.matched, "recorded count must be reproducible: {}", t.ikl);
}
}
#[test]
fn covers_the_constructs_similarity_search_cannot_express() {
let (c, s) = (corpus(), spec());
let cov = coverage(&generate(&c, &s, 3));
for needed in ["conjunction", "negation", "wildcard", "numeric_range", "compound", "unanswerable"] {
assert!(cov.get(needed).copied().unwrap_or(0) > 0, "missing {needed}: {cov:?}");
}
}
#[test]
fn negation_actually_subtracts() {
let (c, s) = (corpus(), spec());
let trajs = generate(&c, &s, 3);
let neg = trajs.iter().find(|t| t.construct == Construct::Negation).expect("a negation example");
let positive = neg.ikl.split_whitespace().nth(1).unwrap().to_string();
let bare = c.query(&positive, 1).count;
assert!(neg.matched < bare, "{} ({}) should be narrower than {positive} ({bare})", neg.ikl, neg.matched);
}
#[test]
fn unanswerable_questions_carry_alternatives_and_no_expression() {
let (c, s) = (corpus(), spec());
let trajs = generate(&c, &s, 3);
let u = trajs.iter().find(|t| t.construct == Construct::Unanswerable).expect("an unanswerable example");
assert!(u.ikl.is_empty(), "no expression may be invented");
assert!(!u.alternatives.is_empty(), "must offer what DOES exist");
assert!(u.alternatives.iter().any(|a| a == "country" || a == "powertrain"));
let jsonl = to_needle_jsonl(&s, std::slice::from_ref(u));
let v: serde_json::Value = serde_json::from_str(jsonl.trim()).unwrap();
assert_eq!(v["answers"].as_array().unwrap().len(), 0);
}
#[test]
fn needle_rows_expose_the_ikl_tool_with_real_stems() {
let (c, s) = (corpus(), spec());
let trajs = generate(&c, &s, 2);
let jsonl = to_needle_jsonl(&s, &trajs);
let first: serde_json::Value = serde_json::from_str(jsonl.lines().next().unwrap()).unwrap();
assert_eq!(first["tools"][0]["name"], "ikl_query");
let desc = first["tools"][0]["description"].as_str().unwrap();
assert!(desc.contains("country"), "facet stems must be advertised: {desc}");
assert!(desc.contains("(and"), "grammar must be advertised");
}
}