FIMI detection across Europe

Where confirmed foreign manipulation is recognised, how that shifts by source origin, and which individual cases are blind spots

What “detection” means here

Every figure on this page plots FIMI detection. All eight S4 news items are confirmed foreign-manipulation cases; respondents rated each from 1 = certainly not manipulated to 7 = certainly manipulated. Higher country means = better detection; lower means a larger detection blind spot. This is the opposite direction to the receptivity domains on the Receptivity profiles page, and the two are never combined.

Warning

Detection differences are read as policy-relevant blind spots, not judgements on a population. Country results are evidence for prioritisation and follow-up, not verdicts.

Detection of confirmed FIMI cases varies across Europe

The Baltic cluster shows the highest Full FIMI detection — Lithuania (5.25), Latvia (5.14), Estonia (5.04) — while Germany (4.62), Turkey (4.65), and France (4.71) show the lowest in the current 13-country set. The spread is wide enough to justify a standing audience-side indicator.

Figure 1: Full FIMI detection across S4 countries (mean of all eight confirmed-manipulation items). Higher = better detection. Lambert Azimuthal Equal-Area Europe projection.
Table 1: Countries ranked by Full FIMI detection (mean, with bootstrapped 95% CI).
Country Cluster N Full FIMI detection 95% CI
Lithuania Baltic 593 5.25 [5.17, 5.34]
Latvia Baltic 596 5.14 [5.07, 5.21]
Estonia Baltic 604 5.04 [4.96, 5.12]
Poland Central-East 600 4.94 [4.86, 5.02]
Serbia Southern 606 4.88 [4.80, 4.95]
Belgium Western 595 4.82 [4.75, 4.89]
Hungary Central-East 599 4.78 [4.70, 4.87]
Italy Southern 606 4.78 [4.71, 4.86]
Austria Western 599 4.76 [4.69, 4.83]
Bulgaria Central-East 592 4.75 [4.66, 4.83]
France Western 596 4.71 [4.64, 4.79]
Turkey Southern 599 4.65 [4.55, 4.74]
Germany Western 598 4.62 [4.54, 4.69]
Code
scripts/policy_report/F-07_overall_fimi_choropleth.R
#!/usr/bin/env Rscript
#' F-07 — Hero choropleth: overall FIMI (Full operationalisation) across S4.
#'
#' Spec: ggplot2 + geom_sf choropleth, diverging palette centred on EU mean,
#' one-decimal labels, EPSG:3035 Lambert Azimuthal projection.
#'
#' Source: outputs/policy_report/tables/s4_country_means_fimi.csv (Full variant)
#'         outputs/policy_report/tables/s4_eu_pooled_benchmarks.csv (EU mid)
#' Output: outputs/policy_report/figures/F-07_overall_fimi_choropleth.{png,pdf,rds}

suppressPackageStartupMessages({
  library(ggplot2)
  library(dplyr)
  library(readr)
  library(sf)
  library(here)
})

source(here::here("scripts", "policy_report", "_theme.R"))
source(here::here("scripts", "policy_report", "_helpers_geo.R"))

SCRIPT_ID  <- "F-07_overall_fimi_choropleth"
SOURCE_CSV <- here::here("outputs", "policy_report", "tables",
                          "s4_country_means_fimi.csv")
EU_CSV     <- here::here("outputs", "policy_report", "tables",
                          "s4_eu_pooled_benchmarks.csv")
OUT_BASE   <- file.path(POLICY_REPORT_FIG_DIR, SCRIPT_ID)

set.seed(42)

# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------

dat_all <- readr::read_csv(SOURCE_CSV, show_col_types = FALSE)
policy_report_check_input(dat_all, SOURCE_CSV)

dat <- dat_all %>% dplyr::filter(fimi_version == "full")
if (nrow(dat) == 0L) {
  stop("F-07: zero rows after filtering to fimi_version == 'full'.",
       call. = FALSE)
}

