# Tracing and Diagnosing Layouts

## Why trace a layout?

Most graph-layout functions return only the final coordinate matrix. That is enough for plotting, but it hides the most useful diagnostic information:

- how the multiscale hierarchy opened,
- whether the geometry stabilized gradually or abruptly,
- whether a parameter setting improved edge-length regularity or only moved the final picture around,
- and whether a weighted solve is actually getting closer to the intended metric geometry.

The trace APIs in `grip` make those questions visible:

- `trace.grip()` for combinatorial layouts,
- `trace.weighted.grip()` for weighted geometry-aware layouts.

Tracing follows the same package decision rule:

- ordinary unweighted or topology-first graphs: trace `trace.grip()`,
- weighted geometry-aware graphs: trace `trace.weighted.grip()`,
- real-data layout search: add trace only when a shortlist or one promising solve needs explanation,
- advanced GKK/LGKK hooks can appear in weighted traces, but they remain public experimental add-ons rather than the default reason to trace.

    library(grip)

    plot.trace.frame <- function(coords, edges, main = "", vertex.col = "black") {
      active <- stats::complete.cases(coords[, 1:2, drop = FALSE])
      xy <- coords[active, 1:2, drop = FALSE]

      xlim <- range(xy[, 1])
      ylim <- range(xy[, 2])
      xpad <- 0.08 * diff(xlim)
      ypad <- 0.08 * diff(ylim)
      if (!is.finite(xpad) || xpad == 0) xpad <- 0.2
      if (!is.finite(ypad) || ypad == 0) ypad <- 0.2

      plot(
        xy[, 1], xy[, 2],
        type = "n",
        asp = 1,
        axes = FALSE,
        xlab = "",
        ylab = "",
        xlim = xlim + c(-xpad, xpad),
        ylim = ylim + c(-ypad, ypad),
        main = main
      )

      active.edges <- edges[active[edges[, 1]] & active[edges[, 2]], , drop = FALSE]
      if (nrow(active.edges) > 0) {
        apply(active.edges, 1, function(e) {
          graphics::segments(
            coords[e[1], 1], coords[e[1], 2],
            coords[e[2], 1], coords[e[2], 2],
            col = "gray82"
          )
        })
      }

      points(xy[, 1], xy[, 2], pch = 16, cex = 0.55, col = vertex.col)
    }

    pick.trace.frames <- function(trace.obj) {
      n <- length(trace.obj$frames)
      unique(c(1L, max(2L, floor((n + 1L) / 2L)), n))
    }

## A first combinatorial trace

The example below traces a 2D layout of a small mesh. Using `trace = "level"` keeps the vignette readable by recording the coarse initialization, the start of each new level, and the final layout.

    mesh.edges <- edges.mesh(5, 5)

    mesh.trace <- trace.grip(
      mesh.edges,
      n = 25,
      dim = 2,
      preset = "mesh",
      rounds = 12,
      final_rounds = 16,
      trace = "level",
      diagnostics = "light",
      seed = 1
    )

    knitr::kable(mesh.trace$meta)

frame | phase | level_index | misf_level | round_in_level | active_vertices
--- | --- | --- | --- | --- | ---
1 | init | 1 | 1 | 0 | 12
2 | level_start | 2 | 0 | 0 | 25
3 | final | 2 | 0 | 16 | 25

    knitr::kable(mesh.trace$diagnostics[, c(
      "frame",
      "edge.length.cv",
      "median.edge.length",
      "sampled.nonedge.sep.ratio"
    )], digits = 3)

frame | edge.length.cv | median.edge.length | sampled.nonedge.sep.ratio
--- | --- | --- | ---
1 | 0.380 | 234.009 | 0.109
2 | 0.346 | 35.365 | 0.172
3 | 0.073 | 27.020 | 0.969

    sel <- pick.trace.frames(mesh.trace)

    op <- par(mfrow = c(1, length(sel)), mar = c(1.2, 1.2, 3, 1.2), bg = "white")
    on.exit(par(op), add = TRUE)

    for (idx in sel) {
      plot.trace.frame(
        mesh.trace$frames[[idx]],
        mesh.edges,
        main = paste(mesh.trace$meta$phase[[idx]], "(frame", idx, ")")
      )
    }

The two most useful tables are:

