or_empty <- function(x) {
if (is.null(x) || length(x) == 0) return(character())
x <- unlist(x, use.names = FALSE)
x <- x[!is.na(x) & nzchar(x)]
x
}
clean_paths <- function(paths) {
paths <- unlist(paths, use.names = FALSE)
paths <- paths[!is.na(paths) & nzchar(paths)]
unique(paths)
}
first_existing <- function(paths, type = c("file", "dir"), required = TRUE, label = "path") {
type <- match.arg(type)
paths <- clean_paths(paths)
exists_fun <- if (type == "file") file.exists else dir.exists
hit <- paths[exists_fun(paths)]
if (length(hit) > 0) return(normalizePath(hit[[1]], mustWork = TRUE))
if (required) {
stop(
"Could not find ", label, ". Tried:\n",
paste(paths, collapse = "\n"),
"\n\nCheck folder names or pass the explicit path with a Quarto parameter."
)
}
NA_character_
}
find_parent_by_file <- function(roots, filename, max_depth = 8) {
roots <- clean_paths(roots)
roots <- roots[dir.exists(roots)]
if (length(roots) == 0) return(character())
hits <- character()
pattern <- paste0("^", gsub("\\.", "\\\\.", filename), "$")
for (r in roots) {
files <- list.files(r, pattern = pattern, recursive = TRUE, full.names = TRUE)
if (length(files) > 0) {
root_norm <- normalizePath(r, mustWork = FALSE)
file_norm <- normalizePath(files, mustWork = FALSE)
rel <- sub(paste0("^", root_norm, "/?"), "", file_norm)
depth <- stringr::str_count(rel, .Platform$file.sep)
hits <- c(hits, dirname(files[depth <= max_depth]))
}
}
unique(hits)
}
read_csv_fast <- function(path, required = TRUE) {
if (is.na(path) || !file.exists(path)) {
if (required) stop("File not found: ", path)
return(tibble())
}
if (file.info(path)$size <= 1) return(tibble())
vroom::vroom(path, delim = ",", show_col_types = FALSE, progress = FALSE)
}
read_optional <- function(path) read_csv_fast(path, required = FALSE)
show_table <- function(data, caption = NULL, digits = 3, ...) {
print(knitr::kable(data, caption = caption, digits = digits, ...))
}
show_plot <- function(plot) {
print(plot)
}
write_table <- function(data, filename) {
readr::write_csv(data, file.path(output_dir, "tables", filename))
}
save_plot <- function(plot, filename, width = 10, height = 6) {
ggplot2::ggsave(
filename = file.path(output_dir, "figures", filename),
plot = plot,
width = width,
height = height,
dpi = 300
)
}
slug <- function(x) {
x <- as.character(x)
x <- iconv(x, from = "", to = "ASCII//TRANSLIT")
x <- stringr::str_to_lower(x)
x <- stringr::str_replace_all(x, "[^a-z0-9]+", "_")
x <- stringr::str_replace_all(x, "^_+|_+$", "")
x
}
make_unordered_pair <- function(a, b) {
a <- as.character(a)
b <- as.character(b)
ifelse(a <= b, paste(a, b, sep = "__"), paste(b, a, sep = "__"))
}
safe_cor <- function(data, x, y, method = "spearman") {
if (!x %in% names(data) || !y %in% names(data)) {
return(tibble(n = 0L, estimate = NA_real_, p.value = NA_real_, method = method))
}
d <- data %>% filter(!is.na(.data[[x]]), !is.na(.data[[y]]))
if (nrow(d) < 3 || sd(d[[x]], na.rm = TRUE) == 0 || sd(d[[y]], na.rm = TRUE) == 0) {
return(tibble(n = nrow(d), estimate = NA_real_, p.value = NA_real_, method = method))
}
test <- suppressWarnings(cor.test(d[[x]], d[[y]], method = method, exact = FALSE))
tibble(n = nrow(d), estimate = unname(test$estimate), p.value = test$p.value, method = method)
}
cor_by_group <- function(data, group_cols, x = "metric_value", y = "human_value", method = "spearman") {
present_group_cols <- intersect(group_cols, names(data))
missing_group_cols <- setdiff(group_cols, names(data))
out <- data %>% filter(!is.na(.data[[x]]), !is.na(.data[[y]]))
if (length(present_group_cols) > 0) {
out <- out %>%
group_by(across(all_of(present_group_cols))) %>%
group_modify(~ safe_cor(.x, x = x, y = y, method = method)) %>%
ungroup()
} else {
out <- safe_cor(out, x = x, y = y, method = method)
}
if (length(missing_group_cols) > 0) {
for (cc in missing_group_cols) out[[cc]] <- NA_character_
}
out %>%
mutate(
p_fdr = p.adjust(p.value, method = "BH"),
abs_estimate = abs(estimate)
) %>%
arrange(desc(abs_estimate), p.value)
}
cor_table <- function(data, predictors, outcome, method = "spearman") {
predictors <- predictors[!is.na(predictors) & nzchar(predictors)]
if (length(predictors) == 0) {
return(tibble(
predictor = character(), outcome = character(), n = integer(),
estimate = double(), p.value = double(), method = character(),
p_fdr = double(), abs_estimate = double()
))
}
purrr::map_dfr(predictors, function(pred) {
safe_cor(data, pred, outcome, method = method) %>%
mutate(predictor = pred, outcome = outcome, .before = 1)
}) %>%
mutate(p_fdr = p.adjust(p.value, method = "BH"), abs_estimate = abs(estimate)) %>%
arrange(desc(abs_estimate), p.value)
}
is_asymmetry_metric <- function(metric_name) {
str_detect(metric_name, regex("asymmetry|a_minus_b|a_to_b_minus_b_to_a", ignore_case = TRUE))
}
is_distance_metric <- function(metric_name) {
str_detect(metric_name, regex("distance|bures|angle|geodesic|frobenius|commutator", ignore_case = TRUE))
}
metric_family_from_source <- function(metric_source) {
case_when(
metric_source == "classical_baselines" ~ "Classical vector baseline",
metric_source == "pairwise_subspace_metrics" ~ "Subspace/projector geometry",
metric_source == "pairwise_operator_density_metrics" ~ "Operator/density geometry",
metric_source == "pairwise_qsm_state_metrics" ~ "Sequential QSM",
TRUE ~ metric_source
)
}
metric_short_label <- function(metric_name) {
case_when(
str_detect(metric_name, "containment_asymmetry") ~ "Containment asymmetry",
str_detect(metric_name, "qsm_neutral_asymmetry") ~ "Neutral QSM asymmetry",
str_detect(metric_name, "qsm_asymmetry") ~ "QSM state asymmetry",
str_detect(metric_name, "qsm_p_b_given_a") ~ "QSM p(B|A)",
str_detect(metric_name, "qsm_p_a_given_b") ~ "QSM p(A|B)",
str_detect(metric_name, "density_fidelity") ~ "Density fidelity",
str_detect(metric_name, "density_bures") ~ "Bures distance, reversed",
str_detect(metric_name, "cosine_hilbert_schmidt") ~ "Hilbert-Schmidt cosine",
str_detect(metric_name, "symmetric_projector_overlap") ~ "Projector overlap",
str_detect(metric_name, "symmetric_trace_normalized_overlap") ~ "Trace-normalized overlap",
str_detect(metric_name, "projector_chordal") ~ "Chordal distance, reversed",
str_detect(metric_name, "mean_cosine_sq") ~ "Mean cos² principal angles",
str_detect(metric_name, "mean_cosine") ~ "Mean principal cosine",
str_detect(metric_name, "classical_.*lsa_term.*cosine") ~ "LSA term cosine",
str_detect(metric_name, "classical_.*contour_centroid.*cosine") ~ "Contour-centroid cosine",
str_detect(metric_name, "classical_.*angular_distance") ~ "Angular distance, reversed",
str_detect(metric_name, "classical_.*euclidean_distance") ~ "Euclidean distance, reversed",
TRUE ~ str_replace_all(metric_name, "_", " ")
)
}
variant_short <- function(x) {
x <- coalesce(as.character(x), "")
x <- str_replace_all(x, "clust-", "")
x <- str_replace_all(x, "__g-", " | g=")
x <- str_replace_all(x, "__l-", " | l=")
x
}
similarity_orient_value <- function(metric_name, value) {
ifelse(is_distance_metric(metric_name), -as.numeric(value), as.numeric(value))
}
orient_asymmetry_to_human_order <- function(metric_name, value, direction_sign) {
ifelse(is_asymmetry_metric(metric_name) & !is.na(direction_sign), as.numeric(value) * direction_sign, as.numeric(value))
}
z_score <- function(x) as.numeric(scale(x))
fit_lm_summary <- function(data, outcome, predictors, model_label) {
predictors <- predictors[predictors %in% names(data)]
needed <- c(outcome, predictors)
d <- data %>% select(any_of(needed)) %>% drop_na()
if (length(predictors) == 0 || nrow(d) < length(predictors) + 3) {
return(tibble(
model = model_label,
n = nrow(d),
r.squared = NA_real_,
adj.r.squared = NA_real_,
AIC = NA_real_,
BIC = NA_real_,
p.value = NA_real_,
df = NA_real_,
df.residual = NA_real_,
status = "not enough complete observations or predictors"
))
}
constant_predictors <- predictors[vapply(predictors, function(pp) {
stats::sd(d[[pp]], na.rm = TRUE) == 0 || is.na(stats::sd(d[[pp]], na.rm = TRUE))
}, logical(1))]
predictors <- setdiff(predictors, constant_predictors)
if (length(predictors) == 0 || nrow(d) < length(predictors) + 3) {
return(tibble(
model = model_label,
n = nrow(d),
r.squared = NA_real_,
adj.r.squared = NA_real_,
AIC = NA_real_,
BIC = NA_real_,
p.value = NA_real_,
df = NA_real_,
df.residual = NA_real_,
status = "all candidate predictors were constant after filtering"
))
}
for (pp in predictors) d[[pp]] <- z_score(d[[pp]])
tryCatch({
model_fit <- stats::lm(stats::reformulate(predictors, response = outcome), data = d)
n_model <- stats::nobs(model_fit)
broom::glance(model_fit) %>%
mutate(
model = model_label,
n = n_model,
predictors = paste(predictors, collapse = " + "),
status = "ok",
.before = 1
) %>%
select(model, n, predictors, r.squared, adj.r.squared, AIC, BIC, p.value, df, df.residual, status, everything())
}, error = function(e) {
tibble(
model = model_label,
n = nrow(d),
predictors = paste(predictors, collapse = " + "),
r.squared = NA_real_,
adj.r.squared = NA_real_,
AIC = NA_real_,
BIC = NA_real_,
p.value = NA_real_,
df = NA_real_,
df.residual = NA_real_,
status = paste("model failed:", e$message)
)
})
}
plot_ranked_correlations <- function(rank_df, title, subtitle = NULL, n = 12, filename = NULL) {
d <- rank_df %>%
filter(!is.na(estimate)) %>%
slice_head(n = n) %>%
mutate(
metric_display = str_wrap(metric_short_label(metric_name), 30),
family_display = str_wrap(metric_family_from_source(metric_source), 24),
label = paste0(metric_display, "\n", family_display),
label = fct_reorder(label, abs_estimate),
significant = if_else(!is.na(p_fdr) & p_fdr < .05, "FDR < .05", "FDR >= .05"),
rho_label = sprintf("%.2f", estimate),
label_x = if_else(estimate >= 0, pmax(estimate - .045, .045), pmin(estimate + .045, -.045)),
label_hjust = if_else(estimate >= 0, 1, 0)
)
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No valid correlations available."))
}
p <- ggplot(d, aes(x = estimate, y = label, fill = significant)) +
geom_vline(xintercept = 0, linewidth = .35, colour = "grey55") +
geom_col(width = .68, alpha = .94) +
geom_text(
aes(x = label_x, label = rho_label, hjust = label_hjust),
size = 3.2,
fontface = "bold",
colour = "white"
) +
scale_x_continuous(
limits = c(-1.05, 1.05),
breaks = seq(-1, 1, .25),
labels = scales::number_format(accuracy = .01),
expand = expansion(mult = c(.01, .01))
) +
scale_fill_manual(values = c("FDR < .05" = "#2F5D8C", "FDR >= .05" = "#B8B8B8"), drop = FALSE) +
labs(title = title, subtitle = subtitle, x = "Spearman correlation with human effect", y = NULL) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_blank(),
axis.text.y = element_text(size = 8.7, lineheight = .92),
legend.position = "bottom"
)
if (!is.null(filename)) save_plot(p, filename, width = 11.5, height = max(5.4, .50 * min(n, nrow(d)) + 2.0))
p
}
plot_benchmark_triage <- function(cross_df, filename = NULL) {
d <- cross_df %>%
filter(!is.na(estimate)) %>%
mutate(
hypothesis_short = factor(hypothesis_short, levels = c("H1", "H2", "H3")),
benchmark_clean = benchmark,
benchmark_label = paste0(str_wrap(benchmark_clean, 28), "\n", str_wrap(metric_short_label(metric_name), 30)),
benchmark_label = fct_reorder(benchmark_label, abs_estimate),
rho_label = sprintf("rho = %.2f", estimate),
label_x = pmax(abs_estimate - .035, .08)
)
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = "Cross-hypothesis benchmark triage", subtitle = "No valid benchmark correlations available."))
}
p <- ggplot(d, aes(x = abs_estimate, y = benchmark_label, fill = benchmark_clean)) +
geom_col(width = .64, alpha = .95) +
geom_text(aes(x = label_x, label = rho_label), hjust = 1, size = 3.15, fontface = "bold", colour = "white") +
facet_wrap(~ hypothesis_short, ncol = 1, scales = "free_y") +
scale_x_continuous(
limits = c(0, 1.03),
breaks = seq(0, 1, .25),
labels = scales::number_format(accuracy = .01),
expand = expansion(mult = c(.01, .02))
) +
scale_fill_manual(values = benchmark_palette, drop = FALSE) +
labs(
title = "Benchmark triage across H1, H2, and H3",
subtitle = "Bars show absolute association strength; labels retain the signed Spearman coefficient.",
x = "Absolute Spearman correlation with human effect",
y = NULL
) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_blank(),
legend.position = "none",
axis.text.y = element_text(size = 8.8, lineheight = .92),
strip.text = element_text(face = "bold", size = 12)
)
if (!is.null(filename)) save_plot(p, filename, width = 10.8, height = 8.2)
p
}
best_by_family <- function(rank_df) {
rank_df %>%
mutate(metric_family = metric_family_from_source(metric_source)) %>%
group_by(metric_family) %>%
slice_max(order_by = abs_estimate, n = 1, with_ties = FALSE) %>%
ungroup() %>%
arrange(desc(abs_estimate))
}
theme_block3a_pub <- function(base_size = 11) {
ggplot2::theme_minimal(base_size = base_size) +
theme(
panel.grid.minor = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.major.x = element_line(linewidth = .25, colour = "grey86"),
axis.title = element_text(face = "bold"),
axis.text = element_text(colour = "grey20"),
plot.title = element_text(face = "bold", size = base_size + 4, margin = margin(b = 5)),
plot.subtitle = element_text(size = base_size + 1, colour = "grey30", margin = margin(b = 8)),
plot.caption = element_text(size = base_size - 2, colour = "grey35", hjust = 0),
legend.position = "bottom",
legend.title = element_blank(),
strip.text = element_text(face = "bold", size = base_size + 1),
plot.margin = margin(8, 18, 8, 8)
)
}
benchmark_palette <- c(
"Best quantum-like directional metric" = "#2F5D8C",
"Best quantum-like metric" = "#2F5D8C",
"Best frequency/dimensionality difference" = "#B35C2E",
"Best classical cosine" = "#6B6B6B",
"Contour-centroid cosine" = "#4F7F52",
"LSA term cosine" = "#8A6FB0"
)
plot_benchmark_strength <- function(benchmark_df, title, subtitle = NULL, filename = NULL) {
d <- benchmark_df %>%
filter(!is.na(abs_estimate)) %>%
mutate(
benchmark_clean = benchmark,
axis_label = paste0(str_wrap(benchmark_clean, 30), "\n", str_wrap(metric_short, 34)),
axis_label = fct_reorder(axis_label, abs_estimate),
signed_label = sprintf("rho = %.2f", estimate),
label_x = pmax(abs_estimate - .035, .08)
)
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No benchmark data available."))
}
p <- ggplot(d, aes(x = abs_estimate, y = axis_label, fill = benchmark_clean)) +
geom_col(width = .62, alpha = .95) +
geom_text(aes(x = label_x, label = signed_label), hjust = 1, size = 3.25, fontface = "bold", colour = "white") +
scale_x_continuous(
limits = c(0, 1.03),
breaks = seq(0, 1, .25),
labels = scales::number_format(accuracy = .01),
expand = expansion(mult = c(.01, .02))
) +
scale_fill_manual(values = benchmark_palette, drop = FALSE) +
labs(
title = title,
subtitle = subtitle,
x = "Absolute Spearman correlation with human effect",
y = NULL
) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_blank(),
axis.text.y = element_text(size = 8.8, lineheight = .92),
legend.position = "none"
)
if (!is.null(filename)) save_plot(p, filename, width = 10.4, height = max(4.9, .72 * nrow(d) + 2.2))
p
}
plot_nested_r2 <- function(model_df, title, subtitle = NULL, filename = NULL) {
d <- model_df %>%
filter(status == "ok", !is.na(r.squared)) %>%
mutate(
model = factor(model, levels = c("FD baseline only", "Quantum-like metric only", "FD + quantum-like metric")),
model_label = fct_reorder(str_wrap(as.character(model), 32), r.squared),
r2_label = sprintf("R² = %.3f", r.squared),
label_x = pmax(r.squared - .035, .08)
)
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No valid nested model results available."))
}
p <- ggplot(d, aes(x = r.squared, y = model_label, fill = model)) +
geom_col(width = .62, alpha = .95) +
geom_text(aes(x = label_x, label = r2_label), hjust = 1, size = 3.35, fontface = "bold", colour = "white") +
scale_x_continuous(
limits = c(0, max(1, max(d$r.squared, na.rm = TRUE) + .04)),
breaks = seq(0, 1, .2),
labels = scales::number_format(accuracy = .01),
expand = expansion(mult = c(.01, .02))
) +
scale_fill_manual(values = c(
"FD baseline only" = "#B35C2E",
"Quantum-like metric only" = "#2F5D8C",
"FD + quantum-like metric" = "#3F7A57"
)) +
labs(
title = title,
subtitle = subtitle,
x = "Explained variance",
y = NULL,
caption = "Adjusted R² values are reported in the corresponding table."
) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_blank(),
legend.position = "none",
axis.text.y = element_text(size = 9.5)
)
if (!is.null(filename)) save_plot(p, filename, width = 9.6, height = 4.9)
p
}
plot_predictor_scatter <- function(nested_df, title, subtitle = NULL, filename = NULL) {
d <- nested_df %>%
select(pair_id, human_value, best_fd_metric, best_quantum_metric) %>%
pivot_longer(
cols = c(best_fd_metric, best_quantum_metric),
names_to = "predictor_family",
values_to = "predictor_value"
) %>%
filter(!is.na(human_value), !is.na(predictor_value)) %>%
group_by(predictor_family) %>%
mutate(
predictor_z = as.numeric(scale(predictor_value)),
human_z = as.numeric(scale(human_value)),
predictor_family = recode(
predictor_family,
best_fd_metric = "Best frequency/dimensionality baseline",
best_quantum_metric = "Best quantum-like metric"
),
predictor_family = str_wrap(predictor_family, 36)
) %>%
ungroup()
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No predictor-level data available."))
}
p <- ggplot(d, aes(x = predictor_z, y = human_z)) +
geom_hline(yintercept = 0, linewidth = .25, colour = "grey75") +
geom_vline(xintercept = 0, linewidth = .25, colour = "grey75") +
geom_point(size = 2.35, alpha = .76, colour = "#2F3A45") +
geom_smooth(method = "lm", se = TRUE, linewidth = .8, colour = "#2F5D8C", fill = "#BFD2E6") +
facet_wrap(~ predictor_family, ncol = 1) +
labs(
title = title,
subtitle = subtitle,
x = "Standardized predictor value",
y = "Standardized human effect"
) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_line(linewidth = .25, colour = "grey88"),
strip.text = element_text(face = "bold", size = 11, lineheight = .92)
)
if (!is.null(filename)) save_plot(p, filename, width = 8.8, height = 8.4)
p
}
plot_h3_metric_landscape <- function(rank_df, title, subtitle = NULL, filename = NULL) {
d <- rank_df %>%
filter(!is.na(estimate)) %>%
mutate(
metric_family = metric_family_from_source(metric_source),
metric_family = fct_reorder(metric_family, abs_estimate, .fun = max),
significant = if_else(!is.na(p_fdr) & p_fdr < .05, "FDR < .05", "FDR >= .05")
)
winners <- d %>%
group_by(metric_family) %>%
slice_max(abs_estimate, n = 1, with_ties = FALSE) %>%
ungroup()
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No valid metric correlations available."))
}
p <- ggplot(d, aes(x = estimate, y = metric_family)) +
geom_vline(xintercept = 0, linewidth = .3, colour = "grey55") +
geom_boxplot(width = .42, outlier.shape = NA, alpha = .16, colour = "grey45") +
geom_jitter(aes(alpha = significant), width = 0, height = .13, size = 1.7, colour = "#2F3A45") +
geom_point(data = winners, shape = 21, size = 3.7, stroke = .65, fill = "#B35C2E", colour = "white") +
scale_x_continuous(
limits = c(-1.05, 1.05),
breaks = seq(-1, 1, .5),
labels = scales::number_format(accuracy = .01),
expand = expansion(mult = c(.01, .01))
) +
scale_alpha_manual(values = c("FDR < .05" = .76, "FDR >= .05" = .18), drop = FALSE) +
labs(
title = title,
subtitle = subtitle,
x = "Spearman correlation with human similarity",
y = NULL,
caption = "Orange markers indicate the strongest metric within each family. Exact metric labels are reported in Table 16."
) +
theme_block3a_pub(base_size = 11) +
theme(
legend.position = "bottom",
panel.grid.major.y = element_blank(),
axis.text.y = element_text(size = 9.2)
)
if (!is.null(filename)) save_plot(p, filename, width = 10.8, height = 5.8)
p
}
plot_h3_benchmark_scatter <- function(metric_data, benchmark_df, title, subtitle = NULL, filename = NULL) {
selected_metrics <- benchmark_df %>%
filter(!is.na(metric_id)) %>%
select(benchmark, metric_id)
d <- metric_data %>%
inner_join(selected_metrics, by = "metric_id") %>%
mutate(benchmark = factor(str_wrap(benchmark, 34), levels = str_wrap(selected_metrics$benchmark, 34))) %>%
group_by(benchmark) %>%
mutate(
metric_value_z = as.numeric(scale(metric_value)),
human_value_z = as.numeric(scale(human_value))
) %>%
ungroup()
if (nrow(d) == 0) {
return(ggplot() + theme_void() + labs(title = title, subtitle = "No H3 benchmark scatter data available."))
}
p <- ggplot(d, aes(x = metric_value_z, y = human_value_z)) +
geom_hline(yintercept = 0, linewidth = .25, colour = "grey75") +
geom_vline(xintercept = 0, linewidth = .25, colour = "grey75") +
geom_point(size = 2.1, alpha = .70, colour = "#2F3A45") +
geom_smooth(method = "lm", se = TRUE, linewidth = .75, colour = "#2F5D8C", fill = "#BFD2E6") +
facet_wrap(~ benchmark, ncol = 1) +
labs(
title = title,
subtitle = subtitle,
x = "Standardized metric value",
y = "Standardized human similarity"
) +
theme_block3a_pub(base_size = 11) +
theme(
panel.grid.major.y = element_line(linewidth = .25, colour = "grey88"),
strip.text = element_text(face = "bold", size = 10.5, lineheight = .92)
)
if (!is.null(filename)) save_plot(p, filename, width = 8.8, height = 10.0)
p
}