---
title: "Project 2025 — Reproducible Text Analysis (Keyness, Collocations, LIWC)"
author: "Your names"
date: "`r format(Sys.Date(), '%Y-%m-%d')`"
output:
  html_document:
    toc: true
    toc_depth: 3
    number_sections: true
    df_print: paged
fontsize: 11pt
---

# 0. Setup

To ensure full reproducibility, we begin by defining a consistent project structure, global chunk options, and helper functions for saving figures and tables. This allows all analyses to be re-run on any machine without manually editing file paths.

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  echo = TRUE, message = FALSE, warning = FALSE,
  fig.align = "center", fig.width = 8, fig.height = 5
)
```

## 0.1 Project structure

All input and output files are organized under a common parent directory. Within this parent, we assume the following structure:

```         
project-root/
├── code/               # this file
│   └── analysis_project2025.Rmd
├── data/
│   ├── keyness/        # keyness CSVs
│   ├── collocations/   # collocation CSVs
│   └── liwc/           # LIWC CSVs
├── figures/            # all plots saved here
└── tables/             # exported tables (for supplementary material)
```

## 0.2 Output directories and helper functions

Since the R Markdown file lives in the code/ folder, we point all outputs one level up into ../figures and ../tables. We also define two wrapper functions — save_figure() and save_table() — which handle saving figures and tables with consistent paths and filenames.

```{r global-settings}
# ---- Define global output directories ----
FIG_DIR <- "../figures"
TAB_DIR <- "../tables"

dir.create(FIG_DIR, showWarnings = FALSE, recursive = TRUE)
dir.create(TAB_DIR, showWarnings = FALSE, recursive = TRUE)
```

## 0.3 Packages

We rely on common R packages from the tidyverse ecosystem for data handling and visualization, as well as specialized libraries for non-parametric tests, effect sizes, and text-based analyses. All packages are loaded here to ensure a consistent working environment throughout the analysis.

```{r packages}
suppressPackageStartupMessages({
  library(tidyverse)
  library(tidytext)
  library(ggplot2)
  library(readr)
  library(dplyr)
  library(tidyr)
  library(stringr)
  library(scales)
  library(quanteda)
  library(quanteda.textplots)
  library(Matrix)
  library(rstatix)    # kruskal_test, dunn_test
  library(effsize)    # cliff.delta
})
```

## 0.4 Input paths

All file paths in the code are written **relative to the project root** by using ../ from within the code/ directory. This ensures that the entire analysis can be reproduced without manually adjusting file paths

```{r paths}
PATHS <- list(
  # POS tagged file
  p25_pos = "../data/keyness/Project2025_lemmaPOS.csv",
  # Keyness results (1-gram & 2-gram; overuse only)
  key1_dem = "../data/keyness/keyness_1gram_pos_manifesto_dem_overuse.csv",
  key1_rep = "../data/keyness/keyness_1gram_pos_manifesto_rep_overuse.csv",
  key2_dem = "../data/keyness/keyness_2gram_pos_manifesto_dem_overuse.csv",
  key2_rep = "../data/keyness/keyness_2gram_pos_manifesto_rep_overuse.csv",

  # Collocation tables (exported from the Python/R collocation pipeline)
  colloc_adj  = "../data/collocations/P2025_colloc_ADJ_w7_top10_per_node.csv",
  colloc_verb = "../data/collocations/P2025_colloc_VERB_w7_top10_per_node.csv",

  # LIWC-tagged CSVs
  liwc_dem = "../data/liwc/Platforms_Democrats_LIWC_subset.csv",
  liwc_rep = "../data/liwc/Platforms_Republicans_LIWC_subset.csv",
  liwc_p25 = "../data/liwc/Project2025_LIWC_subset.csv"
)
```

## 0.5 Corpus Sizes

To account for differences in corpus size, all keyness and collocation analyses rely on the exact token counts of each dataset after preprocessing. Table 1 reports these totals. These values serve as fixed reference points throughout the analysis and are not derived dynamically from the CSVs (which may contain segment-level counts but not authoritative corpus sizes).

**Table 1.** Number of words per corpus.

```{r corpus-sizes}
# Authoritative corpus sizes
N_target  <- 311573   # Project 2025
N_ref_dem <- 121272   # Democratic platforms
N_ref_rep <-  44175   # Republican platforms