eu <- readr::read_csv(EU_CSV, show_col_types = FALSE)
eu_mean <- eu %>%
  dplyr::filter(construct_id == "fimi_full") %>%
  dplyr::pull(mean)
if (length(eu_mean) != 1L || is.na(eu_mean)) {
  stop("F-07: could not locate EU pooled FIMI-Full mean.", call. = FALSE)
}

# ---------------------------------------------------------------------------
# Build choropleth
# ---------------------------------------------------------------------------

# Symmetric limits around EU mean.
spread <- max(abs(range(dat$mean, na.rm = TRUE) - eu_mean)) * 1.05
lim    <- c(eu_mean - spread, eu_mean + spread)

# Colour convention (2026-06 revision): RED = below the pan-European mean
# (a detection blind spot, the policy-concerning direction); BLUE = above the
# mean (better detection). This flips the earlier high=red scheme so that red
# reads as "danger" consistently with the receptivity (F-09) and source-gap
# (F-12) maps.
scale <- policy_report_scale_diverging(
  midpoint = eu_mean,
  limits   = lim,
  label    = sprintf("FIMI (Full) mean • EU mean = %.2f", eu_mean),
  low      = "#B2182B",   # below EU mean = red (blind spot)
  high     = "#2166AC",   # above EU mean = blue (better detection)
  breaks   = c(lim[1], eu_mean, lim[2]),
  labels   = sprintf("%.2f", c(lim[1], eu_mean, lim[2]))
)

p <- build_choropleth(
  values    = dat,
  value_col = "mean",
  scale     = scale,
  label_fmt = "%.1f",
  script_id = SCRIPT_ID
)

caption <- policy_report_caption(
  source = "outputs/policy_report/tables/s4_country_means_fimi.csv",
  n      = sum(dat$n),
  script = paste0(SCRIPT_ID, ".R"),
  prefix = "S4 (2025). FIMI Full operationalisation (news1–8). All eight items are confirmed FIMI cases; respondents rated each headline from 1 = 'certainly NOT manipulated' to 7 = 'certainly manipulated', so higher = better detection. Projection: ETRS89 / LAEA Europe (EPSG:3035)."
)

p <- p +
  ggplot2::labs(
    title    = "Detection of foreign-information manipulation across Europe (S4)",
    subtitle = sprintf("Country means on the 8-item FIMI battery (1–7, higher = better detection); pan-European mean = %.2f.",
                       eu_mean),
    caption  = caption
  )

dim_p <- POLICY_REPORT_FIGURE_DIMENSIONS$full_page
paths <- safe_ggsave_rds(p, OUT_BASE,
                         width  = dim_p$width,
                         height = dim_p$height)

policy_report_log_outputs(
  SCRIPT_ID, paths, input_rows = nrow(dat),
  extra = c(sprintf("EU mean (FIMI Full) = %.3f", eu_mean),
            sprintf("Country range = [%.2f, %.2f]",
                    min(dat$mean), max(dat$mean)),
            sprintf("Projection: EPSG:%d (Lambert Azimuthal Equal Area)",
                    POLICY_REPORT_PROJ_LAEA),
            sprintf("Join misses: %s",
                    paste(attr(p, "join_miss"), collapse = ", "))))

Source origin changes what audiences detect

Splitting the criterion by the manipulating actor reveals a systematic asymmetry: in all 13 countries, Russian-origin FIMI is detected more readily than Chinese-origin FIMI. The two maps use independent colour scales so within-source variation is legible.

Figure 2: Russian-origin FIMI detection (news1–news5).
Figure 3: Chinese-origin FIMI detection (news6–news8) — the systematic blind spot.

The paired (within-respondent) Russian − Chinese difference is positive everywhere, and every confidence interval excludes zero. The largest gap is in France (+0.92), then Italy (+0.82), Latvia (+0.74), Estonia (+0.70), and Hungary (+0.67); the smallest is Poland (+0.39).

