Collect observable evidence
Record who communicates with whom, who issues orders, who supplies intelligence, who funds operations, and who is pursuing whom. Each observation becomes a candidate edge rather than an immediate conclusion.
Networks are made of two ingredients:
For marketers, network analysis can identify influential customers, tightly connected communities, likely referrals, and paths through which information may spread.
For this fan-fiction version of the lab, you are Darth Jar Jar: publicly a clumsy representative from Naboo, secretly the architect of a galaxy-spanning influence network. The Star Wars framing changes the labels, not the mathematics. The same graph methods apply to customers, social-media accounts, websites, organizations, and supply chains.
This table records the package versions used to knit the report. If results ever change after an update, this is the first place to check.
package_versions <- tibble(
package = required_packages,
version = vapply(
required_packages,
function(pkg) as.character(utils::packageVersion(pkg)),
character(1)
)
)
render_table(
package_versions,
caption = "Package versions used for this analysis",
digits = 3
)
| package | version |
|---|---|
| tidyverse | 2.0.0 |
| igraph | 2.3.3 |
| knitr | 1.51 |
| kableExtra | 1.4.1 |
| scales | 1.4.0 |
| networkD3 | 0.4.1 |
| htmlwidgets | 1.6.4 |
The original lab created the starter graph in one long
graph_from_literal() statement and then immediately plotted
it. That can work, but it is difficult to audit and easy to break with a
misspelled name such as Farther.
This version replaces the fragile one-line graph with:
# -------------------------------------------------------------------------
# STARTER VERTEX TABLE
# -------------------------------------------------------------------------
# Each row represents one character in Darth Jar Jar's hidden network.
darth_vertices <- tribble(
~name, ~faction, ~public_role,
"Darth Jar Jar", "Shadow Sith", "Naboo representative",
"Darth Sidious", "Shadow Sith", "Supreme Chancellor",
"Count Dooku", "Separatists", "Separatist leader",
"General Grievous", "Separatists", "Military commander",
"Queen Amidala", "Naboo", "Naboo leader",
"Boss Nass", "Gungan Council", "Gungan leader",
"Anakin Skywalker", "Jedi", "Jedi Knight",
"Obi-Wan Kenobi", "Jedi", "Jedi Master"
)
# -------------------------------------------------------------------------
# STARTER EDGE TABLE
# -------------------------------------------------------------------------
# Each row represents one relationship. An explicit edge table is easier to
# inspect than a dense graph literal and is the preferred pattern when a
# network will later receive attributes.
darth_edges <- tribble(
~from, ~to, ~relationship,
"Darth Jar Jar", "Darth Sidious", "secret alliance",
"Darth Jar Jar", "Count Dooku", "covert coordination",
"Darth Sidious", "Count Dooku", "Sith command",
"Darth Jar Jar", "Queen Amidala", "public trust",
"Darth Jar Jar", "Boss Nass", "Gungan influence",
"Queen Amidala", "Boss Nass", "Naboo alliance",
"Darth Jar Jar", "Anakin Skywalker", "subtle manipulation",
"Darth Sidious", "Anakin Skywalker", "apprentice grooming",
"Anakin Skywalker", "Obi-Wan Kenobi", "Jedi mentorship",
"Count Dooku", "General Grievous", "military command",
"Darth Sidious", "General Grievous", "strategic control"
)
# Build an undirected graph because these starter relationships are being
# treated as mutual connections for introductory network analysis.
g5 <- igraph::graph_from_data_frame(
d = darth_edges,
directed = FALSE,
vertices = darth_vertices
)
# Fail early with an informative error if the graph was built incorrectly.
stopifnot(
igraph::vcount(g5) == nrow(darth_vertices),
igraph::ecount(g5) == nrow(darth_edges),
igraph::is_simple(g5)
)
# Degree is the number of direct connections for each character.
starter_degree <- igraph::degree(g5, mode = "all")
# Force-directed layouts include randomness. Setting a seed makes the same
# graph appear in the same approximate position each time the report knits.
set.seed(580)
starter_layout <- igraph::layout_with_fr(g5)
# Map node size to degree so more-connected characters look larger.
starter_size <- scales::rescale(
starter_degree,
to = c(24, 48)
)
# Create one repeatable color for each faction.
faction_levels <- sort(unique(igraph::V(g5)$faction))
faction_colors <- setNames(
scales::hue_pal()(length(faction_levels)),
faction_levels
)
plot(
g5,
layout = starter_layout,
vertex.size = starter_size,
vertex.color = faction_colors[igraph::V(g5)$faction],
vertex.frame.color = "white",
vertex.label = stringr::str_wrap(igraph::V(g5)$name, width = 14),
vertex.label.color = "black",
vertex.label.cex = 0.85,
edge.width = 2,
edge.color = "grey65",
main = "Darth Jar Jar's Inner Circle\nNode size = number of direct connections"
)
legend(
"topleft",
legend = names(faction_colors),
col = faction_colors,
pch = 19,
pt.cex = 1.4,
bty = "n",
title = "Faction"
)
The starter network demonstrates several foundational concepts:
starter_degree_table <- tibble(
character = names(starter_degree),
degree = as.integer(starter_degree)
) |>
arrange(desc(degree), character)
render_table(
starter_degree_table,
caption = "Direct connections in Darth Jar Jar's starter network",
digits = 0
)
| character | degree |
|---|---|
| Darth Jar Jar | 5 |
| Darth Sidious | 4 |
| Anakin Skywalker | 3 |
| Count Dooku | 3 |
| Boss Nass | 2 |
| General Grievous | 2 |
| Queen Amidala | 2 |
| Obi-Wan Kenobi | 1 |
The original lab modeled Facebook users and friendships. Here we simulate a smaller Holonet influence network with the same structure:
This preserves the original marketing lesson: social networks usually contain a small number of hubs and many less-connected users.
set.seed(580)
n_users <- 150
known_aliases <- c(
"Darth Jar Jar", "Darth Sidious", "Queen Amidala", "Boss Nass",
"Count Dooku", "General Grievous", "Anakin Skywalker", "Obi-Wan Kenobi",
"Ahsoka Tano", "Mace Windu", "Yoda", "Bail Organa", "Mon Mothma",
"Nute Gunray", "Wat Tambor", "Jango Fett", "Boba Fett", "Cad Bane",
"R2-D2", "C-3PO"
)
vertices <- tibble(
# id must be unique because graph_from_data_frame() uses it as the
# symbolic vertex name.
id = seq_len(n_users),
# Use recognizable aliases first, then reproducible generic accounts.
alias = if_else(
id <= length(known_aliases),
known_aliases[id],
sprintf("Holonet Agent %03d", id)
),
# Attributes support later joins and segmentation exercises.
birthday = sample(
seq.Date(
from = as.Date("1985-01-01"),
to = as.Date("2005-12-31"),
by = "day"
),
size = n_users,
replace = TRUE
),
homeworld = sample(
c(
"Naboo", "Coruscant", "Tatooine", "Mandalore",
"Kashyyyk", "Kamino", "Ryloth", "Corellia"
),
size = n_users,
replace = TRUE
),
faction_id = sample(
c(
"Galactic Republic", "Jedi Order", "Separatists",
"Gungan Council", "Crime Syndicate", "Independent"
),
size = n_users,
replace = TRUE
),
academy_id = sample(
c(
"Jedi Temple", "Royal Academy", "Gungan Grand Army",
"Mandalorian Training", "Trade Federation", "None"
),
size = n_users,
replace = TRUE
)
)
# Derive the birth month after the base table is created.
# Birth month is more useful than an exact date in this small simulation
# because it creates enough matches to demonstrate the join reliably.
vertices <- vertices |>
mutate(
birth_month_number = as.integer(format(birthday, "%m")),
birth_month = factor(
month.abb[birth_month_number],
levels = month.abb
)
)
render_table(
vertices |>
select(id, alias, birthday, birth_month, homeworld, faction_id, academy_id) |>
slice_head(n = 8),
caption = "First 8 Holonet accounts in the simulated vertex table",
digits = 0
)
| id | alias | birthday | birth_month | homeworld | faction_id | academy_id |
|---|---|---|---|---|---|---|
| 1 | Darth Jar Jar | 1989-08-29 | Aug | Coruscant | Gungan Council | Jedi Temple |
| 2 | Darth Sidious | 1991-01-04 | Jan | Coruscant | Gungan Council | None |
| 3 | Queen Amidala | 1998-04-19 | Apr | Naboo | Galactic Republic | Mandalorian Training |
| 4 | Boss Nass | 1986-02-27 | Feb | Corellia | Jedi Order | Royal Academy |
| 5 | Count Dooku | 1988-04-09 | Apr | Corellia | Crime Syndicate | Jedi Temple |
| 6 | General Grievous | 1998-07-07 | Jul | Kashyyyk | Independent | Royal Academy |
| 7 | Anakin Skywalker | 1987-09-12 | Sep | Kashyyyk | Separatists | None |
| 8 | Obi-Wan Kenobi | 1986-01-13 | Jan | Naboo | Gungan Council | Royal Academy |
set.seed(580)
# Preferential attachment generates a realistic hub-and-spoke structure.
g_sim <- igraph::sample_pa(
n = n_users,
power = 1.1,
m = 3,
directed = FALSE
)
# Remove any loops or duplicate edges as a defensive reproducibility step.
g_sim <- igraph::simplify(
g_sim,
remove.multiple = TRUE,
remove.loops = TRUE
)
edges <- igraph::as_data_frame(g_sim, what = "edges") |>
transmute(
src = as.integer(from),
dst = as.integer(to)
) |>
arrange(src, dst)
stopifnot(
all(edges$src %in% vertices$id),
all(edges$dst %in% vertices$id),
!any(edges$src == edges$dst)
)
render_table(
edges |> slice_head(n = 8),
caption = "First 8 simulated Holonet connections",
digits = 0
)
| src | dst |
|---|---|
| 1 | 2 |
| 1 | 3 |
| 1 | 4 |
| 1 | 5 |
| 1 | 6 |
| 1 | 7 |
| 1 | 9 |
| 1 | 10 |
fb_graph <- igraph::graph_from_data_frame(
d = edges,
vertices = vertices,
directed = FALSE
)
# These checks confirm that no IDs were lost during graph construction.
stopifnot(
igraph::vcount(fb_graph) == nrow(vertices),
igraph::ecount(fb_graph) == nrow(edges),
igraph::is_simple(fb_graph),
igraph::is_connected(fb_graph)
)
fb_graph
## IGRAPH 30c5a9b UN-- 150 444 --
## + attr: name (v/c), alias (v/c), birthday (v/n), homeworld (v/c),
## | faction_id (v/c), academy_id (v/c), birth_month_number (v/n),
## | birth_month (v/x)
## + edges from 30c5a9b (vertex names):
## [1] 1--2 1--3 1--4 1--5 1--6 1--7 1--9 1--10 1--11 1--12
## [11] 1--19 1--20 1--21 1--25 1--26 1--28 1--29 1--33 1--47 1--50
## [21] 1--57 1--72 1--77 1--79 1--87 1--89 1--96 1--97 1--101 1--102
## [31] 1--104 1--116 1--121 1--131 1--133 1--139 1--140 1--148 1--150 2--3
## [41] 2--4 2--6 2--7 2--8 2--10 2--14 2--17 2--19 2--24 2--27
## [51] 2--30 2--35 2--38 2--44 2--45 2--48 2--61 2--62 2--63 2--64
## + ... omitted several edges
A triplet is an edge plus the attributes of both endpoints. This is useful when the question depends on who is connected and what those two accounts have in common.
edge_table <- graph_edges_integer(fb_graph)
vertex_a <- vertices |>
rename_with(~ paste0("a_", .x))
vertex_b <- vertices |>
rename_with(~ paste0("b_", .x))
triplets <- edge_table |>
left_join(
vertex_a,
by = c("from" = "a_id")
) |>
left_join(
vertex_b,
by = c("to" = "b_id")
)
render_table(
triplets |>
select(
from, a_alias, a_homeworld, a_faction_id,
to, b_alias, b_homeworld, b_faction_id
) |>
slice_head(n = 5),
caption = "Sample triplets: one connection plus both accounts' attributes",
digits = 0
)
| from | a_alias | a_homeworld | a_faction_id | to | b_alias | b_homeworld | b_faction_id |
|---|---|---|---|---|---|---|---|
| 1 | Darth Jar Jar | Coruscant | Gungan Council | 2 | Darth Sidious | Coruscant | Gungan Council |
| 1 | Darth Jar Jar | Coruscant | Gungan Council | 3 | Queen Amidala | Naboo | Galactic Republic |
| 1 | Darth Jar Jar | Coruscant | Gungan Council | 4 | Boss Nass | Corellia | Jedi Order |
| 1 | Darth Jar Jar | Coruscant | Gungan Council | 5 | Count Dooku | Corellia | Crime Syndicate |
| 1 | Darth Jar Jar | Coruscant | Gungan Council | 6 | General Grievous | Kashyyyk | Independent |
The original exercise searched for exact shared birthdays. In a 150-node simulation, exact matching dates among connected accounts can easily produce an empty table. Comparing birth month retains the personalization concept while creating a stable demonstration.
same_birth_month <- edge_table |>
left_join(
vertices |>
select(id, alias, birth_month) |>
rename(
alias_a = alias,
birth_month_a = birth_month
),
by = c("from" = "id")
) |>
left_join(
vertices |>
select(id, alias, birth_month) |>
rename(
alias_b = alias,
birth_month_b = birth_month
),
by = c("to" = "id")
) |>
filter(birth_month_a == birth_month_b) |>
transmute(
account_a = alias_a,
account_b = alias_b,
shared_birth_month = birth_month_a
) |>
arrange(shared_birth_month, account_a, account_b)
render_table(
same_birth_month |> slice_head(n = 10),
caption = "Connected Holonet accounts sharing a birth month",
digits = 0
)
| account_a | account_b | shared_birth_month |
|---|---|---|
| C-3PO | Holonet Agent 037 | Jan |
| Darth Sidious | Holonet Agent 044 | Jan |
| Darth Sidious | Holonet Agent 070 | Jan |
| Darth Sidious | Obi-Wan Kenobi | Jan |
| Holonet Agent 044 | Holonet Agent 086 | Jan |
| Obi-Wan Kenobi | Holonet Agent 040 | Jan |
| Obi-Wan Kenobi | Holonet Agent 091 | Jan |
| Boss Nass | Wat Tambor | Feb |
| Holonet Agent 049 | Holonet Agent 138 | Feb |
| Holonet Agent 073 | Holonet Agent 122 | Feb |
Marketing translation: shared attributes can support personalized promotions, community invitations, or audience segments.
A triangle is a set of three mutually connected accounts. Triangle-heavy users often sit inside cohesive communities where recommendations and information may spread efficiently.
triangle_counts <- tibble(
id = as.integer(igraph::V(fb_graph)$name),
triangle_count = as.integer(igraph::count_triangles(fb_graph))
) |>
left_join(
vertices |> select(id, alias, faction_id),
by = "id"
) |>
arrange(desc(triangle_count), alias)
render_table(
triangle_counts |> slice_head(n = 10),
caption = "Accounts participating in the most closed triangles",
digits = 0
)
| id | triangle_count | alias | faction_id |
|---|---|---|---|
| 1 | 50 | Darth Jar Jar | Gungan Council |
| 3 | 36 | Queen Amidala | Galactic Republic |
| 2 | 35 | Darth Sidious | Gungan Council |
| 6 | 29 | General Grievous | Independent |
| 4 | 15 | Boss Nass | Jedi Order |
| 11 | 14 | Yoda | Separatists |
| 10 | 13 | Mace Windu | Crime Syndicate |
| 21 | 9 | Holonet Agent 021 | Gungan Council |
| 29 | 7 | Holonet Agent 029 | Crime Syndicate |
| 16 | 7 | Jango Fett | Independent |
The original friend-of-friend pipeline converted every edge into a low-ID/high-ID pair and joined only one orientation. That misses valid two-step paths in an undirected graph.
The improved version first expands each edge into both directions, then:
# Canonical direct-edge table used to remove already-connected pairs.
direct_edges <- edge_table |>
transmute(
a = pmin(from, to),
b = pmax(from, to)
) |>
distinct()
# Expand each undirected edge into two directed neighbor records.
adjacency <- bind_rows(
edge_table |> transmute(account = from, mutual_friend = to),
edge_table |> transmute(account = to, mutual_friend = from)
) |>
distinct()
# Self-join through the shared mutual friend.
fof_candidates <- adjacency |>
inner_join(
adjacency,
by = "mutual_friend",
suffix = c("_a", "_b"),
relationship = "many-to-many"
) |>
transmute(
a = pmin(account_a, account_b),
b = pmax(account_a, account_b),
mutual_friend = mutual_friend
) |>
filter(a != b) |>
distinct() |>
anti_join(
direct_edges,
by = c("a", "b")
) |>
count(
a,
b,
name = "mutual_friend_count",
sort = TRUE
)
friends_of_friends <- fof_candidates |>
left_join(
vertices |>
select(id, alias, academy_id) |>
rename(
alias_a = alias,
academy_a = academy_id
),
by = c("a" = "id")
) |>
left_join(
vertices |>
select(id, alias, academy_id) |>
rename(
alias_b = alias,
academy_b = academy_id
),
by = c("b" = "id")
) |>
filter(academy_a == academy_b) |>
select(
account_a = alias_a,
account_b = alias_b,
shared_academy = academy_a,
mutual_friend_count
) |>
arrange(desc(mutual_friend_count), account_a, account_b)
render_table(
friends_of_friends |> slice_head(n = 10),
caption = "Suggested introductions: mutual connections plus shared academy",
digits = 0
)
| account_a | account_b | shared_academy | mutual_friend_count |
|---|---|---|---|
| Boss Nass | General Grievous | Royal Academy | 7 |
| Queen Amidala | Holonet Agent 021 | Mandalorian Training | 5 |
| Darth Jar Jar | Holonet Agent 044 | Jedi Temple | 4 |
| Darth Sidious | Cad Bane | None | 4 |
| Darth Sidious | Holonet Agent 032 | None | 4 |
| Darth Sidious | Holonet Agent 047 | None | 4 |
| Mace Windu | Yoda | Mandalorian Training | 4 |
| Yoda | Holonet Agent 116 | Mandalorian Training | 4 |
| Boss Nass | Holonet Agent 062 | Royal Academy | 3 |
| Boss Nass | R2-D2 | Royal Academy | 3 |
Marketing translation: a recommendation is more credible when it combines network proximity with a relevant shared attribute.
Degree counts direct connections. PageRank also considers the importance of the accounts supplying those connections.
pagerank_vector <- igraph::page_rank(
fb_graph,
directed = FALSE,
damping = 0.85
)$vector
influencers <- tibble(
id = as.integer(names(pagerank_vector)),
pagerank = as.numeric(pagerank_vector),
degree = as.integer(igraph::degree(fb_graph))
) |>
left_join(
vertices |> select(id, alias, faction_id),
by = "id"
) |>
arrange(desc(pagerank), desc(degree), alias)
render_table(
influencers |>
select(alias, faction_id, degree, pagerank) |>
slice_head(n = 10),
caption = "Top Holonet accounts by PageRank",
digits = 5
)
| alias | faction_id | degree | pagerank |
|---|---|---|---|
| Darth Jar Jar | Gungan Council | 39 | 0.03853 |
| Darth Sidious | Gungan Council | 35 | 0.03499 |
| Queen Amidala | Galactic Republic | 28 | 0.02750 |
| General Grievous | Independent | 25 | 0.02438 |
| Yoda | Separatists | 23 | 0.02350 |
| Holonet Agent 021 | Gungan Council | 17 | 0.01743 |
| Mace Windu | Crime Syndicate | 17 | 0.01724 |
| Cad Bane | Jedi Order | 16 | 0.01664 |
| Boss Nass | Jedi Order | 16 | 0.01609 |
| R2-D2 | Jedi Order | 14 | 0.01515 |
This section demonstrates PageRank in a directed network where arrows represent the flow of intelligence, pressure, or influence. The graph data support both the static contact map and the interactive D3 exploration that follows.
render_table(
darth_rank |> slice_head(n = 10),
caption = "Darth Jar Jar influence network ranked by weighted PageRank",
digits = 5
)
| node | faction | in_degree | out_degree | pagerank |
|---|---|---|---|---|
| Darth Jar Jar | Shadow Sith | 5 | 8 | 0.26878 |
| Galactic Senate | Galactic Institution | 4 | 1 | 0.17897 |
| Count Dooku | Separatists | 3 | 2 | 0.09938 |
| Jedi Council | Jedi | 2 | 1 | 0.07829 |
| General Grievous | Separatists | 1 | 0 | 0.07027 |
| Anakin Skywalker | Jedi | 2 | 1 | 0.05406 |
| Darth Sidious | Shadow Sith | 1 | 3 | 0.05209 |
| Gungan Council | Naboo | 1 | 1 | 0.04517 |
| Holonet News | Galactic Institution | 1 | 1 | 0.04517 |
| Queen Amidala | Naboo | 1 | 2 | 0.04517 |
map_factions <- sort(unique(igraph::V(darth_graph)$faction))
map_colors <- setNames(
scales::hue_pal()(length(map_factions)),
map_factions
)
plot(
darth_graph,
layout = darth_layout,
vertex.size = scales::rescale(
igraph::V(darth_graph)$pagerank,
to = c(22, 52)
),
vertex.color = map_colors[igraph::V(darth_graph)$faction],
vertex.frame.color = "white",
vertex.label = stringr::str_wrap(igraph::V(darth_graph)$name, width = 15),
vertex.label.cex = 0.78,
vertex.label.color = "black",
edge.width = scales::rescale(
igraph::E(darth_graph)$weight,
to = c(1, 5)
),
edge.arrow.size = 0.45,
edge.color = "grey55",
main = paste(
"Darth Jar Jar's Directed Influence Map",
"Node size = weighted PageRank; arrow width = influence strength",
sep = "\n"
)
)
legend(
"topleft",
legend = names(map_colors),
col = map_colors,
pch = 19,
pt.cex = 1.4,
bty = "n",
title = "Faction"
)
Interpretation: Darth Jar Jar appears important not merely because he sends many links, but because influential institutions and characters also direct information and access back toward him.
networkD3 expects:
nodes_d3 <- tibble(
name = igraph::V(darth_graph)$name,
faction = igraph::V(darth_graph)$faction,
pagerank = as.numeric(igraph::V(darth_graph)$pagerank),
# Precompute an easy-to-read radius range.
node_size = scales::rescale(
as.numeric(igraph::V(darth_graph)$pagerank),
to = c(8, 24)
)
)
links_d3 <- igraph::as_data_frame(darth_graph, what = "edges") |>
transmute(
# networkD3 requires zero-based indices rather than names.
source = match(from, nodes_d3$name) - 1,
target = match(to, nodes_d3$name) - 1,
value = weight,
channel = channel
)
stopifnot(
all(links_d3$source >= 0),
all(links_d3$target >= 0),
max(links_d3$source) < nrow(nodes_d3),
max(links_d3$target) < nrow(nodes_d3)
)
darth_network_widget <- networkD3::forceNetwork(
Links = as.data.frame(links_d3),
Nodes = as.data.frame(nodes_d3),
Source = "source",
Target = "target",
Value = "value",
NodeID = "name",
Group = "faction",
Nodesize = "node_size",
# Use htmlwidgets::JS explicitly. Calling JS() without its namespace can
# fail when htmlwidgets has not been attached.
radiusCalculation = htmlwidgets::JS("d.nodesize"),
linkDistance = 165,
charge = -500,
opacity = 0.94,
opacityNoHover = 1,
zoom = TRUE,
arrows = TRUE,
fontSize = 12,
fontFamily = "Trebuchet MS",
bounded = TRUE,
legend = TRUE,
height = 700
)
htmlwidgets::onRender(
darth_network_widget,
"function(el, x) {
var root = d3.select(el);
root.selectAll('.nodetext')
.style('fill', '#ffffff')
.style('font-weight', '800')
.style('paint-order', 'stroke')
.style('stroke', '#07111f')
.style('stroke-width', '3px')
.style('stroke-linejoin', 'round')
.style('opacity', 1);
root.selectAll('.legend text')
.style('fill', '#ffffff')
.style('font-size', '12px')
.style('font-weight', '800')
.style('paint-order', 'stroke')
.style('stroke', '#07111f')
.style('stroke-width', '3px')
.style('stroke-linejoin', 'round');
root.selectAll('.legend rect')
.style('stroke', '#edf3fb')
.style('stroke-width', '1px');
}"
)
Interactive interpretation: drag Darth Jar Jar away from the center. The network’s force layout pulls him back toward the institutions and characters with which he exchanges the strongest influence.
Spoiler transmission: this section draws on both seasons of Andor and the political-spy network leading toward Rogue One.
This demonstration uses a deliberately small, classroom-scale evidence model. The relationships are anchored in official character and episode descriptions, but the calculated scores are analytic outputs from this selected model—not canonical power levels or definitive rankings. The objective is to show how an analyst moves from scattered observations to defensible conclusions.
How can a network reveal what kind of operative a character is—even when that character has few visible connections, multiple aliases, or deliberately hidden relationships?
The answer is not “count the most lines on screen.” We build the evidence chronologically, preserve direction and context, resolve identities, measure structure, remove key nodes, and only then translate the pattern into a conclusion.
Record who communicates with whom, who issues orders, who supplies intelligence, who funds operations, and who is pursuing whom. Each observation becomes a candidate edge rather than an immediate conclusion.
Cassian Andor, Kassa, Clem, Keef Girgo, Tourist, and Varian Skye are different names used for the same character. Merge those names before calculating the network, or one highly connected character will be mistaken for several minor characters.
An arrow may represent intelligence flow, command, political access, personal trust, coordination, or adversarial pursuit. Direction matters: Lonni feeding information to Luthen is analytically different from Luthen pressuring or handling Lonni.
Degree measures visible contacts. PageRank rewards connections to important actors. Betweenness identifies brokers sitting on shortest paths. Two-step reach shows how far a message can travel quickly. No single metric is allowed to tell the whole story.
Remove Luthen, Lonni, Cassian, Saw, or Kleya one at a time. If communities split or routes disappear, the removed character was supplying structural cohesion, intelligence access, or operational redundancy.
The final statement must separate canon from inference: ‘Lonni is canonically an ISB double agent’ is observed evidence; ‘Lonni behaves like a high-risk bridge whose loss isolates an intelligence channel’ is a network-derived conclusion.
Kassa → Cassian Andor → Clem → Keef Girgo → Tourist → Varian Skye
| character_name | story_context | network_treatment |
|---|---|---|
| Kassa | Kenari childhood | Count as Cassian Andor |
| Cassian Andor | Ferrix and the rebellion | Use as the combined analysis node |
| Clem | Aldhani mission | Count as Cassian Andor |
| Keef Girgo | Niamos and Narkina 5 | Count as Cassian Andor |
| Tourist | Niamos arrest | Count as Cassian Andor |
| Varian Skye | Ghorman operation | Count as Cassian Andor |
| model | nodes_used_for_one_person | highest_visible_degree | communities_visible_together | analytic_result |
|---|---|---|---|---|
| Aliases left fragmented | 5 | 3 | 1 | Centrality and reach are understated |
| Aliases resolved to Cassian Andor | 1 | 10 | 6 | Cross-community field role becomes visible |
Observed The evidence model connects
Lonni to Luthen, places Luthen in contact with Mon Mothma and Saw
Gerrera, and combines Cassian Andor, Kassa, Clem, Keef Girgo, Tourist,
and Varian Skye as one character node.
Derived Centrality, brokerage, reach,
community position, and node-removal effects below are produced by this
classroom network model.
Boundary A missing edge means “not included
in this evidence model,” not “these characters never interacted.”
Read the map from the outside inward: local groups form recognizable clusters, while brokers occupy the connective tissue between clusters. Red arrows are pursuit, not cooperation; they are shown for situational awareness but excluded from collaboration-community calculations.
Drag a node, zoom into a cluster, and watch the force layout pull brokers back toward the communities they connect. This interaction turns an abstract metric into a physical intuition: a structurally important node is difficult to move away from the network without stretching many relationships.
| character | role | direct_connections | two_step_reach | betweenness | pagerank | new_components_if_removed |
|---|---|---|---|---|---|---|
| Luthen Rael | strategist and handler | 6 | 13 | 0.724 | 0.157 | 2 |
| Cassian Andor | field operative | 6 | 10 | 0.352 | 0.157 | 2 |
| Lonni Jung | embedded intelligence source | 2 | 8 | 0.343 | 0.051 | 1 |
| Saw Gerrera | autonomous cell leader | 3 | 8 | 0.133 | 0.080 | 1 |
| Mon Mothma | political and financial bridge | 2 | 7 | 0.133 | 0.061 | 1 |
| Dedra Meero | counter-insurgency investigator | 2 | 3 | 0.133 | 0.056 | 1 |
| Kleya Marki | operations and continuity | 2 | 9 | 0.000 | 0.057 | 0 |
Farther typo by removing the ambiguous
family-network labels.sessionInfo() for
reproducibility.stopifnot().set.seed().latex_options with
Bootstrap table options.htmlwidgets::JS() explicitly in the D3 graph.sessionInfo()
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.4 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
## [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
## [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
## [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
##
## time zone: UTC
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] htmlwidgets_1.6.4 networkD3_0.4.1 scales_1.4.0 kableExtra_1.4.1
## [5] igraph_2.3.3 lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0
## [9] dplyr_1.2.1 purrr_1.2.2 readr_2.2.0 tidyr_1.3.2
## [13] tibble_3.3.1 ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] sass_0.4.10 generics_0.1.4 xml2_1.6.0 stringi_1.8.7
## [5] hms_1.1.4 digest_0.6.39 magrittr_2.0.5 timechange_0.4.0
## [9] evaluate_1.0.5 grid_4.6.1 RColorBrewer_1.1-3 fastmap_1.2.0
## [13] jsonlite_2.0.0 checkdown_0.0.13 viridisLite_0.4.3 textshaping_1.0.5
## [17] jquerylib_0.1.4 cli_3.6.6 rlang_1.3.0 litedown_0.10
## [21] commonmark_2.0.0 withr_3.0.3 cachem_1.1.0 yaml_2.3.12
## [25] otel_0.2.0 tools_4.6.1 tzdb_0.5.0 vctrs_0.7.3
## [29] R6_2.6.1 lifecycle_1.0.5 pkgconfig_2.0.3 bslib_0.11.0
## [33] pillar_1.11.1 gtable_0.3.6 glue_1.8.1 systemfonts_1.3.2
## [37] xfun_0.60 tidyselect_1.2.1 data.tree_1.2.0 rstudioapi_0.19.0
## [41] knitr_1.51 farver_2.1.2 htmltools_0.5.9 rmarkdown_2.31
## [45] svglite_2.2.2 compiler_4.6.1 S7_0.2.2 markdown_2.0
Csardi, G., and Nepusz, T. (2006). The igraph software package for complex network research. InterJournal, Complex Systems, 1695.
Page, L., Brin, S., Motwani, R., and Winograd, T. (1999). The PageRank Citation Ranking: Bringing Order to the Web. Stanford InfoLab.
Bostock, M., Ogievetsky, V., and Heer, J. (2011). D3: Data-Driven Documents. IEEE Transactions on Visualization and Computer Graphics.
igraph R documentation: https://r.igraph.org/reference/
networkD3 CRAN documentation: https://cran.r-project.org/package=networkD3
StarWars.com Databank. Luthen Rael: https://www.starwars.com/databank/luthen-rael
StarWars.com Databank. Lonni Jung: https://www.starwars.com/databank/lonni-jung
StarWars.com Databank. Cassian Andor: https://www.starwars.com/databank/cassian-andor
StarWars.com. “I Have Friends Everywhere” Episode Guide: https://www.starwars.com/series/andor/season-2-episode-5-episode-guide