# Construct Table 1 with corpus type
table1_corpus <- tibble::tibble(
  `Corpus type`     = c("Target corpus", "Reference corpus", "Reference corpus"),
  `Corpus`          = c("Project 2025", "Platforms Democrats", "Platforms Republicans"),
  `Number of words` = c(N_target, N_ref_dem, N_ref_rep)
) %>%
  dplyr::mutate(`Number of words` = scales::comma(`Number of words`)) %>%
  dplyr::select(`Corpus type`, `Corpus`, `Number of words`)

# Save as CSV for supplementary material
write_csv(table1_corpus, file.path(TAB_DIR, "Table1_corpus_sizes.csv"))

# Print table
table1_corpus
```

# 1. Keyness

Keyness analysis identifies words and phrases that are unusually frequent in the target corpus relative to a reference corpus. To ensure that reported items are both statistically credible and substantively meaningful, we (i) normalize by corpus size (via expected frequencies), (ii) require a minimal total frequency, and (iii) impose an effect-size threshold using log-ratio (≥ 1, i.e., ≥ twofold difference). We rank by log-likelihood (LL), which is robust for sparse count data and widely used in corpus linguistics.

## 1.1 Filters and computation

```{r keyness-helpers}
# 1-gram helper -------------------------------------------------------
make_top_table <- function(df, N_target, N_ref, top_n = 20,
                           pos_filter = NULL, exclude_num = FALSE,
                           drop_zero_ref = TRUE) {
  df <- df %>% tidyr::separate(term, into = c("word_root", "pos"), sep = "_", remove = FALSE)

  N_total <- N_target + N_ref
  df <- df %>%
    mutate(
      f_tot      = freq_target + freq_reference,
      exp_target = N_target * (f_tot / N_total),
      exp_ref    = N_ref    * (f_tot / N_total)
    )

  if (!is.null(pos_filter)) df <- df %>% filter(pos %in% pos_filter)
  if (exclude_num)          df <- df %>% filter(pos != "NUM")
  if (drop_zero_ref)        df <- df %>% filter(freq_reference > 0)

  df %>%
    filter(
      f_tot >= 10,                 # total frequency threshold
      exp_target >= 5, exp_ref >= 5, # expected counts in both corpora
      abs(log_ratio) >= 1          # effect size (≥ twofold)
    ) %>%
    transmute(
      Keyword = word_root,
      POS     = pos,
      `Freq (Project 2025)` = freq_target,
      `Freq (Reference)`    = freq_reference,
      LL = round(log_likelihood, 2),
      LR = round(log_ratio, 2)
    ) %>%
    arrange(desc(LL)) %>%
    slice_head(n = top_n)
}

# 2-gram helpers ------------------------------------------------------
split_bigram_cols <- function(df, term_col = "term") {
  df %>%
    separate({{ term_col }}, into = c("t1", "t2"), sep = " ", remove = FALSE) %>%
    separate(t1, into = c("w1_root", "pos1"), sep = "_", fill = "right", remove = TRUE) %>%
    separate(t2, into = c("w2_root", "pos2"), sep = "_", fill = "right", remove = TRUE) %>%
    mutate(
      Bigram   = paste0(w1_root, " ", w2_root),
      `POS pattern` = paste0(pos1, "-", pos2)
    )
}

make_top_table_2gram <- function(df, N_target, N_ref, top_n = 20,
                                 exclude_num_pairs = TRUE,
                                 pos_pair_filter = NULL,
                                 drop_zero_ref = TRUE) {
  df <- split_bigram_cols(df, term_col = "term")

  N_total <- N_target + N_ref
  df <- df %>%
    mutate(
      f_tot      = freq_target + freq_reference,
      exp_target = N_target * (f_tot / N_total),
      exp_ref    = N_ref    * (f_tot / N_total)
    )

  if (exclude_num_pairs) {
    df <- df %>% filter(pos1 != "NUM", pos2 != "NUM")
  }
  if (!is.null(pos_pair_filter)) {
    df <- df %>% filter(`POS pattern` %in% pos_pair_filter)
  }
  if (drop_zero_ref) {
    df <- df %>% filter(freq_reference > 0)
  }

  df %>%
    filter(
      f_tot >= 10,
      exp_target >= 5, exp_ref >= 5,
      abs(log_ratio) >= 1
    ) %>%
    transmute(
      `2-gram`              = Bigram,
      `POS pattern`,
      `Freq (Project 2025)` = freq_target,
      `Freq (Reference)`    = freq_reference,
      LL = round(log_likelihood, 2),
      LR = round(log_ratio, 2)
    ) %>%
    arrange(desc(LL)) %>%
    slice_head(n = top_n)
}
```

## 1.2 One-word keywords (1-grams)

We compute separate lists against Democratic and Republican references and export both tables for transparency and re-use in the manuscript and supplement.

```{r keyness-1g, results='hide'}
key_dem <- read_csv(PATHS$key1_dem, show_col_types = FALSE)
key_rep <- read_csv(PATHS$key1_rep, show_col_types = FALSE)