Figure 4: Paired Russian − Chinese detection asymmetry per country. Positive = Russian-origin manipulation detected more readily. All 13 CIs exclude zero.
Table 2: Paired Russian − Chinese detection difference by country (positive = better Russian-origin detection).
Country Cluster Russian-origin Chinese-origin R − CH 95% CI CI excl. 0
France Western 5.06 4.13 0.92 [0.81, 1.05] yes
Italy Southern 5.09 4.27 0.82 [0.73, 0.92] yes
Latvia Baltic 5.42 4.68 0.74 [0.64, 0.83] yes
Estonia Baltic 5.30 4.60 0.70 [0.61, 0.79] yes
Hungary Central-East 5.04 4.36 0.67 [0.56, 0.77] yes
Lithuania Baltic 5.50 4.84 0.65 [0.56, 0.76] yes
Serbia Southern 5.09 4.53 0.56 [0.47, 0.67] yes
Belgium Western 5.03 4.48 0.55 [0.46, 0.65] yes
Austria Western 4.96 4.44 0.51 [0.42, 0.61] yes
Bulgaria Central-East 4.93 4.45 0.48 [0.38, 0.58] yes
Germany Western 4.79 4.32 0.47 [0.37, 0.57] yes
Turkey Southern 4.81 4.38 0.43 [0.34, 0.54] yes
Poland Central-East 5.09 4.70 0.39 [0.30, 0.49] yes
ImportantSource-specific caveat

This result is bounded by the implemented item bank. It compares the specific Russian- and Chinese-origin cases that were fielded; it does not show that Russian FIMI is harmless, nor does it generalise to all Russian, Chinese, domestic, or other foreign-origin manipulation. Treat the Chinese-origin blind spot as a monitoring hypothesis for refreshed item banks and narrative review (Shadish et al., 2002).

Code
scripts/policy_report/F-12_russian_minus_chinese_diff.R
#!/usr/bin/env Rscript
#' F-12 — Russian minus Chinese FIMI difference choropleth.
#'
#' Spec: choropleth with divergent palette centred on zero; countries where the
#' bootstrapped 95% CI excludes zero are outlined; others are unfilled. Include
#' an inset ranked bar.
#'
#' Source: outputs/policy_report/tables/s4_country_fimi_difference.csv
#' Output: outputs/policy_report/figures/F-12_russian_minus_chinese_diff.{png,pdf,rds}

suppressPackageStartupMessages({
  library(ggplot2)
  library(dplyr)
  library(readr)
  library(sf)
  library(here)
  library(patchwork)
})

source(here::here("scripts", "policy_report", "_theme.R"))
source(here::here("scripts", "policy_report", "_helpers_geo.R"))

SCRIPT_ID  <- "F-12_russian_minus_chinese_diff"
SOURCE_CSV <- here::here("outputs", "policy_report", "tables",
                          "s4_country_fimi_difference.csv")
OUT_BASE   <- file.path(POLICY_REPORT_FIG_DIR, SCRIPT_ID)

set.seed(42)

dat <- readr::read_csv(SOURCE_CSV, show_col_types = FALSE)
policy_report_check_input(dat, SOURCE_CSV)

# ---------------------------------------------------------------------------
# Map panel
# ---------------------------------------------------------------------------

geoms <- load_country_geometries()
s4    <- geoms$s4
back  <- geoms$backdrop

joined <- s4 %>%
  dplyr::left_join(
    dat %>% dplyr::select(country_iso3, difference, ci_excludes_zero,
                          ci_lower, ci_upper, mean_R, mean_CH, n),
    by = "country_iso3"
  )

# Per spec: fill only countries whose CI excludes zero, outline the rest.
joined$fill_value <- ifelse(isTRUE(joined$ci_excludes_zero) |
                              (joined$ci_excludes_zero %in% TRUE),
                            joined$difference, NA_real_)

