Skip to main content

omgbase_surface/
graph.rs

1//! The `graph` neighborhood macro (`spec/surface/README.md` §4): compiled to
2//! an OQX `follow out_links collect { from dst }` / `follow in_links collect
3//! { from doc }` walk over the link rows (3.0 follow-up: there are no
4//! `out`/`in` relations — `dst` is a link's destination document, `doc` its
5//! source; an absent `dst`, an external or phantom target, contributes no
6//! successor) run through the shared runner and
7//! shaped into `{ documents, links, frontier }`. Port of
8//! `packages/core/src/mcp/graph.ts`; nothing here walks the graph itself.
9
10use std::cmp::Ordering;
11use std::collections::HashMap;
12
13use omgbase_search::EmbeddingProvider;
14use omgbase_store::Store;
15use oqx::ast::{BinaryOp, Consumer, FollowDestination, SelectItem, Where};
16use oqx::{build, print_query};
17use serde_json::{Map, Value as Json, json};
18
19use crate::error::{Result, SurfaceError};
20use crate::query::{QueryOptions, query};
21use crate::read::find_doc_by_ref;
22
23const DEFAULT_DEGREES: i64 = 1;
24const DEFAULT_MAX_DOCUMENTS: i64 = 200;
25const MAX_DEPTH: i64 = 8;
26
27/// `graph`'s arguments.
28#[derive(Clone, Debug, Default)]
29pub struct GraphArgs {
30    pub roots: Vec<String>,
31    pub degrees: Option<i64>,
32    /// `in` | `out` | `both` (default).
33    pub direction: Option<String>,
34    pub predicate: Option<String>,
35    pub select: Vec<String>,
36    pub max_documents: Option<i64>,
37}
38
39/// The edge fields projected from `doc.out_links`/`doc.in_links` — the `links`
40/// target's own names, projected under themselves (`spec/surface` §1.2, 3.0:
41/// no rename between the language and the result) — identical for both
42/// directions so a single filter handles either scan (an edge is a directional
43/// fact regardless of which endpoint the walk reached it from).
44const EDGE_FIELDS: [&str; 12] = [
45    "id",
46    "doc_id",
47    "block_id",
48    "via_id",
49    "dst_id",
50    "dst_path",
51    "uri",
52    "dst_kind",
53    "predicate",
54    "provenance",
55    "anchor",
56    "field",
57];
58
59/// The extra `select` items for caller projections. Each expression gets a
60/// stable internal alias (`_u0`, `_u1`, …) so it can never collide with a
61/// reserved word or the macro's own aliases; the clean output name is derived
62/// separately. An expression is parsed as the item of a projection, so it may
63/// be anything a `select` item may be and nothing more; a malformed one is
64/// `filter_invalid`, as it would be in the `query` tool.
65fn build_user_select(select: &[String]) -> Result<(Vec<SelectItem>, Vec<Option<String>>)> {
66    let mut items = Vec::new();
67    let mut out_names: Vec<Option<String>> = vec![None; select.len()];
68    let mut used: Vec<String> = Vec::new();
69    for (i, expr) in select.iter().enumerate() {
70        let trimmed = expr.trim();
71        if trimmed.is_empty() {
72            continue;
73        }
74        // A bare identifier keeps its spelling as the output key (3.0: a
75        // `title` selection is `title` in the document, as a `query` hit's
76        // would be; an OQX `$` name too); anything else is `sel_<i>`, so a
77        // projection can never overwrite the envelope here.
78        let ident = trimmed.strip_prefix('$').unwrap_or(trimmed);
79        let is_ident = ident
80            .chars()
81            .next()
82            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
83            && ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
84        let mut name = if is_ident {
85            trimmed.to_owned()
86        } else {
87            format!("sel_{i}")
88        };
89        // The envelope keys are the row's own identity: a `id`/`path`
90        // selection lands on them rather than beside them (the engine reads
91        // the same value).
92        if name != "id" && name != "path" {
93            while used.contains(&name) {
94                name = format!("{name}_{i}");
95            }
96            used.push(name.clone());
97        }
98        out_names[i] = Some(name);
99        let parsed = oqx::parse_string(&format!("select _u{i}: {trimmed} from docs"))?;
100        items.extend(parsed.select);
101    }
102    Ok((items, out_names))
103}
104
105/// The walk, as an AST (`spec/oqx/AST.md` §7) printed canonically — never
106/// spliced strings: the seed ids are literals, the caller's projections parsed
107/// items.
108///
109/// ```text
110/// select _depth: $depth, _stop: $stop, _links: doc.<dir>_links collect { <edge fields> } <user items>
111/// from docs where id == <root> or … follow distinct <dir>_links collect { from <dst | doc> } { depth <depth> }
112/// ```
113fn build_query(root_ids: &[String], dir: &str, depth: u32, user_select: &[SelectItem]) -> String {
114    let mut edges_body = build::subquery();
115    edges_body.select = EDGE_FIELDS
116        .iter()
117        .map(|name| build::field(name, build::ident(name)))
118        .collect();
119    let edges = build::op(
120        build::path(&["doc", &format!("{dir}_links")]),
121        Consumer::Collect,
122        edges_body,
123    );
124    let mut seed: Vec<Where> = root_ids
125        .iter()
126        .map(|id| {
127            build::scalar(build::binary(
128                BinaryOp::Eq,
129                build::ident("id"),
130                build::lit(id.as_str()),
131            ))
132        })
133        .collect();
134    let mut q = build::query(build::ident("docs"));
135    q.select = vec![
136        build::field("_depth", build::ident("$depth")),
137        build::field("_stop", build::ident("$stop")),
138        build::collect("_links", edges),
139    ];
140    q.select.extend(user_select.iter().cloned());
141    q.r#where = Some(if seed.len() == 1 {
142        seed.pop().expect("one seed")
143    } else {
144        build::or(seed)
145    });
146    // `depth` is the destination block's own clause (oqx 0.20).
147    let mut step = build::subquery();
148    step.from = vec![build::ident(if dir == "out" { "dst" } else { "doc" })];
149    step.depth = Some(depth);
150    let mut walk = build::follow(vec![FollowDestination::Block(Box::new(build::op(
151        build::ident(&format!("{dir}_links")),
152        Consumer::Collect,
153        step,
154    )))]);
155    walk.distinct = true;
156    q.follow = Some(walk);
157    print_query(&q).expect("a built query has no bindings")
158}
159
160/// Bytewise order (§9: the reference's `localeCompare` was replaced).
161fn locale_compare(a: &str, b: &str) -> Ordering {
162    a.cmp(b)
163}
164
165struct Doc {
166    id: String,
167    path: String,
168    degree: i64,
169    extra: Vec<(String, Json)>,
170}
171
172/// Run the macro.
173pub fn graph_neighborhood(
174    store: &Store,
175    repo_id: &str,
176    args: &GraphArgs,
177    provider: Option<&dyn EmbeddingProvider>,
178    now_ms: Option<i64>,
179) -> Result<Json> {
180    if args.roots.is_empty() {
181        return Err(SurfaceError::new(
182            "target_missing",
183            "graph requires at least one root (path or id)",
184        ));
185    }
186    let mut root_ids: Vec<String> = Vec::new();
187    for r in &args.roots {
188        let Some(info) = find_doc_by_ref(store.conn(), repo_id, r)? else {
189            return Err(SurfaceError::with_data(
190                "doc_missing",
191                format!("no document for {}", Json::String(r.clone())),
192                json!({ "root": r }),
193            ));
194        };
195        if !root_ids.contains(&info.doc_id) {
196            root_ids.push(info.doc_id);
197        }
198    }
199    let degrees = args.degrees.unwrap_or(DEFAULT_DEGREES).max(0);
200    let depth = MAX_DEPTH.min(degrees + 1);
201    let walk_depth = u32::try_from(depth).expect("1..=8");
202    let effective_degrees = depth - 1;
203    let direction = args.direction.clone().unwrap_or_else(|| "both".to_owned());
204    let max_documents =
205        usize::try_from(args.max_documents.unwrap_or(DEFAULT_MAX_DOCUMENTS).max(1)).unwrap_or(1);
206    let dirs: Vec<&str> = match direction.as_str() {
207        "both" => vec!["out", "in"],
208        "in" => vec!["in"],
209        _ => vec!["out"],
210    };
211    let (user_select, out_names) = build_user_select(&args.select)?;
212
213    let mut queries = Vec::new();
214    let mut docs: Vec<Doc> = Vec::new();
215    let mut edges: Vec<Json> = Vec::new();
216    let mut query_truncated = false;
217    for dir in &dirs {
218        let q = build_query(&root_ids, dir, walk_depth, &user_select);
219        queries.push(q.clone());
220        let res = query(
221            store,
222            repo_id,
223            &q,
224            QueryOptions {
225                limit: Some(max_documents + 1),
226                cursor: None,
227                provider,
228                in_memory: false,
229                bare: false,
230                unknown_names: None,
231                vocabulary: None,
232                now_ms,
233                request: None,
234            },
235        )?;
236        if res.truncated {
237            query_truncated = true;
238        }
239        for hit in &res.hits {
240            // The walk's hits carry `id`/`path` (§1.4, 3.0), and so does
241            // `graph`'s own envelope (§4): one spelling for a document's identity.
242            let id = hit["id"].as_str().unwrap_or_default().to_owned();
243            let path = hit["path"].as_str().unwrap_or_default().to_owned();
244            let hop = hit["_depth"].as_f64().unwrap_or(f64::NAN);
245            let degree = (hop - 1.0) as i64;
246            let replace = docs
247                .iter()
248                .position(|d| d.id == id)
249                .map(|i| (i, degree < docs[i].degree));
250            match replace {
251                Some((_, false)) => {}
252                found => {
253                    let extra: Vec<(String, Json)> = out_names
254                        .iter()
255                        .enumerate()
256                        .filter_map(|(i, n)| {
257                            n.as_ref().map(|name| {
258                                (
259                                    name.clone(),
260                                    hit.get(format!("_u{i}")).cloned().unwrap_or(Json::Null),
261                                )
262                            })
263                        })
264                        .collect();
265                    let doc = Doc {
266                        id: id.clone(),
267                        path,
268                        degree,
269                        extra,
270                    };
271                    match found {
272                        Some((i, true)) => docs[i] = doc,
273                        _ => docs.push(doc),
274                    }
275                }
276            }
277            if let Some(raw) = hit["_links"].as_array() {
278                for e in raw {
279                    let eid = e["id"].as_str().unwrap_or_default();
280                    if !edges.iter().any(|x| x["id"].as_str() == Some(eid)) {
281                        edges.push(e.clone());
282                    }
283                }
284            }
285        }
286    }
287
288    if let Some(p) = &args.predicate {
289        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
290        for e in &edges {
291            if e["predicate"].as_str() != Some(p.as_str()) {
292                continue;
293            }
294            let src = e["doc_id"].as_str().unwrap_or_default().to_owned();
295            let dst = e["dst_id"].as_str().unwrap_or_default().to_owned();
296            if dirs.contains(&"out") {
297                adj.entry(src.clone()).or_default().push(dst.clone());
298            }
299            if dirs.contains(&"in") {
300                adj.entry(dst).or_default().push(src);
301            }
302        }
303        let mut depth_of: Vec<(String, i64)> = root_ids.iter().map(|id| (id.clone(), 0)).collect();
304        let mut wave: Vec<String> = root_ids.clone();
305        let mut lvl = 1;
306        while lvl <= effective_degrees && !wave.is_empty() {
307            let mut next = Vec::new();
308            for from in &wave {
309                for to in adj.get(from).map_or(&[][..], Vec::as_slice) {
310                    if docs.iter().any(|d| &d.id == to) && !depth_of.iter().any(|(id, _)| id == to)
311                    {
312                        depth_of.push((to.clone(), lvl));
313                        next.push(to.clone());
314                    }
315                }
316            }
317            wave = next;
318            lvl += 1;
319        }
320        let mut restricted: Vec<Doc> = Vec::new();
321        for (id, d) in depth_of {
322            if let Some(orig) = docs.iter().find(|x| x.id == id) {
323                restricted.push(Doc {
324                    id: orig.id.clone(),
325                    path: orig.path.clone(),
326                    degree: d,
327                    extra: orig.extra.clone(),
328                });
329            }
330        }
331        docs = restricted;
332    }
333
334    docs.sort_by(|a, b| {
335        a.degree
336            .cmp(&b.degree)
337            .then_with(|| locale_compare(&a.path, &b.path))
338    });
339    let capped = docs.len() > max_documents;
340    docs.truncate(max_documents);
341    let truncated = query_truncated || capped;
342
343    let mut frontier = Vec::new();
344    let documents: Vec<Json> = docs
345        .iter()
346        .map(|d| {
347            let is_frontier = d.degree == effective_degrees;
348            if is_frontier {
349                frontier.push(json!({ "id": d.id, "path": d.path, "degree": d.degree }));
350            }
351            let mut m = Map::new();
352            m.insert("id".to_owned(), json!(d.id));
353            m.insert("path".to_owned(), json!(d.path));
354            m.insert("degree".to_owned(), json!(d.degree));
355            m.insert("frontier".to_owned(), json!(is_frontier));
356            for (k, v) in &d.extra {
357                m.insert(k.clone(), v.clone());
358            }
359            Json::Object(m)
360        })
361        .collect();
362
363    let reached = |id: &str| docs.iter().any(|d| d.id == id);
364    let mut kept: Vec<Json> = edges
365        .into_iter()
366        .filter(|e| {
367            if let Some(p) = &args.predicate {
368                if e["predicate"].as_str() != Some(p.as_str()) {
369                    return false;
370                }
371            }
372            let src_in = reached(e["doc_id"].as_str().unwrap_or_default());
373            let dst_kind = e["dst_kind"].as_str().unwrap_or_default();
374            let dst_dangling =
375                dst_kind == "external" || (dst_kind == "document" && e["dst_path"].is_null());
376            let dst_in = reached(e["dst_id"].as_str().unwrap_or_default()) || dst_dangling;
377            src_in && dst_in
378        })
379        .collect();
380    kept.sort_by(|a, b| {
381        let (sa, sb) = (
382            a["doc_id"].as_str().unwrap_or_default(),
383            b["doc_id"].as_str().unwrap_or_default(),
384        );
385        if sa == sb {
386            locale_compare(
387                a["id"].as_str().unwrap_or_default(),
388                b["id"].as_str().unwrap_or_default(),
389            )
390        } else {
391            locale_compare(sa, sb)
392        }
393    });
394
395    Ok(json!({
396        "roots": root_ids,
397        "degrees": effective_degrees,
398        "direction": direction,
399        "documents": documents,
400        "links": kept,
401        "frontier": frontier,
402        "truncated": truncated,
403        "queries": queries,
404    }))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn user_select_aliases() {
413        let (items, names) = build_user_select(&[
414            "layer".into(),
415            "path".into(),
416            "a + 1".into(),
417            "layer".into(),
418        ])
419        .unwrap();
420        let printed: Vec<String> = items
421            .iter()
422            .map(|it| oqx::print(oqx::Node::Select(it)).unwrap())
423            .collect();
424        assert_eq!(
425            printed,
426            ["_u0: layer", "_u1: path", "_u2: a + 1", "_u3: layer"]
427        );
428        assert_eq!(
429            names,
430            [
431                Some("layer".into()),
432                Some("path".into()),
433                Some("sel_2".into()),
434                Some("layer_3".into())
435            ]
436        );
437        assert!(build_user_select(&[]).unwrap().0.is_empty());
438        let e = build_user_select(&["a +".into()]).unwrap_err();
439        assert_eq!(e.code, "filter_invalid");
440    }
441
442    #[test]
443    fn query_shape() {
444        let q = build_query(&["d_0".to_owned()], "out", 2, &[]);
445        assert!(
446            q.starts_with("select _depth: $depth, _stop: $stop, _links: doc.out_links collect {")
447        );
448        assert!(q.ends_with(
449            "from docs where id == \"d_0\" follow distinct out_links collect { from dst depth 2 }"
450        ));
451        assert!(oqx::parse_string(&q).is_ok());
452        // several roots: an `or` seed (the canonical print of the `or` node since
453        // oqx 0.18); the generated text is exactly the macro's pinned shape
454        // (spec/surface `reads.json`)
455        let q = build_query(&["d_14".to_owned(), "d_15".to_owned()], "in", 8, &[]);
456        assert_eq!(
457            q,
458            "select _depth: $depth, _stop: $stop, _links: doc.in_links collect { id: id, doc_id: doc_id, block_id: block_id, via_id: via_id, dst_id: dst_id, dst_path: dst_path, uri: uri, dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, field: field } from docs where id == \"d_14\" or id == \"d_15\" follow distinct in_links collect { from doc depth 8 }"
459        );
460    }
461
462    #[test]
463    fn order_is_bytewise() {
464        assert_eq!(locale_compare("B", "a"), Ordering::Less);
465        assert_eq!(locale_compare("a", "b"), Ordering::Less);
466    }
467}