---
title: "Modeling site, platform, relatedness, and spatial structure"
author: "Selçuk Korkmaz"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Modeling site, platform, relatedness, and spatial structure}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  message = FALSE,
  warning = FALSE
)
library(splitGraph)
```

Beyond the classic subject / batch / study / time relations, `splitGraph`
models several further leakage axes, in two families:

- **Cluster-style relations** — collection **site**, tissue / anatomical
  **region**, sequencing **platform**, and **assay** — are categorical
  groupings. Each is auto-detected from a metadata column and handled by its own
  constraint mode, exactly like `subject` or `batch`.
- **Pairwise relations** — genetic **relatedness** and **spatial** proximity —
  are continuous and defined *between pairs*. They are modelled as thresholded
  edges and grouped by transitive closure, a partition a single categorical
  column cannot express.

This vignette builds and groups by each, shows how the threshold drives the
pairwise grouping, and points out the two ways a structure-aware grouping goes
wrong — collapsing into one group, or dissolving into singletons.

# Cluster-style relations: site, region, platform, assay

`graph_from_metadata()` auto-detects `site_id`, `region_id`, `platform_id`, and
`assay_id` columns and builds the corresponding typed nodes and edges. Each then
has its own constraint mode, and all four behave identically — only the column
and the mode name change.

```{r cluster}
meta <- data.frame(
  sample_id   = paste0("S", 1:6),
  subject_id  = c("P1", "P1", "P2", "P2", "P3", "P3"),
  site_id     = c("NYC", "NYC", "BOS", "BOS", "NYC", "BOS"),
  region_id   = c("cortex", "cortex", "cortex",
                  "hippocampus", "hippocampus", "hippocampus"),
  platform_id = c("illumina", "illumina", "nanopore",
                  "nanopore", "illumina", "nanopore"),
  assay_id    = c("rnaseq", "rnaseq", "rnaseq", "wgs", "wgs", "wgs"),
  stringsAsFactors = FALSE
)

g <- graph_from_metadata(meta, graph_name = "structure-demo")

cluster_modes <- c("site", "region", "platform", "assay")
do.call(cbind, lapply(
  stats::setNames(cluster_modes, cluster_modes),
  function(m) grouping_vector(derive_split_constraints(g, mode = m))
))
```

Each column is a different, equally defensible partition of the same six
samples. Choosing between them is a scientific question, not a technical one:
which of these axes must a model generalise across?

Site structure also has a validation rule of its own. A subject whose samples
were collected at more than one site is flagged, because grouping by site alone
would then place one individual on both sides of a split:

```{r cross-site}
report <- validate_graph(g)
report$issues[report$issues$code == "subject_cross_site_overlap",
              c("severity", "message")]

# Which constraint modes actually sever that path:
risks <- summarize_leakage_risks(g, constraint = derive_split_constraints(g, "subject"))
as.data.frame(risks)[as.data.frame(risks)$category == "subject_cross_site_overlap",
                     c("category", "severed")]
```

Whatever mode is primary, every detected cluster relation is also carried into
the `split_spec` as a *blocking annotation*, so a downstream consumer can block
on site, region, platform, or assay even when the split unit is something else
— here, subject:

```{r block-annotations}
spec <- as_split_spec(derive_split_constraints(g, mode = "subject"), graph = g)
spec$block_vars
head(spec$sample_data[, c("sample_id", "group_id", "site_group",
                          "region_group", "platform_group", "assay_group")])