table_2_dem <- make_top_table(key_dem, N_target, N_ref_dem, top_n = 20,
                               pos_filter = NULL, exclude_num = FALSE, drop_zero_ref = TRUE)
table_3_rep <- make_top_table(key_rep, N_target, N_ref_rep, top_n = 20,
                               pos_filter = NULL, exclude_num = FALSE, drop_zero_ref = TRUE)

write_csv(table_2_dem, file.path(TAB_DIR, "Table2_Dem_1gram_Top20.csv"))
write_csv(table_3_rep, file.path(TAB_DIR, "Table3_Rep_1gram_Top20.csv"))
```

**Table 2.** Top 20 1-grams for Project 2025 vs. Democratic platforms (ranked by LL; filters as specified).

```{r show-t2, echo=FALSE}
table_2_dem
```

**Table 3.** Top 20 1-grams for Project 2025 vs. Republican platforms (ranked by LL; filters as specified).

```{r show-t3, echo=FALSE}
table_3_rep
```

## 1.3 Distribution across chapters

This section visualizes how selected key terms are distributed across the chapters of \*Project 2025\*. We compute relative frequencies per 1,000 words to normalize for varying chapter lengths and visualize trends for four central governance-related nouns.

**Figure 1.**

```{r chapter-distribution}
# ---- Load POS-annotated corpus ----
project25_pos <- read_csv(PATHS$p25_pos, show_col_types = FALSE)

# ---- Prepare chapter identifiers ----
project25_pos <- project25_pos %>%
  mutate(
    Chapter2 = Chapter,
    Chapter = paste(Section, Chapter2, sep = "_")
  )

# ---- Tokenize lemma_POS column ----
tokens <- project25_pos %>%
  unnest_tokens(word, text_lemmaPOS, token = "words", to_lower = TRUE)

# ---- Compute total word counts per chapter ----
total_words_per_chapter <- tokens %>%
  group_by(Chapter) %>%
  summarise(total_words = n(), .groups = "drop")

# ---- Define target keywords (lemma_POS) ----
keywords <- c("program_noun", "agency_noun", "administration_propn", "policy_noun")

# ---- Filter tokens for these keywords ----
filtered_tokens <- tokens %>%
  filter(word %in% keywords)

# ---- Compute relative frequency per 1,000 words ----
relative_frequency <- filtered_tokens %>%
  count(Chapter, word) %>%
  left_join(total_words_per_chapter, by = "Chapter") %>%
  mutate(relative_freq = (n / total_words) * 1000)

# ---- Format chapter variable ----
relative_frequency$Chapter <- factor(relative_frequency$Chapter, levels = unique(relative_frequency$Chapter))

# ---- Identify section boundaries ----
section_transitions <- relative_frequency %>%
  filter(str_detect(Chapter, "_0")) %>%
  pull(Chapter)
section_positions <- which(levels(relative_frequency$Chapter) %in% section_transitions)

