Skip to main content

candle_graph/
query.rs

1//! Bounded queries over [`crate::model_ir::ModelIr`].
2//!
3//! Agents should not have to ingest an entire model graph to answer a local question. Queries
4//! therefore return compact, deterministically ordered records and make truncation explicit.
5//! Listing kinds omit tensor contracts and evidence payloads; narrow singular queries (or a
6//! selector on evidence-bearing lists) unlock detail, with `drill_down` hints pointing the way.
7
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10use anyhow::{bail, Result};
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13
14use crate::model_ir::{Finding, Function, ModelIr, StableId, TensorContract, ExecutionPhase};
15
16pub const QUERY_SCHEMA: &str = "candle-graph/query/1";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum QueryKind {
21    Summary,
22    Doctor,
23    Architecture,
24    Cargo,
25    Components,
26    Component,
27    Modules,
28    Composition,
29    Assembly,
30    Pipeline,
31    Stages,
32    Artifacts,
33    Entrypoints,
34    Functions,
35    Function,
36    Parameters,
37    Parameter,
38    Tensors,
39    Tensor,
40    Operations,
41    Operation,
42    Optimizers,
43    Runtime,
44    Findings,
45    Path,
46    /// Compact agent-oriented rollup for model repair and improvement workflows.
47    ModelImprovement,
48    /// Phase-filtered graph: tensors + operations for training autograd paths.
49    GraphTrain,
50    /// Phase-filtered graph: tensors + operations for inference no-grad paths.
51    GraphInfer,
52    /// Runtime profile rollup: operation and edge timings merged from a v3 trace.
53    Profile,
54}
55
56impl std::str::FromStr for QueryKind {
57    type Err = anyhow::Error;
58
59    fn from_str(value: &str) -> Result<Self> {
60        match value.replace('-', "_").as_str() {
61            "summary" => Ok(Self::Summary),
62            "doctor" | "trust" | "coverage" => Ok(Self::Doctor),
63            "architecture" | "model" => Ok(Self::Architecture),
64            "cargo" | "cfg" | "features" => Ok(Self::Cargo),
65            "components" => Ok(Self::Components),
66            "component" => Ok(Self::Component),
67            "modules" | "module" => Ok(Self::Modules),
68            "composition" | "edges" | "contains" => Ok(Self::Composition),
69            "assembly" | "wiring" | "checkpoint_assembly" => Ok(Self::Assembly),
70            "pipeline" => Ok(Self::Pipeline),
71            "stages" => Ok(Self::Stages),
72            "artifacts" => Ok(Self::Artifacts),
73            "entrypoints" => Ok(Self::Entrypoints),
74            "functions" => Ok(Self::Functions),
75            "function" => Ok(Self::Function),
76            "parameters" => Ok(Self::Parameters),
77            "parameter" => Ok(Self::Parameter),
78            "tensors" => Ok(Self::Tensors),
79            "tensor" => Ok(Self::Tensor),
80            "operations" | "ops" => Ok(Self::Operations),
81            "operation" | "op" => Ok(Self::Operation),
82            "optimizers" | "optimizer" => Ok(Self::Optimizers),
83            "runtime" | "gradient_audit" | "gradients" => {
84                require_runtime_feature("runtime")?;
85                Ok(Self::Runtime)
86            }
87            "findings" | "diagnostics" => Ok(Self::Findings),
88            "path" | "trace" => Ok(Self::Path),
89            "model_improvement" | "model-improvement" | "improvement" | "agent" => {
90                Ok(Self::ModelImprovement)
91            }
92            "graph_train" | "graph-train" | "train_graph" | "train-graph" => {
93                require_runtime_feature("graph_train")?;
94                Ok(Self::GraphTrain)
95            }
96            "graph_infer" | "graph-infer" | "infer_graph" | "infer-graph" => {
97                require_runtime_feature("graph_infer")?;
98                Ok(Self::GraphInfer)
99            }
100            "profile" | "profiler" | "timings" => {
101                require_runtime_feature("profile")?;
102                Ok(Self::Profile)
103            }
104            other => bail!("unknown query kind `{other}`"),
105        }
106    }
107}
108
109fn require_runtime_feature(kind: &str) -> Result<()> {
110    #[cfg(not(feature = "runtime"))]
111    {
112        bail!(
113            "query kind `{kind}` requires the `runtime` crate feature; rebuild with `--features runtime`"
114        );
115    }
116    #[cfg(feature = "runtime")]
117    {
118        let _ = kind;
119        Ok(())
120    }
121}
122
123fn ensure_runtime_query(kind: QueryKind) -> Result<()> {
124    if matches!(
125        kind,
126        QueryKind::Runtime | QueryKind::GraphTrain | QueryKind::GraphInfer | QueryKind::Profile
127    ) {
128        require_runtime_feature(match kind {
129            QueryKind::Runtime => "runtime",
130            QueryKind::GraphTrain => "graph_train",
131            QueryKind::GraphInfer => "graph_infer",
132            QueryKind::Profile => "profile",
133            _ => unreachable!(),
134        })?;
135    }
136    Ok(())
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct QueryRequest {
141    pub kind: QueryKind,
142    pub selector: Option<String>,
143    pub to: Option<String>,
144    pub limit: usize,
145    /// Deterministic start index into the sorted result set; exposed as CLI `--offset`.
146    #[serde(default)]
147    pub offset: usize,
148}
149
150impl QueryRequest {
151    pub fn new(kind: QueryKind) -> Self {
152        Self {
153            kind,
154            selector: None,
155            to: None,
156            limit: 100,
157            offset: 0,
158        }
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct QueryResponse {
164    pub schema: String,
165    pub analysis_id: StableId,
166    pub kind: QueryKind,
167    pub selector: Option<String>,
168    pub total: usize,
169    pub returned: usize,
170    #[serde(default)]
171    pub offset: usize,
172    pub truncated: bool,
173    pub items: Vec<Value>,
174}
175
176pub fn execute(model: &ModelIr, request: &QueryRequest) -> Result<QueryResponse> {
177    if request.limit == 0 {
178        bail!("query limit must be greater than zero");
179    }
180    ensure_runtime_query(request.kind)?;
181    let mut items = match request.kind {
182        QueryKind::Summary => vec![summary(model)],
183        QueryKind::Doctor => vec![doctor(model)],
184        QueryKind::Architecture => vec![architecture(model)],
185        QueryKind::Cargo => vec![json!({
186            "id": "cargo",
187            "context": model.cargo,
188            "drill_down": [
189                {"kind": "summary"},
190                {"kind": "functions"},
191            ],
192        })],
193        QueryKind::Components => model
194            .components
195            .iter()
196            .map(|component| {
197                json!({
198                    "id": component.id,
199                    "name": component.name,
200                    "qualified_name": component.qualified_name,
201                    "source": component.source,
202                    "builders": component.builders.iter().map(|builder| json!({
203                        "name": builder.name,
204                        "role": builder.role,
205                    })).collect::<Vec<_>>(),
206                    "modules": component.modules.len(),
207                    "parameters": component.parameters.len(),
208                    "entrypoints": component.entrypoints,
209                    "drill_down": [
210                        {"kind": "component", "select": component.qualified_name},
211                        {"kind": "modules", "select": component.qualified_name},
212                        {"kind": "entrypoints", "select": component.name},
213                        {"kind": "parameters", "select": component.name},
214                    ],
215                })
216            })
217            .collect(),
218        QueryKind::Component => {
219            let selector = required_selector(request)?;
220            model
221                .components
222                .iter()
223                .filter(|item| {
224                    matches_text(selector, [&item.id.0, &item.name, &item.qualified_name])
225                })
226                .map(|component| {
227                    let mut value = serde_json::to_value(component)?;
228                    if let Some(object) = value.as_object_mut() {
229                        object.insert(
230                            "drill_down".into(),
231                            json!([
232                                {"kind": "modules", "select": component.qualified_name},
233                                {"kind": "entrypoints", "select": component.name},
234                                {"kind": "parameters", "select": component.name},
235                                {"kind": "functions", "select": component.name},
236                                {"kind": "tensors", "select": component.name},
237                                {"kind": "composition", "select": component.qualified_name},
238                            ]),
239                        );
240                    }
241                    Ok(value)
242                })
243                .collect::<serde_json::Result<Vec<_>>>()?
244        }
245        QueryKind::Modules => {
246            let component_names = component_name_lookup(model);
247            model
248                .modules
249                .iter()
250                .filter(|module| {
251                    let component_name = component_names
252                        .get(&module.component)
253                        .map(String::as_str)
254                        .unwrap_or("");
255                    optional_matches(
256                        request.selector.as_deref(),
257                        [
258                            &module.id.0,
259                            &module.type_name,
260                            module.qualified_type.as_deref().unwrap_or(""),
261                            module.field.as_deref().unwrap_or(""),
262                            &module.prefix,
263                            &module.builder_root,
264                            component_name,
265                        ],
266                    )
267                })
268                .map(|module| module_listing(module, &component_names))
269                .collect()
270        }
271        QueryKind::Composition => model
272            .architecture_edges
273            .iter()
274            .filter(|edge| edge.id.0.starts_with("composition-edge:"))
275            .filter(|edge| {
276                composition_matches(
277                    model,
278                    request.selector.as_deref(),
279                    edge.from.clone(),
280                    edge.to.clone(),
281                )
282            })
283            .map(|edge| composition_listing(model, edge))
284            .collect(),
285        QueryKind::Assembly => model
286            .assembly_sites
287            .iter()
288            .filter(|site| {
289                optional_matches(
290                    request.selector.as_deref(),
291                    [
292                        &site.id.0,
293                        &site.component_name,
294                        &site.function_name,
295                        &site.builder_root,
296                        site.varmap.as_deref().unwrap_or(""),
297                        site.checkpoint_load.as_deref().unwrap_or(""),
298                    ],
299                )
300            })
301            .map(assembly_listing)
302            .collect(),
303        QueryKind::Pipeline => vec![json!({
304            "id": "pipeline",
305            "stages": model.stages.iter().map(|stage| json!({
306                "id": stage.id,
307                "name": stage.name,
308                "kind": stage.kind,
309                "order": stage.order,
310                "dispatch": stage.dispatch,
311                "subprocess_key": stage.subprocess_key,
312                "cli_flags": stage.cli_flags,
313            })).collect::<Vec<_>>(),
314            "subprocess_stages": model.coverage.subprocess_stages,
315            "artifacts": model.artifacts.iter().map(|artifact| json!({
316                "id": artifact.id,
317                "name": artifact.name,
318            })).collect::<Vec<_>>(),
319            "optimizers": model.optimizers.len(),
320            "drill_down": [
321                {"kind": "stages"},
322                {"kind": "artifacts"},
323                {"kind": "optimizers"},
324            ],
325        })],
326        QueryKind::Stages => model
327            .stages
328            .iter()
329            .filter(|stage| {
330                optional_matches(request.selector.as_deref(), [&stage.id.0, &stage.name])
331            })
332            .map(|stage| {
333                json!({
334                    "id": stage.id,
335                    "name": stage.name,
336                    "kind": stage.kind,
337                    "order": stage.order,
338                    "dispatch": stage.dispatch,
339                    "subprocess_key": stage.subprocess_key,
340                    "cli_flags": stage.cli_flags,
341                    "launcher": stage.launcher,
342                    "orchestrator": stage.orchestrator,
343                    "source": stage.source,
344                    "components": stage.components.len(),
345                    "drill_down": [
346                        {"kind": "function", "select": stage.source},
347                    ],
348                })
349            })
350            .collect(),
351        QueryKind::Artifacts => model
352            .artifacts
353            .iter()
354            .filter(|artifact| {
355                optional_matches(
356                    request.selector.as_deref(),
357                    [&artifact.id.0, &artifact.name, &artifact.path_expr],
358                )
359            })
360            .map(serde_json::to_value)
361            .collect::<serde_json::Result<Vec<_>>>()?,
362        QueryKind::Entrypoints => {
363            let mut entrypoints: Vec<_> = model
364                .functions
365                .iter()
366                .filter(|function| function.is_entrypoint)
367                .filter(|function| {
368                    optional_matches(
369                        request.selector.as_deref(),
370                        [&function.id.0, &function.name, &function.qualified_name],
371                    )
372                })
373                .map(|function| (function, is_component_entrypoint(function, model)))
374                .collect();
375            entrypoints.sort_by(|(left, left_component), (right, right_component)| {
376                right_component
377                    .cmp(left_component)
378                    .then_with(|| left.qualified_name.cmp(&right.qualified_name))
379            });
380            entrypoints
381                .into_iter()
382                .map(|(function, is_component)| function_listing(function, is_component))
383                .collect()
384        }
385        QueryKind::Functions => model
386            .functions
387            .iter()
388            .filter(|function| {
389                optional_matches(
390                    request.selector.as_deref(),
391                    [&function.id.0, &function.name, &function.qualified_name],
392                )
393            })
394            .map(|function| function_listing(function, is_component_entrypoint(function, model)))
395            .collect(),
396        QueryKind::Function => {
397            let selector = required_selector(request)?;
398            model
399                .functions
400                .iter()
401                .filter(|function| {
402                    matches_text(
403                        selector,
404                        [&function.id.0, &function.name, &function.qualified_name],
405                    )
406                })
407                .map(function_detail)
408                .collect()
409        }
410        QueryKind::Parameters => model
411            .parameters
412            .iter()
413            .filter(|parameter| {
414                optional_matches(
415                    request.selector.as_deref(),
416                    [
417                        &parameter.id.0,
418                        &parameter.key,
419                        &parameter.builder_root,
420                        &parameter.kind,
421                    ],
422                )
423            })
424            .map(|parameter| {
425                json!({
426                    "id": parameter.id,
427                    "component": parameter.component,
428                    "module": parameter.module,
429                    "key": parameter.key,
430                    "builder_root": parameter.builder_root,
431                    "role": parameter.role,
432                    "kind": parameter.kind,
433                    "symbolic_shape": parameter.symbolic_shape,
434                    "checkpoint_shape": parameter.checkpoint_shape,
435                    "checkpoint_dtype": parameter.checkpoint_dtype,
436                    "source": parameter.source,
437                    "uses": parameter.uses,
438                    "optimizer_memberships": parameter.optimizer_memberships,
439                    "drill_down": [
440                        {"kind": "parameter", "select": parameter.id.0},
441                    ],
442                })
443            })
444            .collect(),
445        QueryKind::Parameter => {
446            let selector = required_selector(request)?;
447            model
448                .parameters
449                .iter()
450                .filter(|parameter| {
451                    matches_text(
452                        selector,
453                        [
454                            &parameter.id.0,
455                            &parameter.key,
456                            &parameter.builder_root,
457                            &format!("{}:{}", parameter.builder_root, parameter.key),
458                        ],
459                    )
460                })
461                .map(serde_json::to_value)
462                .collect::<serde_json::Result<Vec<_>>>()?
463        }
464        QueryKind::Tensors => model
465            .tensors
466            .iter()
467            .filter(|tensor| tensor_matches(model, tensor, request.selector.as_deref()))
468            .map(|tensor| tensor_listing(model, tensor))
469            .collect(),
470        QueryKind::Tensor => {
471            let selector = required_selector(request)?;
472            model
473                .tensors
474                .iter()
475                .filter(|tensor| tensor_matches(model, tensor, Some(selector)))
476                .map(serde_json::to_value)
477                .collect::<serde_json::Result<Vec<_>>>()?
478        }
479        QueryKind::Operations => model
480            .operations
481            .iter()
482            .filter(|operation| {
483                let function_name = model
484                    .functions
485                    .iter()
486                    .find(|function| function.id == operation.function)
487                    .map(|function| function.qualified_name.as_str())
488                    .unwrap_or_default();
489                optional_matches(
490                    request.selector.as_deref(),
491                    [
492                        &operation.id.0,
493                        &operation.name,
494                        operation.qualified_name.as_deref().unwrap_or_default(),
495                        &operation.function.0,
496                        function_name,
497                    ],
498                )
499            })
500            .map(|operation| {
501                json!({
502                    "id": operation.id,
503                    "name": operation.name,
504                    "qualified_name": operation.qualified_name,
505                    "function": operation.function,
506                    "inputs": operation.inputs.len(),
507                    "output": operation.output,
508                    "source": operation.source,
509                    "drill_down": [
510                        {"kind": "operation", "select": operation.id.0},
511                        {"kind": "function", "select": operation.function.0},
512                    ],
513                })
514            })
515            .collect(),
516        QueryKind::Operation => {
517            let selector = required_selector(request)?;
518            model
519                .operations
520                .iter()
521                .filter(|operation| {
522                    matches_text(
523                        selector,
524                        [
525                            &operation.id.0,
526                            &operation.name,
527                            operation.qualified_name.as_deref().unwrap_or_default(),
528                        ],
529                    )
530                })
531                .map(serde_json::to_value)
532                .collect::<serde_json::Result<Vec<_>>>()?
533        }
534        QueryKind::Optimizers => model
535            .optimizers
536            .iter()
537            .filter(|optimizer| {
538                optional_matches(
539                    request.selector.as_deref(),
540                    [
541                        &optimizer.id.0,
542                        &optimizer.optimizer,
543                        &optimizer.varmap,
544                        &optimizer.stage.0,
545                    ],
546                )
547            })
548            .map(serde_json::to_value)
549            .collect::<serde_json::Result<Vec<_>>>()?,
550        QueryKind::Runtime => vec![runtime_query(model)],
551        QueryKind::Findings => model
552            .findings
553            .iter()
554            .filter(|finding| {
555                optional_matches(
556                    request.selector.as_deref(),
557                    [&finding.id.0, &finding.rule, &finding.message],
558                )
559            })
560            .map(|finding| {
561                if request.selector.as_deref() == Some(finding.id.0.as_str()) {
562                    serde_json::to_value(finding).unwrap_or_else(|_| json!({}))
563                } else {
564                    finding_listing(finding)
565                }
566            })
567            .collect(),
568        QueryKind::ModelImprovement => vec![model_improvement(model)],
569        QueryKind::GraphTrain => vec![phase_graph(model, ExecutionPhase::Train)],
570        QueryKind::GraphInfer => vec![phase_graph(model, ExecutionPhase::Infer)],
571        QueryKind::Profile => vec![profile_query(model)],
572        QueryKind::Path => {
573            let from = required_selector(request)?;
574            let to = request
575                .to
576                .as_deref()
577                .ok_or_else(|| anyhow::anyhow!("path query requires `to`"))?;
578            let from = resolve_id(model, from)?;
579            let to = resolve_id(model, to)?;
580            shortest_path(model, &from, &to)?
581                .into_iter()
582                .map(|id| describe_id(model, &id))
583                .collect()
584        }
585    };
586
587    if matches!(
588        request.kind,
589        QueryKind::Component
590            | QueryKind::Function
591            | QueryKind::Parameter
592            | QueryKind::Tensor
593            | QueryKind::Operation
594    ) && items.len() > 1
595    {
596        bail!(
597            "{:?} selector matched {} records; use an exact stable id from the compact listing",
598            request.kind,
599            items.len()
600        );
601    }
602
603    if !matches!(
604        request.kind,
605        QueryKind::Summary
606            | QueryKind::Doctor
607            | QueryKind::Architecture
608            | QueryKind::Cargo
609            | QueryKind::Pipeline
610            | QueryKind::Stages
611            | QueryKind::Runtime
612            | QueryKind::ModelImprovement
613            | QueryKind::GraphTrain
614            | QueryKind::GraphInfer
615            | QueryKind::Profile
616    ) {
617        items.sort_by_key(stable_value_key);
618    }
619    let total = items.len();
620    let offset = request.offset.min(total);
621    items = items.into_iter().skip(offset).take(request.limit).collect();
622    Ok(QueryResponse {
623        schema: QUERY_SCHEMA.to_string(),
624        analysis_id: model.analysis_id.clone(),
625        kind: request.kind,
626        selector: request.selector.clone(),
627        total,
628        returned: items.len(),
629        offset,
630        truncated: offset + items.len() < total,
631        items,
632    })
633}
634
635pub fn render_text(response: &QueryResponse) -> String {
636    let mut out = format!(
637        "query {:?}: {} result{}",
638        response.kind,
639        response.total,
640        if response.total == 1 { "" } else { "s" }
641    );
642    if response.offset > 0 || response.truncated {
643        out.push_str(&format!(
644            " (offset {}, showing {})",
645            response.offset, response.returned
646        ));
647    }
648    out.push('\n');
649    for item in &response.items {
650        if let Some(object) = item.as_object() {
651            let id = object
652                .get("id")
653                .and_then(Value::as_str)
654                .or_else(|| object.get("name").and_then(Value::as_str))
655                .unwrap_or("-");
656            let name = object
657                .get("name")
658                .and_then(Value::as_str)
659                .or_else(|| object.get("key").and_then(Value::as_str))
660                .or_else(|| object.get("message").and_then(Value::as_str));
661            out.push_str("  ");
662            out.push_str(id);
663            if let Some(name) = name.filter(|name| *name != id) {
664                out.push_str("  ");
665                out.push_str(name);
666            }
667            if let Some(source) = object.get("source").and_then(Value::as_str) {
668                out.push_str("  @");
669                out.push_str(source);
670            }
671            if let Some(hints) = object.get("drill_down").and_then(Value::as_array) {
672                let kinds = hints
673                    .iter()
674                    .filter_map(|hint| hint.get("kind").and_then(Value::as_str))
675                    .collect::<Vec<_>>()
676                    .join(", ");
677                if !kinds.is_empty() {
678                    out.push_str("  → ");
679                    out.push_str(&kinds);
680                }
681            }
682            out.push('\n');
683        } else {
684            out.push_str("  ");
685            out.push_str(&item.to_string());
686            out.push('\n');
687        }
688    }
689    out
690}
691
692fn summary(model: &ModelIr) -> Value {
693    json!({
694        "id": "summary",
695        "schema": model.schema,
696        "analysis_id": model.analysis_id,
697        "cargo": model.cargo.as_ref().map(|cargo| json!({
698            "build_id": cargo.build_id,
699            "package": cargo.package_name,
700            "version": cargo.package_version,
701            "target": cargo.selected_target,
702            "active_features": cargo.active_features,
703            "candle_packages": cargo.candle_packages,
704        })),
705        "coverage": model.coverage,
706        "components": model.components.iter().map(|c| &c.name).collect::<Vec<_>>(),
707        "pipeline": model.stages.iter().map(|stage| json!({
708            "id": stage.id,
709            "name": stage.name,
710            "kind": stage.kind,
711            "order": stage.order,
712            "dispatch": stage.dispatch,
713            "subprocess_key": stage.subprocess_key,
714            "cli_flags": stage.cli_flags,
715        })).collect::<Vec<_>>(),
716        "finding_counts": finding_counts(model),
717        "runtime": model.runtime,
718        "drill_down": [
719            {"kind": "architecture"},
720            {"kind": "composition"},
721            {"kind": "components"},
722            {"kind": "modules"},
723            {"kind": "functions"},
724            {"kind": "entrypoints"},
725            {"kind": "tensors"},
726            {"kind": "findings"},
727            {"kind": "cargo"},
728            {"kind": "doctor"},
729        ],
730    })
731}
732
733fn doctor(model: &ModelIr) -> Value {
734    let mut by_rule = BTreeMap::new();
735    let mut unknown = 0usize;
736    for finding in &model.findings {
737        *by_rule.entry(finding.rule.clone()).or_insert(0usize) += 1;
738        if matches!(
739            finding.confidence,
740            crate::model_ir::Confidence::Unknown | crate::model_ir::Confidence::Heuristic
741        ) {
742            unknown += 1;
743        }
744    }
745    let source_incomplete = by_rule.get("source-load").copied().unwrap_or(0);
746    let semantic_version_gaps = by_rule
747        .get("candle-semantics-version")
748        .copied()
749        .unwrap_or(0);
750    let dtype_risks = by_rule.get("dtype-risk").copied().unwrap_or(0);
751    let dtype_conflicts = by_rule.get("dtype-conflict").copied().unwrap_or(0);
752    let tensor_dtype_pct = pct(model.coverage.tensors_with_dtype, model.coverage.tensors);
753    let tensor_shape_pct = pct(model.coverage.tensors_with_shape, model.coverage.tensors);
754    let tensor_device_pct = pct(model.coverage.tensors_with_device, model.coverage.tensors);
755    json!({
756        "id": "doctor",
757        "analysis_id": model.analysis_id,
758        "cargo_available": model.cargo.is_some(),
759        "coverage": model.coverage,
760        "coverage_quality": {
761            "tensor_dtype_pct": tensor_dtype_pct,
762            "tensor_shape_pct": tensor_shape_pct,
763            "tensor_device_pct": tensor_device_pct,
764            "dtype_risks": dtype_risks,
765            "dtype_conflicts": dtype_conflicts,
766            "component_entrypoints": model.coverage.component_entrypoints,
767            "total_entrypoints": model.coverage.entrypoints,
768            "composition_edges": model.coverage.composition_edges,
769            "assembly_sites": model.coverage.assembly_sites,
770            "subprocess_stages": model.coverage.subprocess_stages,
771        },
772        "trust": {
773            "source_complete": source_incomplete == 0,
774            "candle_catalog_matched": semantic_version_gaps == 0,
775            "compiler_resolved": false,
776            "runtime_evidence": model.runtime.is_some(),
777            "unknown_or_heuristic_findings": unknown,
778            "actionable_warnings": source_incomplete > 0
779                || semantic_version_gaps > 0
780                || dtype_risks > 0
781                || dtype_conflicts > 0,
782        },
783        "finding_counts_by_rule": by_rule,
784        "limitations": [
785            "Rust names and types are source-resolved, not rustc DefIds",
786            "macros and unresolved dynamic dispatch remain explicit Unknown evidence",
787            "call-order pipeline/optimizer relationships require compiler-resolved value flow",
788            "composition edges follow struct-field types with Heuristic confidence",
789        ],
790        "drill_down": [
791            {"kind": "cargo"},
792            {"kind": "findings"},
793            {"kind": "composition"},
794            {"kind": "assembly"},
795            {"kind": "entrypoints"},
796            {"kind": "modules"},
797        ],
798    })
799}
800
801fn runtime_query(model: &ModelIr) -> Value {
802    json!({
803        "id": "runtime",
804        "summary": model.runtime,
805        "gradient_finding_count": model.findings.iter()
806            .filter(|finding| finding.rule.starts_with("runtime-"))
807            .count(),
808        "drill_down": [
809            {"kind": "findings", "select": "runtime-"},
810            {"kind": "tensors"},
811        ],
812    })
813}
814
815#[cfg(feature = "runtime")]
816fn phase_graph(model: &ModelIr, phase: ExecutionPhase) -> Value {
817    let tensors: Vec<Value> = model
818        .tensors
819        .iter()
820        .filter(|tensor| tensor.execution_phase == Some(phase))
821        .map(|tensor| {
822            json!({
823                "id": tensor.id,
824                "name": tensor.name,
825                "role": format!("{:?}", tensor.role),
826                "requires_grad": tensor.requires_grad,
827                "owner_function": tensor.owner_function,
828            })
829        })
830        .collect();
831    let operations: Vec<Value> = model
832        .operations
833        .iter()
834        .filter(|operation| operation.execution_phase == Some(phase))
835        .map(|operation| {
836            json!({
837                "id": operation.id,
838                "name": operation.name,
839                "function": operation.function,
840                "inputs": operation.inputs,
841                "output": operation.output,
842                "avg_duration_ns": operation.avg_duration_ns,
843                "timing_samples": operation.timing_samples,
844            })
845        })
846        .collect();
847    json!({
848        "id": format!("graph-{}", phase.as_str()),
849        "phase": phase,
850        "tensor_count": tensors.len(),
851        "operation_count": operations.len(),
852        "tensors": tensors,
853        "operations": operations,
854        "drill_down": [
855            {"kind": "tensors"},
856            {"kind": "operations"},
857            {"kind": "profile"},
858        ],
859    })
860}
861
862#[cfg(not(feature = "runtime"))]
863fn phase_graph(_model: &ModelIr, _phase: ExecutionPhase) -> Value {
864    unreachable!("runtime queries require the `runtime` crate feature")
865}
866
867#[cfg(feature = "runtime")]
868fn profile_query(model: &ModelIr) -> Value {
869    let mut slowest: Vec<Value> = model
870        .operations
871        .iter()
872        .filter_map(|operation| {
873            operation.avg_duration_ns.map(|duration| {
874                json!({
875                    "id": operation.id,
876                    "name": operation.name,
877                    "phase": operation.execution_phase,
878                    "avg_duration_ns": duration,
879                    "samples": operation.timing_samples,
880                })
881            })
882        })
883        .collect();
884    slowest.sort_by(|left, right| {
885        right["avg_duration_ns"]
886            .as_u64()
887            .unwrap_or(0)
888            .cmp(&left["avg_duration_ns"].as_u64().unwrap_or(0))
889    });
890    slowest.truncate(50);
891    json!({
892        "id": "profile",
893        "runtime": model.runtime,
894        "slowest_operations": slowest,
895        "drill_down": [
896            {"kind": "graph-train"},
897            {"kind": "graph-infer"},
898            {"kind": "runtime"},
899        ],
900    })
901}
902
903#[cfg(not(feature = "runtime"))]
904fn profile_query(_model: &ModelIr) -> Value {
905    unreachable!("runtime queries require the `runtime` crate feature")
906}
907
908fn model_improvement(model: &ModelIr) -> Value {
909    use crate::model_ir::{Confidence, FindingSeverity};
910
911    let proven_errors: Vec<Value> = model
912        .findings
913        .iter()
914        .filter(|f| {
915            matches!(f.severity, FindingSeverity::Error)
916                && matches!(f.confidence, Confidence::Proven)
917        })
918        .map(finding_listing)
919        .collect();
920
921    let numeric_hazards: Vec<Value> = model
922        .findings
923        .iter()
924        .filter(|f| {
925            matches!(
926                f.rule.as_str(),
927                "numeric-domain-violation"
928                    | "zero-times-infinity"
929                    | "unstable-library-loss"
930            ) && matches!(f.confidence, Confidence::Proven)
931        })
932        .map(finding_listing)
933        .collect();
934
935    let coverage_gaps: Vec<String> = model
936        .findings
937        .iter()
938        .filter(|f| {
939            matches!(f.confidence, Confidence::Unknown | Confidence::Heuristic)
940                && !matches!(f.severity, FindingSeverity::Information)
941        })
942        .map(|f| format!("{}: {}", f.rule, f.message))
943        .take(20)
944        .collect();
945
946    let gradient_gaps = model.runtime.as_ref().map(|rt| {
947        json!({
948            "missing": rt.missing_gradients,
949            "zero": rt.zero_gradients,
950            "non_finite": rt.non_finite_gradients,
951            "first_non_finite_step": rt.first_non_finite_step,
952            "saturating_activations": rt.saturating_activations,
953            "value_observations": rt.value_observations,
954        })
955    });
956
957    let mut suggested = vec![
958        json!({"kind": "doctor"}),
959        json!({"kind": "findings"}),
960    ];
961    if model.components.is_empty() {
962        suggested.push(json!({"kind": "components"}));
963    } else {
964        for component in model.components.iter().take(3) {
965            suggested.push(json!({
966                "kind": "component",
967                "select": component.qualified_name,
968            }));
969        }
970    }
971    if model.runtime.is_some() {
972        suggested.push(json!({"kind": "runtime"}));
973    }
974
975    json!({
976        "id": "model-improvement",
977        "analysis_id": model.analysis_id,
978        "trust": doctor(model).get("trust").cloned().unwrap_or(json!({})),
979        "proven_errors": proven_errors,
980        "proven_error_count": proven_errors.len(),
981        "numeric_hazards": numeric_hazards,
982        "gradient_gaps": gradient_gaps,
983        "coverage_gaps": coverage_gaps,
984        "components": model.components.iter().map(|c| &c.name).collect::<Vec<_>>(),
985        "parameter_count": model.parameters.len(),
986        "suggested_next_queries": suggested,
987        "drill_down": [
988            {"kind": "doctor"},
989            {"kind": "findings"},
990            {"kind": "model-improvement"},
991        ],
992    })
993}
994
995fn architecture(model: &ModelIr) -> Value {
996    json!({
997        "id": "architecture",
998        "components": model.components.iter().map(|component| json!({
999            "id": component.id,
1000            "name": component.name,
1001            "qualified_name": component.qualified_name,
1002            "source": component.source,
1003            "modules": component.modules.len(),
1004            "parameters": component.parameters.len(),
1005            "entrypoints": component.entrypoints.len(),
1006            "drill_down": [
1007                {"kind": "component", "select": component.qualified_name},
1008            ],
1009        })).collect::<Vec<_>>(),
1010        "edges": model.architecture_edges.iter().map(|edge| json!({
1011            "id": edge.id,
1012            "from": edge.from,
1013            "to": edge.to,
1014            "via_function": edge.via_function,
1015            "kind": if edge.id.0.starts_with("composition-edge:") {
1016                "composition"
1017            } else {
1018                "call_flow"
1019            },
1020        })).collect::<Vec<_>>(),
1021        "composition_edges": model.coverage.composition_edges,
1022        "stages": model.stages.iter().map(|stage| json!({
1023            "id": stage.id,
1024            "name": stage.name,
1025            "kind": stage.kind,
1026            "order": stage.order,
1027        })).collect::<Vec<_>>(),
1028        "artifacts": model.artifacts.iter().map(|artifact| json!({
1029            "id": artifact.id,
1030            "name": artifact.name,
1031        })).collect::<Vec<_>>(),
1032        "entrypoints": model.functions.iter()
1033            .filter(|function| function.is_entrypoint)
1034            .count(),
1035        "drill_down": [
1036            {"kind": "components"},
1037            {"kind": "composition"},
1038            {"kind": "modules"},
1039            {"kind": "functions"},
1040            {"kind": "tensors"},
1041            {"kind": "findings"},
1042        ],
1043    })
1044}
1045
1046fn function_listing(function: &Function, is_component_entrypoint: bool) -> Value {
1047    json!({
1048        "id": function.id,
1049        "name": function.name,
1050        "qualified_name": function.qualified_name,
1051        "owner_type": function.owner_type,
1052        "visibility": function.visibility,
1053        "source": function.source,
1054        "is_entrypoint": function.is_entrypoint,
1055        "is_component_entrypoint": is_component_entrypoint,
1056        "is_loss": function.is_loss,
1057        "cfg_active": function.cfg_active,
1058        "calls": function.calls.len(),
1059        "tensor_inputs": function.tensor_inputs.len(),
1060        "tensor_outputs": function.tensor_outputs.len(),
1061        "drill_down": [
1062            {"kind": "function", "select": function.qualified_name},
1063            {"kind": "tensors", "select": function.qualified_name},
1064            {"kind": "operations", "select": function.id.0},
1065        ],
1066    })
1067}
1068
1069fn function_detail(function: &Function) -> Value {
1070    let mut drill_down = vec![
1071        json!({"kind": "tensors", "select": function.qualified_name}),
1072        json!({"kind": "operations", "select": function.id.0}),
1073    ];
1074    if let Some(id) = function.tensor_inputs.first() {
1075        drill_down.push(json!({"kind": "tensor", "select": id.0}));
1076    } else if let Some(id) = function.tensor_outputs.first() {
1077        drill_down.push(json!({"kind": "tensor", "select": id.0}));
1078    }
1079    json!({
1080        "id": function.id,
1081        "name": function.name,
1082        "qualified_name": function.qualified_name,
1083        "owner_type": function.owner_type,
1084        "visibility": function.visibility,
1085        "parameters": function.parameters,
1086        "return_type": function.return_type,
1087        "cfg_predicates": function.cfg_predicates,
1088        "cfg_active": function.cfg_active,
1089        "source": function.source,
1090        "calls": function.calls,
1091        "tensor_inputs": function.tensor_inputs,
1092        "tensor_outputs": function.tensor_outputs,
1093        "is_entrypoint": function.is_entrypoint,
1094        "is_loss": function.is_loss,
1095        "drill_down": drill_down,
1096    })
1097}
1098
1099fn tensor_listing(model: &ModelIr, tensor: &TensorContract) -> Value {
1100    let owner = owner_name(model, tensor);
1101    json!({
1102        "id": tensor.id,
1103        "name": tensor.name,
1104        "role": tensor.role,
1105        "owner_function": tensor.owner_function,
1106        "owner": owner,
1107        "dtype": tensor.dtype,
1108        "shape_rank": tensor.shape.rank,
1109        "requires_grad": tensor.requires_grad,
1110        "drill_down": [
1111            {"kind": "tensor", "select": tensor.id.0},
1112            {"kind": "function", "select": owner},
1113        ],
1114    })
1115}
1116
1117fn finding_listing(finding: &Finding) -> Value {
1118    json!({
1119        "id": finding.id,
1120        "rule": finding.rule,
1121        "severity": finding.severity,
1122        "confidence": finding.confidence,
1123        "message": finding.message,
1124        "source": finding.source,
1125        "related": finding.related.len(),
1126        "drill_down": [
1127            {"kind": "findings", "select": finding.id.0},
1128        ],
1129    })
1130}
1131
1132fn owner_name(model: &ModelIr, tensor: &TensorContract) -> String {
1133    model
1134        .functions
1135        .iter()
1136        .find(|function| function.id == tensor.owner_function)
1137        .map(|function| function.qualified_name.clone())
1138        .unwrap_or_default()
1139}
1140
1141fn tensor_matches(model: &ModelIr, tensor: &TensorContract, selector: Option<&str>) -> bool {
1142    let owner = owner_name(model, tensor);
1143    optional_matches(
1144        selector,
1145        [
1146            tensor.id.0.as_str(),
1147            tensor.name.as_str(),
1148            tensor.owner_function.0.as_str(),
1149            owner.as_str(),
1150        ],
1151    )
1152}
1153
1154fn finding_counts(model: &ModelIr) -> BTreeMap<String, usize> {
1155    let mut counts = BTreeMap::new();
1156    for finding in &model.findings {
1157        *counts
1158            .entry(format!("{:?}", finding.severity).to_lowercase())
1159            .or_insert(0) += 1;
1160    }
1161    counts
1162}
1163
1164fn required_selector(request: &QueryRequest) -> Result<&str> {
1165    request
1166        .selector
1167        .as_deref()
1168        .ok_or_else(|| anyhow::anyhow!("{:?} query requires a selector", request.kind))
1169}
1170
1171fn optional_matches<const N: usize>(selector: Option<&str>, values: [&str; N]) -> bool {
1172    selector.is_none_or(|selector| matches_text(selector, values))
1173}
1174
1175fn matches_text<const N: usize>(selector: &str, values: [&str; N]) -> bool {
1176    let selector = selector.to_ascii_lowercase();
1177    values
1178        .iter()
1179        .any(|value| value.to_ascii_lowercase().contains(&selector))
1180}
1181
1182fn stable_value_key(value: &Value) -> String {
1183    let key = value
1184        .get("id")
1185        .and_then(Value::as_str)
1186        .or_else(|| value.get("name").and_then(Value::as_str))
1187        .or_else(|| value.get("key").and_then(Value::as_str))
1188        .unwrap_or_default()
1189        .to_string();
1190    if let Some((prefix, suffix)) = key.rsplit_once(':') {
1191        if let Ok(sequence) = suffix.parse::<u64>() {
1192            return format!("{prefix}:{sequence:020}");
1193        }
1194    }
1195    key
1196}
1197
1198fn resolve_id(model: &ModelIr, selector: &str) -> Result<StableId> {
1199    let mut hits = all_ids(model)
1200        .into_iter()
1201        .filter(|(id, labels)| {
1202            matches_text(
1203                selector,
1204                [
1205                    id.0.as_str(),
1206                    labels.first().map(String::as_str).unwrap_or_default(),
1207                ],
1208            )
1209        })
1210        .map(|(id, _)| id)
1211        .collect::<Vec<_>>();
1212    hits.sort();
1213    hits.dedup();
1214    match hits.as_slice() {
1215        [id] => Ok(id.clone()),
1216        [] => bail!("selector `{selector}` did not match any model object"),
1217        _ => bail!(
1218            "selector `{selector}` is ambiguous; matched {} objects",
1219            hits.len()
1220        ),
1221    }
1222}
1223
1224fn all_ids(model: &ModelIr) -> Vec<(StableId, Vec<String>)> {
1225    let mut values = Vec::new();
1226    values.extend(
1227        model
1228            .components
1229            .iter()
1230            .map(|v| (v.id.clone(), vec![v.name.clone(), v.qualified_name.clone()])),
1231    );
1232    values.extend(
1233        model
1234            .functions
1235            .iter()
1236            .map(|v| (v.id.clone(), vec![v.name.clone(), v.qualified_name.clone()])),
1237    );
1238    values.extend(
1239        model
1240            .parameters
1241            .iter()
1242            .map(|v| (v.id.clone(), vec![v.key.clone()])),
1243    );
1244    values.extend(
1245        model
1246            .tensors
1247            .iter()
1248            .map(|v| (v.id.clone(), vec![v.name.clone()])),
1249    );
1250    values.extend(
1251        model
1252            .operations
1253            .iter()
1254            .map(|v| (v.id.clone(), vec![v.name.clone()])),
1255    );
1256    values.extend(
1257        model
1258            .stages
1259            .iter()
1260            .map(|v| (v.id.clone(), vec![v.name.clone()])),
1261    );
1262    values.extend(
1263        model
1264            .artifacts
1265            .iter()
1266            .map(|v| (v.id.clone(), vec![v.name.clone(), v.path_expr.clone()])),
1267    );
1268    values
1269}
1270
1271fn shortest_path(model: &ModelIr, from: &StableId, to: &StableId) -> Result<Vec<StableId>> {
1272    let mut adjacency: BTreeMap<StableId, BTreeSet<StableId>> = BTreeMap::new();
1273    for function in &model.functions {
1274        for callee in &function.calls {
1275            connect(&mut adjacency, &function.id, callee);
1276        }
1277        for input in &function.tensor_inputs {
1278            connect(&mut adjacency, input, &function.id);
1279        }
1280        for output in &function.tensor_outputs {
1281            connect(&mut adjacency, &function.id, output);
1282        }
1283    }
1284    for edge in &model.architecture_edges {
1285        connect(&mut adjacency, &edge.from, &edge.to);
1286    }
1287    for operation in &model.operations {
1288        for input in &operation.inputs {
1289            connect(&mut adjacency, input, &operation.id);
1290        }
1291        connect(&mut adjacency, &operation.id, &operation.output);
1292    }
1293    for parameter in &model.parameters {
1294        for use_id in &parameter.uses {
1295            connect(&mut adjacency, &parameter.id, use_id);
1296        }
1297        for optimizer in &parameter.optimizer_memberships {
1298            connect(&mut adjacency, optimizer, &parameter.id);
1299        }
1300    }
1301    for stage in &model.stages {
1302        for dependency in &stage.depends_on {
1303            connect(&mut adjacency, dependency, &stage.id);
1304        }
1305        connect(&mut adjacency, &stage.id, &stage.function);
1306        for artifact in &stage.consumes {
1307            connect(&mut adjacency, artifact, &stage.id);
1308        }
1309        for artifact in &stage.produces {
1310            connect(&mut adjacency, &stage.id, artifact);
1311        }
1312    }
1313
1314    let mut queue = VecDeque::from([from.clone()]);
1315    let mut previous: BTreeMap<StableId, Option<StableId>> = BTreeMap::from([(from.clone(), None)]);
1316    while let Some(current) = queue.pop_front() {
1317        if current == *to {
1318            let mut path = Vec::new();
1319            let mut cursor = Some(current);
1320            while let Some(id) = cursor {
1321                cursor = previous.get(&id).cloned().flatten();
1322                path.push(id);
1323            }
1324            path.reverse();
1325            return Ok(path);
1326        }
1327        for next in adjacency.get(&current).into_iter().flatten() {
1328            if previous.contains_key(next) {
1329                continue;
1330            }
1331            previous.insert(next.clone(), Some(current.clone()));
1332            queue.push_back(next.clone());
1333        }
1334    }
1335    bail!("no path found from `{from}` to `{to}`")
1336}
1337
1338fn connect(adjacency: &mut BTreeMap<StableId, BTreeSet<StableId>>, from: &StableId, to: &StableId) {
1339    adjacency
1340        .entry(from.clone())
1341        .or_default()
1342        .insert(to.clone());
1343}
1344
1345fn describe_id(model: &ModelIr, id: &StableId) -> Value {
1346    if let Some(value) = model.components.iter().find(|v| v.id == *id) {
1347        return json!({"id": id, "kind": "component", "name": value.name, "source": value.source});
1348    }
1349    if let Some(value) = model.functions.iter().find(|v| v.id == *id) {
1350        return json!({"id": id, "kind": "function", "name": value.qualified_name, "source": value.source});
1351    }
1352    if let Some(value) = model.parameters.iter().find(|v| v.id == *id) {
1353        return json!({"id": id, "kind": "parameter", "name": value.key, "source": value.source});
1354    }
1355    if let Some(value) = model.tensors.iter().find(|v| v.id == *id) {
1356        return json!({"id": id, "kind": "tensor", "name": value.name});
1357    }
1358    if let Some(value) = model.operations.iter().find(|v| v.id == *id) {
1359        return json!({"id": id, "kind": "operation", "name": value.name, "source": value.source});
1360    }
1361    if let Some(value) = model.stages.iter().find(|v| v.id == *id) {
1362        return json!({"id": id, "kind": "stage", "name": value.name, "source": value.source});
1363    }
1364    if let Some(value) = model.artifacts.iter().find(|v| v.id == *id) {
1365        return json!({"id": id, "kind": "artifact", "name": value.name, "source": value.source});
1366    }
1367    json!({"id": id, "kind": "unknown"})
1368}
1369
1370fn pct(part: usize, total: usize) -> f64 {
1371    if total == 0 {
1372        0.0
1373    } else {
1374        ((part as f64 / total as f64) * 1000.0).round() / 10.0
1375    }
1376}
1377
1378fn component_name_lookup(model: &ModelIr) -> BTreeMap<StableId, String> {
1379    model
1380        .components
1381        .iter()
1382        .map(|component| (component.id.clone(), component.name.clone()))
1383        .collect()
1384}
1385
1386fn is_component_entrypoint(function: &Function, model: &ModelIr) -> bool {
1387    function.owner_type.as_ref().is_some_and(|owner| {
1388        model
1389            .components
1390            .iter()
1391            .any(|component| component.qualified_name == *owner || component.name == *owner)
1392    })
1393}
1394
1395fn module_listing(
1396    module: &crate::model_ir::Module,
1397    component_names: &BTreeMap<StableId, String>,
1398) -> Value {
1399    let component_name = component_names
1400        .get(&module.component)
1401        .cloned()
1402        .unwrap_or_default();
1403    let mut drill_down = vec![json!({"kind": "parameters", "select": module.prefix})];
1404    if module.qualified_type.as_deref().is_some_and(|type_name| {
1405        component_names.values().any(|name| name == type_name)
1406            || type_name.contains("::")
1407                && component_names
1408                    .values()
1409                    .any(|name| type_name.ends_with(name))
1410    }) {
1411        drill_down.push(json!({
1412            "kind": "composition",
1413            "select": module.qualified_type.clone().unwrap_or(module.type_name.clone()),
1414        }));
1415    }
1416    json!({
1417        "id": module.id,
1418        "component": module.component,
1419        "component_name": component_name,
1420        "parent": module.parent,
1421        "type_name": module.type_name,
1422        "qualified_type": module.qualified_type,
1423        "field": module.field,
1424        "builder_root": module.builder_root,
1425        "prefix": module.prefix,
1426        "repeat": module.repeat,
1427        "source": module.source,
1428        "confidence": module.confidence,
1429        "drill_down": drill_down,
1430    })
1431}
1432
1433fn assembly_listing(site: &crate::model_ir::AssemblySite) -> Value {
1434    json!({
1435        "id": site.id,
1436        "function_name": site.function_name,
1437        "component_name": site.component_name,
1438        "component": site.component,
1439        "builder_root": site.builder_root,
1440        "prefix_chain": site.prefix_chain,
1441        "varmap": site.varmap,
1442        "source_kind": site.source_kind,
1443        "role": site.role,
1444        "checkpoint_load": site.checkpoint_load,
1445        "source": site.source,
1446        "drill_down": [
1447            {"kind": "component", "select": site.component_name},
1448            {"kind": "parameters", "select": site.component_name},
1449            {"kind": "function", "select": site.function_name},
1450        ],
1451    })
1452}
1453
1454fn composition_listing(model: &ModelIr, edge: &crate::model_ir::ArchitectureEdge) -> Value {
1455    let from = model
1456        .components
1457        .iter()
1458        .find(|component| component.id == edge.from);
1459    let to = model
1460        .components
1461        .iter()
1462        .find(|component| component.id == edge.to);
1463    let mut drill_down = Vec::new();
1464    if let Some(component) = from {
1465        drill_down.push(json!({"kind": "component", "select": component.qualified_name}));
1466        drill_down.push(json!({"kind": "modules", "select": component.name}));
1467    }
1468    if let Some(component) = to {
1469        drill_down.push(json!({"kind": "component", "select": component.qualified_name}));
1470    }
1471    json!({
1472        "id": edge.id,
1473        "from": from.map(|component| json!({
1474            "id": component.id,
1475            "name": component.name,
1476            "qualified_name": component.qualified_name,
1477        })).unwrap_or_else(|| json!({"id": edge.from})),
1478        "to": to.map(|component| json!({
1479            "id": component.id,
1480            "name": component.name,
1481            "qualified_name": component.qualified_name,
1482        })).unwrap_or_else(|| json!({"id": edge.to})),
1483        "via_function": edge.via_function,
1484        "source": edge.source,
1485        "confidence": edge.evidence.first().map(|evidence| &evidence.confidence),
1486        "detail": edge.evidence.first().map(|evidence| &evidence.detail),
1487        "drill_down": drill_down,
1488    })
1489}
1490
1491fn composition_matches(
1492    model: &ModelIr,
1493    selector: Option<&str>,
1494    from: StableId,
1495    to: StableId,
1496) -> bool {
1497    let Some(selector) = selector else {
1498        return true;
1499    };
1500    let from_labels = component_labels(model, &from);
1501    let to_labels = component_labels(model, &to);
1502    matches_text(selector, from_labels) || matches_text(selector, to_labels)
1503}
1504
1505fn component_labels<'a>(model: &'a ModelIr, id: &StableId) -> [&'a str; 3] {
1506    if let Some(component) = model
1507        .components
1508        .iter()
1509        .find(|component| component.id == *id)
1510    {
1511        [
1512            component.id.0.as_str(),
1513            component.name.as_str(),
1514            component.qualified_name.as_str(),
1515        ]
1516    } else {
1517        ["", "", ""]
1518    }
1519}