```

Any of these relations can also participate in a **composite** derivation, where
several dependency sources are combined and each connected component becomes one
group. Site and platform partition this cohort the same way, so grouping on both
at once still leaves two groups:

```{r composite}
constraint <- derive_split_constraints(
  g, mode = "composite", strategy = "strict",
  via = c("Site", "Platform")
)
grouping_vector(constraint)
```

### Watch for a composite that collapses

A strict composite can only ever *merge* groups, never split them, so every
relation you add risks merging everything. Add subject to the same call and the
entire cohort becomes one group:

```{r composite-collapse}
collapsed <- derive_split_constraints(
  g, mode = "composite", strategy = "strict",
  via = c("Site", "Platform", "Subject")
)
collapsed$metadata$n_groups
grouping_vector(collapsed)
```

The validation report predicted this. `subject_cross_site_overlap` fired above
because subject `P3` has one sample at each site; in a composite closure that
subject is a bridge, so the `NYC` and `BOS` groups fuse and there is nothing
left to hold out. One group is not an error — it is an arithmetically correct
answer to the question that was asked — and `splitGraph` does not warn about it,
so read `metadata$n_groups` before you rely on a composite.

Two ways out, both already on this page: leave the bridging relation out of
`via` (the `Site` + `Platform` call above), or keep the coarse axis as a
*blocking annotation* rather than the split unit, which is what `site_group` in
the spec is for. `vignette("faq-design-notes")` works through the trade-off.

# Pairwise relation: genetic relatedness

Some leakage is pairwise and continuous rather than a clean grouping. Genetic
relatedness is the canonical example: a kinship coefficient — typically from a
tool such as KING or PLINK — links *pairs* of subjects.
`relatedness_edges_from_kinship()` takes such a pair table, keeps pairs at or
above a threshold, and emits `subject_related_to` edges; `mode = "relatedness"`
then groups by transitive closure over those edges (so a chain of related
individuals lands in one group).

```{r relatedness}
# A kinship table over subject pairs (one sample per subject here for clarity).
# P1-P2 and P2-P3 clear the threshold and chain together; P5-P6 form a second
# related pair; P1-P4 is too weak to count.
kin <- data.frame(
  id1     = c("P1", "P2", "P1", "P5"),
  id2     = c("P2", "P3", "P4", "P6"),
  kinship = c(0.25, 0.20, 0.02, 0.30),
  stringsAsFactors = FALSE
)
rel_edges <- relatedness_edges_from_kinship(kin, threshold = 0.1)

meta_r <- data.frame(
  sample_id  = paste0("S", 1:6),
  subject_id = paste0("P", 1:6),
  stringsAsFactors = FALSE
)
samples  <- create_nodes(meta_r, "Sample", "sample_id")
subjects <- create_nodes(meta_r, "Subject", "subject_id")
belongs  <- create_edges(meta_r, "sample_id", "subject_id",
                         "Sample", "Subject", "sample_belongs_to_subject")

g_rel <- build_dependency_graph(list(samples, subjects), list(belongs, rel_edges))

rel_groups <- grouping_vector(derive_split_constraints(g_rel, mode = "relatedness"))
rel_groups
```

Real kinship tools rarely use those exact column names, and they do not all
emit the long format. Both shapes are accepted. KING and GCTA write a long
table with their own headers, which `id1` / `id2` / `kinship` rename:

```{r kinship-columns}
king <- data.frame(
  ID1     = c("P1", "P2", "P1", "P5"),
  ID2     = c("P2", "P3", "P4", "P6"),
  Kinship = c(0.25, 0.20, 0.02, 0.30),
  stringsAsFactors = FALSE
)
king_edges <- relatedness_edges_from_kinship(
  king, threshold = 0.1, id1 = "ID1", id2 = "ID2", kinship = "Kinship"
)
as.data.frame(king_edges)[, c("from", "to")]
```

PLINK's `--make-rel square` instead writes a square GRM. Pass the matrix
directly, with the subject ids as its dimnames; it is expanded to its
upper-triangle pairs before thresholding, so the diagonal never becomes a
self-edge:

```{r kinship-matrix}
grm <- matrix(
  c(0.50, 0.25, 0.02, 0.00,
    0.25, 0.50, 0.20, 0.00,
    0.02, 0.20, 0.50, 0.00,
    0.00, 0.00, 0.00, 0.50),
  nrow = 4, byrow = TRUE,
  dimnames = list(paste0("P", 1:4), paste0("P", 1:4))
)