# ---- Plot ----
make_relfreq_barplot <- function(df, plot_title,
                                 outfile_tiff = NULL,
                                 width = 8.8, height = 6, dpi = 300) {
  
  blue <- "#2c7fb8"  # single, strong blue tone
  
  p <- ggplot(df, aes(x = Chapter, y = relative_freq)) +
    geom_col(fill = blue, width = 0.75) +
    geom_vline(
      xintercept = section_positions - 0.5,
      linetype = "dotted", color = "grey55", linewidth = 0.4
    ) +
    facet_wrap(~ word, ncol = 2, scales = "free_y") +
    labs(
      title = plot_title,
      x = NULL,
      y = "Relative frequency (per 1,000 words)"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title      = element_text(hjust = 0.5, face = "bold"),
      axis.text.x     = element_text(angle = 90, vjust = 0.5, hjust = 1, size = 9),
      axis.text.y     = element_text(size = 9),
      strip.text      = element_text(face = "bold"),
      panel.grid.major.x = element_blank(),
      panel.grid.minor = element_blank(),
      plot.margin     = margin(10, 20, 10, 10)
    ) +
    expand_limits(y = max(df$relative_freq, na.rm = TRUE) * 1.1)
  
  if (!is.null(outfile_tiff)) {
    ggsave(outfile_tiff, p, width = width, height = height, dpi = dpi,
           device = "tiff", compression = "lzw")
    message("Saved TIFF figure: ", outfile_tiff)
  }
  
  return(p)
}

# ---- Generate and save figure ----
fig_relfreq <- make_relfreq_barplot(
  df = relative_frequency,
  plot_title   = "Relative Frequency of Selected Keywords per Chapter in Project 2025",
  outfile_tiff = file.path(FIG_DIR, "Fig1_RelFreq_Chapters.tiff")
)

fig_relfreq
```

## 1.4 Two-word key phrases (2-grams)

Bigrams complement 1-gram results by surfacing fixed institutional names and policy collocations. We apply the same size-aware filters and rank by LL; numeric bigrams are excluded by default to prioritize lexical content.

```{r keyness-2g, results='hide'}
# Load 2-gram keyness (overuse) CSVs
key2_dem <- read_csv(PATHS$key2_dem, show_col_types = FALSE)
key2_rep <- read_csv(PATHS$key2_rep, show_col_types = FALSE)

# Build Top-20 bigram tables
table_4_dem <- make_top_table_2gram(
  key2_dem, N_target = N_target, N_ref = N_ref_dem,
  top_n = 20, exclude_num_pairs = TRUE, pos_pair_filter = NULL, drop_zero_ref = TRUE
)
table_5_rep <- make_top_table_2gram(
  key2_rep, N_target = N_target, N_ref = N_ref_rep,
  top_n = 20, exclude_num_pairs = TRUE, pos_pair_filter = NULL, drop_zero_ref = TRUE
)

# Save to ../tables
write_csv(table_4_dem, file.path(TAB_DIR, "Table4_Dem_2gram_Top20.csv"))
write_csv(table_5_rep, file.path(TAB_DIR, "Table5_Rep_2gram_Top20.csv"))
```

**Table 4.** Top 20 2-grams for Project 2025 vs. Democratic platforms (ranked by LL; filters as specified).

```{r show-t4, echo=FALSE}
table_4_dem
```

**Table 5.** Top 20 2-grams for Project 2025 vs. Republican platforms (ranked by LL; filters as specified).

```{r show-t5, echo=FALSE}
table_5_rep
```

# 2. Contextual Framing: Collocations

Collocation analysis highlights the contextual framing of central institutional nouns by examining their most strongly associated adjectives and verbs. Unlike keyness, which identifies lexical salience at the level of isolated items, collocations capture semantic prosody and discursive roles by focusing on co-occurrence within a sentence-bounded window. This allows us to assess whether entities such as agency, policy, administration, and program are framed in technical, managerial, evaluative, or action-oriented terms.

We focus on adjectives and verbs as collocates of these four keywords. Adjective collocates highlight evaluative and descriptive framing, while verbs reveal agency, action, and institutional dynamics.

## 2.1 Collocation filters and selection

We rely on a sentence-bounded window of ±7 tokens. Collocational strength is assessed using the T-score, which is more sensitive to frequent co-occurrences than PMI. Significance is additionally evaluated using log-likelihood (LL) with conventional cutoffs (LL ≥ 6.63 ≈ p \< .01, LL ≥ 10.83 ≈ p \< .001, LL ≥ 15.13 ≈ p \< .0001). In the plots, collocates are ranked by T-score, while significance is marked with asterisks. Non-significant items are retained for interpretability but shown in grey.

```{r colloc-helpers}
# ---- Significance stars by LL ----
ll_stars <- function(LL) dplyr::case_when(
  LL >= 15.13 ~ "***",
  LL >= 10.83 ~ "**",
  LL >=  6.63 ~ "*",
  TRUE ~ ""
)