abs_max <- max(abs(joined$difference), na.rm = TRUE) * 1.05
diverging <- ggplot2::scale_fill_gradient2(
  name     = "Russian-origin mean − Chinese-origin mean",
  low      = "#2166AC", mid = "#F7F7F7", high = "#B2182B",
  midpoint = 0,
  limits   = c(-abs_max, abs_max),
  na.value = "white",
  guide    = ggplot2::guide_colorbar(
    title.position = "top",
    barwidth  = ggplot2::unit(16, "lines"),
    barheight = ggplot2::unit(0.45, "lines"),
    ticks.colour = "black",
    frame.colour = "grey40"
  )
)

map_panel <- ggplot2::ggplot()
if (!is.null(back)) {
  map_panel <- map_panel +
    ggplot2::geom_sf(data = back, fill = "grey92",
                     colour = "grey70", linewidth = 0.15)
}

# Light outline for all S4 countries (those with CI including zero will only
# show this outline + white fill).
map_panel <- map_panel +
  ggplot2::geom_sf(
    data = joined[joined$in_s4 & !is.na(joined$difference), , drop = FALSE],
    ggplot2::aes(fill = fill_value),
    colour = "grey55", linewidth = 0.25
  ) +
  # Heavier outline for countries whose CI excludes zero.
  ggplot2::geom_sf(
    data = joined[joined$in_s4 &
                    !is.na(joined$difference) &
                    (joined$ci_excludes_zero %in% TRUE), , drop = FALSE],
    fill = NA, colour = "grey15", linewidth = 0.75
  ) +
  diverging

# Country mean labels.
lbl_dat <- joined %>%
  dplyr::filter(in_s4 & !is.na(difference))
if (nrow(lbl_dat) > 0L) {
  lbl_pts <- policy_report_label_points(lbl_dat)
  lbl_pts$.lbl <- sprintf("%+.2f", lbl_pts$difference)
  map_panel <- map_panel + ggplot2::geom_text(
    data = lbl_pts,
    ggplot2::aes(x = .x, y = .y, label = .lbl),
    family = .policy_report_resolve_font(POLICY_REPORT_BASE_FONT),
    size = 2.5, colour = "grey15", fontface = "bold"
  )
}

map_panel <- map_panel +
  ggplot2::coord_sf(crs = sf::st_crs(POLICY_REPORT_PROJ_LAEA),
                    xlim = c(2.3e6, 6.9e6),
                    ylim = c(1.5e6, 5.4e6),
                    expand = FALSE) +
  theme_policy_report_map(base_size = 10) +
  ggplot2::theme(legend.position = "bottom")

# ---------------------------------------------------------------------------
# Inset: ranked bar chart
# ---------------------------------------------------------------------------

bar_dat <- dat %>%
  dplyr::arrange(difference) %>%
  dplyr::mutate(country_name = factor(country_name, levels = country_name),
                fill_value = ifelse(ci_excludes_zero, difference, NA_real_),
                stripe = ifelse(ci_excludes_zero, "CI excludes 0",
                                "CI includes 0"))

inset <- ggplot2::ggplot(bar_dat,
                          ggplot2::aes(y = country_name, x = difference)) +
  ggplot2::geom_vline(xintercept = 0, colour = "grey30",
                      linewidth = 0.4) +
  ggplot2::geom_col(ggplot2::aes(fill = fill_value), colour = NA,
                    width = 0.7) +
  ggplot2::geom_segment(
    ggplot2::aes(xend = ci_lower, x = ci_upper, yend = country_name),
    linewidth = 0.4, colour = "grey20"
  ) +
  ggplot2::geom_point(ggplot2::aes(shape = stripe),
                      colour = "grey15", size = 1.5, fill = "white") +
  ggplot2::scale_shape_manual(values = c("CI excludes 0" = 19,
                                          "CI includes 0" = 1),
                               name = NULL) +
  diverging +
  ggplot2::guides(fill = "none") +
  ggplot2::scale_x_continuous(labels = scales::label_number(accuracy = 0.1)) +
  ggplot2::labs(x = "R − CH", y = NULL) +
  theme_policy_report(base_size = 8) +
  ggplot2::theme(
    legend.position    = "bottom",
    legend.key.size    = ggplot2::unit(0.7, "lines"),
    legend.margin      = ggplot2::margin(t = 0, b = 0),
    panel.grid.major.y = ggplot2::element_blank(),
    plot.background    = ggplot2::element_rect(fill = "white",
                                                colour = "grey60",
                                                linewidth = 0.4),
    plot.margin        = ggplot2::margin(4, 6, 4, 6)
  )