as.data.frame(relatedness_edges_from_kinship(grm, threshold = 0.1))[
  , c("from", "to")
]
```

Whichever shape you start from, the value that passed the threshold is kept on
the edge, so you can always see *why* a pair was linked rather than trusting the
grouping blind:

```{r kinship-attrs}
kept <- as.data.frame(query_edge_type(g_rel, "subject_related_to"))
data.frame(
  from    = kept$from,
  to      = kept$to,
  kinship = vapply(kept$attrs, function(a) a$kinship, numeric(1))
)
```

The grouping is a transitive closure over the `subject_related_to` edges. The
network below draws those edges between subjects, coloured by the relatedness
group each subject (and therefore its samples) lands in: the P1–P2–P3 chain
becomes one group even though P1 and P3 were never linked directly, P5–P6 form a
second, and the unrelated P4 stands alone.

```{r rel-plot, fig.width = 6.5, fig.height = 4.5}
subject_group <- setNames(rel_groups[meta_r$sample_id], meta_r$subject_id)
kept_pairs <- kin[kin$kinship >= 0.1, c("id1", "id2")]
rel_net <- igraph::graph_from_data_frame(
  kept_pairs, directed = FALSE,
  vertices = data.frame(name = meta_r$subject_id)
)

palette_rel <- c("#4C78A8", "#F58518", "#54A24B", "#B279A2")
set.seed(1)
plot(rel_net,
     vertex.color       = palette_rel[as.integer(factor(subject_group[igraph::V(rel_net)$name]))],
     vertex.size        = 34,
     vertex.label.color = "white",
     vertex.label.font  = 2,
     edge.color         = "grey60",
     edge.width         = 2,
     main               = "Relatedness clusters (kinship >= 0.1)")
```

The threshold is the key knob, and it belongs to the *edge-building* step, not
the grouping. Raising it drops weaker links: at `0.22` the P2–P3 pair (kinship
`0.20`) no longer qualifies, so that chain breaks and P3 splits into its own
group, while the stronger P5–P6 pair is untouched:

```{r rel-threshold}
rel_strict <- relatedness_edges_from_kinship(kin, threshold = 0.22)
g_rel_strict <- build_dependency_graph(list(samples, subjects), list(belongs, rel_strict))

grouping_vector(derive_split_constraints(g_rel_strict, mode = "relatedness"))
```

Push the threshold further and the relation stops doing much work: samples end
up alone in their own groups, which protects nothing. That case *is* detected —
once more than half the samples sit in a group of one, the pairwise deriver
records it in `metadata$warnings`, so a cut that quietly switched the relation
off does not pass unnoticed:

```{r rel-sparse}
sparse <- derive_split_constraints(
  build_dependency_graph(
    list(samples, subjects),
    list(belongs, relatedness_edges_from_kinship(kin, threshold = 0.28))
  ),
  mode = "relatedness"
)
sparse$metadata$n_groups
sparse$metadata$warnings
```

That is the sparse end of the same trade-off the collapsing composite showed at
the dense end: too permissive a cut merges the cohort into one unusable group,
too strict a cut dissolves it into singletons that protect nothing. The
threshold is where you choose between them.

# Pairwise relation: spatial proximity

Spatial proximity works the same way over sample coordinates — for example spot
locations from spatial transcriptomics, positions on a tissue slide, or
geographic site coordinates. `spatial_edges_from_coords()` connects samples
within a radius (Euclidean distance over the coordinate columns), and
`mode = "spatial"` groups the resulting connected components. The distance is
computed over as many coordinate columns as you give it, so a `z` column for a
tissue volume works exactly like a plain `x`/`y` slide.

```{r spatial}
# Two spatial clusters. Cluster 1 (S1-S3) is a chain: neighbouring pairs are
# within the radius, but the endpoints are not.
coords <- data.frame(
  sample_id = paste0("S", 1:6),
  x = c(0, 1, 2,  6.0, 6.9, 6.2),
  y = c(0, 1, 0,  6.0, 6.6, 5.3),
  stringsAsFactors = FALSE
)
adj_edges <- spatial_edges_from_coords(coords, radius = 1.5)