# ---- Select Top-k collocates per node ----
select_top_k <- function(df, k = 10, ll_cut = 0) {
  df %>%
    mutate(Stars = ll_stars(`Log-Likelihood`)) %>%
    filter(`Log-Likelihood` >= ll_cut) %>%
    group_by(Word_1, POS_1) %>%
    arrange(desc(`T-Score`), desc(Frequency), .by_group = TRUE) %>%
    slice_head(n = k) %>%
    ungroup()
}

# ---- Reorder helper for facet plots ----
reorder_within <- function(x, by, within, fun = mean, sep = "___", ...) {
  new_x <- paste(x, within, sep = sep)
  stats::reorder(new_x, by, FUN = fun)
}
scale_x_reordered <- function(sep = "___") {
  ggplot2::scale_x_discrete(labels = function(x) gsub(paste0("^(.*)", sep, ".*$"), "\\1", x))
}
```

## 2.2 Data preparation

We restrict the analysis to four highly frequent and thematically central nouns (*agency*, *policy*, *administration*, *program*). These were identified both in the keyness analysis and in frequency counts across corpora.

```{r colloc-load}
# Load collocation CSVs
top_adj  <- read_csv(PATHS$colloc_adj,  show_col_types = FALSE)
top_verb <- read_csv(PATHS$colloc_verb, show_col_types = FALSE)

# Define target nouns
targets <- tibble(
  Word_1 = c("agency", "policy", "administration", "program"),
  POS_1  = c("NOUN",   "NOUN",   "PROPN",          "NOUN")
)

# Keep only collocates for these targets
filter_to_targets <- function(df) {
  df %>% inner_join(targets, by = c("Word_1","POS_1"))
}
top_adj  <- filter_to_targets(top_adj)
top_verb <- filter_to_targets(top_verb)

# Select Top-10 per target (ranked by T-score)
adj_top10  <- select_top_k(top_adj,  k = 10, ll_cut = 0)
verb_top10 <- select_top_k(top_verb, k = 10, ll_cut = 0)