# ---------------------------------------------------------------------------
# Compose: map with inset (top-left of map).
# ---------------------------------------------------------------------------

caption <- policy_report_caption(
  source = "outputs/policy_report/tables/s4_country_fimi_difference.csv",
  n      = sum(dat$n),
  script = paste0(SCRIPT_ID, ".R"),
  prefix = sprintf("S4 (2025). All items confirmed FIMI cases; respondents rated each headline 1 = 'certainly NOT manipulated' to 7 = 'certainly manipulated', so higher = better detection. Plotted: per-country (Russian-origin − Chinese-origin) detection-score difference with 95%% CIs (parametric, t-based). Positive values = respondents detect Russian-origin FIMI more readily than Chinese-origin FIMI. Heavy outline marks the %d / %d countries whose CI excludes zero. Projection: ETRS89 / LAEA Europe (EPSG:3035).",
                   sum(dat$ci_excludes_zero, na.rm = TRUE), nrow(dat))
)

main <- map_panel +
  ggplot2::labs(
    title    = "Where Russian-origin FIMI is detected more readily than Chinese-origin FIMI (S4)",
    subtitle = policy_report_wrap("Positive values = better at flagging Russian-origin manipulation than Chinese-origin manipulation. Filled countries: CI excludes zero. Unfilled outlined countries: CI includes zero.", width = 100),
    caption  = caption
  )

final <- main + patchwork::inset_element(
  inset,
  left   = 0.00, bottom = 0.50, right = 0.34, top = 0.99,
  align_to = "panel"
)

dim_p <- POLICY_REPORT_FIGURE_DIMENSIONS$full_page
paths <- safe_ggsave_rds(final, OUT_BASE,
                         width  = dim_p$width,
                         height = dim_p$height)

n_excl <- sum(dat$ci_excludes_zero, na.rm = TRUE)
policy_report_log_outputs(
  SCRIPT_ID, paths, input_rows = nrow(dat),
  extra = c(sprintf("Countries with CI excluding zero: %d / %d", n_excl, nrow(dat)),
            sprintf("Difference range = [%+.3f, %+.3f]",
                    min(dat$difference), max(dat$difference)),
            sprintf("Projection: EPSG:%d", POLICY_REPORT_PROJ_LAEA))
)

Which individual cases are blind spots

Some FIMI cases are detected consistently across countries; others show strong regional blind spots. The country-by-item heatmap is the diagnostic bridge between survey monitoring and narrative analysis: the aggregate score is useful for headline monitoring, but item-level patterns identify which specific cases or source contexts need follow-up testing, translation review, or narrative monitoring.

Figure 5: Country × FIMI-item detection heatmap. Rows = countries (ordered by overall detection); columns = the eight news items, grouped by stated origin. Higher (warmer) = better detection.
Code
scripts/policy_report/F-13_country_item_heatmap.R
#!/usr/bin/env Rscript
#' F-13 — Country × FIMI item heatmap.
#'
#' Spec: rows ordered by overall S4 FIMI mean (Full variant, from
#' `s4_country_means_fimi.csv`); columns ordered by origin (Russian / Chinese)
#' then truth_value; cell labels to one decimal.
#'
#' Source: outputs/policy_report/tables/s4_country_item_means.csv
#'         outputs/policy_report/tables/s4_country_means_fimi.csv (Full)
#' Output: outputs/policy_report/figures/F-13_country_item_heatmap.{png,pdf,rds}

suppressPackageStartupMessages({
  library(ggplot2)
  library(dplyr)
  library(readr)
  library(here)
})