meta_s <- data.frame(
  sample_id  = paste0("S", 1:6),
  subject_id = paste0("P", 1:6),
  stringsAsFactors = FALSE
)
samples_s  <- create_nodes(meta_s, "Sample", "sample_id")
subjects_s <- create_nodes(meta_s, "Subject", "subject_id")
belongs_s  <- create_edges(meta_s, "sample_id", "subject_id",
                           "Sample", "Subject", "sample_belongs_to_subject")

g_sp <- build_dependency_graph(list(samples_s, subjects_s), list(belongs_s, adj_edges))

sp_groups <- grouping_vector(derive_split_constraints(g_sp, mode = "spatial"))
sp_groups
```

Plotting the coordinates, drawing the within-radius adjacency edges in grey, and
colouring points by the derived group makes the transitive closure concrete:
S1–S2 and S2–S3 are each within the `1.5` radius, so all three share a group
even though S1 and S3 are `2` units apart and were never linked directly. Every
sample in the second cluster is likewise reachable from the others, while the
two clusters are far enough apart to stay separate:

```{r sp-plot, fig.width = 6.5, fig.height = 5}
sp_grp <- factor(sp_groups[coords$sample_id])
row_of <- setNames(seq_len(nrow(coords)), coords$sample_id)
from_i <- row_of[sub("^sample:", "", adj_edges$data$from)]
to_i   <- row_of[sub("^sample:", "", adj_edges$data$to)]
palette_sp <- c("#4C78A8", "#F58518")

plot(coords$x, coords$y, type = "n", asp = 1, xlab = "x", ylab = "y",
     main = "Spatial groups (radius = 1.5)")
segments(coords$x[from_i], coords$y[from_i],
         coords$x[to_i],   coords$y[to_i], col = "grey60", lwd = 2)
points(coords$x, coords$y, pch = 19, cex = 3.5, col = palette_sp[as.integer(sp_grp)])
text(coords$x, coords$y, labels = coords$sample_id, col = "white", cex = 0.8, font = 2)
legend("topleft", legend = levels(sp_grp), pch = 19,
       col = palette_sp[seq_along(levels(sp_grp))], title = "Spatial group", bty = "n")
```

One thing to watch in a real coordinate table. When `coord_cols` is not given,
every numeric column except the id is treated as a coordinate — so a QC metric
or a slide number sitting in the same frame silently joins the distance
calculation and can switch the relation off entirely:

```{r coord-cols}
coords_qc <- cbind(coords, reads_millions = c(31, 44, 12, 38, 27, 51))

# `reads_millions` is picked up as a third dimension: nothing is within radius.
nrow(as.data.frame(spatial_edges_from_coords(coords_qc, radius = 1.5)))

