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