Skip to main content

panproto_inst/
element_ops.rs

1//! Operations on the category of elements of an instance presheaf.
2//!
3//! An instance is an attributed C-set: a functor `F: Schema → Rel` from
4//! the schema category to the category of sets and relations. The
5//! **category of elements** `∫F` (Grothendieck construction) has objects
6//! `(v, x)` where `v` is a schema vertex and `x ∈ F(v)`.
7//!
8//! [`ElementOps`] abstracts the three operations the query engine needs
9//! to navigate and inspect elements of `∫F`:
10//!
11//! 1. **Fiber selection**: `F(v)` — all elements over vertex `v`
12//! 2. **Relational pushforward**: `F(e)(S)` — direct image along edge `e`
13//! 3. **Stalk projection**: observable data at an element, projected from
14//!    the fiber's dependent sum to a flat evaluation environment
15//!
16//! This trait follows the [`AcsetOps`](crate::AcsetOps) naming convention
17//! and is implemented for all three instance shapes.
18
19use std::collections::HashMap;
20use std::sync::Arc;
21
22use panproto_expr::{BuiltinOp, Literal};
23use panproto_gat::Name;
24
25use crate::functor::FInstance;
26use crate::ginstance::GInstance;
27use crate::metadata::Node;
28use crate::value::{FieldPresence, Value};
29use crate::wtype::{
30    WInstance, build_env_with_children, collect_scalar_child_values, value_to_expr_literal,
31};
32
33/// Operations on the category of elements `∫F` of an instance presheaf.
34///
35/// Elements are uniformly addressed by `u32` IDs. For [`WInstance`] and
36/// [`GInstance`], these are node IDs. For [`FInstance`], these encode
37/// `(table_ordinal, row_index)` pairs via a faithful bijection (see
38/// [`encode_finstance_id`] / [`decode_finstance_id`]).
39pub trait ElementOps {
40    /// Fiber selection: all elements over schema vertex `v`.
41    ///
42    /// Returns `F(v) = {x | sort(x) = v}` as a vector of element IDs.
43    fn fiber(&self, vertex: &Name) -> Vec<u32>;
44
45    /// Relational pushforward along a schema edge.
46    ///
47    /// Given `S ⊆ F(v)` and edge kind `e: v → w`, computes the direct
48    /// image `F(e)(S) = {y ∈ F(w) | ∃x ∈ S, (x,y) ∈ F(e)}`.
49    ///
50    /// The result has set semantics (deduplicated). For tree-shaped
51    /// instances this is automatic; for graph-shaped instances with
52    /// cycles, explicit dedup is required.
53    fn pushforward(&self, elements: &[u32], edge_kind: &Name) -> Vec<u32>;
54
55    /// Stalk projection: observable data at element `x`.
56    ///
57    /// Constructs the evaluation environment from the fiber projection
58    /// `π_stalk: Fiber(sort(x)) → Env`, which includes:
59    /// - Local attributes (`extra_fields` for W/G-instances, columns for F-instances)
60    /// - Scalar child values via the dependent-sum projection
61    ///   `Σ_{e: v→w} π_scalar(Fiber(target(e, x)))`
62    /// - Metadata: `_anchor`, `_id`, `_value`, `_children_count`/`_edge_count`
63    ///
64    /// `extra_fields` take precedence over child scalars on key collision
65    /// (left-biased coproduct injection: transform-local state overrides
66    /// structural data).
67    fn stalk(&self, element_id: u32) -> panproto_expr::Env;
68
69    /// Evaluate a graph traversal builtin at an element.
70    ///
71    /// Interprets `Edge`, `Children`, `HasEdge`, `EdgeCount`, `Anchor`
72    /// in the internal logic of the category of elements.
73    ///
74    /// # Errors
75    ///
76    /// Returns `panproto_expr::ExprError` if argument types are wrong
77    /// or the context node cannot be resolved.
78    fn eval_graph_builtin(
79        &self,
80        op: BuiltinOp,
81        args: &[Literal],
82        context: Option<u32>,
83    ) -> Result<Literal, panproto_expr::ExprError>;
84
85    /// Attribute map: all observable fields at element `x`.
86    ///
87    /// Returns the same data as [`stalk`](Self::stalk) but as raw
88    /// [`Value`]s for query result projection, without converting to
89    /// expression literals.
90    fn attributes(&self, element_id: u32) -> HashMap<String, Value>;
91
92    /// Sort of element `x`: the schema vertex `v` such that `x ∈ F(v)`.
93    fn sort(&self, element_id: u32) -> Option<Name>;
94
95    /// Leaf value at element `x`, if present.
96    fn element_value(&self, element_id: u32) -> Option<FieldPresence>;
97}
98
99// ---------------------------------------------------------------------------
100// WInstance implementation
101// ---------------------------------------------------------------------------
102
103impl ElementOps for WInstance {
104    fn fiber(&self, vertex: &Name) -> Vec<u32> {
105        self.nodes
106            .iter()
107            .filter(|(_, n)| n.anchor == *vertex)
108            .map(|(id, _)| *id)
109            .collect()
110    }
111
112    fn pushforward(&self, elements: &[u32], edge_kind: &Name) -> Vec<u32> {
113        let mut result = Vec::new();
114        for &node_id in elements {
115            for &(src, tgt, ref edge) in &self.arcs {
116                if src == node_id && edge.kind == *edge_kind {
117                    result.push(tgt);
118                }
119            }
120        }
121        result
122    }
123
124    fn stalk(&self, element_id: u32) -> panproto_expr::Env {
125        let Some(node) = self.nodes.get(&element_id) else {
126            return panproto_expr::Env::new();
127        };
128        crate::query::build_node_env(node, self)
129    }
130
131    fn eval_graph_builtin(
132        &self,
133        op: BuiltinOp,
134        args: &[Literal],
135        context: Option<u32>,
136    ) -> Result<Literal, panproto_expr::ExprError> {
137        winstance_graph_builtin(self, op, args, context)
138    }
139
140    fn attributes(&self, element_id: u32) -> HashMap<String, Value> {
141        let Some(node) = self.nodes.get(&element_id) else {
142            return HashMap::new();
143        };
144        let scalars = collect_scalar_child_values(self, element_id);
145        let mut combined = scalars;
146        for (key, val) in &node.extra_fields {
147            combined.insert(key.clone(), val.clone());
148        }
149        combined
150    }
151
152    fn sort(&self, element_id: u32) -> Option<Name> {
153        self.nodes.get(&element_id).map(|n| n.anchor.clone())
154    }
155
156    fn element_value(&self, element_id: u32) -> Option<FieldPresence> {
157        self.nodes.get(&element_id).and_then(|n| n.value.clone())
158    }
159}
160
161// ---------------------------------------------------------------------------
162// GInstance implementation
163// ---------------------------------------------------------------------------
164
165impl ElementOps for GInstance {
166    fn fiber(&self, vertex: &Name) -> Vec<u32> {
167        self.nodes
168            .iter()
169            .filter(|(_, n)| n.anchor == *vertex)
170            .map(|(id, _)| *id)
171            .collect()
172    }
173
174    fn pushforward(&self, elements: &[u32], edge_kind: &Name) -> Vec<u32> {
175        let mut result = Vec::new();
176        let mut seen = rustc_hash::FxHashSet::default();
177        for &node_id in elements {
178            for &(src, tgt, ref edge) in &self.edges {
179                if src == node_id && edge.kind == *edge_kind && seen.insert(tgt) {
180                    result.push(tgt);
181                }
182            }
183        }
184        result
185    }
186
187    fn stalk(&self, element_id: u32) -> panproto_expr::Env {
188        let Some(node) = self.nodes.get(&element_id) else {
189            return panproto_expr::Env::new();
190        };
191
192        // Collect scalar values from outgoing edge targets (analogous to
193        // child scalar collection in WInstance). This is the dependent-sum
194        // projection for graph-shaped instances.
195        let child_scalars = collect_graph_scalar_children(self, element_id);
196        let mut env = build_env_with_children(&node.extra_fields, &child_scalars);
197
198        // Metadata
199        env = env.extend(
200            Arc::from("_anchor"),
201            Literal::Str(node.anchor.as_ref().into()),
202        );
203        env = env.extend(Arc::from("_id"), Literal::Int(i64::from(node.id)));
204
205        if let Some(val) = self.values.get(&element_id) {
206            env = env.extend(Arc::from("_value"), value_to_expr_literal(val));
207        }
208
209        let edge_count = self
210            .edges
211            .iter()
212            .filter(|(src, _, _)| *src == element_id)
213            .count();
214        #[allow(clippy::cast_possible_wrap)]
215        {
216            env = env.extend(Arc::from("_edge_count"), Literal::Int(edge_count as i64));
217        }
218
219        env
220    }
221
222    fn eval_graph_builtin(
223        &self,
224        op: BuiltinOp,
225        args: &[Literal],
226        context: Option<u32>,
227    ) -> Result<Literal, panproto_expr::ExprError> {
228        ginstance_graph_builtin(self, op, args, context)
229    }
230
231    fn attributes(&self, element_id: u32) -> HashMap<String, Value> {
232        let Some(node) = self.nodes.get(&element_id) else {
233            return HashMap::new();
234        };
235        let child_scalars = collect_graph_scalar_children(self, element_id);
236        let mut combined = child_scalars;
237        for (key, val) in &node.extra_fields {
238            combined.insert(key.clone(), val.clone());
239        }
240        combined
241    }
242
243    fn sort(&self, element_id: u32) -> Option<Name> {
244        self.nodes.get(&element_id).map(|n| n.anchor.clone())
245    }
246
247    fn element_value(&self, element_id: u32) -> Option<FieldPresence> {
248        self.values
249            .get(&element_id)
250            .map(|v| FieldPresence::Present(v.clone()))
251    }
252}
253
254/// Collect scalar values from a graph node's outgoing edge targets.
255///
256/// Analogous to [`collect_scalar_child_values`] for W-type instances:
257/// this is the dependent-sum projection `Σ_{e: v→w} π_scalar(Fiber(w))`
258/// for graph-shaped instances. Uses `edge.name` (or `edge.tgt`) as the
259/// field name, matching the W-type convention.
260fn collect_graph_scalar_children(instance: &GInstance, node_id: u32) -> HashMap<String, Value> {
261    let mut result = HashMap::new();
262    for &(src, tgt, ref edge) in &instance.edges {
263        if src != node_id {
264            continue;
265        }
266        // Check node values first (GInstance stores values separately)
267        if let Some(val) = instance.values.get(&tgt) {
268            let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
269            result.insert(field_name.to_string(), val.clone());
270        } else if let Some(tgt_node) = instance.nodes.get(&tgt) {
271            // Fall back to node.value if present
272            if let Some(FieldPresence::Present(val)) = &tgt_node.value {
273                let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
274                result.insert(field_name.to_string(), val.clone());
275            }
276        }
277    }
278    result
279}
280
281// ---------------------------------------------------------------------------
282// FInstance implementation
283// ---------------------------------------------------------------------------
284
285/// Encode an [`FInstance`] element as a `u32` ID.
286///
287/// [`FInstance`] elements are `(table, row)` pairs. We encode them as
288/// `(table_ordinal << 16) | row_index`, where `table_ordinal` is the
289/// index of the table name in lexicographic order. This bijection is
290/// faithful when each table has fewer than 2^16 rows.
291#[must_use]
292pub const fn encode_finstance_id(table_ordinal: u16, row_index: u16) -> u32 {
293    (table_ordinal as u32) << 16 | row_index as u32
294}
295
296/// Decode an [`FInstance`] element ID to `(table_ordinal, row_index)`.
297#[must_use]
298pub const fn decode_finstance_id(id: u32) -> (u16, u16) {
299    #[allow(clippy::cast_possible_truncation)]
300    ((id >> 16) as u16, id as u16)
301}
302
303impl FInstance {
304    /// Sorted table names for deterministic ordinal assignment.
305    fn sorted_table_names(&self) -> Vec<&str> {
306        let mut names: Vec<&str> = self.tables.keys().map(String::as_str).collect();
307        names.sort_unstable();
308        names
309    }
310
311    /// Find the ordinal for a table name.
312    fn table_ordinal(&self, table_name: &str) -> Option<u16> {
313        let names = self.sorted_table_names();
314        names
315            .binary_search(&table_name)
316            .ok()
317            .and_then(|i| u16::try_from(i).ok())
318    }
319
320    /// Build the ordinal-to-table-name mapping (sorted).
321    ///
322    /// Returns `&str` references into `self.tables` keys, ordered by
323    /// lexicographic sort. This avoids repeated sorting in hot paths.
324    fn ordinal_map(&self) -> Vec<&str> {
325        self.sorted_table_names()
326    }
327
328    /// Resolve a synthetic element ID to `(table_name, row_index)`.
329    ///
330    /// The returned `&str` borrows from `self.tables` keys.
331    fn resolve_element(&self, element_id: u32) -> Option<(&str, usize)> {
332        let (table_ord, row_idx) = decode_finstance_id(element_id);
333        let names = self.sorted_table_names();
334        let table_name = *names.get(usize::from(table_ord))?;
335        let rows = self.tables.get(table_name)?;
336        let row = usize::from(row_idx);
337        if row < rows.len() {
338            Some((table_name, row))
339        } else {
340            None
341        }
342    }
343}
344
345impl ElementOps for FInstance {
346    fn fiber(&self, vertex: &Name) -> Vec<u32> {
347        let vertex_str: &str = vertex.as_ref();
348        let Some(rows) = self.tables.get(vertex_str) else {
349            return Vec::new();
350        };
351        let Some(table_ord) = self.table_ordinal(vertex_str) else {
352            return Vec::new();
353        };
354        (0..rows.len())
355            .filter_map(|i| {
356                u16::try_from(i)
357                    .ok()
358                    .map(|ri| encode_finstance_id(table_ord, ri))
359            })
360            .collect()
361    }
362
363    fn pushforward(&self, elements: &[u32], edge_kind: &Name) -> Vec<u32> {
364        let element_set: rustc_hash::FxHashSet<u32> = elements.iter().copied().collect();
365        let names = self.ordinal_map();
366        let mut result = Vec::new();
367        let mut seen = rustc_hash::FxHashSet::default();
368
369        for (edge, pairs) in &self.foreign_keys {
370            if edge.kind != *edge_kind {
371                continue;
372            }
373            let Some(src_ord) = names
374                .binary_search(&&*edge.src)
375                .ok()
376                .and_then(|i| u16::try_from(i).ok())
377            else {
378                continue;
379            };
380            let Some(tgt_ord) = names
381                .binary_search(&&*edge.tgt)
382                .ok()
383                .and_then(|i| u16::try_from(i).ok())
384            else {
385                continue;
386            };
387
388            for &(src_row, tgt_row) in pairs {
389                let Ok(src_row_u16) = u16::try_from(src_row) else {
390                    continue;
391                };
392                let Ok(tgt_row_u16) = u16::try_from(tgt_row) else {
393                    continue;
394                };
395                let src_id = encode_finstance_id(src_ord, src_row_u16);
396                if element_set.contains(&src_id) {
397                    let tgt_id = encode_finstance_id(tgt_ord, tgt_row_u16);
398                    if seen.insert(tgt_id) {
399                        result.push(tgt_id);
400                    }
401                }
402            }
403        }
404
405        result
406    }
407
408    fn stalk(&self, element_id: u32) -> panproto_expr::Env {
409        let Some((table_name, row_idx)) = self.resolve_element(element_id) else {
410            return panproto_expr::Env::new();
411        };
412        let Some(rows) = self.tables.get(table_name) else {
413            return panproto_expr::Env::new();
414        };
415        let Some(row) = rows.get(row_idx) else {
416            return panproto_expr::Env::new();
417        };
418
419        // FInstance has no structural children; the stalk is just
420        // the column values (the fiber F(v) is a flat set of records).
421        let mut env = crate::wtype::build_env_from_extra_fields(row);
422
423        // Metadata
424        env = env.extend(Arc::from("_anchor"), Literal::Str(table_name.into()));
425        env = env.extend(Arc::from("_id"), Literal::Int(i64::from(element_id)));
426
427        // FK out-degree
428        let fk_count = self
429            .foreign_keys
430            .iter()
431            .filter(|(edge, _)| edge.src.as_ref() == table_name)
432            .flat_map(|(_, pairs)| pairs.iter())
433            .filter(|(src_row, _)| *src_row == row_idx)
434            .count();
435        #[allow(clippy::cast_possible_wrap)]
436        {
437            env = env.extend(Arc::from("_edge_count"), Literal::Int(fk_count as i64));
438        }
439
440        env
441    }
442
443    fn eval_graph_builtin(
444        &self,
445        op: BuiltinOp,
446        args: &[Literal],
447        context: Option<u32>,
448    ) -> Result<Literal, panproto_expr::ExprError> {
449        finstance_graph_builtin(self, op, args, context)
450    }
451
452    fn attributes(&self, element_id: u32) -> HashMap<String, Value> {
453        let Some((table_name, row_idx)) = self.resolve_element(element_id) else {
454            return HashMap::new();
455        };
456        self.tables
457            .get(table_name)
458            .and_then(|rows| rows.get(row_idx))
459            .cloned()
460            .unwrap_or_default()
461    }
462
463    fn sort(&self, element_id: u32) -> Option<Name> {
464        self.resolve_element(element_id)
465            .map(|(table_name, _)| Name::from(table_name))
466    }
467
468    fn element_value(&self, _element_id: u32) -> Option<FieldPresence> {
469        // FInstance rows have no distinguished leaf value.
470        None
471    }
472}
473
474// ---------------------------------------------------------------------------
475// Graph builtin implementations
476// ---------------------------------------------------------------------------
477
478/// Resolve a node reference from a literal (int or "self").
479fn resolve_node_ref(lit: &Literal, context: Option<u32>) -> Result<u32, panproto_expr::ExprError> {
480    match lit {
481        Literal::Int(id) => u32::try_from(*id).map_err(|_| panproto_expr::ExprError::TypeError {
482            expected: "non-negative int fitting u32".into(),
483            got: format!("{id}"),
484        }),
485        Literal::Str(s) if s == "self" => context.ok_or_else(|| {
486            panproto_expr::ExprError::UnboundVariable("self (no context node)".into())
487        }),
488        _ => Err(panproto_expr::ExprError::TypeError {
489            expected: "int or \"self\"".into(),
490            got: lit.type_name().into(),
491        }),
492    }
493}
494
495/// Convert a [`Node`]'s data to a [`Literal::Record`] for expression evaluation.
496fn node_to_literal(node: &Node) -> Literal {
497    let mut fields: Vec<(Arc<str>, Literal)> = Vec::new();
498    fields.push((Arc::from("_id"), Literal::Int(i64::from(node.id))));
499    fields.push((
500        Arc::from("_anchor"),
501        Literal::Str(node.anchor.as_ref().into()),
502    ));
503    for (key, val) in &node.extra_fields {
504        fields.push((Arc::from(key.as_str()), value_to_expr_literal(val)));
505    }
506    Literal::Record(fields)
507}
508
509/// Graph builtins for [`WInstance`].
510fn winstance_graph_builtin(
511    instance: &WInstance,
512    op: BuiltinOp,
513    args: &[Literal],
514    context: Option<u32>,
515) -> Result<Literal, panproto_expr::ExprError> {
516    match op {
517        BuiltinOp::Edge => {
518            let node_id = resolve_node_ref(&args[0], context)?;
519            let edge_kind =
520                args[1]
521                    .as_str()
522                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
523                        expected: "string".into(),
524                        got: args[1].type_name().into(),
525                    })?;
526            let edge_name = Name::from(edge_kind);
527            for &(src, tgt, ref edge) in &instance.arcs {
528                if src == node_id && edge.kind == edge_name {
529                    if let Some(node) = instance.nodes.get(&tgt) {
530                        return Ok(node_to_literal(node));
531                    }
532                    return Ok(Literal::Null);
533                }
534            }
535            Ok(Literal::Null)
536        }
537        BuiltinOp::Children => {
538            let node_id = resolve_node_ref(&args[0], context)?;
539            let mut children = Vec::new();
540            for &(src, tgt, _) in &instance.arcs {
541                if src == node_id {
542                    if let Some(node) = instance.nodes.get(&tgt) {
543                        children.push(node_to_literal(node));
544                    }
545                }
546            }
547            Ok(Literal::List(children))
548        }
549        BuiltinOp::HasEdge => {
550            let node_id = resolve_node_ref(&args[0], context)?;
551            let edge_kind =
552                args[1]
553                    .as_str()
554                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
555                        expected: "string".into(),
556                        got: args[1].type_name().into(),
557                    })?;
558            let edge_name = Name::from(edge_kind);
559            let found = instance
560                .arcs
561                .iter()
562                .any(|(src, _, edge)| *src == node_id && edge.kind == edge_name);
563            Ok(Literal::Bool(found))
564        }
565        BuiltinOp::EdgeCount => {
566            let node_id = resolve_node_ref(&args[0], context)?;
567            let count = instance
568                .arcs
569                .iter()
570                .filter(|(src, _, _)| *src == node_id)
571                .count();
572            #[allow(clippy::cast_possible_wrap)]
573            Ok(Literal::Int(count as i64))
574        }
575        BuiltinOp::Anchor => {
576            let node_id = resolve_node_ref(&args[0], context)?;
577            instance
578                .nodes
579                .get(&node_id)
580                .map_or(Ok(Literal::Null), |node| {
581                    Ok(Literal::Str(node.anchor.as_ref().into()))
582                })
583        }
584        _ => Ok(Literal::Null),
585    }
586}
587
588/// Graph builtins for [`GInstance`].
589fn ginstance_graph_builtin(
590    instance: &GInstance,
591    op: BuiltinOp,
592    args: &[Literal],
593    context: Option<u32>,
594) -> Result<Literal, panproto_expr::ExprError> {
595    match op {
596        BuiltinOp::Edge => {
597            let node_id = resolve_node_ref(&args[0], context)?;
598            let edge_kind =
599                args[1]
600                    .as_str()
601                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
602                        expected: "string".into(),
603                        got: args[1].type_name().into(),
604                    })?;
605            let edge_name = Name::from(edge_kind);
606            for &(src, tgt, ref edge) in &instance.edges {
607                if src == node_id && edge.kind == edge_name {
608                    if let Some(node) = instance.nodes.get(&tgt) {
609                        return Ok(node_to_literal(node));
610                    }
611                    return Ok(Literal::Null);
612                }
613            }
614            Ok(Literal::Null)
615        }
616        BuiltinOp::Children => {
617            let node_id = resolve_node_ref(&args[0], context)?;
618            let mut children = Vec::new();
619            for &(src, tgt, _) in &instance.edges {
620                if src == node_id {
621                    if let Some(node) = instance.nodes.get(&tgt) {
622                        children.push(node_to_literal(node));
623                    }
624                }
625            }
626            Ok(Literal::List(children))
627        }
628        BuiltinOp::HasEdge => {
629            let node_id = resolve_node_ref(&args[0], context)?;
630            let edge_kind =
631                args[1]
632                    .as_str()
633                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
634                        expected: "string".into(),
635                        got: args[1].type_name().into(),
636                    })?;
637            let edge_name = Name::from(edge_kind);
638            let found = instance
639                .edges
640                .iter()
641                .any(|(src, _, edge)| *src == node_id && edge.kind == edge_name);
642            Ok(Literal::Bool(found))
643        }
644        BuiltinOp::EdgeCount => {
645            let node_id = resolve_node_ref(&args[0], context)?;
646            let count = instance
647                .edges
648                .iter()
649                .filter(|(src, _, _)| *src == node_id)
650                .count();
651            #[allow(clippy::cast_possible_wrap)]
652            Ok(Literal::Int(count as i64))
653        }
654        BuiltinOp::Anchor => {
655            let node_id = resolve_node_ref(&args[0], context)?;
656            instance
657                .nodes
658                .get(&node_id)
659                .map_or(Ok(Literal::Null), |node| {
660                    Ok(Literal::Str(node.anchor.as_ref().into()))
661                })
662        }
663        _ => Ok(Literal::Null),
664    }
665}
666
667/// Convert an [`FInstance`] row to a [`Literal::Record`].
668fn finstance_row_to_literal(row: &HashMap<String, Value>) -> Literal {
669    let fields: Vec<(Arc<str>, Literal)> = row
670        .iter()
671        .map(|(k, v)| (Arc::from(k.as_str()), value_to_expr_literal(v)))
672        .collect();
673    Literal::Record(fields)
674}
675
676/// Graph builtins for [`FInstance`]: maps graph traversal to FK semantics.
677fn finstance_graph_builtin(
678    instance: &FInstance,
679    op: BuiltinOp,
680    args: &[Literal],
681    context: Option<u32>,
682) -> Result<Literal, panproto_expr::ExprError> {
683    match op {
684        BuiltinOp::Edge => {
685            let element_id = resolve_node_ref(&args[0], context)?;
686            let edge_kind =
687                args[1]
688                    .as_str()
689                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
690                        expected: "string".into(),
691                        got: args[1].type_name().into(),
692                    })?;
693            let Some((src_table, src_row)) = instance.resolve_element(element_id) else {
694                return Ok(Literal::Null);
695            };
696            let edge_name = Name::from(edge_kind);
697            for (edge, pairs) in &instance.foreign_keys {
698                if edge.kind == edge_name && edge.src.as_ref() == src_table {
699                    for &(s, t) in pairs {
700                        if s == src_row {
701                            if let Some(row) =
702                                instance.tables.get(&*edge.tgt).and_then(|r| r.get(t))
703                            {
704                                return Ok(finstance_row_to_literal(row));
705                            }
706                        }
707                    }
708                }
709            }
710            Ok(Literal::Null)
711        }
712        BuiltinOp::Children => {
713            let element_id = resolve_node_ref(&args[0], context)?;
714            let Some((src_table, src_row)) = instance.resolve_element(element_id) else {
715                return Ok(Literal::List(Vec::new()));
716            };
717            let mut children = Vec::new();
718            for (edge, pairs) in &instance.foreign_keys {
719                if edge.src.as_ref() != src_table {
720                    continue;
721                }
722                for &(s, t) in pairs {
723                    if s == src_row {
724                        if let Some(row) = instance.tables.get(&*edge.tgt).and_then(|r| r.get(t)) {
725                            children.push(finstance_row_to_literal(row));
726                        }
727                    }
728                }
729            }
730            Ok(Literal::List(children))
731        }
732        BuiltinOp::HasEdge => {
733            let element_id = resolve_node_ref(&args[0], context)?;
734            let edge_kind =
735                args[1]
736                    .as_str()
737                    .ok_or_else(|| panproto_expr::ExprError::TypeError {
738                        expected: "string".into(),
739                        got: args[1].type_name().into(),
740                    })?;
741            let edge_name = Name::from(edge_kind);
742
743            let Some((src_table, src_row)) = instance.resolve_element(element_id) else {
744                return Ok(Literal::Bool(false));
745            };
746
747            let found = instance.foreign_keys.iter().any(|(edge, pairs)| {
748                edge.kind == edge_name
749                    && edge.src.as_ref() == src_table
750                    && pairs.iter().any(|(s, _)| *s == src_row)
751            });
752            Ok(Literal::Bool(found))
753        }
754        BuiltinOp::EdgeCount => {
755            let element_id = resolve_node_ref(&args[0], context)?;
756            let Some((src_table, src_row)) = instance.resolve_element(element_id) else {
757                return Ok(Literal::Int(0));
758            };
759
760            let count: usize = instance
761                .foreign_keys
762                .iter()
763                .filter(|(edge, _)| edge.src.as_ref() == src_table)
764                .flat_map(|(_, pairs)| pairs.iter())
765                .filter(|(s, _)| *s == src_row)
766                .count();
767            #[allow(clippy::cast_possible_wrap)]
768            Ok(Literal::Int(count as i64))
769        }
770        BuiltinOp::Anchor => {
771            let element_id = resolve_node_ref(&args[0], context)?;
772            instance
773                .resolve_element(element_id)
774                .map_or(Ok(Literal::Null), |(table_name, _)| {
775                    Ok(Literal::Str(table_name.into()))
776                })
777        }
778        _ => Ok(Literal::Null),
779    }
780}
781
782// ---------------------------------------------------------------------------
783// Tests
784// ---------------------------------------------------------------------------
785
786#[cfg(test)]
787#[allow(clippy::unwrap_used)]
788mod tests {
789    use super::*;
790    use panproto_schema::Edge as SchemaEdge;
791
792    // -- WInstance tests --
793
794    fn make_winstance_with_children() -> WInstance {
795        let mut nodes = HashMap::new();
796
797        // Parent "binding" node with no extra_fields
798        nodes.insert(0, Node::new(0, "binding"));
799
800        // Child "binding.var" with leaf value "x0"
801        nodes.insert(
802            1,
803            Node::new(1, "binding.var").with_value(FieldPresence::Present(Value::Str("x0".into()))),
804        );
805
806        // Child "binding.type" with leaf value "noun"
807        nodes.insert(
808            2,
809            Node::new(2, "binding.type")
810                .with_value(FieldPresence::Present(Value::Str("noun".into()))),
811        );
812
813        let arcs = vec![
814            (
815                0,
816                1,
817                SchemaEdge {
818                    src: "binding".into(),
819                    tgt: "binding.var".into(),
820                    kind: "prop".into(),
821                    name: Some("var".into()),
822                },
823            ),
824            (
825                0,
826                2,
827                SchemaEdge {
828                    src: "binding".into(),
829                    tgt: "binding.type".into(),
830                    kind: "prop".into(),
831                    name: Some("type".into()),
832                },
833            ),
834        ];
835
836        WInstance::new(nodes, arcs, vec![], 0, Name::from("binding"))
837    }
838
839    #[test]
840    fn winstance_fiber_selects_by_anchor() {
841        let inst = make_winstance_with_children();
842        let fibers = inst.fiber(&Name::from("binding"));
843        assert_eq!(fibers.len(), 1);
844        assert_eq!(fibers[0], 0);
845    }
846
847    #[test]
848    fn winstance_pushforward_follows_arcs() {
849        let inst = make_winstance_with_children();
850        let children = inst.pushforward(&[0], &Name::from("prop"));
851        assert_eq!(children.len(), 2);
852        assert!(children.contains(&1));
853        assert!(children.contains(&2));
854    }
855
856    #[test]
857    fn winstance_stalk_includes_child_scalars() {
858        let inst = make_winstance_with_children();
859        let env = inst.stalk(0);
860        // "var" should be bound from the child node's value
861        let config = panproto_expr::EvalConfig::default();
862        let var_expr = panproto_expr::Expr::Var("var".into());
863        let result = panproto_expr::eval(&var_expr, &env, &config);
864        assert_eq!(result.unwrap(), Literal::Str("x0".into()));
865
866        let type_expr = panproto_expr::Expr::Var("type".into());
867        let result = panproto_expr::eval(&type_expr, &env, &config);
868        assert_eq!(result.unwrap(), Literal::Str("noun".into()));
869    }
870
871    #[test]
872    fn winstance_attributes_includes_child_scalars() {
873        let inst = make_winstance_with_children();
874        let attrs = inst.attributes(0);
875        assert_eq!(attrs.get("var"), Some(&Value::Str("x0".into())));
876        assert_eq!(attrs.get("type"), Some(&Value::Str("noun".into())));
877    }
878
879    #[test]
880    fn winstance_extra_fields_override_child_scalars() {
881        let mut nodes = HashMap::new();
882        let mut parent = Node::new(0, "thing");
883        // extra_fields has "name" = "override"
884        parent
885            .extra_fields
886            .insert("name".into(), Value::Str("override".into()));
887        nodes.insert(0, parent);
888
889        // Child also provides "name" = "original" via edge.name
890        nodes.insert(
891            1,
892            Node::new(1, "thing.name")
893                .with_value(FieldPresence::Present(Value::Str("original".into()))),
894        );
895
896        let arcs = vec![(
897            0,
898            1,
899            SchemaEdge {
900                src: "thing".into(),
901                tgt: "thing.name".into(),
902                kind: "prop".into(),
903                name: Some("name".into()),
904            },
905        )];
906
907        let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("thing"));
908        let attrs = inst.attributes(0);
909        // extra_fields take precedence (left-biased coproduct)
910        assert_eq!(attrs.get("name"), Some(&Value::Str("override".into())));
911    }
912
913    // -- GInstance tests --
914
915    fn make_ginstance_with_values() -> GInstance {
916        let mut person_a = Node::new(0, "person");
917        person_a
918            .extra_fields
919            .insert("role".into(), Value::Str("manager".into()));
920
921        GInstance::new()
922            .with_node(person_a)
923            .with_node(Node::new(1, "person"))
924            .with_node(Node::new(2, "department"))
925            .with_edge(
926                0,
927                1,
928                SchemaEdge {
929                    src: "person".into(),
930                    tgt: "person".into(),
931                    kind: "knows".into(),
932                    name: None,
933                },
934            )
935            .with_edge(
936                0,
937                2,
938                SchemaEdge {
939                    src: "person".into(),
940                    tgt: "department".into(),
941                    kind: "works_in".into(),
942                    name: Some("department".into()),
943                },
944            )
945            .with_value(0, Value::Str("Alice".into()))
946            .with_value(1, Value::Str("Bob".into()))
947            .with_value(2, Value::Str("Engineering".into()))
948    }
949
950    #[test]
951    fn ginstance_fiber_selects_by_anchor() {
952        let g = make_ginstance_with_values();
953        let persons = g.fiber(&Name::from("person"));
954        assert_eq!(persons.len(), 2);
955        let depts = g.fiber(&Name::from("department"));
956        assert_eq!(depts.len(), 1);
957    }
958
959    #[test]
960    fn ginstance_pushforward_with_dedup() {
961        // Create a graph with two paths to the same target (cycle-like)
962        let g = GInstance::new()
963            .with_node(Node::new(0, "a"))
964            .with_node(Node::new(1, "a"))
965            .with_node(Node::new(2, "b"))
966            .with_edge(
967                0,
968                2,
969                SchemaEdge {
970                    src: "a".into(),
971                    tgt: "b".into(),
972                    kind: "link".into(),
973                    name: None,
974                },
975            )
976            .with_edge(
977                1,
978                2,
979                SchemaEdge {
980                    src: "a".into(),
981                    tgt: "b".into(),
982                    kind: "link".into(),
983                    name: None,
984                },
985            );
986
987        // Both source nodes reach the same target; result should be deduplicated
988        let result = g.pushforward(&[0, 1], &Name::from("link"));
989        assert_eq!(result.len(), 1);
990        assert_eq!(result[0], 2);
991    }
992
993    #[test]
994    fn ginstance_stalk_includes_edge_target_values() {
995        let g = make_ginstance_with_values();
996        let env = g.stalk(0);
997        let config = panproto_expr::EvalConfig::default();
998
999        // "department" should be bound from edge target value via edge.name
1000        let dept_expr = panproto_expr::Expr::Var("department".into());
1001        let result = panproto_expr::eval(&dept_expr, &env, &config);
1002        assert_eq!(result.unwrap(), Literal::Str("Engineering".into()));
1003
1004        // "role" should come from extra_fields
1005        let role_expr = panproto_expr::Expr::Var("role".into());
1006        let result = panproto_expr::eval(&role_expr, &env, &config);
1007        assert_eq!(result.unwrap(), Literal::Str("manager".into()));
1008
1009        // "_value" should be Alice
1010        let val_expr = panproto_expr::Expr::Var("_value".into());
1011        let result = panproto_expr::eval(&val_expr, &env, &config);
1012        assert_eq!(result.unwrap(), Literal::Str("Alice".into()));
1013    }
1014
1015    #[test]
1016    fn ginstance_graph_builtins() {
1017        let g = make_ginstance_with_values();
1018
1019        // EdgeCount for node 0 (has 2 outgoing edges)
1020        let count = g
1021            .eval_graph_builtin(BuiltinOp::EdgeCount, &[Literal::Int(0)], Some(0))
1022            .unwrap();
1023        assert_eq!(count, Literal::Int(2));
1024
1025        // HasEdge for "knows" from node 0
1026        let has = g
1027            .eval_graph_builtin(
1028                BuiltinOp::HasEdge,
1029                &[Literal::Int(0), Literal::Str("knows".into())],
1030                Some(0),
1031            )
1032            .unwrap();
1033        assert_eq!(has, Literal::Bool(true));
1034
1035        // HasEdge for "nonexistent" from node 0
1036        let has_not = g
1037            .eval_graph_builtin(
1038                BuiltinOp::HasEdge,
1039                &[Literal::Int(0), Literal::Str("nonexistent".into())],
1040                Some(0),
1041            )
1042            .unwrap();
1043        assert_eq!(has_not, Literal::Bool(false));
1044
1045        // Anchor of node 2
1046        let anchor = g
1047            .eval_graph_builtin(BuiltinOp::Anchor, &[Literal::Int(2)], Some(0))
1048            .unwrap();
1049        assert_eq!(anchor, Literal::Str("department".into()));
1050    }
1051
1052    // -- FInstance tests --
1053
1054    fn make_finstance_with_fk() -> FInstance {
1055        let mut alice = HashMap::new();
1056        alice.insert("name".to_string(), Value::Str("Alice".into()));
1057        alice.insert("age".to_string(), Value::Int(30));
1058
1059        let mut bob = HashMap::new();
1060        bob.insert("name".to_string(), Value::Str("Bob".into()));
1061        bob.insert("age".to_string(), Value::Int(25));
1062
1063        let mut post1 = HashMap::new();
1064        post1.insert("title".to_string(), Value::Str("Hello".into()));
1065
1066        let fk = SchemaEdge {
1067            src: "posts".into(),
1068            tgt: "users".into(),
1069            kind: "author".into(),
1070            name: Some("author".into()),
1071        };
1072
1073        FInstance::new()
1074            .with_table("users", vec![alice, bob])
1075            .with_table("posts", vec![post1])
1076            .with_foreign_key(fk, vec![(0, 0)]) // post 0 authored by user 0
1077    }
1078
1079    #[test]
1080    fn finstance_fiber_returns_synthetic_ids() {
1081        let f = make_finstance_with_fk();
1082        let users = f.fiber(&Name::from("users"));
1083        assert_eq!(users.len(), 2);
1084
1085        // Verify round-trip: decode each ID and check sort
1086        for &id in &users {
1087            assert_eq!(f.sort(id), Some(Name::from("users")));
1088        }
1089
1090        let posts = f.fiber(&Name::from("posts"));
1091        assert_eq!(posts.len(), 1);
1092        for &id in &posts {
1093            assert_eq!(f.sort(id), Some(Name::from("posts")));
1094        }
1095    }
1096
1097    #[test]
1098    fn finstance_pushforward_follows_fk() {
1099        let f = make_finstance_with_fk();
1100        let posts = f.fiber(&Name::from("posts"));
1101        let authors = f.pushforward(&posts, &Name::from("author"));
1102        assert_eq!(authors.len(), 1);
1103        // The author should be Alice (user row 0)
1104        let attrs = f.attributes(authors[0]);
1105        assert_eq!(attrs.get("name"), Some(&Value::Str("Alice".into())));
1106    }
1107
1108    #[test]
1109    fn finstance_stalk_binds_columns() {
1110        let f = make_finstance_with_fk();
1111        let users = f.fiber(&Name::from("users"));
1112        // Find Alice's element (name == "Alice")
1113        let alice_id = users
1114            .iter()
1115            .find(|&&id| {
1116                let attrs = f.attributes(id);
1117                attrs.get("name") == Some(&Value::Str("Alice".into()))
1118            })
1119            .copied()
1120            .unwrap();
1121
1122        let env = f.stalk(alice_id);
1123        let config = panproto_expr::EvalConfig::default();
1124        let name_expr = panproto_expr::Expr::Var("name".into());
1125        let result = panproto_expr::eval(&name_expr, &env, &config);
1126        assert_eq!(result.unwrap(), Literal::Str("Alice".into()));
1127
1128        let age_expr = panproto_expr::Expr::Var("age".into());
1129        let result = panproto_expr::eval(&age_expr, &env, &config);
1130        assert_eq!(result.unwrap(), Literal::Int(30));
1131    }
1132
1133    #[test]
1134    fn finstance_round_trip_encoding() {
1135        let id = encode_finstance_id(3, 42);
1136        let (table_ord, row_idx) = decode_finstance_id(id);
1137        assert_eq!(table_ord, 3);
1138        assert_eq!(row_idx, 42);
1139    }
1140
1141    #[test]
1142    fn finstance_graph_builtins() {
1143        let f = make_finstance_with_fk();
1144        let posts = f.fiber(&Name::from("posts"));
1145        let post_id = posts[0];
1146
1147        // EdgeCount: post has 1 FK (author)
1148        let count = f
1149            .eval_graph_builtin(
1150                BuiltinOp::EdgeCount,
1151                &[Literal::Int(i64::from(post_id))],
1152                Some(post_id),
1153            )
1154            .unwrap();
1155        assert_eq!(count, Literal::Int(1));
1156
1157        // HasEdge for "author"
1158        let has = f
1159            .eval_graph_builtin(
1160                BuiltinOp::HasEdge,
1161                &[
1162                    Literal::Int(i64::from(post_id)),
1163                    Literal::Str("author".into()),
1164                ],
1165                Some(post_id),
1166            )
1167            .unwrap();
1168        assert_eq!(has, Literal::Bool(true));
1169
1170        // Anchor
1171        let anchor = f
1172            .eval_graph_builtin(
1173                BuiltinOp::Anchor,
1174                &[Literal::Int(i64::from(post_id))],
1175                Some(post_id),
1176            )
1177            .unwrap();
1178        assert_eq!(anchor, Literal::Str("posts".into()));
1179    }
1180}