# Naming the coordinate columns restores the five adjacency edges.
nrow(as.data.frame(
  spatial_edges_from_coords(coords_qc, radius = 1.5, coord_cols = c("x", "y"))
))
```

Pass `coord_cols` whenever the frame carries anything numeric that is not a
coordinate.

# Deriving on a subset is leakage-safe

Real splits are derived on a *subset* of samples — the training rows, say. For
pairwise (and composite) modes this raises a subtle question: if a sample that
*bridges* two others is left out of the subset, could those two still inherit a
shared group from the full graph? They do not. When you pass `samples =`,
grouping is recomputed within that subset, so structure that exists only through
an excluded sample never leaks across the split.

The spatial chain makes this visible. S1 and S3 shared a group only because S2
bridged them; ask for S1 and S3 alone, and they correctly fall into separate
groups:

```{r subset-scoping}
grouping_vector(
  derive_split_constraints(g_sp, mode = "spatial", samples = c("S1", "S3"))
)
```

A subset is sometimes better expressed as a graph in its own right, for example
when you want to validate it, plot it, or hand it around. `subset_graph()` does
that, and it uses the same rule, so the grouping agrees with `samples =`:

```{r subset-graph}
g_two <- subset_graph(g_sp, samples = c("S1", "S3"), graph_name = "S1 + S3")
grouping_vector(derive_split_constraints(g_two, mode = "spatial"))
```

# Combining a pairwise relation with a direct one

Pairwise relations are not a separate world: either of them can be listed in a
composite `via` alongside the direct relations, and all of them feed the same
connected-component search. That matters when two different mechanisms each link
a different pair of samples, and severing only one would leave the other open.

Here relatedness links two subjects that share no batch, while batch links two
samples from unrelated subjects. Neither relation alone separates the cohort
correctly; the composite does.

The graphs earlier on this page were assembled node set by node set to keep
every piece visible, but a pairwise relation does not require that. Build the
direct structure from the metadata frame as usual, then attach the thresholded
edges to it with `add_edges()`:

```{r composite-pairwise}
meta_c <- data.frame(
  sample_id  = paste0("S", 1:4),
  subject_id = paste0("P", 1:4),
  batch_id   = c("B1", "B1", "B2", "B3"),
  stringsAsFactors = FALSE
)
kin_c <- data.frame(id1 = "P2", id2 = "P3", kinship = 0.25, stringsAsFactors = FALSE)

g_mixed <- add_edges(
  graph_from_metadata(meta_c, graph_name = "mixed-structure"),
  relatedness_edges_from_kinship(kin_c, threshold = 0.1)
)

# batch alone: {S1,S2} {S3} {S4}   relatedness alone: {S2,S3}
grouping_vector(derive_split_constraints(g_mixed, "batch"))
grouping_vector(derive_split_constraints(g_mixed, "relatedness"))

# combined, the chain S1-S2 (batch) - S3 (kinship) becomes one group:
mixed <- derive_split_constraints(g_mixed, "composite", via = c("batch", "relatedness"))
grouping_vector(mixed)
mixed$metadata$via
```

Note what `via` accepts and where. `derive_split_constraints()` takes either a
lower-case mode or a node type, and pairwise modes are valid there — that is
what makes the call above possible. The graph-level views take node types only:
`detect_dependency_components()` and `plot(focus = "sample_projection")` project
samples through `Subject`, `Batch` and friends, and reject `"relatedness"`,
because a pairwise relation is an edge set rather than a node type to route
through. Use the derivation to combine them and `grouping_vector()` to read the
result.

Under `strategy = "rule_based"` a pairwise source behaves as a fallback instead:
a sample takes the first relation in `priority` that gives it a non-singleton
group, so relations never chain.

Whichever route you take, the threshold that produced the edges is recorded on
the graph, per relation, and is serialised with it — so a reader of the file can
see which cut created the grouping:

```{r threshold-provenance}
g_mixed$metadata$edge_sources$subject_related_to[c("threshold", "metric")]
```

A spec derived from a *single* pairwise mode also carries that threshold as a
scalar in its own metadata. A composite spec does not, because several
relations (each with its own cut) may have contributed; the graph's
`edge_sources` is the complete record in that case:

```{r threshold-in-spec}
as_split_spec(derive_split_constraints(g_mixed, "relatedness"),
              graph = g_mixed)$metadata[c("threshold", "threshold_metric")]
```

# Thresholds are inputs, not modeling

Because the threshold (kinship cutoff, spatial radius) is applied up front in the
edge-building helpers, it is a *derivation input*, not a modeling choice:
`splitGraph` forms groups over whatever edges survive and never computes folds
itself. The resulting `split_spec` is handed to a downstream consumer for
execution, exactly as with every other mode — see the *adapter-cookbook* and
*cross-language-handoff* vignettes for that step.
