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

@ -73,6 +73,19 @@ impl Graph {
&self.communities &self.communities
} }
/// Hub degree threshold: top 5% by degree
pub fn hub_threshold(&self) -> usize {
let mut degrees: Vec<usize> = self.keys.iter()
.map(|k| self.degree(k))
.collect();
degrees.sort_unstable();
if degrees.len() >= 20 {
degrees[degrees.len() * 95 / 100]
} else {
usize::MAX
}
}
/// Local clustering coefficient: fraction of a node's neighbors /// Local clustering coefficient: fraction of a node's neighbors
/// that are also neighbors of each other. /// that are also neighbors of each other.
/// cc(v) = 2E / (deg * (deg - 1)) /// cc(v) = 2E / (deg * (deg - 1))
@ -187,7 +200,7 @@ impl Graph {
let n = degrees.len(); let n = degrees.len();
if n < 2 { return 0.0; } if n < 2 { return 0.0; }
degrees.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); degrees.sort_by(|a, b| a.total_cmp(b));
let mean = degrees.iter().sum::<f64>() / n as f64; let mean = degrees.iter().sum::<f64>() / n as f64;
if mean < 1e-10 { return 0.0; } if mean < 1e-10 { return 0.0; }
@ -255,17 +268,7 @@ impl Graph {
pub fn link_impact(&self, source: &str, target: &str) -> LinkImpact { pub fn link_impact(&self, source: &str, target: &str) -> LinkImpact {
let source_deg = self.degree(source); let source_deg = self.degree(source);
let target_deg = self.degree(target); let target_deg = self.degree(target);
let hub_threshold = self.hub_threshold();
// Hub threshold: top 5% by degree
let mut all_degrees: Vec<usize> = self.keys.iter()
.map(|k| self.degree(k))
.collect();
all_degrees.sort_unstable();
let hub_threshold = if all_degrees.len() >= 20 {
all_degrees[all_degrees.len() * 95 / 100]
} else {
usize::MAX // can't define hubs with <20 nodes
};
let is_hub_link = source_deg >= hub_threshold || target_deg >= hub_threshold; let is_hub_link = source_deg >= hub_threshold || target_deg >= hub_threshold;
// Community check // Community check
@ -469,7 +472,7 @@ fn label_propagation(
// Adopt the label with most votes // Adopt the label with most votes
if let Some((&best_label, _)) = votes.iter() if let Some((&best_label, _)) = votes.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .max_by(|a, b| a.1.total_cmp(b.1))
{ {
let current = labels[key]; let current = labels[key];
if best_label != current { if best_label != current {

View file

@ -318,16 +318,9 @@ fn cmd_status() -> Result<(), String> {
fn cmd_graph() -> Result<(), String> { fn cmd_graph() -> Result<(), String> {
let store = capnp_store::Store::load()?; let store = capnp_store::Store::load()?;
let g = store.build_graph(); let g = store.build_graph();
println!("Top nodes by degree:"); println!("Top nodes by degree:");
let results = query::execute_query( query::run_query(&store, &g,
&store, &g, "* | sort degree | limit 10")?; "* | sort degree | limit 10 | select degree,clustering_coefficient")
for r in &results {
let deg = g.degree(&r.key);
let cc = g.clustering_coefficient(&r.key);
println!(" {:40} deg={:3} cc={:.3}", r.key, deg, cc);
}
Ok(())
} }
fn cmd_used(args: &[String]) -> Result<(), String> { fn cmd_used(args: &[String]) -> Result<(), String> {
@ -486,14 +479,9 @@ fn cmd_link(args: &[String]) -> Result<(), String> {
let store = capnp_store::Store::load()?; let store = capnp_store::Store::load()?;
let resolved = store.resolve_key(&key)?; let resolved = store.resolve_key(&key)?;
let g = store.build_graph(); let g = store.build_graph();
println!("Neighbors of '{}':", resolved); println!("Neighbors of '{}':", resolved);
let neighbors = g.neighbors(&resolved); query::run_query(&store, &g,
for (i, (n, strength)) in neighbors.iter().enumerate() { &format!("neighbors('{}') | select strength,clustering_coefficient", resolved))
let cc = g.clustering_coefficient(n);
println!(" {:2}. [{:.2}] {} (cc={:.3})", i + 1, strength, n, cc);
}
Ok(())
} }
fn cmd_replay_queue(args: &[String]) -> Result<(), String> { fn cmd_replay_queue(args: &[String]) -> Result<(), String> {
@ -843,29 +831,7 @@ fn cmd_experience_mine(args: &[String]) -> Result<(), String> {
let jsonl_path = if let Some(path) = args.first() { let jsonl_path = if let Some(path) = args.first() {
path.clone() path.clone()
} else { } else {
// Find the most recent JSONL transcript find_current_transcript()
let projects_dir = std::path::Path::new(&std::env::var("HOME").unwrap_or_default())
.join(".claude/projects");
let mut entries: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
if let Ok(dirs) = std::fs::read_dir(&projects_dir) {
for dir in dirs.flatten() {
if let Ok(files) = std::fs::read_dir(dir.path()) {
for file in files.flatten() {
let path = file.path();
if path.extension().map_or(false, |ext| ext == "jsonl") {
if let Ok(meta) = file.metadata() {
if let Ok(mtime) = meta.modified() {
entries.push((mtime, path));
}
}
}
}
}
}
}
entries.sort_by(|a, b| b.0.cmp(&a.0));
entries.first()
.map(|(_, p)| p.to_string_lossy().to_string())
.ok_or("no JSONL transcripts found")? .ok_or("no JSONL transcripts found")?
}; };
@ -1222,11 +1188,7 @@ fn cmd_spectral_suggest(args: &[String]) -> Result<(), String> {
fn cmd_list_keys() -> Result<(), String> { fn cmd_list_keys() -> Result<(), String> {
let store = capnp_store::Store::load()?; let store = capnp_store::Store::load()?;
let g = store.build_graph(); let g = store.build_graph();
let results = query::execute_query(&store, &g, "* | sort key asc")?; query::run_query(&store, &g, "* | sort key asc")
for r in &results {
println!("{}", r.key);
}
Ok(())
} }
fn cmd_list_edges() -> Result<(), String> { fn cmd_list_edges() -> Result<(), String> {
@ -1637,44 +1599,5 @@ Pipe stages:\n \
let query_str = args.join(" "); let query_str = args.join(" ");
let store = capnp_store::Store::load()?; let store = capnp_store::Store::load()?;
let graph = store.build_graph(); let graph = store.build_graph();
query::run_query(&store, &graph, &query_str)
let stages = query::output_stages(&query_str)?;
let results = query::execute_query(&store, &graph, &query_str)?;
// Check for count stage
if stages.iter().any(|s| matches!(s, query::Stage::Count)) {
println!("{}", results.len());
return Ok(());
}
if results.is_empty() {
eprintln!("No results");
return Ok(());
}
// Check for select stage
let fields: Option<&Vec<String>> = stages.iter().find_map(|s| match s {
query::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(query::format_field(f, &r.key, &store, &graph));
}
println!("{}", row.join("\t"));
}
} else {
for r in &results {
println!("{}", r.key);
}
}
Ok(())
} }

View file

@ -23,19 +23,10 @@ use std::collections::HashMap;
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn home() -> PathBuf { fn home() -> PathBuf {
PathBuf::from(env::var("HOME").expect("HOME not set")) PathBuf::from(env::var("HOME").expect("HOME not set"))
} }
fn now_epoch() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}
// Old system data structures (just enough for deserialization) // Old system data structures (just enough for deserialization)
#[derive(Deserialize)] #[derive(Deserialize)]
@ -206,7 +197,7 @@ pub fn migrate() -> Result<(), String> {
let node = Node { let node = Node {
uuid, uuid,
version: 1, version: 1,
timestamp: now_epoch(), timestamp: capnp_store::now_epoch(),
node_type: if key.contains("journal") { node_type: if key.contains("journal") {
NodeType::EpisodicSession NodeType::EpisodicSession
} else { } else {
@ -246,7 +237,7 @@ pub fn migrate() -> Result<(), String> {
let node = Node { let node = Node {
uuid, uuid,
version: 1, version: 1,
timestamp: now_epoch(), timestamp: capnp_store::now_epoch(),
node_type: if key.contains("journal") { node_type: if key.contains("journal") {
NodeType::EpisodicSession NodeType::EpisodicSession
} else { } else {

View file

@ -10,14 +10,7 @@ use crate::similarity;
use crate::spectral::{self, SpectralEmbedding, SpectralPosition}; use crate::spectral::{self, SpectralEmbedding, SpectralPosition};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH}; use crate::capnp_store::now_epoch;
fn now_epoch() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}
const SECS_PER_DAY: f64 = 86400.0; const SECS_PER_DAY: f64 = 86400.0;
@ -137,7 +130,7 @@ pub fn replay_queue_with_graph(
}) })
.collect(); .collect();
items.sort_by(|a, b| b.priority.partial_cmp(&a.priority).unwrap_or(std::cmp::Ordering::Equal)); items.sort_by(|a, b| b.priority.total_cmp(&a.priority));
items.truncate(count); items.truncate(count);
items items
} }
@ -228,7 +221,7 @@ fn format_topology_header(graph: &Graph) -> String {
let e = graph.edge_count(); let e = graph.edge_count();
// Identify saturated hubs — nodes with degree well above threshold // Identify saturated hubs — nodes with degree well above threshold
let threshold = hub_threshold(graph); let threshold = graph.hub_threshold();
let mut hubs: Vec<_> = graph.nodes().iter() let mut hubs: Vec<_> = graph.nodes().iter()
.map(|k| (k.clone(), graph.degree(k))) .map(|k| (k.clone(), graph.degree(k)))
.filter(|(_, d)| *d >= threshold) .filter(|(_, d)| *d >= threshold)
@ -262,22 +255,10 @@ fn format_topology_header(graph: &Graph) -> String {
n, e, graph.community_count(), sigma, alpha, gini, avg_cc, hub_list) n, e, graph.community_count(), sigma, alpha, gini, avg_cc, hub_list)
} }
/// Compute the hub degree threshold (top 5% by degree)
fn hub_threshold(graph: &Graph) -> usize {
let mut degrees: Vec<usize> = graph.nodes().iter()
.map(|k| graph.degree(k))
.collect();
degrees.sort_unstable();
if degrees.len() >= 20 {
degrees[degrees.len() * 95 / 100]
} else {
usize::MAX
}
}
/// Format node data section for prompt templates /// Format node data section for prompt templates
fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> String { fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> String {
let hub_thresh = hub_threshold(graph); let hub_thresh = graph.hub_threshold();
let mut out = String::new(); let mut out = String::new();
for item in items { for item in items {
let node = match store.nodes.get(&item.key) { let node = match store.nodes.get(&item.key) {
@ -363,7 +344,7 @@ fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> S
}) })
.filter(|(_, sim)| *sim > 0.1) .filter(|(_, sim)| *sim > 0.1)
.collect(); .collect();
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
candidates.truncate(8); candidates.truncate(8);
if !candidates.is_empty() { if !candidates.is_empty() {
@ -568,7 +549,7 @@ pub fn agent_prompt(store: &Store, agent: &str, count: usize) -> Result<String,
.filter(|(k, _)| k.contains("journal") || k.contains("session")) .filter(|(k, _)| k.contains("journal") || k.contains("session"))
.map(|(k, n)| (k.clone(), n.timestamp)) .map(|(k, n)| (k.clone(), n.timestamp))
.collect(); .collect();
episodes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); episodes.sort_by(|a, b| b.1.total_cmp(&a.1));
episodes.truncate(count); episodes.truncate(count);
let episode_keys: Vec<_> = episodes.iter().map(|(k, _)| k.clone()).collect(); let episode_keys: Vec<_> = episodes.iter().map(|(k, _)| k.clone()).collect();
@ -959,7 +940,7 @@ pub fn differentiate_hub_with_graph(store: &Store, hub_key: &str, graph: &Graph)
} }
} }
moves.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap_or(std::cmp::Ordering::Equal)); moves.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
Some(moves) Some(moves)
} }
@ -1017,7 +998,7 @@ pub fn apply_differentiation(
/// Find all file-level hubs that have section children to split into. /// Find all file-level hubs that have section children to split into.
pub fn find_differentiable_hubs(store: &Store) -> Vec<(String, usize, usize)> { pub fn find_differentiable_hubs(store: &Store) -> Vec<(String, usize, usize)> {
let graph = store.build_graph(); let graph = store.build_graph();
let threshold = hub_threshold(&graph); let threshold = graph.hub_threshold();
let mut hubs = Vec::new(); let mut hubs = Vec::new();
for key in graph.nodes() { for key in graph.nodes() {
@ -1093,7 +1074,7 @@ pub fn triangle_close(
} }
} }
pair_scores.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); pair_scores.sort_by(|a, b| b.2.total_cmp(&a.2));
let to_add = pair_scores.len().min(max_links_per_hub); let to_add = pair_scores.len().min(max_links_per_hub);
if to_add > 0 { if to_add > 0 {
@ -1168,7 +1149,7 @@ pub fn link_orphans(
.filter(|(_, s)| *s >= sim_threshold) .filter(|(_, s)| *s >= sim_threshold)
.collect(); .collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); scores.sort_by(|a, b| b.1.total_cmp(&a.1));
let to_link = scores.len().min(links_per_orphan); let to_link = scores.len().min(links_per_orphan);
if to_link == 0 { continue; } if to_link == 0 { continue; }

View file

@ -24,6 +24,7 @@
use crate::capnp_store::{NodeType, Provenance, RelationType, Store}; use crate::capnp_store::{NodeType, Provenance, RelationType, Store};
use crate::graph::Graph; use crate::graph::Graph;
use regex::Regex; use regex::Regex;
use std::collections::BTreeMap;
// -- AST types -- // -- AST types --
@ -240,40 +241,26 @@ fn as_str(v: &Value) -> String {
} }
fn compare(lhs: &Value, op: CmpOp, rhs: &Value) -> bool { 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 { match op {
CmpOp::Match => { CmpOp::Eq => ord.is_eq(),
let text = as_str(lhs); CmpOp::Ne => !ord.is_eq(),
let pat = as_str(rhs); CmpOp::Gt => ord.is_gt(),
match Regex::new(&pat) { CmpOp::Lt => ord.is_lt(),
Ok(re) => re.is_match(&text), CmpOp::Ge => !ord.is_lt(),
Err(_) => false, CmpOp::Le => !ord.is_gt(),
} CmpOp::Match => unreachable!(),
}
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!(),
}
}
} }
} }
@ -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 { /// Evaluate an expression against a field resolver.
match expr { /// The resolver returns field values — different for nodes vs edges.
Expr::All => true, fn eval(
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(
expr: &Expr, expr: &Expr,
_source: &str, resolve: &dyn Fn(&str) -> Option<Value>,
target: &str,
strength: f32,
rel_type: RelationType,
store: &Store, store: &Store,
graph: &Graph, graph: &Graph,
) -> bool { ) -> bool {
match expr { match expr {
Expr::All => true, Expr::All => true,
Expr::Comparison { field, op, value } => { Expr::Comparison { field, op, value } => {
let lhs = match field.as_str() { let lhs = match resolve(field) {
"strength" => Value::Num(strength as f64), Some(v) => v,
"rel_type" => Value::Str(rel_type_label(rel_type).to_string()), None => return false,
_ => match resolve_field(field, target, store, graph) {
Some(v) => v,
None => return false,
},
}; };
let rhs = resolve_value(value, store, graph); let rhs = resolve_value(value, store, graph);
compare(&lhs, *op, &rhs) compare(&lhs, *op, &rhs)
} }
Expr::And(a, b) => { Expr::And(a, b) => eval(a, resolve, store, graph) && eval(b, resolve, store, graph),
eval_edge(a, _source, target, strength, rel_type, store, graph) Expr::Or(a, b) => eval(a, resolve, store, graph) || eval(b, resolve, store, graph),
&& eval_edge(b, _source, target, strength, rel_type, store, graph) Expr::Not(e) => !eval(e, resolve, 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::Neighbors { .. } => false, Expr::Neighbors { .. } => false,
} }
} }
@ -363,6 +317,7 @@ fn eval_edge(
pub struct QueryResult { pub struct QueryResult {
pub key: String, pub key: String,
pub fields: BTreeMap<String, Value>,
} }
// -- Query executor -- // -- Query executor --
@ -374,7 +329,14 @@ pub fn execute_query(
) -> Result<Vec<QueryResult>, String> { ) -> Result<Vec<QueryResult>, String> {
let q = query_parser::query(query_str) let q = query_parser::query(query_str)
.map_err(|e| format!("Parse error: {}", e))?; .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 { let mut results = match &q.expr {
Expr::Neighbors { key, filter } => { Expr::Neighbors { key, filter } => {
let resolved = store.resolve_key(key).unwrap_or_else(|_| key.clone()); let resolved = store.resolve_key(key).unwrap_or_else(|_| key.clone());
@ -382,14 +344,24 @@ pub fn execute_query(
let mut out = Vec::new(); let mut out = Vec::new();
for edge in edges { for edge in edges {
let include = match filter { let include = match filter {
Some(f) => eval_edge( Some(f) => {
f, &resolved, &edge.target, let strength = edge.strength;
edge.strength, edge.rel_type, store, graph, 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, None => true,
}; };
if include { 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 out
@ -398,14 +370,43 @@ pub fn execute_query(
let mut out = Vec::new(); let mut out = Vec::new();
for key in store.nodes.keys() { for key in store.nodes.keys() {
if store.nodes[key].deleted { continue; } if store.nodes[key].deleted { continue; }
if eval_node(&q.expr, key, store, graph) { if eval(&q.expr, &|f| resolve_field(f, key, store, graph), store, graph) {
out.push(QueryResult { key: key.clone() }); out.push(QueryResult { key: key.clone(), fields: BTreeMap::new() });
} }
} }
out 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 // Apply pipeline stages
let mut has_sort = false; let mut has_sort = false;
for stage in &q.stages { for stage in &q.stages {
@ -414,25 +415,17 @@ pub fn execute_query(
has_sort = true; has_sort = true;
let asc = *ascending; let asc = *ascending;
results.sort_by(|a, b| { results.sort_by(|a, b| {
let va = resolve_field(field, &a.key, store, graph) let va = a.fields.get(field).and_then(|v| as_num(v));
.and_then(|v| as_num(&v)); let vb = b.fields.get(field).and_then(|v| as_num(v));
let vb = resolve_field(field, &b.key, store, graph) let ord = match (va, vb) {
.and_then(|v| as_num(&v)); (Some(a), Some(b)) => a.total_cmp(&b),
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 sa = resolve_field(field, &a.key, store, graph) let sa = a.fields.get(field).map(|v| as_str(v)).unwrap_or_default();
.map(|v| as_str(&v)).unwrap_or_default(); let sb = b.fields.get(field).map(|v| as_str(v)).unwrap_or_default();
let sb = resolve_field(field, &b.key, store, graph) sa.cmp(&sb)
.map(|v| as_str(&v)).unwrap_or_default();
if asc { sa.cmp(&sb) } else { sb.cmp(&sa) }
} }
} };
if asc { ord } else { ord.reverse() }
}); });
} }
Stage::Limit(n) => { Stage::Limit(n) => {
@ -454,33 +447,66 @@ pub fn execute_query(
Ok(results) Ok(results)
} }
/// Extract the output stages from a parsed query (for cmd_query formatting) /// Format a Value for display
pub fn output_stages(query_str: &str) -> Result<Vec<Stage>, String> { pub fn format_value(v: &Value) -> String {
let q = query_parser::query(query_str) match v {
.map_err(|e| format!("Parse error: {}", e))?; Value::Num(n) => {
Ok(q.stages) if *n == n.floor() && n.abs() < 1e15 {
} format!("{}", *n as i64)
/// 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)
} else { } else {
format!("{:.3}", n) format!("{:.3}", n)
} }
} }
Some(Value::Str(s)) => { Value::Str(s) => s.clone(),
if field == "content" { Value::Ident(s) => s.clone(),
let truncated: String = s.chars().take(80).collect(); Value::FnCall(_) => "?".to_string(),
if s.len() > 80 { format!("{}...", truncated) } else { truncated }
} else {
s
}
}
Some(Value::Ident(s)) => s,
Some(Value::FnCall(_)) => "?".to_string(),
None => "-".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(())
}

View file

@ -7,7 +7,6 @@
use crate::capnp_store::StoreView; use crate::capnp_store::StoreView;
use crate::graph::Graph; use crate::graph::Graph;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
pub struct SearchResult { pub struct SearchResult {
@ -57,7 +56,7 @@ fn spreading_activation(
} }
let mut results: Vec<_> = activation.into_iter().collect(); let mut results: Vec<_> = activation.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); results.sort_by(|a, b| b.1.total_cmp(&a.1));
results results
} }

View file

@ -100,7 +100,7 @@ pub fn pairwise_similar(
} }
} }
results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); results.sort_by(|a, b| b.2.total_cmp(&a.2));
results results
} }