# Save tables to ../tables
write_csv(adj_top10, file.path(TAB_DIR, "SuppTable_S1_ADJ_collocates.csv"))
write_csv(verb_top10, file.path(TAB_DIR, "SuppTable_S2_VERB_collocates.csv"))
```

## 2.3 Figures

Figures 1 and 2 visualize collocational framing by target noun. Each facet represents one node word (*agency*, *policy*, *administration*, *program*), with collocates ranked by T-score. Bars are colored by significance: blue for significant collocates (LL ≥ 6.63), grey for non-significant ones. A dashed horizontal line marks T ≈ 2, a heuristic threshold for reliable association. Asterisks denote significance levels (\* p \< .01, \*\* p \< .001, \*\*\* p \< .0001).

```{r colloc-plots}
make_colloc_barplot <- function(df, plot_title,
                                outfile_tiff = NULL,
                                width = 8.8, height = 7.4, dpi = 300) {
  
  df_plot <- df %>%
    mutate(
      Target = paste0(Word_1, " (", POS_1, ")"),
      Colloc = paste0(Word_2, " (", POS_2, ")"),
      sig    = ifelse(Stars == "", "n.s.", "sig"),
      Colloc = str_replace_all(Colloc, "_", " ")
    ) %>%
    group_by(Target) %>%
    arrange(desc(`T-Score`), desc(Frequency), .by_group = TRUE) %>%
    mutate(Colloc_ord = reorder_within(Colloc, `T-Score`, Target)) %>%
    ungroup()
  
  p <- ggplot(df_plot, aes(x = Colloc_ord, y = `T-Score`)) +
    geom_col(aes(fill = sig, alpha = sig), width = 0.72) +
    geom_text(aes(label = Stars), hjust = -0.18, size = 3.8, fontface = "bold") +
    coord_flip(clip = "off") +
    facet_wrap(~ Target, scales = "free_y", ncol = 2) +
    scale_x_reordered() +
    scale_fill_manual(values = c("sig" = "#2c7fb8", "n.s." = "grey70")) +
    scale_alpha_manual(values = c("sig" = 1, "n.s." = 0.45)) +
    geom_hline(yintercept = 2, linetype = "dashed", linewidth = 0.35, colour = "grey55") +
    labs(
      title = plot_title,
      x = NULL,
      y = "T-score (ranked)"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title      = element_text(hjust = 0.5, face = "bold"),
      legend.position = "none",
      strip.text      = element_text(face = "bold"),
      axis.text.y     = element_text(size = 9),
      plot.margin     = margin(10, 30, 10, 10)
    ) +
    expand_limits(y = max(df_plot$`T-Score`, na.rm = TRUE) * 1.12)
  
  # Save as TIFF (for PLOS ONE)
  if (!is.null(outfile_tiff)) {
    ggsave(outfile_tiff, p, width = width, height = height, dpi = dpi,
           device = "tiff", compression = "lzw")
    message("Saved TIFF figure: ", outfile_tiff)
  }
  
  return(p)
}
```

**Figure 2.** Adjective collocates by target (ranked by T-score; LL stars).

```{r fig-colloc-adj, fig.cap='Adjective collocates for agency/policy/administration/program'}
p_adj <- make_colloc_barplot(
  adj_top10,
  plot_title   = "Collocates (ADJ) for agency/policy/administration/program",
  outfile_tiff = file.path(FIG_DIR, "Fig2_ADJ_collocates.tiff")
)
p_adj
```

**Figure 3.** Verb collocates by target (ranked by T-score; LL stars).

```{r fig-colloc-adj, fig.cap='Verb collocates for agency/policy/administration/program'}
p_verb <- make_colloc_barplot(
  verb_top10,
  plot_title   = "Collocates (VERB) for agency/policy/administration/program",
  outfile_tiff = file.path(FIG_DIR, "Fig3_VERB_collocates.tiff")
)
p_verb
```

# 3. LIWC: Style, Temporal Focus, Drives

The final set of analyses employs the Linguistic Inquiry and Word Count (LIWC) framework to assess psychological and emotional dimensions of the texts. We focus on three groups of categories:

(i) style metrics (Analytic, Clout, Authentic, Tone),

(ii) temporal orientation (focuspast, focuspresent, focusfuture), and

(iii) motivational drives (Power, Achievement, Affiliation).

All tests are non-parametric due to non-normal distributions. Group differences are first assessed via the Kruskal–Wallis test. If significant, pairwise contrasts are evaluated with Dunn’s post-hoc tests (Holm correction). To quantify the strength of these differences, Cliff’s Delta is reported.

```{r liwc-load}
# Load LIWC files
liwc_dem <- read_csv(PATHS$liwc_dem) %>% mutate(Corpus = "Democrats")
liwc_rep <- read_csv(PATHS$liwc_rep) %>% mutate(Corpus = "Republicans")
liwc_p25 <- read_csv(PATHS$liwc_p25) %>% mutate(Corpus = "Project2025")

liwc_all <- bind_rows(liwc_dem, liwc_rep, liwc_p25)

# Define sets
style_vars   <- c("Analytic", "Clout", "Authentic", "Tone")
time_vars    <- c("focuspast", "focuspresent", "focusfuture")
drives_vars  <- c("power", "achieve", "affiliation")
all_vars     <- c(style_vars, time_vars, drives_vars)

# Reshape
liwc_long <- liwc_all %>%
  pivot_longer(cols = all_of(all_vars), names_to = "Dimension", values_to = "Score") %>%
  mutate(Corpus = factor(Corpus, levels = c("Project2025", "Democrats", "Republicans")))
```

## 3.1 Statistical tests

Supplementary Tables S3–S5. Full statistical results for LIWC analyses (Kruskal–Wallis, Dunn post-hoc, Cliff’s Delta).

```{r liwc-stats}
# Kruskal-Wallis
kw_tbl <- liwc_long %>%
  group_by(Dimension) %>%
  kruskal_test(Score ~ Corpus) %>%
  ungroup()

# Dunn post-hoc with Holm correction
dunn_tbl <- liwc_long %>%
  group_by(Dimension) %>%
  dunn_test(Score ~ Corpus, p.adjust.method = "holm") %>%
  ungroup()

# Cliff's Delta
pair_levels <- list(
  c("Project2025","Democrats"),
  c("Project2025","Republicans"),
  c("Democrats","Republicans")
)