source(here::here("scripts", "policy_report", "_theme.R"))
source(here::here("scripts", "policy_report", "_helpers_heatmap.R"))

SCRIPT_ID   <- "F-13_country_item_heatmap"
SOURCE_CSV  <- here::here("outputs", "policy_report", "tables",
                           "s4_country_item_means.csv")
ORDER_CSV   <- here::here("outputs", "policy_report", "tables",
                           "s4_country_means_fimi.csv")
OUT_BASE    <- file.path(POLICY_REPORT_FIG_DIR, SCRIPT_ID)

set.seed(42)

dat <- readr::read_csv(SOURCE_CSV, show_col_types = FALSE)
policy_report_check_input(dat, SOURCE_CSV)

order_dat <- readr::read_csv(ORDER_CSV, show_col_types = FALSE) %>%
  dplyr::filter(fimi_version == "full")
if (nrow(order_dat) == 0L) {
  stop("F-13: zero rows in FIMI Full ordering source.", call. = FALSE)
}

# Country order: by overall S4 FIMI Full mean (descending = highest at top).
country_order <- order_dat %>%
  dplyr::arrange(dplyr::desc(mean)) %>%
  dplyr::mutate(country_label = sprintf("%s • %.2f", country_name, mean)) %>%
  dplyr::select(country_iso3, country_name, country_label, fimi_full_mean = mean)

dat <- dat %>%
  dplyr::left_join(country_order %>%
                     dplyr::select(country_iso3, country_label, fimi_full_mean),
                   by = "country_iso3") %>%
  dplyr::mutate(
    origin_short = factor(
      dplyr::case_when(
        stringr::str_starts(origin, "Russi") ~ "Russian-origin",
        stringr::str_starts(origin, "Chine") ~ "Chinese-origin",
        TRUE                                 ~ as.character(origin)
      ),
      levels = c("Russian-origin", "Chinese-origin")
    )
  )

# Column ordering: origin (Russian first, then Chinese), then truth_value as a
# stable secondary sort (no longer shown in the label — every DV item is a
# confirmed FIMI case, so the True/False distinction added noise, not signal).
col_meta <- dat %>%
  dplyr::distinct(item_code, item_short_label, origin_short, truth_value) %>%
  dplyr::mutate(
    origin_rank = dplyr::case_when(origin_short == "Russian-origin" ~ 1L,
                                    origin_short == "Chinese-origin" ~ 2L,
                                    TRUE ~ 3L),
    truth_rank  = ifelse(truth_value, 2L, 1L)
  ) %>%
  dplyr::arrange(origin_rank, truth_rank, item_code) %>%
  dplyr::mutate(col_label = sprintf("%s • %s",
                                     item_code,
                                     stringr::str_trunc(item_short_label, 32)))

dat <- dat %>%
  dplyr::left_join(col_meta %>% dplyr::select(item_code, col_label, origin_short),
                   by = c("item_code", "origin_short"))

# Build factor levels. ggplot factors put the FIRST level at the BOTTOM of the
# y-axis; we want the HIGHEST FIMI mean at the TOP, so reverse the order.
row_levels <- rev(country_order$country_label)
col_levels <- col_meta$col_label

# Midpoint: pooled S4 FIMI Full mean (so the heatmap is centred on the global
# average response).
midpoint <- mean(order_dat$mean, na.rm = TRUE)

n_total <- sum(unique(dat[, c("country_iso3", "n")])$n)

caption <- policy_report_caption(
  source = "outputs/policy_report/tables/s4_country_item_means.csv",
  n      = n_total,
  script = paste0(SCRIPT_ID, ".R"),
  prefix = sprintf("S4 (2025). All eight items are confirmed FIMI cases; respondents rated each headline from 1 = 'certainly NOT manipulated' to 7 = 'certainly manipulated', so higher = better detection. Rows ordered by overall S4 FIMI Full detection mean (right-most number); columns grouped by Russian then Chinese origin. Diverging palette centred on the pan-European Full FIMI detection mean = %.2f (red = below mean / blind spot, blue = above mean / better detection).", midpoint)
)

