query: rich QueryResult + toolkit cleanup

QueryResult carries a fields map (BTreeMap<String, Value>) so callers
don't re-resolve fields after queries run. Neighbors queries inject
edge context (strength, rel_type) at construction time.

New public API:
- run_query(): parse + execute + format in one call
- format_value(): format a Value for display
- execute_parsed(): internal, avoids double-parse in run_query

Removed: output_stages(), format_field()

Simplified commands:
- cmd_query, cmd_graph, cmd_link, cmd_list_keys all delegate to run_query
- cmd_experience_mine uses existing find_current_transcript()

Deduplication:
- now_epoch() 3 copies → 1 (capnp_store's public fn)
- hub_threshold → Graph::hub_threshold() method
- eval_node + eval_edge → single eval() with closure for field resolution
- compare() collapsed via Ordering (35 → 15 lines)

Modernization:
- 12 sites of partial_cmp().unwrap_or(Ordering::Equal) → total_cmp()
This commit is contained in:
ProofOfConcept 2026-03-03 12:07:04 -05:00
parent 64d2b441f0
commit fa7fe8c14b
7 changed files with 187 additions and 264 deletions

View file

@ -24,6 +24,7 @@
use crate::capnp_store::{NodeType, Provenance, RelationType, Store};
use crate::graph::Graph;
use regex::Regex;
use std::collections::BTreeMap;
// -- AST types --
@ -240,40 +241,26 @@ fn as_str(v: &Value) -> String {
}
fn compare(lhs: &Value, op: CmpOp, rhs: &Value) -> bool {
if let CmpOp::Match = op {
return Regex::new(&as_str(rhs))
.map(|re| re.is_match(&as_str(lhs)))
.unwrap_or(false);
}
// Numeric comparison if both parse, otherwise string
let ord = match (as_num(lhs), as_num(rhs)) {
(Some(a), Some(b)) => a.total_cmp(&b),
_ => as_str(lhs).cmp(&as_str(rhs)),
};
match op {
CmpOp::Match => {
let text = as_str(lhs);
let pat = as_str(rhs);
match Regex::new(&pat) {
Ok(re) => re.is_match(&text),
Err(_) => false,
}
}
CmpOp::Eq => {
if let (Some(a), Some(b)) = (as_num(lhs), as_num(rhs)) {
a == b
} else {
as_str(lhs) == as_str(rhs)
}
}
CmpOp::Ne => {
if let (Some(a), Some(b)) = (as_num(lhs), as_num(rhs)) {
a != b
} else {
as_str(lhs) != as_str(rhs)
}
}
CmpOp::Gt | CmpOp::Lt | CmpOp::Ge | CmpOp::Le => {
let a = as_num(lhs).unwrap_or(f64::NAN);
let b = as_num(rhs).unwrap_or(f64::NAN);
match op {
CmpOp::Gt => a > b,
CmpOp::Lt => a < b,
CmpOp::Ge => a >= b,
CmpOp::Le => a <= b,
_ => unreachable!(),
}
}
CmpOp::Eq => ord.is_eq(),
CmpOp::Ne => !ord.is_eq(),
CmpOp::Gt => ord.is_gt(),
CmpOp::Lt => ord.is_lt(),
CmpOp::Ge => !ord.is_lt(),
CmpOp::Le => !ord.is_gt(),
CmpOp::Match => unreachable!(),
}
}
@ -301,60 +288,27 @@ fn resolve_value(v: &Value, store: &Store, graph: &Graph) -> Value {
}
}
fn eval_node(expr: &Expr, key: &str, store: &Store, graph: &Graph) -> bool {
match expr {
Expr::All => true,
Expr::Comparison { field, op, value } => {
let lhs = match resolve_field(field, key, store, graph) {
Some(v) => v,
None => return false,
};
let rhs = resolve_value(value, store, graph);
compare(&lhs, *op, &rhs)
}
Expr::And(a, b) => {
eval_node(a, key, store, graph) && eval_node(b, key, store, graph)
}
Expr::Or(a, b) => {
eval_node(a, key, store, graph) || eval_node(b, key, store, graph)
}
Expr::Not(e) => !eval_node(e, key, store, graph),
Expr::Neighbors { .. } => false,
}
}
fn eval_edge(
/// Evaluate an expression against a field resolver.
/// The resolver returns field values — different for nodes vs edges.
fn eval(
expr: &Expr,
_source: &str,
target: &str,
strength: f32,
rel_type: RelationType,
resolve: &dyn Fn(&str) -> Option<Value>,
store: &Store,
graph: &Graph,
) -> bool {
match expr {
Expr::All => true,
Expr::Comparison { field, op, value } => {
let lhs = match field.as_str() {
"strength" => Value::Num(strength as f64),
"rel_type" => Value::Str(rel_type_label(rel_type).to_string()),
_ => match resolve_field(field, target, store, graph) {
Some(v) => v,
None => return false,
},
let lhs = match resolve(field) {
Some(v) => v,
None => return false,
};
let rhs = resolve_value(value, store, graph);
compare(&lhs, *op, &rhs)
}
Expr::And(a, b) => {
eval_edge(a, _source, target, strength, rel_type, store, graph)
&& eval_edge(b, _source, target, strength, rel_type, store, graph)
}
Expr::Or(a, b) => {
eval_edge(a, _source, target, strength, rel_type, store, graph)
|| eval_edge(b, _source, target, strength, rel_type, store, graph)
}
Expr::Not(e) => !eval_edge(e, _source, target, strength, rel_type, store, graph),
Expr::And(a, b) => eval(a, resolve, store, graph) && eval(b, resolve, store, graph),
Expr::Or(a, b) => eval(a, resolve, store, graph) || eval(b, resolve, store, graph),
Expr::Not(e) => !eval(e, resolve, store, graph),
Expr::Neighbors { .. } => false,
}
}
@ -363,6 +317,7 @@ fn eval_edge(
pub struct QueryResult {
pub key: String,
pub fields: BTreeMap<String, Value>,
}
// -- Query executor --
@ -374,7 +329,14 @@ pub fn execute_query(
) -> Result<Vec<QueryResult>, String> {
let q = query_parser::query(query_str)
.map_err(|e| format!("Parse error: {}", e))?;
execute_parsed(store, graph, &q)
}
fn execute_parsed(
store: &Store,
graph: &Graph,
q: &Query,
) -> Result<Vec<QueryResult>, String> {
let mut results = match &q.expr {
Expr::Neighbors { key, filter } => {
let resolved = store.resolve_key(key).unwrap_or_else(|_| key.clone());
@ -382,14 +344,24 @@ pub fn execute_query(
let mut out = Vec::new();
for edge in edges {
let include = match filter {
Some(f) => eval_edge(
f, &resolved, &edge.target,
edge.strength, edge.rel_type, store, graph,
),
Some(f) => {
let strength = edge.strength;
let rt = edge.rel_type;
let target = &edge.target;
eval(f, &|field| match field {
"strength" => Some(Value::Num(strength as f64)),
"rel_type" => Some(Value::Str(rel_type_label(rt).to_string())),
_ => resolve_field(field, target, store, graph),
}, store, graph)
}
None => true,
};
if include {
out.push(QueryResult { key: edge.target.clone() });
let mut fields = BTreeMap::new();
fields.insert("strength".into(), Value::Num(edge.strength as f64));
fields.insert("rel_type".into(),
Value::Str(rel_type_label(edge.rel_type).to_string()));
out.push(QueryResult { key: edge.target.clone(), fields });
}
}
out
@ -398,14 +370,43 @@ pub fn execute_query(
let mut out = Vec::new();
for key in store.nodes.keys() {
if store.nodes[key].deleted { continue; }
if eval_node(&q.expr, key, store, graph) {
out.push(QueryResult { key: key.clone() });
if eval(&q.expr, &|f| resolve_field(f, key, store, graph), store, graph) {
out.push(QueryResult { key: key.clone(), fields: BTreeMap::new() });
}
}
out
}
};
// Collect fields needed by select/sort stages and resolve them once
let needed: Vec<String> = {
let mut set = Vec::new();
for stage in &q.stages {
match stage {
Stage::Select(fields) => {
for f in fields {
if !set.contains(f) { set.push(f.clone()); }
}
}
Stage::Sort { field, .. } => {
if !set.contains(field) { set.push(field.clone()); }
}
_ => {}
}
}
set
};
for r in &mut results {
for f in &needed {
if !r.fields.contains_key(f) {
if let Some(v) = resolve_field(f, &r.key, store, graph) {
r.fields.insert(f.clone(), v);
}
}
}
}
// Apply pipeline stages
let mut has_sort = false;
for stage in &q.stages {
@ -414,25 +415,17 @@ pub fn execute_query(
has_sort = true;
let asc = *ascending;
results.sort_by(|a, b| {
let va = resolve_field(field, &a.key, store, graph)
.and_then(|v| as_num(&v));
let vb = resolve_field(field, &b.key, store, graph)
.and_then(|v| as_num(&v));
match (va, vb) {
(Some(a), Some(b)) => if asc {
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
} else {
b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal)
},
// String fallback for non-numeric fields
let va = a.fields.get(field).and_then(|v| as_num(v));
let vb = b.fields.get(field).and_then(|v| as_num(v));
let ord = match (va, vb) {
(Some(a), Some(b)) => a.total_cmp(&b),
_ => {
let sa = resolve_field(field, &a.key, store, graph)
.map(|v| as_str(&v)).unwrap_or_default();
let sb = resolve_field(field, &b.key, store, graph)
.map(|v| as_str(&v)).unwrap_or_default();
if asc { sa.cmp(&sb) } else { sb.cmp(&sa) }
let sa = a.fields.get(field).map(|v| as_str(v)).unwrap_or_default();
let sb = b.fields.get(field).map(|v| as_str(v)).unwrap_or_default();
sa.cmp(&sb)
}
}
};
if asc { ord } else { ord.reverse() }
});
}
Stage::Limit(n) => {
@ -454,33 +447,66 @@ pub fn execute_query(
Ok(results)
}
/// Extract the output stages from a parsed query (for cmd_query formatting)
pub fn output_stages(query_str: &str) -> Result<Vec<Stage>, String> {
let q = query_parser::query(query_str)
.map_err(|e| format!("Parse error: {}", e))?;
Ok(q.stages)
}
/// Format a field value for display
pub fn format_field(field: &str, key: &str, store: &Store, graph: &Graph) -> String {
match resolve_field(field, key, store, graph) {
Some(Value::Num(n)) => {
if n == n.floor() && n.abs() < 1e15 {
format!("{}", n as i64)
/// Format a Value for display
pub fn format_value(v: &Value) -> String {
match v {
Value::Num(n) => {
if *n == n.floor() && n.abs() < 1e15 {
format!("{}", *n as i64)
} else {
format!("{:.3}", n)
}
}
Some(Value::Str(s)) => {
if field == "content" {
let truncated: String = s.chars().take(80).collect();
if s.len() > 80 { format!("{}...", truncated) } else { truncated }
} else {
s
}
}
Some(Value::Ident(s)) => s,
Some(Value::FnCall(_)) => "?".to_string(),
None => "-".to_string(),
Value::Str(s) => s.clone(),
Value::Ident(s) => s.clone(),
Value::FnCall(_) => "?".to_string(),
}
}
/// Execute query and print formatted output.
pub fn run_query(store: &Store, graph: &Graph, query_str: &str) -> Result<(), String> {
let q = query_parser::query(query_str)
.map_err(|e| format!("Parse error: {}", e))?;
let results = execute_parsed(store, graph, &q)?;
// Count stage
if q.stages.iter().any(|s| matches!(s, Stage::Count)) {
println!("{}", results.len());
return Ok(());
}
if results.is_empty() {
eprintln!("No results");
return Ok(());
}
// Select stage
let fields: Option<&Vec<String>> = q.stages.iter().find_map(|s| match s {
Stage::Select(f) => Some(f),
_ => None,
});
if let Some(fields) = fields {
let mut header = vec!["key".to_string()];
header.extend(fields.iter().cloned());
println!("{}", header.join("\t"));
for r in &results {
let mut row = vec![r.key.clone()];
for f in fields {
row.push(match r.fields.get(f) {
Some(v) => format_value(v),
None => "-".to_string(),
});
}
println!("{}", row.join("\t"));
}
} else {
for r in &results {
println!("{}", r.key);
}
}
Ok(())
}