Skip to main content

operator/statement/semantic_graph/
relationships.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The `semantic_relationships` derivation: the plan builders for every edge
16//! branch behind the computed table — trace-derived service `calls` (with
17//! virtual-node edges for unmatched clients), agent `calls` from span
18//! structure, same-row co-declared edges, and the declared-edge union. Shared
19//! expression helpers and the entity registry live in the parent module.
20
21use common_catalog::consts::{
22    CONFIDENCE_COLUMN, DST_ID_COLUMN, DST_TYPE_COLUMN, DURATION_COUNT_COLUMN, DURATION_NANO_COLUMN,
23    DURATION_SUM_COLUMN, EDGE_ATTRIBUTES_COLUMN, ENTITY_SCOPE_COLUMN, ERROR_COUNT_COLUMN,
24    FRESH_UNTIL_COLUMN, GENERATION_ID_COLUMN, OBSERVED_AT_COLUMN, PARENT_SPAN_ID_COLUMN,
25    PROVENANCE_COLUMN, REL_TYPE_COLUMN, REQUEST_COUNT_COLUMN, SPAN_ID_COLUMN, SPAN_KIND_CLIENT,
26    SPAN_KIND_COLUMN, SPAN_KIND_SERVER, SPAN_STATUS_CODE_COLUMN, SPAN_STATUS_ERROR, SRC_ID_COLUMN,
27    SRC_TYPE_COLUMN, TRACE_ID_COLUMN, TRACE_TIMESTAMP_COLUMN, VALID_FROM_COLUMN,
28    VALID_UNTIL_COLUMN, WINDOW_END_COLUMN, WINDOW_START_COLUMN,
29};
30use datafusion::arrow::datatypes::DataType;
31use datafusion::dataframe::DataFrame;
32use datafusion::functions::core as core_fns;
33use datafusion::functions_aggregate::expr_fn::{bool_or, count, min, sum};
34use datafusion::functions_window::expr_fn::row_number;
35use datafusion_common::{Result as DfResult, ScalarValue};
36use datafusion_expr::{Expr, ExprFunctionExt, JoinType, LogicalPlan, cast, ident, lit, when};
37
38use crate::statement::semantic_graph::conventions::{
39    CONNECTION_TYPE_DATABASE, CONNECTION_TYPE_VIRTUAL_NODE, Conventions, ENTITY_TYPE_GEN_AI_AGENT,
40    ENTITY_TYPE_SERVICE, PROVENANCE_ATTRIBUTE, PROVENANCE_TRACE, REL_TYPE_CALLS,
41};
42use crate::statement::semantic_graph::{
43    DECLARED_EDGE_IDENTITY_COLUMNS, EntityDeclaration, GraphQueryWindow, bin_interval, bin_ms,
44    conventions, entity_id_expr, identifies, interval, null_json, parse_json_expr, qcol, union_all,
45    unnest_rows,
46};
47
48/// The embedded conventions, with a broken file surfaced as a plan error.
49fn builtin() -> DfResult<&'static Conventions> {
50    conventions().map_err(datafusion_common::DataFusionError::Internal)
51}
52
53/// The projected columns of `semantic_relationships`, in order. Every derived
54/// branch and the declared-edge branch must project exactly these so the
55/// top-level `UNION ALL` type-aligns; `build_relationships_plan` re-selects
56/// them over the union to enforce the contract. (The physical declared table
57/// additionally stores `valid_from`/`valid_until`, which feed the validity
58/// filter and the projected window columns.)
59const RELATIONSHIP_COLUMNS: [&str; 16] = [
60    OBSERVED_AT_COLUMN,
61    WINDOW_START_COLUMN,
62    WINDOW_END_COLUMN,
63    FRESH_UNTIL_COLUMN,
64    SRC_TYPE_COLUMN,
65    SRC_ID_COLUMN,
66    DST_TYPE_COLUMN,
67    DST_ID_COLUMN,
68    REL_TYPE_COLUMN,
69    PROVENANCE_COLUMN,
70    CONFIDENCE_COLUMN,
71    REQUEST_COUNT_COLUMN,
72    ERROR_COUNT_COLUMN,
73    DURATION_SUM_COLUMN,
74    DURATION_COUNT_COLUMN,
75    EDGE_ATTRIBUTES_COLUMN,
76];
77
78/// A child server span starts no earlier than 5 minutes before its client span
79/// (clock-skew allowance) and no later than 1 hour after it; the bounds keep the
80/// join windowed instead of pairing arbitrarily distant spans of a long-lived
81/// trace.
82const CHILD_SPAN_EARLY_NANOS: i64 = 5 * 60 * 1_000_000_000;
83const CHILD_SPAN_LATE_NANOS: i64 = 60 * 60 * 1_000_000_000;
84
85/// A trace table's scan paired with the entity declarations its derivations
86/// key on — a unit of `build_relationships_plan`. The `service` declaration
87/// feeds service calls, the `agent` declaration agent calls; the two are
88/// independent, so a table whose service declaration is unusable still
89/// derives agent edges (and vice versa).
90pub struct CallsSource {
91    pub service: Option<EntityDeclaration>,
92    pub agent: Option<EntityDeclaration>,
93    pub scan: DataFrame,
94}
95
96/// A declaring table's scan paired with its entity declarations, from which
97/// the same-row co-declaration rules derive edges. `is_trace` gates the
98/// agent-edge vocabulary, which the RFC ties to span structure.
99pub struct CoDeclaredSource {
100    pub declarations: Vec<EntityDeclaration>,
101    pub is_trace: bool,
102    pub scan: DataFrame,
103}
104
105/// The declared-edge table's scan (`semantic_relationships_declared`), whose
106/// rows `build_relationships_plan` unions into the edge set.
107pub struct DeclaredSource {
108    pub scan: DataFrame,
109}
110
111/// Everything `build_relationships_plan` derives edges from.
112pub struct RelationshipSources {
113    pub traces: Vec<CallsSource>,
114    pub co_declared: Vec<CoDeclaredSource>,
115    pub declared: Option<DeclaredSource>,
116}
117
118/// Builds the `semantic_relationships` plan: the service-calls, agent-calls,
119/// co-declared, and declared-edge branches unioned and re-projected to the
120/// 16-column contract. Returns `None` when no source can contribute edges, so
121/// the computed table streams empty.
122pub fn build_relationships_plan(
123    sources: RelationshipSources,
124    window: &GraphQueryWindow,
125) -> DfResult<Option<LogicalPlan>> {
126    let mut union_df = calls_branch(&sources.traces, window)?;
127    if let Some(agent_calls) = agent_calls_branch(&sources.traces, window)? {
128        union_df = union_all(union_df, agent_calls)?;
129    }
130    if let Some(co_declared) = co_declared_branch(sources.co_declared, window)? {
131        union_df = union_all(union_df, co_declared)?;
132    }
133    if let Some(declared) = sources.declared {
134        union_df = union_all(union_df, declared_edges(declared.scan, window)?)?;
135    }
136    union_df
137        .map(|df| {
138            Ok(df
139                .select_columns(&RELATIONSHIP_COLUMNS)?
140                .into_unoptimized_plan())
141        })
142        .transpose()
143}
144
145const CO_DECLARED_VALID_COLUMN: &str = "__edge_valid";
146
147/// The same-row co-declared branch: for each source table, every vocabulary
148/// pair whose two entity types the table declares yields an edge projection,
149/// expanded from one scan via `unnest_rows` (the registry idiom). Attribute
150/// edges carry `provenance = 'attribute'`; agent edges are span-structure
151/// observations and carry `provenance = 'trace'`.
152fn co_declared_branch(
153    sources: Vec<CoDeclaredSource>,
154    window: &GraphQueryWindow,
155) -> DfResult<Option<DataFrame>> {
156    let vocabulary = builtin()?;
157    let mut union_df: Option<DataFrame> = None;
158    for source in sources {
159        let find = |ty: &str| source.declarations.iter().find(|d| d.entity_type == ty);
160        let mut pairs = Vec::new();
161        for rule in &vocabulary.co_declared_edges {
162            if let (Some(src), Some(dst)) = (find(&rule.src), find(&rule.dst)) {
163                pairs.push((src, dst, rule.rel.as_str(), PROVENANCE_ATTRIBUTE));
164            }
165        }
166        if source.is_trace {
167            // Agent edges are tied to span structure by the RFC: a non-trace
168            // table co-declaring these types must not fabricate invocations.
169            for rule in &vocabulary.trace_co_declared_edges {
170                if let (Some(src), Some(dst)) = (find(&rule.src), find(&rule.dst)) {
171                    pairs.push((src, dst, rule.rel.as_str(), PROVENANCE_TRACE));
172                }
173            }
174        }
175        let Some((first, _, _, _)) = pairs.first() else {
176            continue;
177        };
178
179        let ts = ident(&first.time_index);
180        let bin = bin_ms(ts.clone());
181        let window_predicate = ts
182            .clone()
183            .gt_eq(window.source_start())
184            .and(ts.lt(window.source_end()));
185        let null_i64 = || lit(ScalarValue::Int64(None));
186        let rows = pairs
187            .iter()
188            .map(|(src, dst, rel_type, provenance)| {
189                // Both endpoints must identify something on the row for the
190                // row to witness the edge.
191                let valid = src
192                    .id_columns
193                    .iter()
194                    .chain(&dst.id_columns)
195                    .fold(lit(true), |predicate, id| predicate.and(identifies(id)));
196                vec![
197                    valid,
198                    bin.clone(),
199                    bin.clone(),
200                    bin.clone() + bin_interval(),
201                    bin.clone() + bin_interval(),
202                    lit(src.entity_type.as_str()),
203                    entity_id_expr(&src.id_columns, &|c| ident(c)),
204                    lit(dst.entity_type.as_str()),
205                    entity_id_expr(&dst.id_columns, &|c| ident(c)),
206                    lit(*rel_type),
207                    lit(*provenance),
208                    lit(1.0_f64),
209                    null_i64(),
210                    null_i64(),
211                    lit(ScalarValue::Float64(None)),
212                    null_i64(),
213                    null_json(),
214                ]
215            })
216            .collect();
217
218        let branch = unnest_rows(
219            source.scan,
220            window_predicate,
221            CO_DECLARED_VALID_COLUMN,
222            &RELATIONSHIP_COLUMNS,
223            rows,
224        )?;
225        union_df = union_all(union_df, branch)?;
226    }
227    // The per-source DISTINCT inside `unnest_rows` cannot see other sources:
228    // two tables witnessing the same edge in the same window must still fold
229    // into one row per (window, edge).
230    union_df.map(DataFrame::distinct).transpose()
231}
232
233/// Ranking column used to pick the latest declared-edge revision per edge key.
234const DECLARED_REVISION_COLUMN: &str = "__declared_revision";
235
236/// The declared-edge branch: the latest revision per edge key *as of the
237/// queried window* (mito dedups on primary key + `observed_at`, so re-asserting
238/// an edge stores a new revision), filtered by business-validity overlap.
239///
240/// `valid_from` defaults to the declaration time; a NULL `valid_until` means
241/// the edge holds while its row exists (TTL retires it), so `window_end` /
242/// `fresh_until` take the window's upper bound. The output `observed_at` is
243/// synthesized inside the queried range: the scan's filters are re-applied
244/// above the computed table, and the physical revision time would fail them.
245fn declared_edges(scan: DataFrame, window: &GraphQueryWindow) -> DfResult<DataFrame> {
246    let eff_valid_from =
247        core_fns::coalesce().call(vec![ident(VALID_FROM_COLUMN), ident(OBSERVED_AT_COLUMN)]);
248    let eff_valid_until =
249        core_fns::coalesce().call(vec![ident(VALID_UNTIL_COLUMN), window.observed_end()]);
250
251    // As-of the queried window: a revision recorded after its end, or whose
252    // validity starts after it, was not the edge's state inside the window and
253    // must not outrank (and thereby hide) the revision that was.
254    let as_of = ident(OBSERVED_AT_COLUMN)
255        .lt(window.observed_end())
256        .and(eff_valid_from.clone().lt(window.observed_end()));
257
258    let revision = row_number()
259        .partition_by(
260            DECLARED_EDGE_IDENTITY_COLUMNS
261                .iter()
262                .map(|c| ident(*c))
263                .collect(),
264        )
265        // generation_id/scope break observed_at ties deterministically (rows
266        // differing only in them are not merged by the storage dedup).
267        .order_by(vec![
268            ident(OBSERVED_AT_COLUMN).sort(false, false),
269            ident(GENERATION_ID_COLUMN).sort(false, false),
270            ident(ENTITY_SCOPE_COLUMN).sort(false, false),
271        ])
272        .build()?
273        .alias(DECLARED_REVISION_COLUMN);
274
275    // The as-of filter already bounds eff_valid_from below the window's end;
276    // only the retirement side of the overlap remains to check.
277    let still_valid = ident(VALID_UNTIL_COLUMN)
278        .is_null()
279        .or(ident(VALID_UNTIL_COLUMN).gt(window.observed_start()));
280    // Tags come out of the storage engine dictionary-encoded; cast to plain
281    // strings so the union with the derived branches type-aligns.
282    let tag_utf8 = |name: &str| cast(ident(name), DataType::Utf8).alias(name);
283
284    scan.filter(as_of)?
285        .window(vec![revision])?
286        .filter(ident(DECLARED_REVISION_COLUMN).eq(lit(1_u64)))?
287        .filter(still_valid)?
288        .select(vec![
289            core_fns::greatest()
290                .call(vec![eff_valid_from.clone(), window.observed_start()])
291                .alias(OBSERVED_AT_COLUMN),
292            eff_valid_from.alias(WINDOW_START_COLUMN),
293            eff_valid_until.clone().alias(WINDOW_END_COLUMN),
294            eff_valid_until.alias(FRESH_UNTIL_COLUMN),
295            tag_utf8(SRC_TYPE_COLUMN),
296            tag_utf8(SRC_ID_COLUMN),
297            tag_utf8(DST_TYPE_COLUMN),
298            tag_utf8(DST_ID_COLUMN),
299            tag_utf8(REL_TYPE_COLUMN),
300            tag_utf8(PROVENANCE_COLUMN),
301            ident(CONFIDENCE_COLUMN),
302            ident(REQUEST_COUNT_COLUMN),
303            ident(ERROR_COUNT_COLUMN),
304            ident(DURATION_SUM_COLUMN),
305            ident(DURATION_COUNT_COLUMN),
306            ident(EDGE_ATTRIBUTES_COLUMN),
307        ])
308}
309
310/// Confidence of a virtual-node edge: the peer was named by a client-side
311/// attribute, not witnessed by a server span (the RFC requires `< 1.0`).
312const VIRTUAL_NODE_CONFIDENCE: f64 = 0.5;
313
314/// The `calls` derivation: union the client spans and the server spans of all
315/// trace tables (each projected to a normalized shape with its own table's
316/// `service` identity), left-join clients to their child server spans on
317/// `trace_id` + `parent_span_id`, and aggregate RED metrics per 60s window —
318/// the plan form of the Tempo servicegraph connector. Unioning spans before
319/// the join pairs a client with a server stored in a *different* trace table
320/// (`x-greptime-trace-table-name` routing); with a single table it degenerates
321/// to the previous self-join. Returns `None` when no trace table has a
322/// usable `service` declaration.
323///
324/// A client with no matching server span is an edge to a **virtual node**
325/// named by the conventions' virtual-destination candidates, with the
326/// client's own status/duration
327/// and `confidence < 1.0`. Real pairs win: when a window's edge key holds any
328/// pair, the edge reports only the pair population (the RFC pins RED metrics
329/// to observed span pairs) and the unmatched clients are suppressed, so one
330/// `(window, edge)` never yields two rows.
331fn calls_branch(traces: &[CallsSource], window: &GraphQueryWindow) -> DfResult<Option<DataFrame>> {
332    let mut clients: Option<DataFrame> = None;
333    let mut servers: Option<DataFrame> = None;
334    for trace in traces {
335        let Some(service) = &trace.service else {
336            continue;
337        };
338        clients = union_all(clients, client_spans(service, &trace.scan, window)?)?;
339        servers = union_all(servers, server_spans(service, trace.scan.clone(), window)?)?;
340    }
341    let (Some(clients), Some(servers)) = (clients, servers) else {
342        return Ok(None);
343    };
344
345    let client = clients.alias("client")?;
346    let server = servers.alias("server")?;
347    let join_conditions = vec![
348        qcol("client", TRACE_ID_COLUMN).eq(qcol("server", TRACE_ID_COLUMN)),
349        qcol("server", PARENT_SPAN_ID_COLUMN).eq(qcol("client", SPAN_ID_COLUMN)),
350        qcol("server", TRACE_TIMESTAMP_COLUMN)
351            .gt_eq(qcol("client", TRACE_TIMESTAMP_COLUMN) - interval(CHILD_SPAN_EARLY_NANOS)),
352        qcol("server", TRACE_TIMESTAMP_COLUMN)
353            .lt_eq(qcol("client", TRACE_TIMESTAMP_COLUMN) + interval(CHILD_SPAN_LATE_NANOS)),
354    ];
355
356    let observations = client
357        .join_on(server, JoinType::Left, join_conditions)?
358        .select(vec![
359            bin_ms(qcol("client", TRACE_TIMESTAMP_COLUMN)).alias(OBSERVED_AT_COLUMN),
360            qcol("client", SRC_ID_COLUMN).alias(SRC_ID_COLUMN),
361            core_fns::coalesce()
362                .call(vec![
363                    qcol("server", DST_ID_COLUMN),
364                    qcol("client", "virtual_dst"),
365                ])
366                .alias(DST_ID_COLUMN),
367            qcol("server", TRACE_ID_COLUMN)
368                .is_not_null()
369                .alias("paired"),
370            qcol("server", SPAN_STATUS_CODE_COLUMN).alias("server_status"),
371            qcol("server", DURATION_NANO_COLUMN).alias("server_duration_nano"),
372            qcol("client", SPAN_STATUS_CODE_COLUMN).alias("client_status"),
373            qcol("client", DURATION_NANO_COLUMN).alias("client_duration_nano"),
374            qcol("client", "virtual_conn").alias("virtual_conn"),
375        ])?
376        // No destination means no edge; a self-call is not an edge between
377        // two distinct entities.
378        .filter(
379            ident(DST_ID_COLUMN)
380                .is_not_null()
381                .and(ident(SRC_ID_COLUMN).not_eq(ident(DST_ID_COLUMN))),
382        )?;
383
384    let paired = ident("paired");
385    let df = observations
386        .aggregate(
387            vec![
388                ident(OBSERVED_AT_COLUMN),
389                ident(SRC_ID_COLUMN),
390                ident(DST_ID_COLUMN),
391            ],
392            vec![
393                count(lit(1))
394                    .filter(paired.clone())
395                    .build()?
396                    .alias("pair_count"),
397                count(lit(1))
398                    .filter(
399                        paired
400                            .clone()
401                            .and(ident("server_status").eq(lit(SPAN_STATUS_ERROR))),
402                    )
403                    .build()?
404                    .alias("pair_errors"),
405                sum(ident("server_duration_nano"))
406                    .filter(paired.clone())
407                    .build()?
408                    .alias("pair_duration_nano"),
409                count(lit(1))
410                    .filter(!paired.clone())
411                    .build()?
412                    .alias("unmatched_count"),
413                count(lit(1))
414                    .filter(
415                        (!paired.clone()).and(ident("client_status").eq(lit(SPAN_STATUS_ERROR))),
416                    )
417                    .build()?
418                    .alias("unmatched_errors"),
419                sum(ident("client_duration_nano"))
420                    .filter(!paired.clone())
421                    .build()?
422                    .alias("unmatched_duration_nano"),
423                bool_or(paired.clone()).alias("has_pair"),
424                // `min` makes a mixed-provenance virtual edge deterministic
425                // (`database` sorts before `virtual_node`).
426                min(ident("virtual_conn"))
427                    .filter(!paired)
428                    .build()?
429                    .alias("virtual_conn"),
430            ],
431        )?
432        .select(vec![
433            ident(OBSERVED_AT_COLUMN),
434            ident(OBSERVED_AT_COLUMN).alias(WINDOW_START_COLUMN),
435            (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(WINDOW_END_COLUMN),
436            (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(FRESH_UNTIL_COLUMN),
437            lit(ENTITY_TYPE_SERVICE).alias(SRC_TYPE_COLUMN),
438            ident(SRC_ID_COLUMN),
439            lit(ENTITY_TYPE_SERVICE).alias(DST_TYPE_COLUMN),
440            ident(DST_ID_COLUMN),
441            lit(REL_TYPE_CALLS).alias(REL_TYPE_COLUMN),
442            lit(PROVENANCE_TRACE).alias(PROVENANCE_COLUMN),
443            real_wins(lit(1.0_f64), lit(VIRTUAL_NODE_CONFIDENCE))?.alias(CONFIDENCE_COLUMN),
444            real_wins(ident("pair_count"), ident("unmatched_count"))?.alias(REQUEST_COUNT_COLUMN),
445            real_wins(ident("pair_errors"), ident("unmatched_errors"))?.alias(ERROR_COUNT_COLUMN),
446            // duration sums in nanoseconds; the contract column is seconds.
447            (cast(
448                real_wins(
449                    ident("pair_duration_nano"),
450                    ident("unmatched_duration_nano"),
451                )?,
452                DataType::Float64,
453            ) / lit(1e9_f64))
454            .alias(DURATION_SUM_COLUMN),
455            real_wins(ident("pair_count"), ident("unmatched_count"))?.alias(DURATION_COUNT_COLUMN),
456            real_wins(null_json(), virtual_attrs_expr()?)?.alias(EDGE_ATTRIBUTES_COLUMN),
457        ])?;
458    Ok(Some(df))
459}
460
461/// `CASE WHEN has_pair THEN real ELSE virtual END` — the real-wins projection
462/// over the mixed aggregate.
463fn real_wins(real: Expr, r#virtual: Expr) -> DfResult<Expr> {
464    when(ident("has_pair"), real).otherwise(r#virtual)
465}
466
467/// The `attributes` JSON of a virtual edge, from the aggregated
468/// `connection_type`.
469fn virtual_attrs_expr() -> DfResult<Expr> {
470    when(
471        ident("virtual_conn").eq(lit(CONNECTION_TYPE_DATABASE)),
472        parse_json_expr(lit(format!(
473            r#"{{"connection_type":"{CONNECTION_TYPE_DATABASE}"}}"#
474        ))),
475    )
476    .otherwise(parse_json_expr(lit(format!(
477        r#"{{"connection_type":"{CONNECTION_TYPE_VIRTUAL_NODE}"}}"#
478    ))))
479}
480
481/// The window + span-kind + identity predicate shared by both join sides.
482/// `strict` bounds the scan to the source window; the non-strict side widens
483/// by the join's time-proximity allowance (the join bounds reference the other
484/// side's timestamp and cannot prune this side's scan on their own).
485fn span_predicate(service: &EntityDeclaration, window: &GraphQueryWindow, strict: bool) -> Expr {
486    let ts = ident(TRACE_TIMESTAMP_COLUMN);
487    let mut predicate = if strict {
488        ts.clone()
489            .gt_eq(window.source_start())
490            .and(ts.lt(window.source_end()))
491    } else {
492        ts.clone()
493            .gt_eq(window.source_start() - interval(CHILD_SPAN_EARLY_NANOS))
494            .and(ts.lt(window.source_end() + interval(CHILD_SPAN_LATE_NANOS)))
495    };
496    // An absent identity component identifies nothing, on either endpoint.
497    for id in &service.id_columns {
498        predicate = predicate.and(identifies(id));
499    }
500    predicate
501}
502
503/// One trace table's client spans, normalized to
504/// `(timestamp, trace_id, span_id, src_id, status_code, duration_nano,
505/// virtual_dst, virtual_conn)`. `src_id` is built from the table's `service`
506/// declaration, so edges land on exactly the entity ids the registry emits (a
507/// composite identity renders the same sorted `k=v` form); the per-table
508/// projection is what lets tables with different declarations union.
509fn client_spans(
510    service: &EntityDeclaration,
511    scan: &DataFrame,
512    window: &GraphQueryWindow,
513) -> DfResult<DataFrame> {
514    // Attribute columns are dynamic in `greptime_trace_v1` (created on first
515    // use), so only candidate columns present in the table's schema
516    // participate. NULLIF('') lets an empty value fall through to the next
517    // candidate.
518    let present: Vec<(Expr, &str)> = {
519        let schema = scan.schema();
520        builtin()?
521            .virtual_dst_candidates
522            .iter()
523            .filter(|candidate| schema.has_column_with_unqualified_name(&candidate.column))
524            .map(|candidate| {
525                let value = core_fns::nullif().call(vec![
526                    cast(ident(&candidate.column), DataType::Utf8),
527                    lit(""),
528                ]);
529                (value, candidate.connection_type.as_str())
530            })
531            .collect()
532    };
533    let null_utf8 = || lit(ScalarValue::Utf8(None));
534    let virtual_dst = if present.is_empty() {
535        null_utf8()
536    } else {
537        core_fns::coalesce().call(present.iter().map(|(value, _)| value.clone()).collect())
538    };
539    let mut virtual_conn = null_utf8();
540    for (value, conn) in present.into_iter().rev() {
541        virtual_conn = when(value.is_not_null(), lit(conn)).otherwise(virtual_conn)?;
542    }
543
544    scan.clone()
545        .filter(
546            ident(SPAN_KIND_COLUMN)
547                .eq(lit(SPAN_KIND_CLIENT))
548                .and(span_predicate(service, window, true)),
549        )?
550        .select(vec![
551            ident(TRACE_TIMESTAMP_COLUMN),
552            ident(TRACE_ID_COLUMN),
553            ident(SPAN_ID_COLUMN),
554            // The cast inside entity_id_expr also normalizes tag columns, which
555            // come out of the storage engine dictionary-encoded.
556            entity_id_expr(&service.id_columns, &|c| ident(c)).alias(SRC_ID_COLUMN),
557            ident(SPAN_STATUS_CODE_COLUMN),
558            ident(DURATION_NANO_COLUMN),
559            virtual_dst.alias("virtual_dst"),
560            virtual_conn.alias("virtual_conn"),
561        ])
562}
563
564/// One trace table's server spans, normalized to
565/// `(timestamp, trace_id, parent_span_id, dst_id, status_code, duration_nano)`.
566fn server_spans(
567    service: &EntityDeclaration,
568    trace: DataFrame,
569    window: &GraphQueryWindow,
570) -> DfResult<DataFrame> {
571    trace
572        .filter(
573            ident(SPAN_KIND_COLUMN)
574                .eq(lit(SPAN_KIND_SERVER))
575                .and(span_predicate(service, window, false)),
576        )?
577        .select(vec![
578            ident(TRACE_TIMESTAMP_COLUMN),
579            ident(TRACE_ID_COLUMN),
580            ident(PARENT_SPAN_ID_COLUMN),
581            entity_id_expr(&service.id_columns, &|c| ident(c)).alias(DST_ID_COLUMN),
582            ident(SPAN_STATUS_CODE_COLUMN),
583            ident(DURATION_NANO_COLUMN),
584        ])
585}
586
587/// The `parent_agent calls agent` derivation over trace tables declaring an
588/// `agent` entity: pair each span with its child span (no span-kind filter —
589/// agent spans are typically INTERNAL) across all agent-declaring tables, keep
590/// pairs whose agent identities differ, and aggregate RED metrics per 60s
591/// window. Anchored like the service derivation on the caller: the parent side
592/// is bounded to the source window and stamps `observed_at`, the child side
593/// widens by the time-proximity allowance and supplies status/duration.
594fn agent_calls_branch(
595    traces: &[CallsSource],
596    window: &GraphQueryWindow,
597) -> DfResult<Option<DataFrame>> {
598    let mut parents: Option<DataFrame> = None;
599    let mut children: Option<DataFrame> = None;
600    for trace in traces {
601        let Some(agent) = &trace.agent else {
602            continue;
603        };
604        let parent = trace
605            .scan
606            .clone()
607            .filter(span_predicate(agent, window, true))?
608            .select(vec![
609                ident(TRACE_TIMESTAMP_COLUMN),
610                ident(TRACE_ID_COLUMN),
611                ident(SPAN_ID_COLUMN),
612                entity_id_expr(&agent.id_columns, &|c| ident(c)).alias(SRC_ID_COLUMN),
613            ])?;
614        let child = trace
615            .scan
616            .clone()
617            .filter(span_predicate(agent, window, false))?
618            .select(vec![
619                ident(TRACE_TIMESTAMP_COLUMN),
620                ident(TRACE_ID_COLUMN),
621                ident(PARENT_SPAN_ID_COLUMN),
622                entity_id_expr(&agent.id_columns, &|c| ident(c)).alias(DST_ID_COLUMN),
623                ident(SPAN_STATUS_CODE_COLUMN),
624                ident(DURATION_NANO_COLUMN),
625            ])?;
626        parents = union_all(parents, parent)?;
627        children = union_all(children, child)?;
628    }
629    let (Some(parents), Some(children)) = (parents, children) else {
630        return Ok(None);
631    };
632
633    let parent = parents.alias("parent")?;
634    let child = children.alias("child")?;
635    let join_conditions = vec![
636        qcol("parent", TRACE_ID_COLUMN).eq(qcol("child", TRACE_ID_COLUMN)),
637        qcol("child", PARENT_SPAN_ID_COLUMN).eq(qcol("parent", SPAN_ID_COLUMN)),
638        qcol("child", TRACE_TIMESTAMP_COLUMN)
639            .gt_eq(qcol("parent", TRACE_TIMESTAMP_COLUMN) - interval(CHILD_SPAN_EARLY_NANOS)),
640        qcol("child", TRACE_TIMESTAMP_COLUMN)
641            .lt_eq(qcol("parent", TRACE_TIMESTAMP_COLUMN) + interval(CHILD_SPAN_LATE_NANOS)),
642    ];
643
644    let df = parent
645        .join_on(child, JoinType::Inner, join_conditions)?
646        .select(vec![
647            bin_ms(qcol("parent", TRACE_TIMESTAMP_COLUMN)).alias(OBSERVED_AT_COLUMN),
648            qcol("parent", SRC_ID_COLUMN).alias(SRC_ID_COLUMN),
649            qcol("child", DST_ID_COLUMN).alias(DST_ID_COLUMN),
650            qcol("child", SPAN_STATUS_CODE_COLUMN).alias("status_code"),
651            qcol("child", DURATION_NANO_COLUMN).alias(DURATION_NANO_COLUMN),
652        ])?
653        // A sub-span of the same agent is internal structure, not a call
654        // between two agents.
655        .filter(ident(SRC_ID_COLUMN).not_eq(ident(DST_ID_COLUMN)))?
656        .aggregate(
657            vec![
658                ident(OBSERVED_AT_COLUMN),
659                ident(SRC_ID_COLUMN),
660                ident(DST_ID_COLUMN),
661            ],
662            vec![
663                count(lit(1)).alias(REQUEST_COUNT_COLUMN),
664                count(lit(1))
665                    .filter(ident("status_code").eq(lit(SPAN_STATUS_ERROR)))
666                    .build()?
667                    .alias(ERROR_COUNT_COLUMN),
668                sum(ident(DURATION_NANO_COLUMN)).alias("duration_nano_sum"),
669            ],
670        )?
671        .select(vec![
672            ident(OBSERVED_AT_COLUMN),
673            ident(OBSERVED_AT_COLUMN).alias(WINDOW_START_COLUMN),
674            (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(WINDOW_END_COLUMN),
675            (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(FRESH_UNTIL_COLUMN),
676            lit(ENTITY_TYPE_GEN_AI_AGENT).alias(SRC_TYPE_COLUMN),
677            ident(SRC_ID_COLUMN),
678            lit(ENTITY_TYPE_GEN_AI_AGENT).alias(DST_TYPE_COLUMN),
679            ident(DST_ID_COLUMN),
680            lit(REL_TYPE_CALLS).alias(REL_TYPE_COLUMN),
681            lit(PROVENANCE_TRACE).alias(PROVENANCE_COLUMN),
682            lit(1.0_f64).alias(CONFIDENCE_COLUMN),
683            ident(REQUEST_COUNT_COLUMN),
684            ident(ERROR_COUNT_COLUMN),
685            (cast(ident("duration_nano_sum"), DataType::Float64) / lit(1e9_f64))
686                .alias(DURATION_SUM_COLUMN),
687            ident(REQUEST_COUNT_COLUMN).alias(DURATION_COUNT_COLUMN),
688            null_json().alias(EDGE_ATTRIBUTES_COLUMN),
689        ])?;
690    Ok(Some(df))
691}
692
693#[cfg(test)]
694mod tests {
695    use std::sync::Arc;
696
697    use common_catalog::consts::{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME, SERVICE_NAME_COLUMN};
698    use datafusion::arrow::array::{
699        Array, ArrayRef, BinaryArray, Float64Array, Int64Array, StringArray,
700        TimestampMillisecondArray, TimestampNanosecondArray, UInt64Array,
701    };
702    use datafusion::arrow::datatypes::{Field, Schema, TimeUnit};
703    use datafusion::arrow::record_batch::RecordBatch;
704    use datafusion::datasource::MemTable;
705    use datafusion::prelude::SessionContext;
706
707    use super::super::test_util::{collect, json_texts, strings, ts_values};
708    use super::*;
709
710    fn test_window() -> GraphQueryWindow {
711        GraphQueryWindow::from_observed(0, 10 * 60 * 1000)
712    }
713
714    /// One span row of the fixed `greptime_trace_v1` shape:
715    /// `(ts_ms, trace_id, span_id, parent_span_id, span_kind, status_code,
716    /// service_name, duration_nano)`.
717    type Span<'a> = (
718        i64,
719        &'a str,
720        &'a str,
721        Option<&'a str>,
722        &'a str,
723        &'a str,
724        &'a str,
725        u64,
726    );
727
728    /// Registers a trace-v1-shaped table (ns timestamps) plus optional dynamic
729    /// string columns (`extra`), one value per span.
730    fn register_trace_table(
731        ctx: &SessionContext,
732        name: &str,
733        extra: &[(&str, &[Option<&str>])],
734        spans: &[Span<'_>],
735    ) {
736        let mut fields = vec![
737            Field::new(
738                TRACE_TIMESTAMP_COLUMN,
739                DataType::Timestamp(TimeUnit::Nanosecond, None),
740                false,
741            ),
742            Field::new(TRACE_ID_COLUMN, DataType::Utf8, false),
743            Field::new(SPAN_ID_COLUMN, DataType::Utf8, false),
744            Field::new(PARENT_SPAN_ID_COLUMN, DataType::Utf8, true),
745            Field::new(SPAN_KIND_COLUMN, DataType::Utf8, false),
746            Field::new(SPAN_STATUS_CODE_COLUMN, DataType::Utf8, false),
747            Field::new(SERVICE_NAME_COLUMN, DataType::Utf8, false),
748            Field::new(DURATION_NANO_COLUMN, DataType::UInt64, false),
749        ];
750        for (column, _) in extra {
751            fields.push(Field::new(*column, DataType::Utf8, true));
752        }
753        let schema = Arc::new(Schema::new(fields));
754        const MS: i64 = 1_000_000;
755        let mut columns: Vec<ArrayRef> = vec![
756            Arc::new(TimestampNanosecondArray::from(
757                spans.iter().map(|s| s.0 * MS).collect::<Vec<_>>(),
758            )),
759            Arc::new(StringArray::from(
760                spans.iter().map(|s| s.1).collect::<Vec<_>>(),
761            )),
762            Arc::new(StringArray::from(
763                spans.iter().map(|s| s.2).collect::<Vec<_>>(),
764            )),
765            Arc::new(StringArray::from(
766                spans.iter().map(|s| s.3).collect::<Vec<_>>(),
767            )),
768            Arc::new(StringArray::from(
769                spans.iter().map(|s| s.4).collect::<Vec<_>>(),
770            )),
771            Arc::new(StringArray::from(
772                spans.iter().map(|s| s.5).collect::<Vec<_>>(),
773            )),
774            Arc::new(StringArray::from(
775                spans.iter().map(|s| s.6).collect::<Vec<_>>(),
776            )),
777            Arc::new(UInt64Array::from(
778                spans.iter().map(|s| s.7).collect::<Vec<_>>(),
779            )),
780        ];
781        for (_, values) in extra {
782            columns.push(Arc::new(StringArray::from(values.to_vec())));
783        }
784        let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();
785        ctx.register_table(
786            name,
787            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
788        )
789        .unwrap();
790    }
791
792    const UNSET: &str = "STATUS_CODE_UNSET";
793    const ERROR: &str = "STATUS_CODE_ERROR";
794    const CLIENT: &str = "SPAN_KIND_CLIENT";
795    const SERVER: &str = "SPAN_KIND_SERVER";
796
797    /// Two client->server pairs frontend->cart (one errored), one pair
798    /// cart->cart (self-call, excluded), one unmatched client span.
799    const BASE_SPANS: [Span<'static>; 7] = [
800        (1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0),
801        (
802            1_010,
803            "t1",
804            "s1",
805            Some("c1"),
806            SERVER,
807            UNSET,
808            "cart",
809            500_000_000,
810        ),
811        (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
812        (
813            2_010,
814            "t2",
815            "s2",
816            Some("c2"),
817            SERVER,
818            ERROR,
819            "cart",
820            1_500_000_000,
821        ),
822        (3_000, "t3", "c3", None, CLIENT, UNSET, "cart", 0),
823        (3_010, "t3", "s3", Some("c3"), SERVER, UNSET, "cart", 100),
824        (4_000, "t4", "c4", None, CLIENT, UNSET, "frontend", 0),
825    ];
826
827    fn trace_table_ctx() -> SessionContext {
828        let ctx = SessionContext::new();
829        let namespaces = [Some("ns1"); 7];
830        register_trace_table(
831            &ctx,
832            "opentelemetry_traces",
833            &[("service_namespace", &namespaces)],
834            &BASE_SPANS,
835        );
836        ctx
837    }
838
839    fn trace_service_decl(id_columns: &[&str]) -> EntityDeclaration {
840        EntityDeclaration {
841            schema: "public".to_string(),
842            table: "opentelemetry_traces".to_string(),
843            time_index: TRACE_TIMESTAMP_COLUMN.to_string(),
844            entity_type: "service".to_string(),
845            id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
846            descriptive_columns: vec![],
847            scope_columns: vec![],
848        }
849    }
850
851    #[tokio::test]
852    async fn calls_plan_aggregates_red_metrics() {
853        let ctx = trace_table_ctx();
854        let trace = ctx.table("opentelemetry_traces").await.unwrap();
855        let plan = build_relationships_plan_for_test(
856            vec![CallsSource {
857                service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
858                agent: None,
859                scan: trace,
860            }],
861            &test_window(),
862        )
863        .unwrap()
864        .unwrap();
865
866        let names = plan
867            .schema()
868            .fields()
869            .iter()
870            .map(|f| f.name().as_str())
871            .collect::<Vec<_>>();
872        assert_eq!(names, RELATIONSHIP_COLUMNS);
873
874        let batches = collect(&ctx, plan).await;
875        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
876        // frontend->cart only: the self-call and the unmatched client drop out;
877        // both pairs land in the same 60s bin.
878        assert_eq!(total, 1);
879        let batch = &batches[0];
880        assert_eq!(strings(batch, 5), vec!["frontend"]);
881        assert_eq!(strings(batch, 7), vec!["cart"]);
882        assert_eq!(strings(batch, 8), vec!["calls"]);
883        assert_eq!(strings(batch, 9), vec!["trace"]);
884
885        let request_count = batch
886            .column(11)
887            .as_any()
888            .downcast_ref::<Int64Array>()
889            .unwrap();
890        assert_eq!(request_count.value(0), 2);
891        let error_count = batch
892            .column(12)
893            .as_any()
894            .downcast_ref::<Int64Array>()
895            .unwrap();
896        assert_eq!(error_count.value(0), 1);
897        let duration_sum = batch
898            .column(13)
899            .as_any()
900            .downcast_ref::<Float64Array>()
901            .unwrap();
902        assert!((duration_sum.value(0) - 2.0).abs() < 1e-9);
903        let duration_count = batch
904            .column(14)
905            .as_any()
906            .downcast_ref::<Int64Array>()
907            .unwrap();
908        assert_eq!(duration_count.value(0), 2);
909        // Derived calls edges carry no attributes: a typed-JSON NULL.
910        assert!(json_texts(batch, 15)[0].is_none());
911    }
912
913    /// RED columns of one output row: `(request_count, error_count,
914    /// duration_sum, duration_count)`.
915    fn red_metrics(batch: &RecordBatch, row: usize) -> (i64, i64, f64, i64) {
916        let i64_at = |column: usize| {
917            batch
918                .column(column)
919                .as_any()
920                .downcast_ref::<Int64Array>()
921                .unwrap()
922                .value(row)
923        };
924        let duration_sum = batch
925            .column(13)
926            .as_any()
927            .downcast_ref::<Float64Array>()
928            .unwrap()
929            .value(row);
930        (i64_at(11), i64_at(12), duration_sum, i64_at(14))
931    }
932
933    fn confidence(batch: &RecordBatch, row: usize) -> f64 {
934        batch
935            .column(10)
936            .as_any()
937            .downcast_ref::<Float64Array>()
938            .unwrap()
939            .value(row)
940    }
941
942    fn sources(scans: &[DataFrame]) -> Vec<CallsSource> {
943        scans
944            .iter()
945            .map(|scan| CallsSource {
946                service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
947                agent: None,
948                scan: scan.clone(),
949            })
950            .collect()
951    }
952
953    #[tokio::test]
954    async fn calls_plan_merges_edges_across_trace_tables() {
955        let ctx = SessionContext::new();
956        // Distinct traces observing the same frontend->cart edge, one per table.
957        register_trace_table(
958            &ctx,
959            "trace_a",
960            &[],
961            &[
962                (1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0),
963                (
964                    1_010,
965                    "t1",
966                    "s1",
967                    Some("c1"),
968                    SERVER,
969                    UNSET,
970                    "cart",
971                    500_000_000,
972                ),
973            ],
974        );
975        register_trace_table(
976            &ctx,
977            "trace_b",
978            &[],
979            &[
980                (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
981                (
982                    2_010,
983                    "t2",
984                    "s2",
985                    Some("c2"),
986                    SERVER,
987                    ERROR,
988                    "cart",
989                    1_500_000_000,
990                ),
991            ],
992        );
993        let a = ctx.table("trace_a").await.unwrap();
994        let b = ctx.table("trace_b").await.unwrap();
995        let plan = build_relationships_plan_for_test(sources(&[a, b]), &test_window())
996            .unwrap()
997            .unwrap();
998
999        let batches = collect(&ctx, plan).await;
1000        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1001        assert_eq!(total, 1);
1002        assert_eq!(red_metrics(&batches[0], 0), (2, 1, 2.0, 2));
1003    }
1004
1005    #[tokio::test]
1006    async fn calls_pair_split_across_trace_tables() {
1007        let ctx = SessionContext::new();
1008        // The client span and its child server span land in different tables
1009        // (per-table trace routing): the union-then-join pairing must find it.
1010        register_trace_table(
1011            &ctx,
1012            "trace_a",
1013            &[],
1014            &[(1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0)],
1015        );
1016        register_trace_table(
1017            &ctx,
1018            "trace_b",
1019            &[],
1020            &[(
1021                1_010,
1022                "t1",
1023                "s1",
1024                Some("c1"),
1025                SERVER,
1026                UNSET,
1027                "cart",
1028                500_000_000,
1029            )],
1030        );
1031        let a = ctx.table("trace_a").await.unwrap();
1032        let b = ctx.table("trace_b").await.unwrap();
1033        let plan = build_relationships_plan_for_test(sources(&[a, b]), &test_window())
1034            .unwrap()
1035            .unwrap();
1036
1037        let batches = collect(&ctx, plan).await;
1038        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1039        assert_eq!(total, 1);
1040        let batch = &batches[0];
1041        assert_eq!(strings(batch, 5), vec!["frontend"]);
1042        assert_eq!(strings(batch, 7), vec!["cart"]);
1043        assert_eq!(confidence(batch, 0), 1.0);
1044        assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1045    }
1046
1047    #[tokio::test]
1048    async fn virtual_node_edge_from_unmatched_client() {
1049        let ctx = SessionContext::new();
1050        register_trace_table(
1051            &ctx,
1052            "trace_a",
1053            &[(
1054                "span_attributes.peer.service",
1055                &[Some("redis"), None, Some("frontend")],
1056            )],
1057            &[
1058                // Unmatched client naming its peer: a virtual-node edge with
1059                // the client's own status and duration.
1060                (
1061                    1_000,
1062                    "t1",
1063                    "c1",
1064                    None,
1065                    CLIENT,
1066                    ERROR,
1067                    "frontend",
1068                    250_000_000,
1069                ),
1070                // Unmatched client without a peer attribute: no edge.
1071                (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
1072                // Peer attribute naming the client's own service: excluded.
1073                (3_000, "t3", "c3", None, CLIENT, UNSET, "frontend", 0),
1074            ],
1075        );
1076        let a = ctx.table("trace_a").await.unwrap();
1077        let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1078            .unwrap()
1079            .unwrap();
1080
1081        let batches = collect(&ctx, plan).await;
1082        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1083        assert_eq!(total, 1);
1084        let batch = &batches[0];
1085        assert_eq!(strings(batch, 5), vec!["frontend"]);
1086        assert_eq!(strings(batch, 7), vec!["redis"]);
1087        assert_eq!(strings(batch, 8), vec!["calls"]);
1088        assert_eq!(strings(batch, 9), vec!["trace"]);
1089        assert_eq!(confidence(batch, 0), VIRTUAL_NODE_CONFIDENCE);
1090        assert_eq!(red_metrics(batch, 0), (1, 1, 0.25, 1));
1091        assert_eq!(
1092            json_texts(batch, 15),
1093            vec![Some(r#"{"connection_type":"virtual_node"}"#.to_string())]
1094        );
1095    }
1096
1097    #[tokio::test]
1098    async fn virtual_endpoint_fallback_and_connection_type() {
1099        let ctx = SessionContext::new();
1100        register_trace_table(
1101            &ctx,
1102            "trace_a",
1103            &[
1104                // An empty high-precedence value must fall through to the next
1105                // candidate, whose match maps connection_type to `database`.
1106                ("span_attributes.service.peer.name", &[Some("")]),
1107                ("span_attributes.db.namespace", &[Some("mysql")]),
1108            ],
1109            &[(1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 100)],
1110        );
1111        let a = ctx.table("trace_a").await.unwrap();
1112        let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1113            .unwrap()
1114            .unwrap();
1115
1116        let batches = collect(&ctx, plan).await;
1117        let batch = &batches[0];
1118        assert_eq!(strings(batch, 7), vec!["mysql"]);
1119        assert_eq!(
1120            json_texts(batch, 15),
1121            vec![Some(r#"{"connection_type":"database"}"#.to_string())]
1122        );
1123    }
1124
1125    #[tokio::test]
1126    async fn agent_calls_from_parent_child_spans() {
1127        let ctx = SessionContext::new();
1128        const INTERNAL: &str = "SPAN_KIND_INTERNAL";
1129        register_trace_table(
1130            &ctx,
1131            "agent_traces",
1132            &[(
1133                "agent_id",
1134                &[
1135                    Some("orchestrator"),
1136                    Some("researcher"),
1137                    Some("researcher"),
1138                    Some("researcher"),
1139                ],
1140            )],
1141            &[
1142                // orchestrator delegates to researcher twice (one errored);
1143                // the researcher's own sub-span is same-agent structure.
1144                (1_000, "t1", "p1", None, INTERNAL, UNSET, "app", 0),
1145                (
1146                    1_010,
1147                    "t1",
1148                    "a1",
1149                    Some("p1"),
1150                    INTERNAL,
1151                    ERROR,
1152                    "app",
1153                    2_000_000_000,
1154                ),
1155                (
1156                    2_000,
1157                    "t1",
1158                    "a2",
1159                    Some("p1"),
1160                    INTERNAL,
1161                    UNSET,
1162                    "app",
1163                    1_000_000_000,
1164                ),
1165                (1_020, "t1", "a3", Some("a1"), INTERNAL, UNSET, "app", 100),
1166            ],
1167        );
1168        let scan = ctx.table("agent_traces").await.unwrap();
1169        let plan = build_relationships_plan(
1170            RelationshipSources {
1171                traces: vec![CallsSource {
1172                    service: None,
1173                    agent: Some(co_decl("gen_ai.agent", &["agent_id"])),
1174                    scan,
1175                }],
1176                co_declared: vec![],
1177                declared: None,
1178            },
1179            &test_window(),
1180        )
1181        .unwrap()
1182        .unwrap();
1183
1184        let batches = collect(&ctx, plan).await;
1185        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1186        // The same-agent child (a3 under a1) is excluded; agent edges need
1187        // no service declaration.
1188        assert_eq!(total, 1);
1189        let batch = &batches[0];
1190        assert_eq!(strings(batch, 4), vec!["gen_ai.agent"]);
1191        assert_eq!(strings(batch, 5), vec!["orchestrator"]);
1192        assert_eq!(strings(batch, 6), vec!["gen_ai.agent"]);
1193        assert_eq!(strings(batch, 7), vec!["researcher"]);
1194        assert_eq!(strings(batch, 8), vec!["calls"]);
1195        assert_eq!(strings(batch, 9), vec!["trace"]);
1196        assert_eq!(confidence(batch, 0), 1.0);
1197        assert_eq!(red_metrics(batch, 0), (2, 1, 3.0, 2));
1198    }
1199
1200    #[tokio::test]
1201    async fn agent_calls_anchor_on_the_parent_span() {
1202        let ctx = SessionContext::new();
1203        const INTERNAL: &str = "SPAN_KIND_INTERNAL";
1204        // The parent is inside the queried window; the child starts after its
1205        // end but within the proximity allowance and must still pair.
1206        register_trace_table(
1207            &ctx,
1208            "agent_traces",
1209            &[("agent_id", &[Some("orchestrator"), Some("researcher")])],
1210            &[
1211                (599_000, "t1", "p1", None, INTERNAL, UNSET, "app", 0),
1212                (
1213                    601_000,
1214                    "t1",
1215                    "a1",
1216                    Some("p1"),
1217                    INTERNAL,
1218                    UNSET,
1219                    "app",
1220                    500_000_000,
1221                ),
1222            ],
1223        );
1224        let scan = ctx.table("agent_traces").await.unwrap();
1225        let plan = build_relationships_plan(
1226            RelationshipSources {
1227                traces: vec![CallsSource {
1228                    service: None,
1229                    agent: Some(co_decl("gen_ai.agent", &["agent_id"])),
1230                    scan,
1231                }],
1232                co_declared: vec![],
1233                declared: None,
1234            },
1235            &test_window(),
1236        )
1237        .unwrap()
1238        .unwrap();
1239
1240        let batches = collect(&ctx, plan).await;
1241        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1242        assert_eq!(total, 1);
1243        let batch = &batches[0];
1244        // observed_at is the parent's bin.
1245        assert_eq!(ts_values(batch, 0), vec![540_000]);
1246        assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1247    }
1248
1249    /// A metric-like declaring table: ms timestamps, one row with a NULL
1250    /// `instance` (witnessing nothing for instance edges), two rows duplicating
1251    /// the same identities inside one bin (collapsed by DISTINCT).
1252    fn co_declared_ctx() -> SessionContext {
1253        let schema = Arc::new(Schema::new(vec![
1254            Field::new(
1255                "ts",
1256                DataType::Timestamp(TimeUnit::Millisecond, None),
1257                false,
1258            ),
1259            Field::new("instance", DataType::Utf8, true),
1260            Field::new("host", DataType::Utf8, false),
1261            Field::new("service", DataType::Utf8, false),
1262            Field::new("agent_id", DataType::Utf8, false),
1263            Field::new("model_name", DataType::Utf8, false),
1264            Field::new("tool_name", DataType::Utf8, false),
1265        ]));
1266        let batch = RecordBatch::try_new(
1267            schema.clone(),
1268            vec![
1269                Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 3_000])) as ArrayRef,
1270                Arc::new(StringArray::from(vec![Some("i-1"), Some("i-1"), None])),
1271                Arc::new(StringArray::from(vec!["h-1", "h-1", "h-2"])),
1272                Arc::new(StringArray::from(vec!["svc", "svc", "svc"])),
1273                Arc::new(StringArray::from(vec!["agent-1"; 3])),
1274                Arc::new(StringArray::from(vec!["gpt"; 3])),
1275                Arc::new(StringArray::from(vec!["search"; 3])),
1276            ],
1277        )
1278        .unwrap();
1279        let ctx = SessionContext::new();
1280        for name in ["co_metrics", "co_metrics_2"] {
1281            ctx.register_table(
1282                name,
1283                Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap()),
1284            )
1285            .unwrap();
1286        }
1287        ctx
1288    }
1289
1290    fn co_decl(entity_type: &str, id_columns: &[&str]) -> EntityDeclaration {
1291        EntityDeclaration {
1292            schema: "public".to_string(),
1293            table: "co_metrics".to_string(),
1294            time_index: "ts".to_string(),
1295            entity_type: entity_type.to_string(),
1296            id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
1297            descriptive_columns: vec![],
1298            scope_columns: vec![],
1299        }
1300    }
1301
1302    async fn co_declared_edges(
1303        ctx: &SessionContext,
1304        declarations: Vec<EntityDeclaration>,
1305        is_trace: bool,
1306    ) -> Option<Vec<(String, String, String, String, String, String)>> {
1307        let scan = ctx.table("co_metrics").await.unwrap();
1308        let plan = build_relationships_plan(
1309            RelationshipSources {
1310                traces: vec![],
1311                co_declared: vec![CoDeclaredSource {
1312                    declarations,
1313                    is_trace,
1314                    scan,
1315                }],
1316                declared: None,
1317            },
1318            &test_window(),
1319        )
1320        .unwrap()?;
1321        assert_eq!(
1322            plan.schema()
1323                .fields()
1324                .iter()
1325                .map(|f| f.name().as_str())
1326                .collect::<Vec<_>>(),
1327            RELATIONSHIP_COLUMNS
1328        );
1329        let batches = collect(ctx, plan).await;
1330        let mut rows = vec![];
1331        for batch in &batches {
1332            for i in 0..batch.num_rows() {
1333                rows.push((
1334                    strings(batch, 4)[i].clone(),
1335                    strings(batch, 5)[i].clone(),
1336                    strings(batch, 6)[i].clone(),
1337                    strings(batch, 7)[i].clone(),
1338                    strings(batch, 8)[i].clone(),
1339                    strings(batch, 9)[i].clone(),
1340                ));
1341                assert_eq!(confidence(batch, i), 1.0);
1342                // Co-declared edges carry no RED metrics or attributes.
1343                for column in 11..=15 {
1344                    assert!(batch.column(column).is_null(i));
1345                }
1346            }
1347        }
1348        rows.sort();
1349        Some(rows)
1350    }
1351
1352    #[tokio::test]
1353    async fn co_declared_direction_follows_vocabulary() {
1354        let ctx = co_declared_ctx();
1355        // Declaration order must not matter: the vocabulary fixes direction.
1356        let rows = co_declared_edges(
1357            &ctx,
1358            vec![
1359                co_decl("host", &["host"]),
1360                co_decl("service", &["service"]),
1361                co_decl("service.instance", &["instance"]),
1362            ],
1363            false,
1364        )
1365        .await
1366        .unwrap();
1367        // The two non-NULL-instance rows share one 60s bin, so DISTINCT folds
1368        // them into one row per edge; the NULL-instance row witnesses nothing.
1369        assert_eq!(
1370            rows,
1371            vec![
1372                (
1373                    "service.instance".to_string(),
1374                    "i-1".to_string(),
1375                    "host".to_string(),
1376                    "h-1".to_string(),
1377                    "runs_on".to_string(),
1378                    "attribute".to_string(),
1379                ),
1380                (
1381                    "service.instance".to_string(),
1382                    "i-1".to_string(),
1383                    "service".to_string(),
1384                    "svc".to_string(),
1385                    "part_of".to_string(),
1386                    "attribute".to_string(),
1387                ),
1388            ]
1389        );
1390    }
1391
1392    #[tokio::test]
1393    async fn co_declared_edge_folds_across_sources() {
1394        let ctx = co_declared_ctx();
1395        let declarations = || {
1396            vec![
1397                co_decl("service.instance", &["instance"]),
1398                co_decl("host", &["host"]),
1399            ]
1400        };
1401        let source = |scan| CoDeclaredSource {
1402            declarations: declarations(),
1403            is_trace: false,
1404            scan,
1405        };
1406        let a = ctx.table("co_metrics").await.unwrap();
1407        let b = ctx.table("co_metrics_2").await.unwrap();
1408        let plan = build_relationships_plan(
1409            RelationshipSources {
1410                traces: vec![],
1411                co_declared: vec![source(a), source(b)],
1412                declared: None,
1413            },
1414            &test_window(),
1415        )
1416        .unwrap()
1417        .unwrap();
1418
1419        let batches = collect(&ctx, plan).await;
1420        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1421        // Both tables witness the same runs_on edge in the same window: one
1422        // row per (window, edge), not one per source.
1423        assert_eq!(total, 1);
1424    }
1425
1426    #[tokio::test]
1427    async fn co_declared_ignores_types_outside_the_vocabulary() {
1428        let ctx = co_declared_ctx();
1429        // (service, host) is not a vocabulary pair: no edge, no plan.
1430        let rows = co_declared_edges(
1431            &ctx,
1432            vec![co_decl("service", &["service"]), co_decl("host", &["host"])],
1433            false,
1434        )
1435        .await;
1436        assert!(rows.is_none());
1437    }
1438
1439    #[tokio::test]
1440    async fn agent_edges_require_a_trace_source() {
1441        let ctx = co_declared_ctx();
1442        let declarations = || {
1443            vec![
1444                co_decl("gen_ai.agent", &["agent_id"]),
1445                co_decl("gen_ai.model", &["model_name"]),
1446                co_decl("gen_ai.tool", &["tool_name"]),
1447            ]
1448        };
1449        // A non-trace table co-declaring agent/model/tool must not fabricate
1450        // invocations.
1451        assert!(
1452            co_declared_edges(&ctx, declarations(), false)
1453                .await
1454                .is_none()
1455        );
1456
1457        let rows = co_declared_edges(&ctx, declarations(), true).await.unwrap();
1458        assert_eq!(
1459            rows,
1460            vec![
1461                (
1462                    "gen_ai.agent".to_string(),
1463                    "agent-1".to_string(),
1464                    "gen_ai.model".to_string(),
1465                    "gpt".to_string(),
1466                    "uses".to_string(),
1467                    "trace".to_string(),
1468                ),
1469                (
1470                    "gen_ai.agent".to_string(),
1471                    "agent-1".to_string(),
1472                    "gen_ai.tool".to_string(),
1473                    "search".to_string(),
1474                    "invokes".to_string(),
1475                    "trace".to_string(),
1476                ),
1477            ]
1478        );
1479    }
1480
1481    #[tokio::test]
1482    async fn real_pair_suppresses_virtual_edge() {
1483        let ctx = SessionContext::new();
1484        register_trace_table(
1485            &ctx,
1486            "trace_a",
1487            &[(
1488                "span_attributes.peer.service",
1489                &[Some("cart"), None, Some("cart")],
1490            )],
1491            &[
1492                // A sampled-out server: the client names the same peer an
1493                // actual pair witnesses in the same window.
1494                (1_000, "t1", "c1", None, CLIENT, ERROR, "frontend", 999),
1495                (
1496                    2_010,
1497                    "t2",
1498                    "s2",
1499                    Some("c2"),
1500                    SERVER,
1501                    UNSET,
1502                    "cart",
1503                    500_000_000,
1504                ),
1505                (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
1506            ],
1507        );
1508        let a = ctx.table("trace_a").await.unwrap();
1509        let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1510            .unwrap()
1511            .unwrap();
1512
1513        let batches = collect(&ctx, plan).await;
1514        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1515        // One (window, edge) row: the pair population wins; the unmatched
1516        // client neither adds a second row nor inflates the pair RED metrics.
1517        assert_eq!(total, 1);
1518        let batch = &batches[0];
1519        assert_eq!(strings(batch, 7), vec!["cart"]);
1520        assert_eq!(confidence(batch, 0), 1.0);
1521        assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1522        assert!(json_texts(batch, 15)[0].is_none());
1523    }
1524
1525    #[tokio::test]
1526    async fn calls_endpoints_follow_service_declaration() {
1527        let ctx = trace_table_ctx();
1528        let trace = ctx.table("opentelemetry_traces").await.unwrap();
1529        let plan = build_relationships_plan_for_test(
1530            vec![CallsSource {
1531                service: Some(trace_service_decl(&[
1532                    SERVICE_NAME_COLUMN,
1533                    "service_namespace",
1534                ])),
1535                agent: None,
1536                scan: trace,
1537            }],
1538            &test_window(),
1539        )
1540        .unwrap()
1541        .unwrap();
1542
1543        let batches = collect(&ctx, plan).await;
1544        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1545        assert_eq!(total, 1);
1546        let batch = &batches[0];
1547        // Composite service identity renders the same sorted `k=v` form the
1548        // registry emits, so edges land on registry entity ids.
1549        assert_eq!(
1550            strings(batch, 5),
1551            vec!["service_name=frontend,service_namespace=ns1"]
1552        );
1553        assert_eq!(
1554            strings(batch, 7),
1555            vec!["service_name=cart,service_namespace=ns1"]
1556        );
1557    }
1558
1559    fn build_relationships_plan_for_test(
1560        traces: Vec<CallsSource>,
1561        window: &GraphQueryWindow,
1562    ) -> DfResult<Option<LogicalPlan>> {
1563        build_relationships_plan(
1564            RelationshipSources {
1565                traces,
1566                co_declared: vec![],
1567                declared: None,
1568            },
1569            window,
1570        )
1571    }
1572
1573    /// Registers a declared-edge table holding:
1574    /// - frontend->db: two revisions (0.5 then 1.0), the newer retiring the edge at 300s;
1575    /// - api->cache: expired at 100s;
1576    /// - agent-1->search: open-ended `provenance = 'agent'` with JSON attributes;
1577    /// - webapp->auth: two revisions, the newer declared at 900s;
1578    /// - batch->queue: declared at 100s but only valid from 700s;
1579    /// - gateway->redis: asserted twice at the same instant as generations g1/g2.
1580    fn declared_table(ctx: &SessionContext) {
1581        let ts_field = |name: &str| {
1582            Field::new(
1583                name,
1584                DataType::Timestamp(TimeUnit::Millisecond, None),
1585                name != OBSERVED_AT_COLUMN,
1586            )
1587        };
1588        let schema = Arc::new(Schema::new(vec![
1589            ts_field(OBSERVED_AT_COLUMN),
1590            ts_field("window_start"),
1591            ts_field("window_end"),
1592            ts_field("fresh_until"),
1593            ts_field("valid_from"),
1594            ts_field("valid_until"),
1595            Field::new("src_type", DataType::Utf8, false),
1596            Field::new("src_id", DataType::Utf8, false),
1597            Field::new("rel_type", DataType::Utf8, false),
1598            Field::new("dst_type", DataType::Utf8, false),
1599            Field::new("dst_id", DataType::Utf8, false),
1600            Field::new("provenance", DataType::Utf8, false),
1601            Field::new("scope", DataType::Utf8, false),
1602            Field::new("generation_id", DataType::Utf8, false),
1603            Field::new("confidence", DataType::Float64, true),
1604            Field::new("request_count", DataType::Int64, true),
1605            Field::new("error_count", DataType::Int64, true),
1606            Field::new("duration_sum", DataType::Float64, true),
1607            Field::new("duration_count", DataType::Int64, true),
1608            Field::new("attributes", DataType::Binary, true),
1609        ]));
1610        let attrs = jsonb::parse_value(br#"{"connection_type":"virtual_node"}"#)
1611            .unwrap()
1612            .to_vec();
1613        let no_ts = || Arc::new(TimestampMillisecondArray::from(vec![None::<i64>; 9])) as ArrayRef;
1614        let batch = RecordBatch::try_new(
1615            schema.clone(),
1616            vec![
1617                Arc::new(TimestampMillisecondArray::from(vec![
1618                    100_000, 200_000, 50_000, 90_000, 100_000, 900_000, 100_000, 100_000, 100_000,
1619                ])) as ArrayRef,
1620                no_ts(), // window_start
1621                no_ts(), // window_end
1622                no_ts(), // fresh_until
1623                Arc::new(TimestampMillisecondArray::from(vec![
1624                    None,
1625                    None,
1626                    None,
1627                    None,
1628                    None,
1629                    None,
1630                    Some(700_000),
1631                    None,
1632                    None,
1633                ])),
1634                Arc::new(TimestampMillisecondArray::from(vec![
1635                    None,
1636                    Some(300_000),
1637                    Some(100_000),
1638                    None,
1639                    None,
1640                    None,
1641                    None,
1642                    None,
1643                    None,
1644                ])),
1645                Arc::new(StringArray::from(vec![
1646                    "service", "service", "service", "agent", "service", "service", "service",
1647                    "service", "service",
1648                ])),
1649                Arc::new(StringArray::from(vec![
1650                    "frontend", "frontend", "api", "agent-1", "webapp", "webapp", "batch",
1651                    "gateway", "gateway",
1652                ])),
1653                Arc::new(StringArray::from(vec![
1654                    "depends_on",
1655                    "depends_on",
1656                    "depends_on",
1657                    "uses",
1658                    "depends_on",
1659                    "depends_on",
1660                    "depends_on",
1661                    "depends_on",
1662                    "depends_on",
1663                ])),
1664                Arc::new(StringArray::from(vec![
1665                    "service", "service", "service", "tool", "service", "service", "service",
1666                    "service", "service",
1667                ])),
1668                Arc::new(StringArray::from(vec![
1669                    "db", "db", "cache", "search", "auth", "auth", "queue", "redis", "redis",
1670                ])),
1671                Arc::new(StringArray::from(vec![
1672                    "declared", "declared", "declared", "agent", "declared", "declared",
1673                    "declared", "declared", "declared",
1674                ])),
1675                Arc::new(StringArray::from(vec![""; 9])),
1676                Arc::new(StringArray::from(vec![
1677                    "", "", "", "", "", "", "", "g1", "g2",
1678                ])),
1679                Arc::new(Float64Array::from(vec![
1680                    Some(0.5),
1681                    Some(1.0),
1682                    Some(1.0),
1683                    Some(0.8),
1684                    Some(0.5),
1685                    Some(1.0),
1686                    Some(1.0),
1687                    Some(0.5),
1688                    Some(1.0),
1689                ])),
1690                Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1691                Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1692                Arc::new(Float64Array::from(vec![None::<f64>; 9])),
1693                Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1694                Arc::new(BinaryArray::from(vec![
1695                    None,
1696                    None,
1697                    None,
1698                    Some(attrs.as_slice()),
1699                    None,
1700                    None,
1701                    None,
1702                    None,
1703                    None,
1704                ])),
1705            ],
1706        )
1707        .unwrap();
1708        ctx.register_table(
1709            SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
1710            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
1711        )
1712        .unwrap();
1713    }
1714
1715    #[tokio::test]
1716    async fn declared_edges_latest_revision_validity_and_synthetic_time() {
1717        let ctx = SessionContext::new();
1718        declared_table(&ctx);
1719        let scan = ctx
1720            .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1721            .await
1722            .unwrap();
1723
1724        // Queried window [120s, 600s).
1725        let window = GraphQueryWindow::from_observed(120_000, 600_000);
1726        let plan = build_relationships_plan(
1727            RelationshipSources {
1728                traces: vec![],
1729                co_declared: vec![],
1730                declared: Some(DeclaredSource { scan }),
1731            },
1732            &window,
1733        )
1734        .unwrap()
1735        .unwrap();
1736        let names = plan
1737            .schema()
1738            .fields()
1739            .iter()
1740            .map(|f| f.name().as_str())
1741            .collect::<Vec<_>>();
1742        assert_eq!(names, RELATIONSHIP_COLUMNS);
1743
1744        // (src_id, observed_at, window_start, window_end, fresh_until,
1745        //  provenance, attributes)
1746        type DeclaredRow = (String, i64, i64, i64, i64, String, Option<String>);
1747
1748        let batches = collect(&ctx, plan).await;
1749        let mut rows: Vec<DeclaredRow> = vec![];
1750        for batch in &batches {
1751            let observed = ts_values(batch, 0);
1752            let window_start = ts_values(batch, 1);
1753            let window_end = ts_values(batch, 2);
1754            let fresh_until = ts_values(batch, 3);
1755            let src = strings(batch, 5);
1756            let provenance = strings(batch, 9);
1757            let attributes = json_texts(batch, 15);
1758            for i in 0..batch.num_rows() {
1759                rows.push((
1760                    src[i].clone(),
1761                    observed[i],
1762                    window_start[i],
1763                    window_end[i],
1764                    fresh_until[i],
1765                    provenance[i].clone(),
1766                    attributes[i].clone(),
1767                ));
1768            }
1769        }
1770        rows.sort();
1771
1772        // api->cache expired before the window; batch->queue is not yet valid;
1773        // frontend->db keeps only the latest revision; webapp->auth keeps the
1774        // first revision (the second postdates the window); gateway->redis
1775        // collapses to one row across generations.
1776        assert_eq!(rows.len(), 4);
1777
1778        // agent->tool: open-ended validity declared at 90s. The synthesized
1779        // observed_at is clamped into the queried window; window_end and
1780        // fresh_until take the window's upper bound.
1781        assert_eq!(
1782            rows[0],
1783            (
1784                "agent-1".to_string(),
1785                120_000,
1786                90_000,
1787                600_000,
1788                600_000,
1789                "agent".to_string(),
1790                Some(r#"{"connection_type":"virtual_node"}"#.to_string()),
1791            )
1792        );
1793
1794        // frontend->db: the latest revision (declared at 200s, retired at 300s)
1795        // supersedes the open-ended first revision.
1796        assert_eq!(
1797            rows[1],
1798            (
1799                "frontend".to_string(),
1800                200_000,
1801                200_000,
1802                300_000,
1803                300_000,
1804                "declared".to_string(),
1805                None,
1806            )
1807        );
1808
1809        assert_eq!(
1810            rows[2],
1811            (
1812                "gateway".to_string(),
1813                120_000,
1814                100_000,
1815                600_000,
1816                600_000,
1817                "declared".to_string(),
1818                None,
1819            )
1820        );
1821
1822        assert_eq!(
1823            rows[3],
1824            (
1825                "webapp".to_string(),
1826                120_000,
1827                100_000,
1828                600_000,
1829                600_000,
1830                "declared".to_string(),
1831                None,
1832            )
1833        );
1834    }
1835
1836    #[tokio::test]
1837    async fn declared_edges_pick_the_revision_as_of_the_window() {
1838        let ctx = SessionContext::new();
1839        declared_table(&ctx);
1840        let scan = ctx
1841            .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1842            .await
1843            .unwrap();
1844
1845        // A historical window that predates frontend->db's second revision:
1846        // the first revision was the edge's state then and must not be hidden
1847        // by the newer one.
1848        let window = GraphQueryWindow::from_observed(0, 150_000);
1849        let plan = build_relationships_plan(
1850            RelationshipSources {
1851                traces: vec![],
1852                co_declared: vec![],
1853                declared: Some(DeclaredSource { scan }),
1854            },
1855            &window,
1856        )
1857        .unwrap()
1858        .unwrap();
1859
1860        let batches = collect(&ctx, plan).await;
1861        let mut rows: Vec<(String, f64)> = vec![];
1862        for batch in &batches {
1863            let src = strings(batch, 5);
1864            let confidence = batch
1865                .column(10)
1866                .as_any()
1867                .downcast_ref::<Float64Array>()
1868                .unwrap();
1869            for (i, src) in src.into_iter().enumerate() {
1870                rows.push((src, confidence.value(i)));
1871            }
1872        }
1873        rows.sort_by(|a, b| a.0.cmp(&b.0));
1874
1875        // api->cache is valid inside this window; batch->queue is not yet.
1876        assert_eq!(
1877            rows,
1878            vec![
1879                ("agent-1".to_string(), 0.8),
1880                ("api".to_string(), 1.0),
1881                ("frontend".to_string(), 0.5),
1882                ("gateway".to_string(), 1.0),
1883                ("webapp".to_string(), 0.5),
1884            ]
1885        );
1886    }
1887
1888    #[tokio::test]
1889    async fn declared_edges_union_calls_branch() {
1890        let ctx = trace_table_ctx();
1891        declared_table(&ctx);
1892        let trace = ctx.table("opentelemetry_traces").await.unwrap();
1893        let declared = ctx
1894            .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1895            .await
1896            .unwrap();
1897
1898        let plan = build_relationships_plan(
1899            RelationshipSources {
1900                traces: vec![CallsSource {
1901                    service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
1902                    agent: None,
1903                    scan: trace,
1904                }],
1905                co_declared: vec![],
1906                declared: Some(DeclaredSource { scan: declared }),
1907            },
1908            &test_window(),
1909        )
1910        .unwrap()
1911        .unwrap();
1912
1913        let batches = collect(&ctx, plan).await;
1914        let mut provenance: Vec<String> = batches.iter().flat_map(|b| strings(b, 9)).collect();
1915        provenance.sort();
1916        // One trace-derived calls edge plus the declared edges valid somewhere
1917        // inside [0s, 600s): frontend, api, webapp, gateway, and the
1918        // agent-asserted one.
1919        assert_eq!(
1920            provenance,
1921            vec![
1922                "agent", "declared", "declared", "declared", "declared", "trace"
1923            ]
1924        );
1925    }
1926}