cliffs_tbl <- map_dfr(all_vars, function(dimn){
  dd <- liwc_long %>% filter(Dimension == dimn)
  map_dfr(pair_levels, function(pr){
    g1 <- dd %>% filter(Corpus == pr[1]) %>% pull(Score)
    g2 <- dd %>% filter(Corpus == pr[2]) %>% pull(Score)
    cd <- suppressWarnings(cliff.delta(g1, g2))
    tibble(
      Dimension = dimn,
      group1 = pr[1], group2 = pr[2],
      estimate = unname(cd$estimate),
      magnitude = unname(cd$magnitude)
    )
  })
})

# Save test tables
write_csv(kw_tbl,   file.path(TAB_DIR, "S3_Table_LIWC_Kruskal.csv"))
write_csv(dunn_tbl, file.path(TAB_DIR, "S4_Table_LIWC_DunnHolm.csv"))
write_csv(cliffs_tbl, file.path(TAB_DIR, "S5_Table_LIWC_CliffsDelta.csv"))
```

## 3.2 Figures

```{r liwc-plot-helper}
make_liwc_plot_clean <- function(df_long,
                                 dims,
                                 title,
                                 outfile   = NULL,
                                 y_limits  = c(0, 100),
                                 y_breaks  = waiver(),
                                 base_size = 15,
                                 palette   = c("#20A39E","#F18F01","#6175C1")) {
  
  pdat <- df_long %>%
    filter(Dimension %in% dims) %>%
    mutate(
      Corpus    = factor(Corpus, levels = c("Project2025","Democrats","Republicans")),
      Dimension = factor(Dimension, levels = dims)
    )
  
  p <- ggplot(pdat, aes(x = Corpus, y = Score, fill = Corpus)) +
    geom_violin(trim = FALSE, alpha = 0.20, linewidth = 0.3, color = NA) +
    geom_boxplot(width = 0.16, outlier.shape = NA, alpha = 0.65, linewidth = 0.35) +
    scale_fill_manual(values = palette) +
    scale_y_continuous(limits = y_limits, breaks = y_breaks,
                       expand = expansion(mult = c(0.02, 0.05))) +
    facet_wrap(~ Dimension, scales = "fixed", nrow = 1) +
    labs(title = title, x = NULL, y = "Percentage of words (%)", fill = NULL) +
    theme_minimal(base_size = base_size) +
    theme(
      plot.title      = element_text(hjust = 0.5, face = "bold"),
      legend.position = "none",
      panel.grid.minor= element_blank(),
      strip.text      = element_text(face = "bold"),
      axis.text.x     = element_text(angle = 90, vjust = 0.5, hjust = 1)
    )
  
  if (!is.null(outfile)) {
    ggsave(outfile, p, width = 10, height = 8, dpi = 300, device = "tiff", compression = "lzw")
  }
  p
}
```

**Figure 4.** LIWC style metrics across corpora (Analytic, Clout, Authentic, Tone). Violin plots show distributions; boxes mark interquartile range.

```{r liwc-style}
p_style <- make_liwc_plot_clean(
  liwc_long, style_vars,
  "LIWC style metrics: Analytic, Clout, Authentic, Tone",
  outfile   = file.path(FIG_DIR, "Fig4_LIWC_Style.tiff"),
  y_limits  = c(0, 100),
  y_breaks  = seq(0, 100, 20),
  base_size = 16
)
p_style
```

**Figure 5.** LIWC temporal focus categories (past, present, future) across corpora.

```{r liwc-time}
p_time <- make_liwc_plot_clean(
  liwc_long, time_vars,
  "Temporal focus (LIWC): Past, Present, Future",
  outfile   = file.path(FIG_DIR, "Fig5_LIWC_Time.tiff"),
  y_limits  = c(0, 12),
  y_breaks  = seq(0, 15, 3),
  base_size = 16
)
p_time
```

**Figure 6.** LIWC motivational drives (Power, Achievement, Affiliation) across corpora.

```{r liwc-drives}
p_drives <- make_liwc_plot_clean(
  liwc_long, drives_vars,
  "Drives (LIWC): Power, Achievement, Affiliation",
  outfile   = file.path(FIG_DIR, "Fig6_LIWC_Drives.tiff"),
  y_limits  = c(0, 25),
  y_breaks  = seq(0, 30, 5),
  base_size = 16
)
p_drives
```

# 5. Supplementary Outputs

# 6. Session Info

```{r session}
sessionInfo()
```