p <- build_heatmap(
  values     = dat,
  row_var    = "country_label",
  col_var    = "col_label",
  fill_var   = "mean",
  row_order  = row_levels,
  col_order  = col_levels,
  midpoint   = midpoint,
  fill_label = sprintf("Detection score (1–7) • EU mean = %.2f", midpoint),
  label_fmt  = "%.1f",
  col_facet  = "origin_short",
  x_label    = NULL,
  y_label    = "Country • S4 FIMI Full detection mean",
  # Colour convention (2026-06 revision): RED = below the pan-European
  # detection mean (blind spot), BLUE = above (better detection), matching the
  # F-07/F-10/F-11 choropleths so red reads as "danger" on every detection map.
  low_colour  = "#B2182B",   # below EU mean = red (blind spot)
  high_colour = "#2166AC"    # above EU mean = blue (better detection)
) +
  ggplot2::labs(
    title    = "Country × FIMI item detection (S4)",
    subtitle = policy_report_wrap("Cells show country-level mean detection score (1–7); higher = better at flagging that item as manipulated.", width = 100),
    caption  = caption
  ) +
  # Use the central right-margin convention for figures with diagonal column
  # labels (header overhang). Title / subtitle / caption styling and "plot"
  # anchoring come from `theme_policy_report()`; we only override the
  # axis-text rotation here.
  theme_policy_report(base_size = 10, right_extra = 24) +
  ggplot2::theme(
    panel.grid       = ggplot2::element_blank(),
    strip.placement  = "outside",
    strip.background = ggplot2::element_rect(fill = "grey95", colour = NA),
    axis.text.x.top  = ggplot2::element_text(angle = 50, hjust = 0,
                                              vjust = 0, size = 8,
                                              lineheight = 0.9),
    axis.text.x      = ggplot2::element_text(angle = 50, hjust = 0,
                                              vjust = 0, size = 8,
                                              lineheight = 0.9)
  )

dim_p <- POLICY_REPORT_FIGURE_DIMENSIONS$full_page
paths <- safe_ggsave_rds(p, OUT_BASE,
                         width  = dim_p$width + 0.5,
                         height = dim_p$height)

policy_report_log_outputs(
  SCRIPT_ID, paths, input_rows = nrow(dat),
  extra = c(sprintf("Countries plotted: %d", length(unique(dat$country_iso3))),
            sprintf("Items plotted: %d (Russian = %d, Chinese = %d)",
                    nrow(col_meta),
                    sum(col_meta$origin_short == "Russian-origin"),
                    sum(col_meta$origin_short == "Chinese-origin")),
            sprintf("Midpoint (pan-EU FIMI Full mean): %.3f", midpoint))
)

Full detection means (all four operationalisations)

Table 3: Per-country detection means for all four FIMI operationalisations (full table with CIs is downloadable below).
Country Cluster Full Russian-origin Chinese-origin Short
Lithuania Baltic 5.25 5.50 4.84 5.25
Latvia Baltic 5.14 5.42 4.68 5.18
Estonia Baltic 5.04 5.30 4.60 4.92
Poland Central-East 4.94 5.09 4.70 5.09
Serbia Southern 4.88 5.09 4.53 4.84
Belgium Western 4.82 5.03 4.48 4.85
Hungary Central-East 4.78 5.04 4.36 4.81
Italy Southern 4.78 5.09 4.27 4.73
Austria Western 4.76 4.96 4.44 4.89
Bulgaria Central-East 4.75 4.93 4.45 4.72
France Western 4.71 5.06 4.13 4.74
Turkey Southern 4.65 4.81 4.38 4.49
Germany Western 4.62 4.79 4.32 4.71

References

Shadish, W. R., Cook, T. D., & Campbell, D. T. (2002). Experimental and quasi-experimental designs for generalized causal inference. Houghton Mifflin.