Skip to main content

reddb_server/runtime/
impl_graph_commands.rs

1//! Execution of GRAPH and SEARCH SQL-like commands.
2//!
3//! Maps parsed `GraphCommand` and `SearchCommand` AST nodes to the existing
4//! runtime graph analytics and search methods, returning results wrapped in
5//! `RuntimeQueryResult`.
6
7use super::*;
8use crate::storage::query::ast::GraphCommandOrderBy;
9use std::cmp::Ordering;
10
11impl RedDBRuntime {
12    /// Execute a GRAPH analytics command.
13    pub fn execute_graph_command(
14        &self,
15        raw_query: &str,
16        cmd: &GraphCommand,
17    ) -> RedDBResult<RuntimeQueryResult> {
18        match cmd {
19            GraphCommand::Neighborhood {
20                source,
21                depth,
22                direction,
23                edge_labels,
24            } => {
25                let dir = parse_direction(direction)?;
26                let res = self.graph_neighborhood(
27                    source,
28                    dir,
29                    *depth as usize,
30                    edge_labels.clone(),
31                    None,
32                )?;
33                let mut result = UnifiedResult::with_columns(vec![
34                    "node_id".into(),
35                    "label".into(),
36                    "node_type".into(),
37                    "depth".into(),
38                ]);
39                for visit in &res.nodes {
40                    let mut record = UnifiedRecord::new();
41                    record.set("node_id", Value::text(visit.node.id.clone()));
42                    record.set("label", Value::text(visit.node.label.clone()));
43                    record.set("node_type", Value::text(visit.node.node_type.clone()));
44                    record.set("depth", Value::Integer(visit.depth as i64));
45                    result.push(record);
46                }
47                Ok(RuntimeQueryResult {
48                    query: raw_query.to_string(),
49                    mode: QueryMode::Sql,
50                    statement: "graph_neighborhood",
51                    engine: "runtime-graph",
52                    result,
53                    affected_rows: 0,
54                    statement_type: "select",
55                    bookmark: None,
56                })
57            }
58            GraphCommand::ShortestPath {
59                source,
60                target,
61                algorithm,
62                direction,
63                limit,
64                order_by,
65            } => {
66                let dir = parse_direction(direction)?;
67                let alg = parse_path_algorithm(algorithm)?;
68                let res = self.graph_shortest_path(source, target, dir, alg, None, None)?;
69                let mut result = UnifiedResult::with_columns(vec![
70                    "source".into(),
71                    "target".into(),
72                    "nodes_visited".into(),
73                    "negative_cycle_detected".into(),
74                    "path_found".into(),
75                    "hop_count".into(),
76                    "total_weight".into(),
77                ]);
78                let mut record = UnifiedRecord::new();
79                record.set("source", Value::text(res.source));
80                record.set("target", Value::text(res.target));
81                record.set("nodes_visited", Value::Integer(res.nodes_visited as i64));
82                record.set(
83                    "negative_cycle_detected",
84                    match res.negative_cycle_detected {
85                        Some(value) => Value::Boolean(value),
86                        None => Value::Null,
87                    },
88                );
89                if let Some(ref path) = res.path {
90                    record.set("path_found", Value::Boolean(true));
91                    record.set("hop_count", Value::Integer(path.hop_count as i64));
92                    record.set("total_weight", Value::Float(path.total_weight));
93                } else {
94                    record.set("path_found", Value::Boolean(false));
95                    record.set("hop_count", Value::Null);
96                    record.set("total_weight", Value::Null);
97                }
98                result.push(record);
99                apply_graph_order_and_limit(
100                    &mut result,
101                    "graph_shortest_path",
102                    order_by.as_ref(),
103                    limit.map(|n| n as usize),
104                )?;
105                Ok(RuntimeQueryResult {
106                    query: raw_query.to_string(),
107                    mode: QueryMode::Sql,
108                    statement: "graph_shortest_path",
109                    engine: "runtime-graph",
110                    result,
111                    affected_rows: 0,
112                    statement_type: "select",
113                    bookmark: None,
114                })
115            }
116            GraphCommand::Properties { source } => {
117                if let Some(node_ref) = source {
118                    // Per-node property lookup (#423). Uses the same label
119                    // resolution as NEIGHBORHOOD/TRAVERSE so '<label>' and
120                    // '<numeric id>' both work.
121                    let graph =
122                        materialize_graph_with_projection(self.inner.db.store().as_ref(), None)?;
123                    let resolved = resolve_graph_node_id(&graph, node_ref)?;
124                    let stored = graph
125                        .get_node(&resolved)
126                        .ok_or_else(|| RedDBError::NotFound(node_ref.to_string()))?;
127                    let node_type = self
128                        .inner
129                        .db
130                        .store()
131                        .query_all(|entity| {
132                            entity.id.raw().to_string() == resolved
133                                && matches!(
134                                    entity.kind,
135                                    crate::storage::unified::EntityKind::GraphNode(_)
136                                )
137                        })
138                        .into_iter()
139                        .find_map(|(_, entity)| match entity.kind {
140                            crate::storage::unified::EntityKind::GraphNode(node) => {
141                                Some(node.node_type)
142                            }
143                            _ => None,
144                        })
145                        .unwrap_or_else(|| stored.node_type.clone());
146                    let all_props =
147                        materialize_graph_node_properties(self.inner.db.store().as_ref())?;
148                    let props = all_props.get(&resolved).cloned().unwrap_or_default();
149
150                    // Fixed columns first, then property keys in sorted order so
151                    // the schema is stable across snapshots / wire renders.
152                    let mut prop_keys: Vec<&String> = props.keys().collect();
153                    prop_keys.sort();
154                    let mut columns: Vec<String> = Vec::with_capacity(3 + prop_keys.len());
155                    columns.push("node_id".into());
156                    columns.push("label".into());
157                    columns.push("node_type".into());
158                    for k in &prop_keys {
159                        columns.push((*k).clone());
160                    }
161                    let mut result = UnifiedResult::with_columns(columns);
162                    let mut record = UnifiedRecord::new();
163                    record.set("node_id", Value::text(stored.id.clone()));
164                    record.set("label", Value::text(stored.label.clone()));
165                    record.set("node_type", Value::text(node_type));
166                    for k in &prop_keys {
167                        if let Some(v) = props.get(*k) {
168                            record.set(k.as_str(), v.clone());
169                        }
170                    }
171                    result.push(record);
172                    return Ok(RuntimeQueryResult {
173                        query: raw_query.to_string(),
174                        mode: QueryMode::Sql,
175                        statement: "graph_properties",
176                        engine: "runtime-graph",
177                        result,
178                        affected_rows: 0,
179                        statement_type: "select",
180                        bookmark: None,
181                    });
182                }
183                let res = self.graph_properties(None)?;
184                let mut result = UnifiedResult::with_columns(vec![
185                    "node_count".into(),
186                    "edge_count".into(),
187                    "is_connected".into(),
188                    "is_complete".into(),
189                    "is_cyclic".into(),
190                    "density".into(),
191                ]);
192                let mut record = UnifiedRecord::new();
193                record.set("node_count", Value::Integer(res.node_count as i64));
194                record.set("edge_count", Value::Integer(res.edge_count as i64));
195                record.set("is_connected", Value::Boolean(res.is_connected));
196                record.set("is_complete", Value::Boolean(res.is_complete));
197                record.set("is_cyclic", Value::Boolean(res.is_cyclic));
198                record.set("density", Value::Float(res.density));
199                result.push(record);
200                Ok(RuntimeQueryResult {
201                    query: raw_query.to_string(),
202                    mode: QueryMode::Sql,
203                    statement: "graph_properties",
204                    engine: "runtime-graph",
205                    result,
206                    affected_rows: 0,
207                    statement_type: "select",
208                    bookmark: None,
209                })
210            }
211            GraphCommand::Traverse {
212                source,
213                strategy,
214                depth,
215                direction,
216                edge_labels,
217            } => {
218                let dir = parse_direction(direction)?;
219                let strat = parse_traversal_strategy(strategy)?;
220                let res = self.graph_traverse(
221                    source,
222                    dir,
223                    *depth as usize,
224                    strat,
225                    edge_labels.clone(),
226                    None,
227                )?;
228                let mut result = UnifiedResult::with_columns(vec![
229                    "node_id".into(),
230                    "label".into(),
231                    "node_type".into(),
232                    "depth".into(),
233                ]);
234                for visit in &res.visits {
235                    let mut record = UnifiedRecord::new();
236                    record.set("node_id", Value::text(visit.node.id.clone()));
237                    record.set("label", Value::text(visit.node.label.clone()));
238                    record.set("node_type", Value::text(visit.node.node_type.clone()));
239                    record.set("depth", Value::Integer(visit.depth as i64));
240                    result.push(record);
241                }
242                Ok(RuntimeQueryResult {
243                    query: raw_query.to_string(),
244                    mode: QueryMode::Sql,
245                    statement: "graph_traverse",
246                    engine: "runtime-graph",
247                    result,
248                    affected_rows: 0,
249                    statement_type: "select",
250                    bookmark: None,
251                })
252            }
253            GraphCommand::Centrality {
254                algorithm,
255                limit,
256                order_by,
257            } => {
258                let alg = parse_centrality_algorithm(algorithm)?;
259                // `limit = None` keeps historical implicit top-100 cap.
260                // `Some(0)` returns zero rows (standard SQL LIMIT 0 semantics).
261                let limit_usize = limit.map(|n| n as usize);
262                let order_needs_full_set = order_by
263                    .as_ref()
264                    .map(|order| order.ascending)
265                    .unwrap_or(false);
266                let top_k = if order_needs_full_set {
267                    usize::MAX
268                } else {
269                    limit_usize.unwrap_or(100).max(1)
270                };
271                let res = self.graph_centrality(alg, top_k, false, None, None, None, None)?;
272                let mut result = UnifiedResult::with_columns(vec![
273                    "node_id".into(),
274                    "label".into(),
275                    "score".into(),
276                ]);
277                for score in &res.scores {
278                    let mut record = UnifiedRecord::new();
279                    record.set("node_id", Value::text(score.node.id.clone()));
280                    record.set("label", Value::text(score.node.label.clone()));
281                    record.set("score", Value::Float(score.score));
282                    result.push(record);
283                }
284                for ds in &res.degree_scores {
285                    let mut record = UnifiedRecord::new();
286                    record.set("node_id", Value::text(ds.node.id.clone()));
287                    record.set("label", Value::text(ds.node.label.clone()));
288                    record.set("score", Value::Float(ds.total_degree as f64));
289                    result.push(record);
290                }
291                apply_graph_order_and_limit(
292                    &mut result,
293                    "graph_centrality",
294                    order_by.as_ref(),
295                    Some(limit_usize.unwrap_or(100)),
296                )?;
297                Ok(RuntimeQueryResult {
298                    query: raw_query.to_string(),
299                    mode: QueryMode::Sql,
300                    statement: "graph_centrality",
301                    engine: "runtime-graph",
302                    result,
303                    affected_rows: 0,
304                    statement_type: "select",
305                    bookmark: None,
306                })
307            }
308            GraphCommand::Community {
309                algorithm,
310                max_iterations,
311                limit,
312                order_by,
313                return_assignments,
314            } => {
315                let alg = parse_community_algorithm(algorithm)?;
316                let res =
317                    self.graph_communities(alg, 1, Some(*max_iterations as usize), None, None)?;
318                let result = if *return_assignments {
319                    // Per-node node→community map (#660). Communities arrive
320                    // sorted (size desc, id asc); within each, sort nodes for a
321                    // deterministic row order. ORDER BY metrics target the
322                    // aggregate shape, so they don't apply here — only LIMIT
323                    // (row cap) is honoured.
324                    let mut result =
325                        UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
326                    let row_cap = limit.map(|n| n as usize);
327                    'outer: for community in &res.communities {
328                        let mut nodes = community.nodes.clone();
329                        nodes.sort();
330                        for node_id in nodes {
331                            if row_cap.is_some_and(|cap| result.records.len() >= cap) {
332                                break 'outer;
333                            }
334                            let mut record = UnifiedRecord::new();
335                            record.set("node_id", Value::text(node_id));
336                            record.set("community_id", Value::text(community.id.clone()));
337                            result.push(record);
338                        }
339                    }
340                    result
341                } else {
342                    let mut result =
343                        UnifiedResult::with_columns(vec!["community_id".into(), "size".into()]);
344                    for community in &res.communities {
345                        let mut record = UnifiedRecord::new();
346                        record.set("community_id", Value::text(community.id.clone()));
347                        record.set("size", Value::Integer(community.size as i64));
348                        result.push(record);
349                    }
350                    apply_graph_order_and_limit(
351                        &mut result,
352                        "graph_community",
353                        order_by.as_ref(),
354                        limit.map(|n| n as usize),
355                    )?;
356                    result
357                };
358                Ok(RuntimeQueryResult {
359                    query: raw_query.to_string(),
360                    mode: QueryMode::Sql,
361                    statement: "graph_community",
362                    engine: "runtime-graph",
363                    result,
364                    affected_rows: 0,
365                    statement_type: "select",
366                    bookmark: None,
367                })
368            }
369            GraphCommand::Components {
370                mode,
371                limit,
372                order_by,
373            } => {
374                let m = parse_components_mode(mode)?;
375                let res = self.graph_components(m, 1, None)?;
376                let mut result =
377                    UnifiedResult::with_columns(vec!["component_id".into(), "size".into()]);
378                for component in &res.components {
379                    let mut record = UnifiedRecord::new();
380                    record.set("component_id", Value::text(component.id.clone()));
381                    record.set("size", Value::Integer(component.size as i64));
382                    result.push(record);
383                }
384                apply_graph_order_and_limit(
385                    &mut result,
386                    "graph_components",
387                    order_by.as_ref(),
388                    limit.map(|n| n as usize),
389                )?;
390                Ok(RuntimeQueryResult {
391                    query: raw_query.to_string(),
392                    mode: QueryMode::Sql,
393                    statement: "graph_components",
394                    engine: "runtime-graph",
395                    result,
396                    affected_rows: 0,
397                    statement_type: "select",
398                    bookmark: None,
399                })
400            }
401            GraphCommand::Cycles { max_length } => {
402                let res = self.graph_cycles(*max_length as usize, 100, None)?;
403                let mut result =
404                    UnifiedResult::with_columns(vec!["cycle_index".into(), "length".into()]);
405                for (i, cycle) in res.cycles.iter().enumerate() {
406                    let mut record = UnifiedRecord::new();
407                    record.set("cycle_index", Value::Integer(i as i64));
408                    record.set("length", Value::Integer(cycle.nodes.len() as i64));
409                    result.push(record);
410                }
411                Ok(RuntimeQueryResult {
412                    query: raw_query.to_string(),
413                    mode: QueryMode::Sql,
414                    statement: "graph_cycles",
415                    engine: "runtime-graph",
416                    result,
417                    affected_rows: 0,
418                    statement_type: "select",
419                    bookmark: None,
420                })
421            }
422            GraphCommand::Clustering => {
423                let res = self.graph_clustering(100, true, None)?;
424                let mut result = UnifiedResult::with_columns(vec![
425                    "node_id".into(),
426                    "label".into(),
427                    "score".into(),
428                ]);
429                // First row: global coefficient
430                let mut global_record = UnifiedRecord::new();
431                global_record.set("node_id", Value::text("__global__"));
432                global_record.set("label", Value::text("global_clustering"));
433                global_record.set("score", Value::Float(res.global));
434                result.push(global_record);
435                for score in &res.local {
436                    let mut record = UnifiedRecord::new();
437                    record.set("node_id", Value::text(score.node.id.clone()));
438                    record.set("label", Value::text(score.node.label.clone()));
439                    record.set("score", Value::Float(score.score));
440                    result.push(record);
441                }
442                Ok(RuntimeQueryResult {
443                    query: raw_query.to_string(),
444                    mode: QueryMode::Sql,
445                    statement: "graph_clustering",
446                    engine: "runtime-graph",
447                    result,
448                    affected_rows: 0,
449                    statement_type: "select",
450                    bookmark: None,
451                })
452            }
453            GraphCommand::TopologicalSort => {
454                let res = self.graph_topological_sort(None)?;
455                let mut result = UnifiedResult::with_columns(vec![
456                    "order".into(),
457                    "node_id".into(),
458                    "label".into(),
459                ]);
460                for (i, node) in res.ordered_nodes.iter().enumerate() {
461                    let mut record = UnifiedRecord::new();
462                    record.set("order", Value::Integer(i as i64));
463                    record.set("node_id", Value::text(node.id.clone()));
464                    record.set("label", Value::text(node.label.clone()));
465                    result.push(record);
466                }
467                Ok(RuntimeQueryResult {
468                    query: raw_query.to_string(),
469                    mode: QueryMode::Sql,
470                    statement: "graph_topological_sort",
471                    engine: "runtime-graph",
472                    result,
473                    affected_rows: 0,
474                    statement_type: "select",
475                    bookmark: None,
476                })
477            }
478        }
479    }
480
481    /// Execute a SEARCH command.
482    pub fn execute_search_command(
483        &self,
484        raw_query: &str,
485        cmd: &SearchCommand,
486    ) -> RedDBResult<RuntimeQueryResult> {
487        match cmd {
488            SearchCommand::Similar {
489                vector,
490                text,
491                provider,
492                collection,
493                limit,
494                min_score,
495                vector_param,
496                limit_param,
497                min_score_param,
498                text_param,
499            } => {
500                if vector_param.is_some() {
501                    return Err(RedDBError::Query(
502                        "SEARCH SIMILAR $N vector parameter was not bound before execution"
503                            .to_string(),
504                    ));
505                }
506                if limit_param.is_some() {
507                    return Err(RedDBError::Query(
508                        "SEARCH SIMILAR LIMIT $N parameter was not bound before execution"
509                            .to_string(),
510                    ));
511                }
512                if min_score_param.is_some() {
513                    return Err(RedDBError::Query(
514                        "SEARCH SIMILAR MIN_SCORE $N parameter was not bound before execution"
515                            .to_string(),
516                    ));
517                }
518                if text_param.is_some() {
519                    return Err(RedDBError::Query(
520                        "SEARCH SIMILAR TEXT $N parameter was not bound before execution"
521                            .to_string(),
522                    ));
523                }
524                // If text provided, generate embedding first (semantic search)
525                let search_vector = if let Some(query_text) = text {
526                    let (default_provider, _) = crate::ai::resolve_defaults_from_runtime(self);
527                    let provider = match provider.as_deref() {
528                        Some(p) => crate::ai::parse_provider(p)?,
529                        None => default_provider,
530                    };
531                    // S3 / #711: planner-level provider gate. Runs
532                    // before the credential resolver so the resolver
533                    // audit event is not emitted when policy denies.
534                    crate::runtime::ai::provider_gate::enforce(self, &provider)?;
535                    let api_key = crate::ai::resolve_api_key_from_runtime(&provider, None, self)?;
536                    let model = std::env::var("REDDB_OPENAI_EMBEDDING_MODEL")
537                        .ok()
538                        .unwrap_or_else(|| provider.default_embedding_model().to_string());
539                    let transport = crate::runtime::ai::transport::AiTransport::from_runtime(self);
540                    let request = crate::ai::OpenAiEmbeddingRequest {
541                        api_key,
542                        model,
543                        inputs: vec![query_text.clone()],
544                        dimensions: None,
545                        api_base: provider.resolve_api_base(),
546                    };
547                    let response = crate::runtime::ai::block_on_ai(async move {
548                        crate::ai::openai_embeddings_async(&transport, request).await
549                    })
550                    .and_then(|result| result)?;
551                    response.embeddings.into_iter().next().ok_or_else(|| {
552                        RedDBError::Query("embedding API returned no vectors".to_string())
553                    })?
554                } else {
555                    vector.clone()
556                };
557                // Issue #119: route through AuthorizedSearch so the
558                // candidate set is gated by `EffectiveScope.visible_collections`
559                // before any similarity score is computed.
560                let scope = self.ai_scope();
561                let results =
562                    if super::statement_frame::ReadFrame::visible_collections(&scope).is_some() {
563                        crate::runtime::authorized_search::AuthorizedSearch::execute_similar(
564                            self,
565                            &scope,
566                            collection,
567                            &search_vector,
568                            *limit,
569                            *min_score,
570                        )?
571                    } else {
572                        // Embedded / no-auth caller: keep legacy behaviour.
573                        self.search_similar(collection, &search_vector, *limit, *min_score)?
574                    };
575                let mut result =
576                    UnifiedResult::with_columns(vec!["entity_id".into(), "score".into()]);
577                for sr in &results {
578                    let mut record = UnifiedRecord::new();
579                    record.set("entity_id", Value::UnsignedInteger(sr.entity_id.raw()));
580                    record.set("score", Value::Float(sr.score as f64));
581                    result.push(record);
582                }
583                Ok(RuntimeQueryResult {
584                    query: raw_query.to_string(),
585                    mode: QueryMode::Sql,
586                    statement: "search_similar",
587                    engine: "runtime-search",
588                    result,
589                    affected_rows: 0,
590                    statement_type: "select",
591                    bookmark: None,
592                })
593            }
594            SearchCommand::Text {
595                query,
596                collection,
597                limit,
598                fuzzy,
599                limit_param,
600            } => {
601                if limit_param.is_some() {
602                    return Err(RedDBError::Query(
603                        "SEARCH TEXT LIMIT $N parameter was not bound before execution".to_string(),
604                    ));
605                }
606                let collections = collection.as_ref().map(|c| vec![c.clone()]);
607                // Issue #119: gate the candidate set by visible_collections.
608                let scope = self.ai_scope();
609                let res =
610                    if super::statement_frame::ReadFrame::visible_collections(&scope).is_some() {
611                        crate::runtime::authorized_search::AuthorizedSearch::execute_text(
612                            self,
613                            &scope,
614                            query.clone(),
615                            collections,
616                            None,
617                            None,
618                            None,
619                            Some(*limit),
620                            *fuzzy,
621                        )?
622                    } else {
623                        self.search_text(
624                            query.clone(),
625                            collections,
626                            None,
627                            None,
628                            None,
629                            Some(*limit),
630                            *fuzzy,
631                        )?
632                    };
633                let mut result =
634                    UnifiedResult::with_columns(vec!["entity_id".into(), "score".into()]);
635                for item in &res.matches {
636                    let mut record = UnifiedRecord::new();
637                    record.set("entity_id", Value::UnsignedInteger(item.entity.id.raw()));
638                    record.set("score", Value::Float(item.score as f64));
639                    result.push(record);
640                }
641                Ok(RuntimeQueryResult {
642                    query: raw_query.to_string(),
643                    mode: QueryMode::Sql,
644                    statement: "search_text",
645                    engine: "runtime-search",
646                    result,
647                    affected_rows: 0,
648                    statement_type: "select",
649                    bookmark: None,
650                })
651            }
652            SearchCommand::Hybrid {
653                vector,
654                query,
655                collection,
656                limit,
657                limit_param,
658            } => {
659                if limit_param.is_some() {
660                    return Err(RedDBError::Query(
661                        "SEARCH HYBRID LIMIT $N parameter was not bound before execution"
662                            .to_string(),
663                    ));
664                }
665                let res = self.search_hybrid(
666                    vector.clone(),
667                    query.clone(),
668                    Some(*limit),
669                    Some(vec![collection.clone()]),
670                    None,
671                    None,
672                    None,
673                    Vec::new(),
674                    None,
675                    None,
676                    Some(*limit),
677                )?;
678                let mut result =
679                    UnifiedResult::with_columns(vec!["entity_id".into(), "score".into()]);
680                for item in &res.matches {
681                    let mut record = UnifiedRecord::new();
682                    record.set("entity_id", Value::UnsignedInteger(item.entity.id.raw()));
683                    record.set("score", Value::Float(item.score as f64));
684                    result.push(record);
685                }
686                Ok(RuntimeQueryResult {
687                    query: raw_query.to_string(),
688                    mode: QueryMode::Sql,
689                    statement: "search_hybrid",
690                    engine: "runtime-search",
691                    result,
692                    affected_rows: 0,
693                    statement_type: "select",
694                    bookmark: None,
695                })
696            }
697            SearchCommand::Multimodal {
698                query,
699                collection,
700                limit,
701                limit_param,
702            } => {
703                if limit_param.is_some() {
704                    return Err(RedDBError::Query(
705                        "SEARCH MULTIMODAL LIMIT $N parameter was not bound before execution"
706                            .to_string(),
707                    ));
708                }
709                let collections = collection.as_ref().map(|c| vec![c.clone()]);
710                let res =
711                    self.search_multimodal(query.clone(), collections, None, None, Some(*limit))?;
712                let mut result =
713                    UnifiedResult::with_columns(vec!["entity_id".into(), "score".into()]);
714                for item in &res.matches {
715                    let mut record = UnifiedRecord::new();
716                    record.set("entity_id", Value::UnsignedInteger(item.entity.id.raw()));
717                    record.set("score", Value::Float(item.score as f64));
718                    result.push(record);
719                }
720                Ok(RuntimeQueryResult {
721                    query: raw_query.to_string(),
722                    mode: QueryMode::Sql,
723                    statement: "search_multimodal",
724                    engine: "runtime-search",
725                    result,
726                    affected_rows: 0,
727                    statement_type: "select",
728                    bookmark: None,
729                })
730            }
731            SearchCommand::Index {
732                index,
733                value,
734                collection,
735                limit,
736                exact,
737                limit_param,
738            } => {
739                if limit_param.is_some() {
740                    return Err(RedDBError::Query(
741                        "SEARCH INDEX LIMIT $N parameter was not bound before execution"
742                            .to_string(),
743                    ));
744                }
745                let collections = collection.as_ref().map(|c| vec![c.clone()]);
746                let res = self.search_index(
747                    index.clone(),
748                    value.clone(),
749                    *exact,
750                    collections,
751                    None,
752                    None,
753                    Some(*limit),
754                )?;
755                let mut result =
756                    UnifiedResult::with_columns(vec!["entity_id".into(), "score".into()]);
757                for item in &res.matches {
758                    let mut record = UnifiedRecord::new();
759                    record.set("entity_id", Value::UnsignedInteger(item.entity.id.raw()));
760                    record.set("score", Value::Float(item.score as f64));
761                    result.push(record);
762                }
763                Ok(RuntimeQueryResult {
764                    query: raw_query.to_string(),
765                    mode: QueryMode::Sql,
766                    statement: "search_index",
767                    engine: "runtime-search",
768                    result,
769                    affected_rows: 0,
770                    statement_type: "select",
771                    bookmark: None,
772                })
773            }
774            SearchCommand::Context {
775                query,
776                field,
777                collection,
778                limit,
779                depth,
780                limit_param,
781            } => {
782                if limit_param.is_some() {
783                    return Err(RedDBError::Query(
784                        "SEARCH CONTEXT LIMIT $N parameter was not bound before execution"
785                            .to_string(),
786                    ));
787                }
788                use crate::application::SearchContextInput;
789                // Issue #119: route through AuthorizedSearch so the
790                // candidate set + every expansion bucket is bounded by
791                // `EffectiveScope.visible_collections`.
792                let input = SearchContextInput {
793                    query: query.clone(),
794                    field: field.clone(),
795                    vector: None,
796                    collections: collection.as_ref().map(|c| vec![c.clone()]),
797                    graph_depth: Some(*depth),
798                    graph_max_edges: None,
799                    max_cross_refs: None,
800                    follow_cross_refs: None,
801                    expand_graph: None,
802                    global_scan: None,
803                    reindex: None,
804                    limit: Some(*limit),
805                    min_score: None,
806                };
807                let scope = self.ai_scope();
808                let res =
809                    if super::statement_frame::ReadFrame::visible_collections(&scope).is_some() {
810                        crate::runtime::authorized_search::AuthorizedSearch::execute_context(
811                            self, &scope, input,
812                        )?
813                    } else {
814                        self.search_context(input)?
815                    };
816                let mut result = UnifiedResult::with_columns(vec![
817                    "entity_id".into(),
818                    "collection".into(),
819                    "score".into(),
820                    "discovery".into(),
821                    "kind".into(),
822                ]);
823                let all_entities = res
824                    .tables
825                    .iter()
826                    .map(|e| (e, "table"))
827                    .chain(res.graph.nodes.iter().map(|e| (e, "graph_node")))
828                    .chain(res.graph.edges.iter().map(|e| (e, "graph_edge")))
829                    .chain(res.vectors.iter().map(|e| (e, "vector")))
830                    .chain(res.documents.iter().map(|e| (e, "document")))
831                    .chain(res.key_values.iter().map(|e| (e, "kv")));
832                for (entity, kind) in all_entities {
833                    let mut record = UnifiedRecord::new();
834                    record.set("entity_id", Value::UnsignedInteger(entity.entity.id.raw()));
835                    record.set("collection", Value::text(entity.collection.clone()));
836                    record.set("score", Value::Float(entity.score as f64));
837                    record.set("discovery", Value::text(format!("{:?}", entity.discovery)));
838                    record.set("kind", Value::text(kind.to_string()));
839                    result.push(record);
840                }
841                Ok(RuntimeQueryResult {
842                    query: raw_query.to_string(),
843                    mode: QueryMode::Sql,
844                    statement: "search_context",
845                    engine: "runtime-context",
846                    result,
847                    affected_rows: 0,
848                    statement_type: "select",
849                    bookmark: None,
850                })
851            }
852            SearchCommand::SpatialRadius {
853                center_lat,
854                center_lon,
855                radius_km,
856                collection,
857                column,
858                limit,
859                limit_param,
860            } => {
861                if limit_param.is_some() {
862                    return Err(RedDBError::Query(
863                        "SEARCH SPATIAL RADIUS LIMIT $N parameter was not bound before execution"
864                            .to_string(),
865                    ));
866                }
867                use crate::storage::unified::spatial_index::haversine_km;
868                // When the geo column carries an H3 index, gather candidates
869                // from a covering kRing over the disk B-tree cell ids; the
870                // ring is a provable superset of the in-radius rows, so the
871                // haversine post-filter below yields byte-identical results
872                // to the full scan. Without an H3 index, scan every row
873                // exactly as before (PRD #1574 slice 3).
874                let h3_candidates = self.h3_radius_candidate_ids(
875                    collection,
876                    column,
877                    *center_lat,
878                    *center_lon,
879                    *radius_km,
880                );
881                let store = self.inner.db.store();
882                let entities = scan_collection_with_candidates(&store, collection, &h3_candidates);
883
884                let mut hits: Vec<(u64, f64)> = Vec::new();
885                for entity in &entities {
886                    // Extract lat/lon from GeoPoint values in entity data
887                    if let Some((lat, lon)) = extract_geo_from_entity(entity) {
888                        let dist = haversine_km(*center_lat, *center_lon, lat, lon);
889                        if dist <= *radius_km {
890                            hits.push((entity.id.raw(), dist));
891                        }
892                    }
893                }
894                hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
895                hits.truncate(*limit);
896
897                let mut result =
898                    UnifiedResult::with_columns(vec!["entity_id".into(), "distance_km".into()]);
899                for (id, dist) in &hits {
900                    let mut record = UnifiedRecord::new();
901                    record.set("entity_id", Value::UnsignedInteger(*id));
902                    record.set("distance_km", Value::Float(*dist));
903                    result.push(record);
904                }
905                Ok(RuntimeQueryResult {
906                    query: raw_query.to_string(),
907                    mode: QueryMode::Sql,
908                    statement: "search_spatial_radius",
909                    engine: "runtime-spatial",
910                    result,
911                    affected_rows: 0,
912                    statement_type: "select",
913                    bookmark: None,
914                })
915            }
916            SearchCommand::SpatialBbox {
917                min_lat,
918                min_lon,
919                max_lat,
920                max_lon,
921                collection,
922                column,
923                limit,
924                limit_param,
925            } => {
926                if limit_param.is_some() {
927                    return Err(RedDBError::Query(
928                        "SEARCH SPATIAL BBOX LIMIT $N parameter was not bound before execution"
929                            .to_string(),
930                    ));
931                }
932                // Cover the bbox with a kRing big enough to enclose its
933                // farthest corner from the centre, scan those cells off the
934                // H3 disk index, then apply the exact bbox filter. The ring
935                // is a superset of the box, so results match the full scan.
936                let h3_candidates = self.h3_bbox_candidate_ids(
937                    collection, column, *min_lat, *min_lon, *max_lat, *max_lon,
938                );
939                let store = self.inner.db.store();
940                let entities = scan_collection_with_candidates(&store, collection, &h3_candidates);
941
942                let mut result = UnifiedResult::with_columns(vec!["entity_id".into()]);
943                let mut count = 0;
944                for entity in &entities {
945                    if count >= *limit {
946                        break;
947                    }
948                    if let Some((lat, lon)) = extract_geo_from_entity(entity) {
949                        if lat >= *min_lat && lat <= *max_lat && lon >= *min_lon && lon <= *max_lon
950                        {
951                            let mut record = UnifiedRecord::new();
952                            record.set("entity_id", Value::UnsignedInteger(entity.id.raw()));
953                            result.push(record);
954                            count += 1;
955                        }
956                    }
957                }
958                Ok(RuntimeQueryResult {
959                    query: raw_query.to_string(),
960                    mode: QueryMode::Sql,
961                    statement: "search_spatial_bbox",
962                    engine: "runtime-spatial",
963                    result,
964                    affected_rows: 0,
965                    statement_type: "select",
966                    bookmark: None,
967                })
968            }
969            SearchCommand::SpatialNearest {
970                lat,
971                lon,
972                k,
973                collection,
974                column,
975                k_param,
976            } => {
977                if k_param.is_some() {
978                    return Err(RedDBError::Query(
979                        "SEARCH SPATIAL NEAREST K $N parameter was not bound before execution"
980                            .to_string(),
981                    ));
982                }
983                use crate::storage::unified::spatial_index::haversine_km;
984                // Expand rings outward off the H3 disk index until the K-th
985                // nearest candidate is provably closer than any unscanned
986                // cell, then post-filter exactly as the full scan does. When
987                // no H3 index covers the column (or the cover can't be
988                // proven within the ring cap), fall back to the full scan so
989                // results stay byte-identical (PRD #1574 slice 3).
990                let h3_candidates =
991                    self.h3_nearest_candidate_ids(collection, column, *lat, *lon, *k);
992                let store = self.inner.db.store();
993                let entities = scan_collection_with_candidates(&store, collection, &h3_candidates);
994
995                let mut hits: Vec<(u64, f64)> = Vec::new();
996                for entity in &entities {
997                    if let Some((elat, elon)) = extract_geo_from_entity(entity) {
998                        let dist = haversine_km(*lat, *lon, elat, elon);
999                        hits.push((entity.id.raw(), dist));
1000                    }
1001                }
1002                hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1003                hits.truncate(*k);
1004
1005                let mut result =
1006                    UnifiedResult::with_columns(vec!["entity_id".into(), "distance_km".into()]);
1007                for (id, dist) in &hits {
1008                    let mut record = UnifiedRecord::new();
1009                    record.set("entity_id", Value::UnsignedInteger(*id));
1010                    record.set("distance_km", Value::Float(*dist));
1011                    result.push(record);
1012                }
1013                Ok(RuntimeQueryResult {
1014                    query: raw_query.to_string(),
1015                    mode: QueryMode::Sql,
1016                    statement: "search_spatial_nearest",
1017                    engine: "runtime-spatial",
1018                    result,
1019                    affected_rows: 0,
1020                    statement_type: "select",
1021                    bookmark: None,
1022                })
1023            }
1024        }
1025    }
1026}
1027
1028fn apply_graph_order_and_limit(
1029    result: &mut UnifiedResult,
1030    statement: &str,
1031    order_by: Option<&GraphCommandOrderBy>,
1032    limit: Option<usize>,
1033) -> RedDBResult<()> {
1034    if let Some(order) = order_by {
1035        let column = graph_order_metric_column(statement, &order.metric)?;
1036        let columns = result.columns.clone();
1037        result.records.sort_by(|left, right| {
1038            let cmp = compare_graph_values(left.get(column), right.get(column));
1039            let cmp = if order.ascending { cmp } else { cmp.reverse() };
1040            if cmp == Ordering::Equal {
1041                compare_graph_rows(left, right, &columns)
1042            } else {
1043                cmp
1044            }
1045        });
1046    }
1047    if let Some(limit) = limit {
1048        result.records.truncate(limit);
1049    }
1050    Ok(())
1051}
1052
1053fn graph_order_metric_column(statement: &str, metric: &str) -> RedDBResult<&'static str> {
1054    let metric = metric.to_ascii_lowercase();
1055    match (statement, metric.as_str()) {
1056        ("graph_centrality", "score" | "centrality_score") => Ok("score"),
1057        ("graph_community", "size" | "community_size") => Ok("size"),
1058        ("graph_components", "size" | "component_size") => Ok("size"),
1059        ("graph_shortest_path", "hop_count" | "total_weight" | "nodes_visited") => {
1060            Ok(match metric.as_str() {
1061                "total_weight" => "total_weight",
1062                "nodes_visited" => "nodes_visited",
1063                _ => "hop_count",
1064            })
1065        }
1066        _ => Err(RedDBError::Query(format!(
1067            "unsupported ORDER BY metric '{metric}' for GRAPH {}",
1068            statement.trim_start_matches("graph_")
1069        ))),
1070    }
1071}
1072
1073fn compare_graph_rows(left: &UnifiedRecord, right: &UnifiedRecord, columns: &[String]) -> Ordering {
1074    for column in columns {
1075        let cmp = compare_graph_values(left.get(column), right.get(column));
1076        if cmp != Ordering::Equal {
1077            return cmp;
1078        }
1079    }
1080    Ordering::Equal
1081}
1082
1083fn compare_graph_values(left: Option<&Value>, right: Option<&Value>) -> Ordering {
1084    match (left, right) {
1085        (None, None) => Ordering::Equal,
1086        (None, Some(_)) => Ordering::Less,
1087        (Some(_), None) => Ordering::Greater,
1088        (Some(Value::Null), Some(Value::Null)) => Ordering::Equal,
1089        (Some(Value::Null), Some(_)) => Ordering::Less,
1090        (Some(_), Some(Value::Null)) => Ordering::Greater,
1091        (Some(Value::Integer(left)), Some(Value::Integer(right))) => left.cmp(right),
1092        (Some(Value::UnsignedInteger(left)), Some(Value::UnsignedInteger(right))) => {
1093            left.cmp(right)
1094        }
1095        (Some(Value::Float(left)), Some(Value::Float(right))) => {
1096            left.partial_cmp(right).unwrap_or(Ordering::Equal)
1097        }
1098        (Some(Value::Integer(left)), Some(Value::Float(right))) => {
1099            (*left as f64).partial_cmp(right).unwrap_or(Ordering::Equal)
1100        }
1101        (Some(Value::Float(left)), Some(Value::Integer(right))) => left
1102            .partial_cmp(&(*right as f64))
1103            .unwrap_or(Ordering::Equal),
1104        (Some(Value::UnsignedInteger(left)), Some(Value::Float(right))) => {
1105            (*left as f64).partial_cmp(right).unwrap_or(Ordering::Equal)
1106        }
1107        (Some(Value::Float(left)), Some(Value::UnsignedInteger(right))) => left
1108            .partial_cmp(&(*right as f64))
1109            .unwrap_or(Ordering::Equal),
1110        (Some(Value::Integer(left)), Some(Value::UnsignedInteger(right))) => {
1111            (*left as i128).cmp(&(*right as i128))
1112        }
1113        (Some(Value::UnsignedInteger(left)), Some(Value::Integer(right))) => {
1114            (*left as i128).cmp(&(*right as i128))
1115        }
1116        (Some(Value::Timestamp(left)), Some(Value::Timestamp(right))) => left.cmp(right),
1117        (Some(Value::Text(left)), Some(Value::Text(right))) => left.cmp(right),
1118        (Some(Value::Boolean(left)), Some(Value::Boolean(right))) => left.cmp(right),
1119        (Some(left), Some(right)) => format!("{left:?}").cmp(&format!("{right:?}")),
1120    }
1121}
1122
1123// =============================================================================
1124// Conversion helpers for string -> enum
1125// =============================================================================
1126
1127fn parse_direction(s: &str) -> RedDBResult<RuntimeGraphDirection> {
1128    match s.to_lowercase().as_str() {
1129        "outgoing" | "out" => Ok(RuntimeGraphDirection::Outgoing),
1130        "incoming" | "in" => Ok(RuntimeGraphDirection::Incoming),
1131        "both" | "any" => Ok(RuntimeGraphDirection::Both),
1132        _ => Err(RedDBError::Query(format!(
1133            "unknown direction: '{s}', expected outgoing|incoming|both"
1134        ))),
1135    }
1136}
1137
1138fn parse_path_algorithm(s: &str) -> RedDBResult<RuntimeGraphPathAlgorithm> {
1139    match s.to_lowercase().as_str() {
1140        "bfs" => Ok(RuntimeGraphPathAlgorithm::Bfs),
1141        "dijkstra" => Ok(RuntimeGraphPathAlgorithm::Dijkstra),
1142        "astar" | "a*" => Ok(RuntimeGraphPathAlgorithm::AStar),
1143        "bellman_ford" | "bellmanford" => Ok(RuntimeGraphPathAlgorithm::BellmanFord),
1144        _ => Err(RedDBError::Query(format!(
1145            "unknown path algorithm: '{s}', expected bfs|dijkstra|astar|bellman_ford"
1146        ))),
1147    }
1148}
1149
1150fn parse_traversal_strategy(s: &str) -> RedDBResult<RuntimeGraphTraversalStrategy> {
1151    match s.to_lowercase().as_str() {
1152        "bfs" => Ok(RuntimeGraphTraversalStrategy::Bfs),
1153        "dfs" => Ok(RuntimeGraphTraversalStrategy::Dfs),
1154        _ => Err(RedDBError::Query(format!(
1155            "unknown traversal strategy: '{s}', expected bfs|dfs"
1156        ))),
1157    }
1158}
1159
1160fn parse_centrality_algorithm(s: &str) -> RedDBResult<RuntimeGraphCentralityAlgorithm> {
1161    match s.to_lowercase().as_str() {
1162        "degree" => Ok(RuntimeGraphCentralityAlgorithm::Degree),
1163        "closeness" => Ok(RuntimeGraphCentralityAlgorithm::Closeness),
1164        "betweenness" => Ok(RuntimeGraphCentralityAlgorithm::Betweenness),
1165        "eigenvector" => Ok(RuntimeGraphCentralityAlgorithm::Eigenvector),
1166        "pagerank" | "page_rank" => Ok(RuntimeGraphCentralityAlgorithm::PageRank),
1167        _ => Err(RedDBError::Query(format!(
1168            "unknown centrality algorithm: '{s}', expected degree|closeness|betweenness|eigenvector|pagerank"
1169        ))),
1170    }
1171}
1172
1173fn parse_community_algorithm(s: &str) -> RedDBResult<RuntimeGraphCommunityAlgorithm> {
1174    match s.to_lowercase().as_str() {
1175        "label_propagation" | "labelpropagation" => {
1176            Ok(RuntimeGraphCommunityAlgorithm::LabelPropagation)
1177        }
1178        "louvain" => Ok(RuntimeGraphCommunityAlgorithm::Louvain),
1179        _ => Err(RedDBError::Query(format!(
1180            "unknown community algorithm: '{s}', expected label_propagation|louvain"
1181        ))),
1182    }
1183}
1184
1185fn parse_components_mode(s: &str) -> RedDBResult<RuntimeGraphComponentsMode> {
1186    match s.to_lowercase().as_str() {
1187        "connected" => Ok(RuntimeGraphComponentsMode::Connected),
1188        "weak" | "weakly_connected" => Ok(RuntimeGraphComponentsMode::Weak),
1189        "strong" | "strongly_connected" => Ok(RuntimeGraphComponentsMode::Strong),
1190        _ => Err(RedDBError::Query(format!(
1191            "unknown components mode: '{s}', expected connected|weak|strong"
1192        ))),
1193    }
1194}
1195
1196/// Extract (latitude, longitude) from an entity.
1197///
1198/// Looks for GeoPoint values in the entity data (row columns or node properties)
1199/// or dedicated lat/lon fields. Returns degrees.
1200fn extract_geo_from_entity(entity: &UnifiedEntity) -> Option<(f64, f64)> {
1201    match &entity.data {
1202        EntityData::Row(row) => {
1203            // Search named columns for GeoPoint or lat/lon pairs
1204            if let Some(ref named) = row.named {
1205                // Direct GeoPoint value
1206                for value in named.values() {
1207                    if let Value::GeoPoint(lat_micro, lon_micro) = value {
1208                        return Some((
1209                            *lat_micro as f64 / 1_000_000.0,
1210                            *lon_micro as f64 / 1_000_000.0,
1211                        ));
1212                    }
1213                }
1214                // Try lat/lon or latitude/longitude named fields
1215                let lat =
1216                    named
1217                        .get("lat")
1218                        .or_else(|| named.get("latitude"))
1219                        .and_then(|v| match v {
1220                            Value::Float(f) => Some(*f),
1221                            Value::Integer(i) => Some(*i as f64),
1222                            _ => None,
1223                        });
1224                let lon = named
1225                    .get("lon")
1226                    .or_else(|| named.get("lng"))
1227                    .or_else(|| named.get("longitude"))
1228                    .and_then(|v| match v {
1229                        Value::Float(f) => Some(*f),
1230                        Value::Integer(i) => Some(*i as f64),
1231                        _ => None,
1232                    });
1233                if let (Some(la), Some(lo)) = (lat, lon) {
1234                    return Some((la, lo));
1235                }
1236            }
1237            // Search positional columns for GeoPoint
1238            for value in &row.columns {
1239                if let Value::GeoPoint(lat_micro, lon_micro) = value {
1240                    return Some((
1241                        *lat_micro as f64 / 1_000_000.0,
1242                        *lon_micro as f64 / 1_000_000.0,
1243                    ));
1244                }
1245            }
1246            None
1247        }
1248        EntityData::Node(node) => {
1249            // Search node properties
1250            for value in node.properties.values() {
1251                if let Value::GeoPoint(lat_micro, lon_micro) = value {
1252                    return Some((
1253                        *lat_micro as f64 / 1_000_000.0,
1254                        *lon_micro as f64 / 1_000_000.0,
1255                    ));
1256                }
1257            }
1258            let lat = node
1259                .properties
1260                .get("lat")
1261                .or_else(|| node.properties.get("latitude"))
1262                .and_then(|v| match v {
1263                    Value::Float(f) => Some(*f),
1264                    Value::Integer(i) => Some(*i as f64),
1265                    _ => None,
1266                });
1267            let lon = node
1268                .properties
1269                .get("lon")
1270                .or_else(|| node.properties.get("lng"))
1271                .or_else(|| node.properties.get("longitude"))
1272                .and_then(|v| match v {
1273                    Value::Float(f) => Some(*f),
1274                    Value::Integer(i) => Some(*i as f64),
1275                    _ => None,
1276                });
1277            if let (Some(la), Some(lo)) = (lat, lon) {
1278                return Some((la, lo));
1279            }
1280            None
1281        }
1282        _ => None,
1283    }
1284}
1285
1286/// Collect the entities a spatial post-filter runs over. With an H3 cover
1287/// (`Some`), the collection scan is restricted to the candidate entity ids
1288/// while preserving `query_all` order, so the downstream stable sort and
1289/// early-LIMIT behaviour stay byte-identical to the full scan. Without a
1290/// cover (`None`), every row is scanned exactly as before (PRD #1574 slice 3).
1291fn scan_collection_with_candidates(
1292    store: &std::sync::Arc<crate::storage::unified::store::UnifiedStore>,
1293    collection: &str,
1294    candidates: &Option<std::collections::HashSet<u64>>,
1295) -> Vec<UnifiedEntity> {
1296    match candidates {
1297        Some(ids) => store
1298            .get_collection(collection)
1299            .map(|m| m.query_all(|e| ids.contains(&e.id.raw())))
1300            .unwrap_or_default(),
1301        None => store
1302            .get_collection(collection)
1303            .map(|m| m.query_all(|_| true))
1304            .unwrap_or_default(),
1305    }
1306}
1307
1308/// Cells covering a disk of `radius_km` around `(lat, lon)` at `resolution`.
1309///
1310/// Sizes the kRing as `ceil(radius_km / edge_km) + 1`: one grid step spans
1311/// at least one edge length, so the division over-estimates the ring count,
1312/// and the extra ring is the boundary margin that prevents the geohash-style
1313/// edge miss. Returns an empty vec — signalling the caller to fall back to
1314/// the full scan — for an un-encodable coordinate or a cover so large it
1315/// would be cheaper to scan the collection than to enumerate the cells.
1316fn h3_cover_cells(lat: f64, lon: f64, radius_km: f64, resolution: u8) -> Vec<u64> {
1317    let cell = crate::geo::h3::lat_lng_to_cell(lat, lon, resolution);
1318    if cell == 0 {
1319        return Vec::new();
1320    }
1321    let edge_km = crate::geo::h3::edge_length_km(resolution).max(f64::MIN_POSITIVE);
1322    // Cap the cover so a large radius over a fine resolution cannot blow up
1323    // into hundreds of millions of cells; beyond it the full scan is cheaper.
1324    const MAX_COVER_RING: u32 = 128;
1325    let k_f = (radius_km / edge_km).ceil() + 1.0;
1326    if !k_f.is_finite() || k_f > f64::from(MAX_COVER_RING) {
1327        return Vec::new();
1328    }
1329    crate::geo::h3::grid_disk(cell, k_f as u32)
1330}
1331
1332impl RedDBRuntime {
1333    /// Resolution of the H3 index registered on `(collection, column)`, if
1334    /// the column is covered by one. `None` for any other (or no) index.
1335    fn h3_index_resolution(&self, collection: &str, column: &str) -> Option<u8> {
1336        match self
1337            .inner
1338            .index_store
1339            .find_index_for_column(collection, column)?
1340            .method
1341        {
1342            super::index_store::IndexMethodKind::H3 { resolution } => Some(resolution),
1343            _ => None,
1344        }
1345    }
1346
1347    /// Map a set of H3 cell ids to the entity ids stored under them in the
1348    /// disk B-tree (sorted) index via per-cell point lookups. `None` when no
1349    /// cells were supplied or the column has no sorted index entry.
1350    fn h3_cell_candidate_ids(
1351        &self,
1352        collection: &str,
1353        column: &str,
1354        cells: &[u64],
1355    ) -> Option<std::collections::HashSet<u64>> {
1356        if cells.is_empty() {
1357            return None;
1358        }
1359        let keys: Vec<_> = cells
1360            .iter()
1361            .filter_map(|c| {
1362                crate::storage::schema::value_to_canonical_key(&Value::UnsignedInteger(*c))
1363            })
1364            .collect();
1365        let ids = self.inner.index_store.sorted.in_lookup_limited(
1366            collection,
1367            column,
1368            &keys,
1369            usize::MAX,
1370        )?;
1371        Some(ids.into_iter().map(|id| id.raw()).collect())
1372    }
1373
1374    /// Covering-kRing candidate ids for a RADIUS query, or `None` to fall
1375    /// back to the full scan (no H3 index, un-encodable centre, or a cover
1376    /// too large to enumerate).
1377    fn h3_radius_candidate_ids(
1378        &self,
1379        collection: &str,
1380        column: &str,
1381        center_lat: f64,
1382        center_lon: f64,
1383        radius_km: f64,
1384    ) -> Option<std::collections::HashSet<u64>> {
1385        let resolution = self.h3_index_resolution(collection, column)?;
1386        let cells = h3_cover_cells(center_lat, center_lon, radius_km, resolution);
1387        self.h3_cell_candidate_ids(collection, column, &cells)
1388    }
1389
1390    /// Covering-kRing candidate ids for a BBOX query: cover a disk centred on
1391    /// the box whose radius reaches its farthest corner, then let the exact
1392    /// bbox filter trim it. `None` falls back to the full scan.
1393    fn h3_bbox_candidate_ids(
1394        &self,
1395        collection: &str,
1396        column: &str,
1397        min_lat: f64,
1398        min_lon: f64,
1399        max_lat: f64,
1400        max_lon: f64,
1401    ) -> Option<std::collections::HashSet<u64>> {
1402        let resolution = self.h3_index_resolution(collection, column)?;
1403        let center_lat = (min_lat + max_lat) / 2.0;
1404        let center_lon = (min_lon + max_lon) / 2.0;
1405        let radius_km = [
1406            (min_lat, min_lon),
1407            (min_lat, max_lon),
1408            (max_lat, min_lon),
1409            (max_lat, max_lon),
1410        ]
1411        .into_iter()
1412        .map(|(la, lo)| crate::geo::haversine_km(center_lat, center_lon, la, lo))
1413        .fold(0.0_f64, f64::max);
1414        let cells = h3_cover_cells(center_lat, center_lon, radius_km, resolution);
1415        self.h3_cell_candidate_ids(collection, column, &cells)
1416    }
1417
1418    /// Ring-expansion candidate ids for a NEAREST-K query. Expands the cover
1419    /// outward until the K-th nearest candidate is provably closer than any
1420    /// point in an unscanned cell, returning that superset. `None` (→ full
1421    /// scan) when the column has no H3 index, the centre is un-encodable, or
1422    /// the proof is not reached within the ring cap (e.g. fewer than K rows).
1423    fn h3_nearest_candidate_ids(
1424        &self,
1425        collection: &str,
1426        column: &str,
1427        lat: f64,
1428        lon: f64,
1429        k: usize,
1430    ) -> Option<std::collections::HashSet<u64>> {
1431        let resolution = self.h3_index_resolution(collection, column)?;
1432        if k == 0 {
1433            return None;
1434        }
1435        let center = crate::geo::h3::lat_lng_to_cell(lat, lon, resolution);
1436        if center == 0 {
1437            return None;
1438        }
1439        let edge_km = crate::geo::h3::edge_length_km(resolution).max(f64::MIN_POSITIVE);
1440        // Bound the expansion so an index holding fewer than K points cannot
1441        // loop forever; past the cap the exact full scan takes over.
1442        const MAX_RING: u32 = 64;
1443        let store = self.inner.db.store();
1444        for r in 0..=MAX_RING {
1445            let cells = crate::geo::h3::grid_disk(center, r);
1446            let Some(ids) = self.h3_cell_candidate_ids(collection, column, &cells) else {
1447                continue;
1448            };
1449            if ids.len() < k {
1450                continue;
1451            }
1452            let candidates = Some(ids);
1453            let mut dists: Vec<f64> =
1454                scan_collection_with_candidates(&store, collection, &candidates)
1455                    .iter()
1456                    .filter_map(|e| {
1457                        extract_geo_from_entity(e)
1458                            .map(|(elat, elon)| crate::geo::haversine_km(lat, lon, elat, elon))
1459                    })
1460                    .collect();
1461            if dists.len() < k {
1462                continue;
1463            }
1464            dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1465            let d_k = dists[k - 1];
1466            // A point in any unscanned cell (grid distance > r) lies at least
1467            // `r * edge_km` from the centre — a conservative lower bound. Once
1468            // that already meets or exceeds the current K-th distance, the
1469            // cover is a proven superset of the true nearest-K.
1470            if f64::from(r) * edge_km >= d_k {
1471                return candidates;
1472            }
1473        }
1474        None
1475    }
1476}