- `meta`, which explains where each frame came from,
- `diagnostics`, which tracks simple quality signals frame by frame.

## Level traces versus round traces

When you need more detail, `trace = "round"` records intermediate refinement rounds within each active level.

    mesh.trace.round <- trace.grip(
      mesh.edges,
      n = 25,
      dim = 2,
      preset = "mesh",
      rounds = 6,
      final_rounds = 8,
      trace = "round",
      trace.every = 4,
      diagnostics = "light",
      seed = 1
    )

    data.frame(
      trace_mode = c("level", "round"),
      n.frames = c(length(mesh.trace$frames), length(mesh.trace.round$frames))
    )
    #>   trace_mode n.frames
    #> 1      level        3
    #> 2      round        6

Use:

- `trace = "level"` for most reporting and teaching,
- `trace = "round"` when you are diagnosing a tuning issue.

## Weighted traces

Weighted traces answer a slightly different question. Instead of only asking whether the layout becomes visually cleaner, we can also ask whether it moves toward a target geometry.

Here the graph is a mesh whose edge lengths come from a curved 3D saddle surface.

    weighted.mesh <- mesh.surface.graph(
      4, 4,
      surface = "saddle",
      amplitude = 0.8
    )

    weighted.trace <- trace.weighted.grip(
      weighted.mesh$edges,
      n = weighted.mesh$n,
      edge_weights = weighted.mesh$edge_weights,
      dim = 3,
      preset = "mesh",
      rounds = 8,
      final_rounds = 12,
      trace = "level",
      diagnostics = "light",
      target_coords = weighted.mesh$coords_surface,
      seed = 2
    )

    knitr::kable(weighted.trace$meta)

frame | phase | level_index | misf_level | round_in_level | active_vertices
--- | --- | --- | --- | --- | ---
1 | init | 1 | 1 | 0 | 12
2 | level_start | 2 | 0 | 0 | 16
3 | final | 2 | 0 | 12 | 16

    knitr::kable(weighted.trace$diagnostics[, c(
      "frame",
      "edge.length.cv",
      "sampled.nonedge.sep.ratio",
      "procrustes.rmse"
    )], digits = 3)

frame | edge.length.cv | sampled.nonedge.sep.ratio | procrustes.rmse
--- | --- | --- | ---
1 | 0.381 | 0.154 | 0.887
2 | 0.303 | 0.411 | 0.430
3 | 0.103 | 1.088 | 0.360

    sel.w <- pick.trace.frames(weighted.trace)

    op <- par(mfrow = c(1, length(sel.w)), mar = c(1.2, 1.2, 3, 1.2), bg = "white")
    on.exit(par(op), add = TRUE)

    for (idx in sel.w) {
      frame.xy <- project.3d(weighted.trace$frames[[idx]], azimuth = 35, elevation = 20)
      plot.trace.frame(
        frame.xy,
        weighted.mesh$edges,
        main = paste(weighted.trace$meta$phase[[idx]], "(frame", idx, ")"),
        vertex.col = "#1F3B73"
      )
    }

The important extra column is `procrustes.rmse`. When a meaningful target geometry exists, it gives a direct per-frame measure of whether the layout is getting closer to that target up to rigid motion and scale.

## Trace objects are analysis objects

A trace object is more than a list of pictures. It can support:

- frame-by-frame diagnostics,
- manual inspection of where a solve destabilizes,
- teaching demonstrations of the multiscale hierarchy,
- and regression checks for future algorithm changes.

The most useful components are:

- `final`: the final coordinate matrix,
- `frames`: the stored coordinate frames,
- `meta`: frame provenance,
- `diagnostics`: per-frame metrics,
- `lgkk.polish`: optional post-layout LGKK result when enabled as an advanced experimental refinement step.

## Practical guidance

- Start with `trace = "level"` and `diagnostics = "light"`.
- Use `trace = "round"` only when you need finer-grained diagnostics.
- Supply `target_coords` when a meaningful geometric target exists.
- Prefer weighted traces for weighted graph families; they are much more interpretable than tracing the combinatorial solver on a graph with nontrivial edge lengths.

## Where to go next

- `Getting Started with grip` gives the short overview of the main APIs.
- `Weighted Graph Layouts with grip` focuses on weighted solving and scoring.
- `Choosing Layouts for Real Data` shows how trace fits into a real-data selection workflow.