1. Data Preparation

Data Import

Raw Data

We import the five currently available Qualtrics/SPSS exports and standardise their column names upfront. Paths are resolved via here::here() so the script stays portable regardless of the rendering location.

Code
raw_paths <- list(
  S1 = here::here("data", "raw_data", "s1", "Study 1_DisInforMeter scale_Dec 2024_Norstat LT.sav"),  # Lithuania, Dec 2024
  S2 = here::here("data", "raw_data", "s2", "Study 2_DisInforMeter scale_March 2025_Norstat LT.sav"),  # Lithuania, Mar 2025
  S3 = here::here("data", "raw_data", "s3", "Study 3_DisInforMeter scale_May 2025_Norstat GERMANY.sav"),  # Germany, May 2025
  S4 = here::here("data", "raw_data", "s4", "Study 4_DE-CONSPIRATOR Vrije Survey.sav"),  # Cross-national DE-CONSPIRATOR wave, 2025
  S5 = here::here("data", "raw_data", "s5", "Study 5_DeConspirator_validation_VU_LAB_March_April+14,+2026_03.08.sav")  # Lithuania VU SONA, Mar–Apr 2026
)

raw_data <- purrr::imap(raw_paths, ~ {
  dat <- haven::read_sav(.x)  # pull labelled SPSS file
  names(dat) <- clean_column_names(names(dat))  # harmonise column naming immediately
  as_tibble(dat)  # switch to tibble for tidyverse workflows
})

raw_meta <- purrr::imap_dfr(raw_data, ~ tibble(
  study = .y,
  n_rows = nrow(.x),
  n_variables = ncol(.x)
))

duration_summary <- purrr::imap_dfr(raw_data, ~ {
  if (!"duration_in_seconds" %in% names(.x)) return(tibble())
  vals <- haven::zap_labels(.x$duration_in_seconds)  # convert labelled numeric to plain vector
  tibble(
    study = .y,
    min_sec = min(vals, na.rm = TRUE),
    median_sec = median(vals, na.rm = TRUE),
    mean_sec = mean(vals, na.rm = TRUE),
    max_sec = max(vals, na.rm = TRUE)
  )
})

duration_summary <- duration_summary %>%
  mutate(across(contains("sec"), ~ .x / 60, .names = "{.col}_min"))  # convert to minutes for readability
Code
# summarise high-level dimensions to confirm Qualtrics exports loaded correctly
raw_meta %>%
  kable(align = c("l", "r", "r"), caption = "Raw data overview") %>%
  kable_styling(full_width = FALSE)
Raw data overview
study n_rows n_variables
S1 705 144
S2 610 176
S3 798 188
S4 8606 435
S5 261 210
Code
# contrast completion-time distributions in minutes for sanity checks
duration_summary %>%
  mutate(across(contains("_min"), ~ round(.x, 2))) %>%
  select(study, min_sec_min, median_sec_min, mean_sec_min, max_sec_min) %>%
  kable(
    align = c("l", rep("r", 4)),
    col.names = c("Study", "Min (min)", "Median (min)", "Mean (min)", "Max (min)"),
    caption = "Completion time (minutes) across raw datasets"
  ) %>%
  kable_styling(full_width = FALSE)
Completion time (minutes) across raw datasets
Study Min (min) Median (min) Mean (min) Max (min)
S1 3.28 15.5 51.5 4924
S2 4.07 17.1 71.9 7355
S3 2.73 15.7 34.6 1496
S4 3.38 16.9 42.1 7777
S5 0.85 21.4 25.8 986

Data Cleaning

We perform study-specific cleaning that (a) reproduces the LISREL input requirements, and (b) documents every exclusion. Each subsection lays out the transformations, diagnostics, and resulting datasets.

Construct taxonomy and naming conventions

Before diving into the per-study cleaning code, we anchor the rest of the report on three mutually exclusive construct families. Every cleaned item, every scale score, and every downstream model treats these families as distinct:

  1. Outcome / Dependent variable — FIMI (and the abbreviated FIMI_SHORT). These are the news-evaluation items (news1–news{8,15}). Respondents rate the credibility / agreement of disinformation headlines; the row-mean is our primary measure of receptivity to foreign-information-manipulation-and-interference (FIMI). FIMI is the outcome the DisInformeter scale is designed to predict — it is not part of the scale itself. S5 deliberately omits the news battery, so the FIMI outcome is only available in S1–S4.
  2. DisInformeter scale — the predictor battery. These are the attitudinal / belief items the scale measures: ideological threat, exploitation, abandonment by state / media / international allies, pragmatism toward Russia and China, superiority attributions, immigration, LGBT+, poisonous ethnocentrism, etc. They are operationalised differently across waves (S1 used a broader formative pool; S2–S5 use the consolidated reflective instrument), but the canonical short codes below are kept stable across studies wherever the underlying items are equivalent. These predictors are what we mean by “the DisInformeter scale” throughout the rest of the report.
  3. External validation batteries — Study 5 only. The Vilnius lab wave (S5) adds established psychometric instruments (CMQ, Populist Attitudes, Institutional Trust, RWA, ITT, I-PANAS-SF, feeling thermometers) used for external-validity and nomological-network validation; they are not part of the DisInformeter scale and are not the FIMI outcome.

Canonical short codes that recur across studies (full lists are in the per-study s*_scale_items registries below):

Family Short code Construct Studies (item set)
Outcome FIMI Disinformation receptivity (full news battery) S1 (news1–15), S2/S3/S4 (news1–8)
Outcome FIMI_SHORT Disinformation receptivity (4-item source-balanced short form) S1 (news4, news9, news14, news15), S2/S3/S4 (news2, news5, news7, news8)
Predictor GEN General DisInformeter items S2/S3/S5 (gen1–gen8), S4 (5-item subset)
Predictor THREAT Ideological threat S2/S3 (th9, th4, th6), S4 (th9, th6), S5 (th1–th12); S1 has a 2-item proto-version (exp1, lgb3)
Predictor EXPL Exploitation by foreign actors S2/S3 (exp2–exp5), S5 (exp1–exp5); S1 legacy: USE (poi3, poi6, exp3, exp4, dec1, dec4) — the canonical latent label in R/sem_specs.R is now EXPL everywhere
Predictor GAY LGBT+ rejection S1 (lgb1, lgb2, lgb4), S2/S3 (lgb2, lgb3, lgb4), S5 (lgb1–lgb6)
Predictor MIGR Anti-immigration S1 (unp3, unp4), S2/S3 (mi2, mi3, mi4), S5 (mi1–mi7)
Predictor ABAND Distrust / betrayal / abandonment (emotional) S2/S3/S4 (ab2, ab4, ab7), S5 (ab1–ab8); S1 legacy: LEFT (aban1, unp8) — the canonical latent label is now ABAND everywhere
Predictor ABANH Abandoned by own state All studies (aban1–aban5 in S2–S5; S1 4-item proto block); S1 legacy: LONE; S5 wrangled scale-registry name: ABAN_STATE
Predictor ABANM Abandoned by media (censorship) S2/S3 (cen2, cen4, cen5, cen6), S5 (cen1–cen6); not measured in S1; S5 wrangled scale-registry name: CEN
Predictor ABANF Unprotected by international allies All studies (unp1–unp4 in S2–S5; S1 uses a 3-item formative subset); S1 legacy: SAFE; S5 wrangled scale-registry name: UNP
Predictor PRAGR / PRAGCH Pragmatism toward Russia / China S2/S3 (pru1, pru2, pru4, pru5), S5 (pru1–pru6, pkin1–pkin6); S1 legacy: ANXR / ANXK
Predictor FEAR Fear / anxiety about conflict (affective accommodation composite) S2/S3/S4 (pa2, pa6; canonical in R/sem_specs.R), S5 (pa1–pa6); S1 has a 2-item proto FEAR (pkin3, pru3); S2–S4 wrangled scale-registry name: PRAG (the canonical latent is FEAR)
Predictor SUPR Superiority Russia (belief items, sru*) S2/S3 (6-item subset), S5 (sru1–sru12); S1 has a 12-item long version. Note: S4 fields fru* affective admiration as SUPF (legacy: SUPR), not the belief composite.
Predictor SUPCH Superiority China (belief items, ski*) S5 (ski1–ski11); S1 legacy: SUPK (10-item version). S4 fields fch* affective admiration as SUPFCH (legacy: SUPCH).
Predictor SUPF / SUPFCH Foreign-power affective admiration (Russia / China) S2/S3 (fru2, fru3, fru4 → SUPF), S4 (fru* → SUPF; fch* → SUPFCH), S5 (sup1–sup4 → SUPF; sup5–sup8 → SUPFCH); S5 wrangled scale-registry name for the legacy combined block: SUP_GEN. S1 has a 2-item RULE (ski2, sru2) capturing a related but not equivalent construct
Predictor CET Poisonous ethnocentrism S2/S3 (cet1–cet8), S5 (cet1–cet8); not present in S1/S4
Validation CMQ, POP*, TRUST_*, FEEL_*, RWA, ITT_*, PANAS_* External validation batteries S5 only — see roadmap_helpers and validation_hypotheses_prereg.csv

The canonical latent labels in R/sem_specs.R (EXPL, ABANH, ABANF, ABANM, ABAND, PRAGR, PRAGCH, FEAR, SUPR, SUPCH, SUPF, SUPFCH) carry across every wave. The per-wave s*_scale_items registries below keep the historical scale-registry keys (e.g. USE, LONE, SAFE, ANXR, ANXK, SUPK, LEFT in S1; ABAN_STATE, CEN, UNP, SUP_GEN, PRAG in S5 / S2–S4) because those keys drive Layer-2 (scales.parquet) column naming (scale_USE, scale_ABAN_STATE, …). The single source-of-truth construct_taxonomy.rds tags each scale label as outcome / predictor / validation for downstream reports.

Study 1 — Lithuania (December 2024)

Cleaning logic

  • keep only finished surveys (finished == 1),
  • retain respondents affirming conscientious responding (cons == 1),
  • constrain age to an 18–85 window (capturing the intended adult panel),
  • standardise item names to the LISREL conventions (lgbt → lgb, prag_ru → pru, …),
  • derive fast-completion flags using the 1st percentile of total duration (for diagnostics, not exclusion),
  • create labelled factor versions for demographics while preserving numeric coding used downstream.

Study 1 administered the 15-item FIMI news battery (DV) alongside the broader S1-formative DisInformeter pool. The attlt attention check (“Last summer I spent my vacation on Bonferroni island”) is retained in the cleaned export for downstream sensitivity analyses but is not used as a hard exclusion here, because the conscientiousness self-report (cons == 1) already removes the same target cases without introducing a second, dependent filter.

Code
s1_pattern_map <- c(
  "sup_ru" = "sru",
  "sup_kin" = "ski",
  "prag_ru" = "pru",
  "prag_kin" = "pkin",
  "lgbt" = "lgb",
  "cens" = "cen"
)

s1_prepared <- raw_data$S1 %>%
  rename_with(~ stringr::str_replace_all(.x, s1_pattern_map), everything()) %>%  # align prefixes with LISREL syntax
  rename(
    response_id = responseid,
    ip_address = ipaddress,
    start_time_raw = startdate,
    end_time_raw = enddate,
    recorded_time_raw = recordeddate,
    duration_seconds = duration_in_seconds
  ) %>%
  mutate(
    start_time = lubridate::ymd_hms(start_time_raw, tz = "UTC"),  # raw timestamp
    end_time = lubridate::ymd_hms(end_time_raw, tz = "UTC"),
    recorded_time = lubridate::ymd_hms(recorded_time_raw, tz = "UTC"),
    start_time_local = lubridate::with_tz(start_time, tzone = "Europe/Vilnius"),  # convert to field time zone
    end_time_local = lubridate::with_tz(end_time, tzone = "Europe/Vilnius"),
    recorded_date = as.Date(recorded_time)
  )

s1_numeric_all <- s1_prepared %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels))  # drop labels before numeric filters

s1_conditions <- list(
  "Finished survey" = rlang::expr(finished == 1),
  "Passed conscientiousness check" = rlang::expr(cons == 1),
  "Age between 18 and 85" = rlang::expr(dplyr::between(age, 18, 85))
)

s1_flow_res <- run_flow(s1_numeric_all, s1_conditions, id_var = "ppt")
s1_flow <- format_flow_table(s1_flow_res$log)
s1_ids_keep <- s1_flow_res$data$ppt
s1_fast_cutoff <- stats::quantile(s1_numeric_all$duration_seconds, probs = 0.01, na.rm = TRUE)  # 1% benchmark for speeders

s1_factor_vars <- c("gender", "lang", "language", "edu", "inc", "freq", "cons")

s1_clean <- s1_prepared %>%
  filter(ppt %in% s1_ids_keep) %>%
  distinct(ppt, .keep_all = TRUE) %>%
  mutate(across(all_of(s1_factor_vars), make_factor, .names = "{.col}_fct")) %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(
    duration_minutes = duration_seconds / 60,
    fast_completion = duration_seconds < s1_fast_cutoff,
    study = "S1_LT_2024"
  ) %>%
  select(-start_time_raw, -end_time_raw, -recorded_time_raw) %>%
  relocate(any_of(c(
    "study", "ppt", "response_id", "start_time", "end_time", "start_time_local",
    "end_time_local", "recorded_time", "recorded_date", "duration_seconds", "duration_minutes",
    "fast_completion", "progress", "finished", "cons", "consentlt"
  )))

s1_item_columns <- names(s1_clean) %>%
  stringr::str_subset("^(poi|exp|dec|lgb|unp|aban|cen|pru|pkin|sru|ski|news)")  # retain only measurement items

s1_scale_items <- list(
  # ---- Outcome (DV) — FIMI news-evaluation items --------------------------------
  # 15-item full battery + source-balanced 4-item short form. These are NOT part
  # of the DisInformeter scale; they are the outcome the scale predicts.
  FIMI = paste0("news", 1:15),
  FIMI_SHORT = c("news4", "news9", "news14", "news15"),
  # ---- DisInformeter scale predictors (S1 formative structure) ------------------
  # S1 used a broader formative pool that was later consolidated for S2+. Registry
  # keys here follow the S1 manuscript (USE/GAY/MIGR/THREAT/LONE/SAFE/SUPR/SUPK/
  # RULE/ANXR/ANXK/FEAR/LEFT). These keys drive the Layer-2 parquet column names
  # (`scale_USE`, `scale_LONE`, …) and are intentionally preserved.
  #
  # The canonical latent labels in R/sem_specs.R for the same constructs are:
  #   USE -> EXPL    LONE -> ABANH    SAFE -> ABANF
  #   ANXR -> PRAGR  ANXK -> PRAGCH   SUPK -> SUPCH
  #   LEFT -> ABAND  (hybrid composite; SEM spec uses borrowed `unp8` + `aban1`)
  #
  # The construct taxonomy table above maps both registry keys and SEM labels to
  # the S2+ vocabulary.
  USE = c("poi3", "poi6", "exp3", "exp4", "dec1", "dec4"),
  GAY = c("lgb1", "lgb2", "lgb4"),
  MIGR = c("unp3", "unp4"),
  THREAT = c("exp1", "lgb3"),
  LONE = c("aban2", "aban3", "aban4", "aban5"),
  SAFE = c("unp1", "unp6", "unp7"),
  LEFT = c("aban1", "unp8"),
  ANXR = c("pru4", "pru2", "pru1", "pru5", "pru6"),
  ANXK = c("pkin4", "pkin2", "pkin1", "pkin5", "pkin6"),
  FEAR = c("pkin3", "pru3"),
  SUPR = c("sru1", "sru3", "sru4", "sru6", "sru7", "sru9", "sru10", "sru11", "sru12", "sru13", "sru14", "sru15"),
  SUPK = c("ski1", "ski3", "ski4", "ski5", "ski6", "ski7", "ski8", "ski9", "ski10", "ski11"),
  RULE = c("ski2", "sru2")
)

# Three-way taxonomy used by descriptives + downstream models to render scales in
# their correct conceptual group (and to avoid mixing the DV in with the predictors).
s1_construct_taxonomy <- list(
  outcome = c("FIMI", "FIMI_SHORT"),
  predictor = c("USE", "GAY", "MIGR", "THREAT", "LONE", "SAFE", "LEFT",
                "ANXR", "ANXK", "FEAR", "SUPR", "SUPK", "RULE"),
  validation = character()
)
Code
# render step-by-step exclusion log
s1_flow %>%
  kable(align = c("l", "r", "r"), caption = "Study 1 sampling flow") %>%
  kable_styling(full_width = FALSE)
Study 1 sampling flow
step n dropped
Imported cases 705 0
Finished survey 705 0
Passed conscientiousness check 681 24
Age between 18 and 85 681 0
Removed duplicate IDs 681 0

Key takeaways

  • The final Study 1 sample contains 681 respondents (raw 705).
  • All exclusions stem from conscientiousness failures; the 1st-percentile completion-time flag identifies 6 extremely fast surveys but none were removed at this stage.
  • The cleaning stage exposes 15 construct registries and 95 measurement indicators. Demographic profiles, completion-time distributions, item missingness, and scale reliability / score distributions live in the dedicated descriptives report (02-descriptives.qmd).

Study 2 — Lithuania (March 2025)

Cleaning logic

  • align item naming with the Study 3 and LISREL conventions (e.g., Twor1 → th1, emotional abandonment items → ab1–ab8),
  • collapse the legacy four-item Russia admiration block (sru1–sru4 in the raw Qualtrics export) into fru1–fru4 so the 12-item Superiority Russia battery (suru1–suru12) can take the canonical sru1–sru12 namespace; the same swap applies to the China admiration items (sch* → fch*, such* → ski*),
  • filter to consented (consentlt == 1) and conscientious (cons == 1) respondents,
  • compute completion-time diagnostics (1st percentile) without enforced exclusion,
  • derive categorical age (age_fct) and midpoint approximation (age_midpoint) to allow both ordinal and quasi-continuous usage.

Study 2’s SAV export does not carry a finished flag — Qualtrics already strips abandoned sessions before export, and all 610 raw rows have progress == 100. We therefore omit the finished == 1 filter that S1/S3 apply. Study 2 administers the 8-item FIMI battery (DV) plus an int1–int3 “access to banned media” block that is retained in the cleaned export but is not part of the canonical DisInformeter scale predictors.

Code
s2_th_map <- c(
  th1 = "twor1",
  th2 = "tsad",
  th3 = "tthr",
  th4 = "thate",
  th5 = "twor2",
  th6 = "tang1",
  th7 = "tang2",
  th8 = "twor3",
  th9 = "twor4",
  th10 = "tang3",
  th11 = "tdis",
  th12 = "tnost"
)

s2_pa_map <- c(
  pa1 = "panx1",
  pa2 = "pfear1",
  pa3 = "pfear2",
  pa4 = "panx2",
  pa5 = "panx3",
  pa6 = "pang"
)

s2_ab_map <- c(
  ab1 = "adist",
  ab2 = "adisap",
  ab3 = "airr1",
  ab4 = "aang1",
  ab5 = "asad",
  ab6 = "ahate",
  ab7 = "aang2",
  ab8 = "airr2"
)

s2_age_midpoints <- c(
  `0` = NA_real_,
  `1` = 21,
  `2` = 29.5,
  `3` = 39.5,
  `4` = 49.5,
  `5` = 59.5,
  `6` = 70,
  `7` = NA_real_
)

s2_prepared <- raw_data$S2
s2_prepared <- rename_if_exists(s2_prepared, "responseid", "response_id")
s2_prepared <- rename_if_exists(s2_prepared, "startdate", "start_time_raw")
s2_prepared <- rename_if_exists(s2_prepared, "enddate", "end_time_raw")
s2_prepared <- rename_if_exists(s2_prepared, "recordeddate", "recorded_time_raw")
s2_prepared <- rename_if_exists(s2_prepared, "duration_in_seconds", "duration_seconds")

s2_prepared <- s2_prepared %>%
  rename_with(~ stringr::str_replace(.x, "^sru(\\d+)$", "fru\\1"), .cols = dplyr::matches("^sru\\d+$")) %>%
  rename_with(~ stringr::str_replace(.x, "^suru", "sru"), .cols = dplyr::matches("^suru")) %>%
  rename_with(~ stringr::str_replace(.x, "^such", "ski"), .cols = dplyr::matches("^such")) %>%
  rename_with(~ stringr::str_replace(.x, "^sch", "fch"), .cols = dplyr::matches("^sch")) %>%
  rename(!!!s2_th_map) %>%  # threat items
  rename(!!!s2_ab_map) %>%  # abandonment items
  rename(!!!s2_pa_map) %>%  # fear
  mutate(
    start_time = lubridate::ymd_hms(start_time_raw, tz = "UTC"),
    end_time = lubridate::ymd_hms(end_time_raw, tz = "UTC"),
    recorded_time = lubridate::ymd_hms(recorded_time_raw, tz = "UTC"),
    start_time_local = lubridate::with_tz(start_time, "Europe/Vilnius"),
    end_time_local = lubridate::with_tz(end_time, "Europe/Vilnius"),
    recorded_date = as.Date(recorded_time),
    study = "S2_LT_2025"
  )

s2_numeric_all <- s2_prepared %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels))  # ensure logical filters operate on numerics

s2_conditions <- list(
  "Provided informed consent" = rlang::expr(consentlt == 1),
  "Passed conscientiousness check" = rlang::expr(cons == 1)
)

s2_flow_res <- run_flow(s2_numeric_all, s2_conditions, id_var = "ppt")
s2_flow <- format_flow_table(s2_flow_res$log)
s2_ids_keep <- s2_flow_res$data$ppt
s2_fast_cutoff <- stats::quantile(s2_numeric_all$duration_seconds, probs = 0.01, na.rm = TRUE)

s2_factor_vars <- c("consentlt", "cons", "gender", "lang", "age", "income", "edu", "city")

s2_clean <- s2_prepared %>%
  filter(ppt %in% s2_ids_keep) %>%
  distinct(ppt, .keep_all = TRUE) %>%
  mutate(across(all_of(s2_factor_vars), make_factor, .names = "{.col}_fct")) %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(
    age_midpoint = dplyr::recode(as.character(age), !!!s2_age_midpoints),  # convert category to midpoint for models
    age_midpoint = as.numeric(age_midpoint),
    duration_minutes = duration_seconds / 60,
    fast_completion = duration_seconds < s2_fast_cutoff
  ) %>%
  select(-start_time_raw, -end_time_raw, -recorded_time_raw) %>%
  relocate(any_of(c(
    "study", "ppt", "response_id", "start_time", "end_time", "start_time_local",
    "end_time_local", "recorded_time", "recorded_date", "duration_seconds", "duration_minutes",
    "fast_completion", "progress", "finished", "consentlt", "cons"
  )))

s2_item_columns <- names(s2_clean) %>%
  stringr::str_subset("^(gen|th|cet|exp|lgb|mi|unp|aban|ab|cen|pru|pa|sru|fru|ski|fch|news|int|panx|pfear|pch)[0-9]")  # cross-study indicator pool

s2_scale_items <- list(
  # ---- Outcome (DV) — FIMI news-evaluation items --------------------------------
  # 8-item battery + source-balanced 4-item short form. Outcome, NOT part of
  # the DisInformeter scale.
  FIMI = paste0("news", 1:8),
  FIMI_SHORT = c("news2", "news5", "news7", "news8"),
  # ---- DisInformeter scale predictors (S2/S3 consolidated instrument) -----------
  # Short reflective scales that anchor the lavaan models in 03-models/03b-*.qmd.
  # Registry keys here drive Layer-2 (`scale_*`) column naming and are kept
  # stable; the canonical latent label for `PRAG` in R/sem_specs.R is `FEAR`.
  EXPL = c("exp2", "exp3", "exp4", "exp5"),
  GAY = c("lgb2", "lgb3", "lgb4"),
  MIGR = c("mi2", "mi3", "mi4"),
  THREAT = c("th9", "th4", "th6"),
  ABANF = c("unp1", "unp2", "unp3", "unp4"),     # Abandoned by international allies (formerly "Unprotected")
  ABANH = c("aban1", "aban2", "aban3", "aban4", "aban5"),  # Abandoned by own state (Home)
  ABANM = c("cen2", "cen4", "cen5", "cen6"),     # Abandoned by Media (censorship)
  ABAND = c("ab2", "ab4", "ab7"),                # Distrust/betrayal/abandonment (emotional reactions)
  PRAGR = c("pru1", "pru2", "pru4", "pru5"),     # Pragmatism Russia (specific)
  PRAG = c("pa2", "pa6"),                        # Fear/anxiety (formative anchors) — canonical SEM latent: FEAR
  SUPR = c("sru1", "sru2", "sru3", "sru6", "sru8", "sru9"),  # Superiority Russia (specific, belief items)
  SUPF = c("fru2", "fru3", "fru4")               # Superiority — affective admiration anchors (Russia)
)

s2_construct_taxonomy <- list(
  outcome = c("FIMI", "FIMI_SHORT"),
  predictor = c("EXPL", "GAY", "MIGR", "THREAT", "ABANF", "ABANH", "ABANM",
                "ABAND", "PRAGR", "PRAG", "SUPR", "SUPF"),
  validation = character()
)
Code
# render step-by-step exclusion log
s2_flow %>%
  kable(align = c("l", "r", "r"), caption = "Study 2 sampling flow") %>%
  kable_styling(full_width = FALSE)
Study 2 sampling flow
step n dropped
Imported cases 610 0
Provided informed consent 610 0
Passed conscientiousness check 582 28
Removed duplicate IDs 582 0

Key takeaways

  • The final Study 2 sample comprises 582 respondents (raw 610).
  • 1st-percentile completion time is 5.4 minutes; 7 fast completions are flagged for potential sensitivity checks.
  • Item renaming now mirrors the Study 3 instrument, simplifying LISREL syntax reuse (e.g., th9, ab2, ab7).

Study 3 — Germany (May 2025)

Cleaning logic

  • harmonise metadata columns (response_id, timestamps, etc.) and convert UTC timestamps to the Berlin time zone,
  • enforce completion (finished == 1), consent (consentlt == 1), and conscientiousness (cons == 1) filters,
  • retain categorical age (age_fct) and midpoint proxy (age_midpoint) in line with Study 2 for pooled analyses,
  • compute completion-time diagnostics (again using the 1st percentile),
  • preserve the already-standardised th1–th12 and ab1–ab8 naming scheme.
Code
s3_age_midpoints <- c(
  `0` = NA_real_,
  `1` = 21,
  `2` = 29.5,
  `3` = 39.5,
  `4` = 49.5,
  `5` = 59.5,
  `6` = 70,
  `7` = NA_real_
)

s3_prepared <- raw_data$S3 %>%
  rename(
    response_id = responseid,
    start_time_raw = startdate,
    end_time_raw = enddate,
    recorded_time_raw = recordeddate,
    duration_seconds = duration_in_seconds,
    country = land  # store sampling country before labelling
  ) %>%
  mutate(
    start_time = lubridate::ymd_hms(start_time_raw, tz = "UTC"),
    end_time = lubridate::ymd_hms(end_time_raw, tz = "UTC"),
    recorded_time = lubridate::ymd_hms(recorded_time_raw, tz = "UTC"),
    start_time_local = lubridate::with_tz(start_time, "Europe/Berlin"),  # convert to CET/CEST
    end_time_local = lubridate::with_tz(end_time, "Europe/Berlin"),
    recorded_date = as.Date(recorded_time),
    study = "S3_DE_2025"
  )

s3_numeric_all <- s3_prepared %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels))

s3_conditions <- list(
  "Finished survey" = rlang::expr(finished == 1),
  "Provided informed consent" = rlang::expr(consentlt == 1),
  "Passed conscientiousness check" = rlang::expr(cons == 1)
)

s3_flow_res <- run_flow(s3_numeric_all, s3_conditions, id_var = "ppt")
s3_flow <- format_flow_table(s3_flow_res$log)
s3_ids_keep <- s3_flow_res$data$ppt
s3_fast_cutoff <- stats::quantile(s3_numeric_all$duration_seconds, probs = 0.01, na.rm = TRUE)

s3_factor_vars <- c("consentlt", "cons", "gender", "lang", "age", "income", "edu", "country")

s3_clean <- s3_prepared %>%
  filter(ppt %in% s3_ids_keep) %>%
  distinct(ppt, .keep_all = TRUE) %>%
  mutate(across(all_of(s3_factor_vars), make_factor, .names = "{.col}_fct")) %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(
    age_midpoint = dplyr::recode(as.character(age), !!!s3_age_midpoints),
    age_midpoint = as.numeric(age_midpoint),
    duration_minutes = duration_seconds / 60,
    fast_completion = duration_seconds < s3_fast_cutoff
  ) %>%
  select(-start_time_raw, -end_time_raw, -recorded_time_raw) %>%
  relocate(any_of(c(
    "study", "ppt", "response_id", "start_time", "end_time", "start_time_local",
    "end_time_local", "recorded_time", "recorded_date", "duration_seconds", "duration_minutes",
    "fast_completion", "progress", "finished", "consentlt", "cons"
  )))

s3_item_columns <- names(s3_clean) %>%
  stringr::str_subset("^(gen|th|cet|exp|lgb|mi|unp|aban|ab|cen|pru|pa|sru|fru|ski|pch|fch|news|int)[0-9]")

s3_scale_items <- list(
  # ---- Outcome (DV) — FIMI news-evaluation items --------------------------------
  # Same 8-item battery + 4-item source-balanced short form as S2
  # (Germany cross-national replication).
  FIMI = paste0("news", 1:8),
  FIMI_SHORT = c("news2", "news5", "news7", "news8"),
  # ---- DisInformeter scale predictors (S2/S3 consolidated instrument) -----------
  # Registry keys drive Layer-2 column naming; canonical SEM latent for `PRAG`
  # in R/sem_specs.R is `FEAR`.
  EXPL = c("exp2", "exp3", "exp4", "exp5"),
  GAY = c("lgb2", "lgb3", "lgb4"),
  MIGR = c("mi2", "mi3", "mi4"),
  THREAT = c("th9", "th4", "th6"),
  ABANF = c("unp1", "unp2", "unp3", "unp4"),
  ABANH = c("aban1", "aban2", "aban3", "aban4", "aban5"),
  ABANM = c("cen2", "cen4", "cen5", "cen6"),
  ABAND = c("ab2", "ab4", "ab7"),
  PRAGR = c("pru1", "pru2", "pru4", "pru5"),
  PRAG = c("pa2", "pa6"),                                    # canonical SEM latent: FEAR
  SUPR = c("sru1", "sru2", "sru3", "sru6", "sru8", "sru9"),  # belief items
  SUPF = c("fru2", "fru3", "fru4")                           # affective admiration anchors
)

s3_construct_taxonomy <- list(
  outcome = c("FIMI", "FIMI_SHORT"),
  predictor = c("EXPL", "GAY", "MIGR", "THREAT", "ABANF", "ABANH", "ABANM",
                "ABAND", "PRAGR", "PRAG", "SUPR", "SUPF"),
  validation = character()
)
Code
# render step-by-step exclusion log
s3_flow %>%
  kable(align = c("l", "r", "r"), caption = "Study 3 sampling flow") %>%
  kable_styling(full_width = FALSE)
Study 3 sampling flow
step n dropped
Imported cases 798 0
Finished survey 797 1
Provided informed consent 797 0
Passed conscientiousness check 782 15
Removed duplicate IDs 782 0

Key takeaways

  • Study 3 retains 782 respondents (raw 798).
  • The 1st-percentile duration is 4.7 minutes; 5 surveys are flagged for potential robustness checks.
  • Germany already used the new naming scheme, so no substantive renaming was necessary beyond metadata harmonisation. Demographic profiles, completion-time distributions, missingness, and scale reliability are reported in 02-descriptives.qmd.

Study 4 — Cross-national DE-CONSPIRATOR wave (2025)

Cleaning logic

Study 4 is the cross-national DE-CONSPIRATOR experiment. It combines (a) the short FIMI outcome battery (news1–news8), (b) the DisInformeter scale predictors (gen*, th*, ab*, pa*, fru*, fch*) — the construct items used to predict FIMI receptivity, (c) a randomised treatment block (COND: 0 control, 1 dismiss, 2 distort, 3 distract, 4 dismay, 5 divide), and (d) an extended set of attitudinal/behavioural outcomes (Q3–Q32) probing media use, misinformation perceptions, civic trust, political efficacy and democratic attitudes. We therefore prepare three layers in parallel: the FIMI outcome (DV), the DisInformeter measurement items needed for cross-national validation in 03-models/03d-study4-crossnational.qmd, and the broader outcome battery used to test downstream effects of the experimental condition in the 4c COND appendix.

  • standardise metadata columns and convert UTC timestamps to local Brussels time (study collected across the EU/EEA),
  • enforce informed consent (consent == 1) and finished surveys (finished == 1); the SAV already drops anyone who dropped out,
  • mirror the project team’s canonical retention list by filtering to the ResponseId values in Study 4_DE-CONSPIRATOR Survey_cleaned.xlsx (this enforces the primary Q16 attention check: Q16_Attention_Check_ == 3),
  • flag — but do not drop — the embedded attention check att != 4 and the 1st-percentile completion-time speeders for sensitivity analyses,
  • map year (age bracket) to a numeric midpoint to harmonise with the Study 2/3 conventions, while keeping the labelled factor for descriptives,
  • expose cond (numeric 0–5) and cond_fct (labelled factor) as the canonical experimental indicator, plus lang derived from UserLanguage,
  • keep country / country_fct as the current country-of-birth placeholder used by downstream analyses, while also exposing duplicated country_of_origin / country_of_origin_fct aliases so the placeholder status is explicit,
  • audit the newly released participant-region file without using it as the canonical country source yet, because it covers only a subset of retained panel IDs.
Code
s4_age_midpoints <- c(
  `4` = 23.5,  # 18-29
  `5` = 37,    # 30-44
  `6` = 52,    # 45-59
  `7` = 67     # 60+
)

s4_cleaned_ids <- readxl::read_excel(
  here::here("data", "raw_data", "s4", "Study 4_DE-CONSPIRATOR Survey_cleaned.xlsx"),
  sheet = 1
) %>% dplyr::pull(ResponseId) %>% unique()  # canonical retained respondents

s4_region_mapping_raw <- readr::read_csv(
  here::here("data", "raw_data", "s4", "Study 4_participant-region-mapping.csv"),
  show_col_types = FALSE
) 

s4_participant_regions <- s4_region_mapping_raw %>%
  transmute(
    psid = ENCRYPTED_ID,
    panel_source_country = stringr::str_to_upper(`Panel Source Country`)
  ) %>%
  filter(!is.na(psid), psid != "", !is.na(panel_source_country), panel_source_country != "") %>%
  distinct(psid, .keep_all = TRUE)

s4_participant_regions_external_pid <- s4_region_mapping_raw %>%
  transmute(
    external_pid = EXTERNAL_PID,
    panel_source_country = stringr::str_to_upper(`Panel Source Country`)
) %>%
  filter(!is.na(external_pid), external_pid != "", !is.na(panel_source_country), panel_source_country != "") %>%
  distinct(external_pid, .keep_all = TRUE)

stopifnot(!anyDuplicated(s4_participant_regions$psid))  # one residence record per panel ID
stopifnot(!anyDuplicated(s4_participant_regions_external_pid$external_pid))  # second provider ID is unique but currently not joinable

s4_prepared <- raw_data$S4 %>%
  rename(
    response_id = responseid,
    start_time_raw = startdate,
    end_time_raw = enddate,
    recorded_time_raw = recordeddate,
    duration_seconds = duration_in_seconds,
    lang = userlanguage,                 # Qualtrics-recorded survey language (ISO code)
    age_bracket = year,                  # raw "How old are you?" coded as 18-29 / 30-44 / 45-59 / 60+
    att_q16 = q16_attention_check        # explicit attention check (3 = "Newspaper")
  ) %>%
  rename_with(~ stringr::str_replace(.x, "^q6__", "q6_"), .cols = dplyr::matches("^q6__")) %>%  # collapse the double-underscore artefact on Q6 items
  mutate(
    country_of_origin = country,         # duplicate birth-country item so the placeholder status remains explicit
    start_time = lubridate::ymd_hms(start_time_raw, tz = "UTC"),
    end_time = lubridate::ymd_hms(end_time_raw, tz = "UTC"),
    recorded_time = lubridate::ymd_hms(recorded_time_raw, tz = "UTC"),
    start_time_local = lubridate::with_tz(start_time, "Europe/Brussels"),  # single CET reference for the multi-country sample
    end_time_local = lubridate::with_tz(end_time, "Europe/Brussels"),
    recorded_date = as.Date(recorded_time),
    study = "S4_INTL_2025"
  )

s4_numeric_all <- s4_prepared %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels))  # logical filters operate on numerics

s4_conditions <- list(
  "Provided informed consent" = rlang::expr(consent == 1),
  "Finished survey" = rlang::expr(finished == 1),
  "Retained in cleaned ID list (Q16 attention check)" = rlang::expr(response_id %in% s4_cleaned_ids)
)

s4_flow_res <- run_flow(s4_numeric_all, s4_conditions, id_var = "response_id")
s4_flow <- format_flow_table(s4_flow_res$log)
s4_ids_keep <- s4_flow_res$data$response_id
s4_fast_cutoff <- stats::quantile(s4_numeric_all$duration_seconds, probs = 0.01, na.rm = TRUE)

s4_factor_vars <- c("consent", "gender", "education", "marital_st", "country", "country_of_origin", "lang", "age_bracket", "cond")

s4_clean <- s4_prepared %>%
  filter(response_id %in% s4_ids_keep) %>%
  distinct(response_id, .keep_all = TRUE) %>%
  mutate(across(all_of(s4_factor_vars), make_factor, .names = "{.col}_fct")) %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(
    age_midpoint = as.numeric(dplyr::recode(as.character(age_bracket), !!!s4_age_midpoints)),
    duration_minutes = duration_seconds / 60,
    fast_completion = duration_seconds < s4_fast_cutoff,
    att_pass_q16 = att_q16 == 3,           # primary attention check (already enforced by the cleaned ID list, retained for transparency)
    att_pass_embedded = att == 4,          # secondary embedded check inside the DisInformeter block
    att_pass_q29 = q29_2 == 1              # tertiary check embedded in the EU referendum item
  ) %>%
  select(-start_time_raw, -end_time_raw, -recorded_time_raw) %>%
  relocate(any_of(c(
    "study", "response_id", "start_time", "end_time", "start_time_local",
    "end_time_local", "recorded_time", "recorded_date", "duration_seconds", "duration_minutes",
    "fast_completion", "progress", "finished", "consent", "cond", "cond_fct",
    "country", "country_fct", "country_of_origin", "country_of_origin_fct",
    "lang", "lang_fct", "age_bracket", "age_bracket_fct", "age_midpoint",
    "gender", "gender_fct", "education", "education_fct", "marital_st", "marital_st_fct",
    "att_q16", "att", "att_pass_q16", "att_pass_embedded", "att_pass_q29"
  )))

s4_region_mapping_audit <- s4_clean %>%
  mutate(region_mapping_available = psid %in% s4_participant_regions$psid) %>%
  summarise(
    retained_n = n(),
    unique_retained_psid_n = n_distinct(psid),
    region_mapping_rows_n = nrow(s4_region_mapping_raw),
    unique_region_mapping_psid_n = n_distinct(s4_participant_regions$psid),
    external_pid_rows_n = nrow(s4_participant_regions_external_pid),
    unique_external_pid_n = n_distinct(s4_participant_regions_external_pid$external_pid),
    matched_rows_n = sum(region_mapping_available),
    unmatched_rows_n = sum(!region_mapping_available),
    matched_unique_psid_n = n_distinct(psid[region_mapping_available]),
    unmatched_unique_psid_n = n_distinct(psid[!region_mapping_available]),
    mapping_ids_not_retained_n = sum(!s4_participant_regions$psid %in% psid),
    external_pid_matches_psid_n = sum(psid %in% s4_participant_regions_external_pid$external_pid),
    external_pid_matches_response_id_n = sum(response_id %in% s4_participant_regions_external_pid$external_pid),
    mapping_ids_with_literal_star_n = sum(stringr::str_detect(s4_region_mapping_raw$ENCRYPTED_ID, stringr::fixed("*"))),
    raw_psids_with_literal_star_n = sum(stringr::str_detect(psid, stringr::fixed("*"))),
    panel_source_country_n = n_distinct(s4_region_mapping_raw$`Panel Source Country`),
    encrypted_id_panel_source_country_n = n_distinct(s4_participant_regions$panel_source_country),
    external_pid_panel_source_country_n = n_distinct(s4_participant_regions_external_pid$panel_source_country),
    origin_country_n = n_distinct(country_of_origin_fct, na.rm = TRUE),
    survey_language_n = n_distinct(lang_fct, na.rm = TRUE)
  )

s4_region_mapping_panel_countries <- sort(unique(stats::na.omit(s4_region_mapping_raw[["Panel Source Country"]])))

s4_region_mapping_coverage_by_origin <- s4_clean %>%
  mutate(region_mapping_available = psid %in% s4_participant_regions$psid) %>%
  count(country_of_origin_fct, region_mapping_available, name = "n") %>%
  tidyr::pivot_wider(
    names_from = region_mapping_available,
    values_from = n,
    values_fill = 0,
    names_prefix = "mapped_"
  ) %>%
  rename(
    origin_country = country_of_origin_fct,
    unmapped_n = mapped_FALSE,
    mapped_n = mapped_TRUE
  ) %>%
  mutate(total_n = mapped_n + unmapped_n, mapped_pct = mapped_n / total_n) %>%
  arrange(desc(unmapped_n), desc(total_n))

# DisInformeter measurement items (carry the S2/S3 naming) plus the cross-national outcome battery (Q*).
# The include pattern anchors each construct prefix to a digit so that pre-computed Qualtrics
# aggregate columns ("threat", "aband", "general", "newsorder", ...) and demographics like
# "gender" are NOT mistaken for items. The exclude pattern then drops Qualtrics timing blocks
# (Q146–Q169 page-level click timers), free-text follow-ups, and the engineered attention flags.
s4_item_columns <- names(s4_clean) %>%
  stringr::str_subset("^(gen[0-9]|th[0-9]|ab[0-9]|pa[0-9]|fru[0-9]|fch[0-9]|news[0-9]|att$|q[0-9])") %>%
  stringr::str_subset("(_first_click$|_last_click$|_page_submit$|_click_count$|_text$|^q_|^q1(46|47|48|49|50|51|56|57|58|59|61|62|63|65|66|67|68|69)(_|$)|^att_pass_)", negate = TRUE)

# Scale registry — grouped by purpose. Items are listed once each; constructs that have
# multiple plausible operationalisations (e.g. THREAT in the long vs short DisInformeter)
# are exposed under separate names so downstream models can pick freely.
s4_scale_items <- list(
  # ---- Outcome (DV) — FIMI news-evaluation items --------------------------------
  # Same 8-item battery + 4-item source-balanced short form as S2/S3. Outcome
  # the DisInformeter scale is designed to predict — NOT part of the scale itself.
  FIMI = paste0("news", 1:8),
  FIMI_SHORT = c("news2", "news5", "news7", "news8"),
  # ---- DisInformeter scale predictors (cross-national validation in 03d) --------
  # Registry keys drive Layer-2 column naming; canonical SEM latents in
  # R/sem_specs.R are:
  #   PRAG / PRAG_LONG -> FEAR  (affective fear / pragmatic accommodation)
  #   SUPR             -> SUPF  (S4 fields *affective* admiration via `fru*`)
  #   SUPCH            -> SUPFCH (S4 fields affective China admiration via `fch*`)
  GEN = c("gen1", "gen2", "gen3", "gen5", "gen7"),
  THREAT = c("th9", "th6"),                                   # core indicators retained from S2/S3 (th4 is absent in S4)
  THREAT_LONG = c("th1", "th5", "th6", "th7", "th8", "th9", "th10"),
  ABAND = c("ab2", "ab4", "ab7"),                             # S2/S3 ABAND triplet
  ABAND_LONG = c("ab1", "ab2", "ab3", "ab4", "ab7", "ab8"),
  PRAG = c("pa2"),                                            # only PRAG anchor available (pa6 is absent in S4); SEM latent: FEAR
  PRAG_LONG = c("pa1", "pa2", "pa4"),                         # SEM latent: FEAR
  SUPR = c("fru1", "fru2", "fru3"),                           # Russia *affective* admiration; SEM latent: SUPF
  SUPCH = c("fch1", "fch2", "fch3"),                          # China *affective* admiration (new in S4); SEM latent: SUPFCH
  # ---- Misinformation perceptions and behaviours (candidate COND outcomes) -----
  MEDIA_FREQ = c("q3_1", "q3_2", "q3_3", "q3_4", "q3_5"),     # frequency of media use for news
  MISINFO_REGUL = c("q4_2", "q4_5"),                          # pro-regulation attitudes
  MISINFO_FREE = c("q4_4"),                                   # free-speech preference (reverse-keyed)
  MISINFO_CONF = c("q4_1"),                                   # confidence in detecting misinfo
  MISINFO_POL = c("q4_3"),                                    # belief that misinfo is politically intentional
  MISINFO_SRC = c("q5_1", "q5_2", "q5_3", "q5_4", "q5_5", "q5_6", "q5_7", "q5_8", "q5_9"),
  MISINFO_FOREIGN = c("q6_1", "q6_2", "q6_3", "q6_4", "q6_5", "q6_6", "q6_7", "q6_8", "q6_9"),
  CLICK_REASONS = c("q11_1", "q11_2", "q11_3", "q11_4", "q11_5", "q11_6"),
  MISINFO_ACTION = c("q12_1", "q12_2", "q12_3", "q12_4", "q12_5", "q12_6"),   # binary-style behavioural set
  MISINFO_IMPACT = c("q14_1", "q14_2", "q14_3", "q14_4", "q14_5", "q14_6"),
  MISINFO_RESP = c("q15_1", "q15_2", "q15_3", "q15_4", "q15_5", "q15_6", "q15_7", "q15_8", "q15_9"),
  # ---- Civic / political attitudes (broader COND outcomes) ----------------------
  SOC_TRUST = c("q17", "q18", "q19"),                          # ESS-style generalised social trust (0–10 with 1-shift)
  INST_TRUST = c("q23_1", "q23_2", "q23_3", "q23_4", "q23_5", "q23_6", "q23_7", "q23_8"),
  POL_PARTIC = c("q25_1", "q25_2", "q25_3", "q25_4", "q25_5", "q25_6", "q25_7", "q25_8"),  # excludes "None" and free-text
  EU_SUPPORT = c("q28"),
  DEM_IMPORT = c("q30"),
  LIFE_SAT = c("q27"),
  LR_SCALE = c("q26"),
  POL_INTEREST = c("q20"),
  POL_EFF_EXT = c("q21"),
  POL_EFF_INT = c("q22"),
  RELIGIOSITY = c("q32")
)

# Three-way taxonomy for S4. The DisInformeter scale predictors live alongside the
# experimental-condition outcomes; FIMI is the *primary* outcome the scale predicts,
# while the Q-block items are *secondary* outcomes for the COND treatment effects.
s4_construct_taxonomy <- list(
  outcome = c("FIMI", "FIMI_SHORT"),
  predictor = c("GEN", "THREAT", "THREAT_LONG", "ABAND", "ABAND_LONG",
                "PRAG", "PRAG_LONG", "SUPR", "SUPCH"),
  cond_outcome = c("MEDIA_FREQ", "MISINFO_REGUL", "MISINFO_FREE", "MISINFO_CONF",
                   "MISINFO_POL", "MISINFO_SRC", "MISINFO_FOREIGN", "CLICK_REASONS",
                   "MISINFO_ACTION", "MISINFO_IMPACT", "MISINFO_RESP",
                   "SOC_TRUST", "INST_TRUST", "POL_PARTIC", "EU_SUPPORT",
                   "DEM_IMPORT", "LIFE_SAT", "LR_SCALE", "POL_INTEREST",
                   "POL_EFF_EXT", "POL_EFF_INT", "RELIGIOSITY"),
  validation = character()
)
Code
# render step-by-step exclusion log
s4_flow %>%
  kable(align = c("l", "r", "r"), caption = "Study 4 sampling flow") %>%
  kable_styling(full_width = FALSE)
Study 4 sampling flow
step n dropped
Imported cases 8606 0
Provided informed consent 8606 0
Finished survey 8606 0
Retained in cleaned ID list (Q16 attention check) 8040 566
Removed duplicate IDs 8040 0
Code
s4_region_mapping_audit %>%
  kable(
    align = rep("r", ncol(s4_region_mapping_audit)),
    caption = "Study 4 participant-region file coverage audit"
  ) %>%
  kable_styling(full_width = FALSE)
Study 4 participant-region file coverage audit
retained_n unique_retained_psid_n region_mapping_rows_n unique_region_mapping_psid_n external_pid_rows_n unique_external_pid_n matched_rows_n unmatched_rows_n matched_unique_psid_n unmatched_unique_psid_n mapping_ids_not_retained_n external_pid_matches_psid_n external_pid_matches_response_id_n mapping_ids_with_literal_star_n raw_psids_with_literal_star_n panel_source_country_n encrypted_id_panel_source_country_n external_pid_panel_source_country_n origin_country_n survey_language_n
8040 7984 7315 5514 1801 1801 5541 2499 5497 2487 17 0 0 NA 520 12 9 3 15 17
Code
s4_region_mapping_coverage_by_origin %>%
  mutate(mapped_pct = scales::percent(mapped_pct, accuracy = 0.1)) %>%
  kable(
    align = c("l", "r", "r", "r", "r"),
    caption = "Study 4 participant-region file coverage by birth-country response"
  ) %>%
  kable_styling(full_width = FALSE)
Study 4 participant-region file coverage by birth-country response
origin_country mapped_n unmapped_n total_n mapped_pct
Estonia 2 602 604 0.3%
Serbia 7 599 606 1.2%
Latvia 1 595 596 0.2%
Lithuania 1 592 593 0.2%
Other (Please specify) 156 100 256 60.9%
Italy 603 3 606 99.5%
Germany 595 3 598 99.5%
France 594 2 596 99.7%
Poland 599 1 600 99.8%
Belgium 594 1 595 99.8%
Georgia 0 1 1 0.0%
Austria 599 0 599 100.0%
Hungary 599 0 599 100.0%
Turkey 599 0 599 100.0%
Bulgaria 592 0 592 100.0%

Key takeaways

  • Study 4 retains 8040 respondents (raw 8606); the only enforced behavioural exclusion is the Q16 “Newspaper” attention check inherited from the cleaned ID list.
  • The six experimental conditions (cond_fct) are nearly evenly distributed — randomisation was successful, with cell sizes within ~1% of equal allocation.
  • country / country_fct remain the current country-of-birth placeholder, not verified residence country. The same values are duplicated as country_of_origin / country_of_origin_fct so this limitation is visible in downstream tables.
  • The participant-region file is not used as the canonical country source yet. The file now lists 12 panel-source countries (AUT, BEL, BGR, DEU, EST, FRA, HUN, ITA, LTU, LVA, POL, TUR), but only the ENCRYPTED_ID rows currently match the local raw data (psid). This safe join matches 5,541 retained rows and leaves 2,499 retained rows unmatched.
  • The newly added EXTERNAL_PID values are unique and cover 1,801 provider rows, but they currently match neither local raw psid (0 retained-row matches) nor Qualtrics response_id (0 retained-row matches). The local raw exports therefore do not yet contain a joinable counterpart for those IDs.
  • Literal ** characters in ENCRYPTED_ID / psid do not explain the missing matches: they are present in both files and are part of the exact 24-character ID string (NA region-file IDs and 520 retained raw psids contain *).
  • The country-of-birth placeholder spans 15 origin-country categories. The sample also spans 17 survey languages, providing additional deployment breadth for 03-models/03d-study4-crossnational.qmd.
  • Two further attention checks (att_pass_embedded for the att item; att_pass_q29 for the EU-referendum item) are exposed as columns rather than as exclusions, so the supplementary analyses can run sensitivity checks against stricter screening.
  • The scale registry deliberately splits the FIMI outcome (DV) — FIMI and FIMI_SHORT, which are the news-evaluation items the DisInformeter scale is designed to predict — from the DisInformeter scale predictors (GEN, THREAT, ABAND, PRAG, SUPR, SUPCH) and the three blocks of candidate COND outcomes for the 4c COND appendix:
    1. Misinformation perceptions and behaviours — MISINFO_REGUL, MISINFO_CONF, MISINFO_POL, MISINFO_SRC, MISINFO_FOREIGN, MISINFO_IMPACT, MISINFO_RESP, CLICK_REASONS, MISINFO_ACTION. These are the strongest theoretical targets for the dismiss/distort/distract/dismay/divide treatments, because they capture how respondents diagnose, attribute and respond to false content.
    2. Civic and political attitudes — SOC_TRUST, INST_TRUST, POL_PARTIC, EU_SUPPORT, DEM_IMPORT, LIFE_SAT, LR_SCALE, POL_INTEREST, POL_EFF_EXT, POL_EFF_INT. The “divide” and “dismay” arms in particular have a plausible mechanism through trust erosion and democratic disaffection.
    3. Religion and ideology controls — RELIGIOSITY plus the labelled q31_fct denomination indicator are kept as covariates rather than primary outcomes.
  • Items that are unlikely COND outcomes (MEDIA_FREQ, q1_* platform usage, q2_* language-of-use, q24 / q25_* past behaviours, demographics) are retained for descriptive use but flagged here so the supplementary analysis does not treat them as treatment-sensitive endpoints by default.

Study 5 — Lithuania VU lab (March–April 2026)

Cleaning logic

Study 5 was fielded through the Vilnius University SONA participant pool. Its two stated aims are (1) short-scale validation of the DisInformeter measurement model and (2) external-validity and nomological-network validation against an external battery of established scales (see validation_hypotheses_prereg.csv and R/roadmap_helpers.R). Crucially, Study 5 does not include the FIMI news-evaluation items: it administers the long DisInformeter instrument plus the following validation measures:

  1. Conspiracy Mentality Questionnaire (CMQ; Bruder et al., 2013) — 5 items, 11-point likelihood scale.
  2. Populist attitudes scale (Silva, 2017) — 10 items, 7-point Likert, three subscales (people-centrism, anti-elitism, manichaean outlook). Item 5 (pop5, “My birthday is on February 30”) is an embedded attention check.
  3. Institutional trust battery (ESS) — 7 items, 0–10 scale, split into local authorities (Parliament, Legal System, Police, Politicians, Political Parties), EU (European Parliament) and UN.
  4. Feeling thermometers — 0–100 scale toward EU, NATO, Russia and China.
  5. Right-Wing Authoritarianism short scale (VSA; 6 items, 9-point Likert; items 1, 4 and 5 are reverse-keyed).
  6. Intergroup threat scale (ITT) — separate symbolic and realistic subscales for both Immigrants (3+3 items) and the LGBT+ community (4+4 items), 7-point Likert.
  7. Trait affectivity (I-PANAS-SF) — 10 items, 7-point Likert (1 = never, 7 = always); items 1–5 capture positive affect, items 6–10 negative affect.

The cleaning logic mirrors S1–S3 with a few S5-specific adaptations:

  • harmonise metadata columns and convert UTC timestamps to the Vilnius zone,
  • rename the long, mixed-case Qualtrics labels to the canonical short prefixes used in S1–S3 (e.g. THR_1 → th1, The_LGBT__1 → lgb1, imi_1 → mi1, FEAR_1 → pa1, Pragm_Russia_1 → pru1, PRG_CHINA_1 → pkin1, SPR_Russia_1 → sru1, SPR_China_1 → ski1, Superiority_1 → sup1),
  • rename the validation batteries to short prefixes (CMQ__Bruder_2013_x → cmqX, Populist_attitudes_x → popX, Political_trust_x → ptrustX, Favourability1_x → feel_eu/nato/ru/ch, VSA_x → rwaX, ITT batteries to itt_imi_sym/real and itt_lgbt_sym/real, PANAS_1_x → panasX),
  • enforce four exclusions: (a) Status == 0 (real Qualtrics responses, not previews/spam), (b) finished == 1, (c) honest-response check response_bias == 1, and (d) embedded attention-check pop5 <= 2 (strongly disagreeing with “My birthday is on February 30”),
  • restrict to the adult panel age window (18–85) to drop out-of-range entries (one 17-year-old, plus the standard 99 self-reported non-response),
  • compute the 1st-percentile completion-time flag for sensitivity analyses (not enforced),
  • reverse-key RWA items 1, 4, 5 onto a _r column and store the recoded scale items under rwaX_r while keeping the raw rwaX; reverse-key populist items 2, 6, 9 the same way (pop2_r, pop6_r, pop9_r),
  • expose subscale scores for Populist attitudes (people-centrism, anti-elitism, manichaean outlook), ITT (symbolic/realistic × immigrant/LGBT), PANAS (positive/negative affect) and institutional trust (local/EU/UN) — these are the building blocks for the external-validity and nomological-network matrix.
Code
# Wide → canonical name map for the DisInformeter measurement battery (S5 ports the S1–S3
# items but keeps Qualtrics' verbose labels; renaming aligns the items with the existing
# lavaan syntax used in 03-models/ and the validation-hypothesis labels in roadmap_helpers).
s5_dim_renames <- c(
  gen = "gen_",
  th = "THR_",
  cet = "cet_",
  exp = "exp_",
  lgb = "The_LGBT__",
  mi = "imi_",
  ab = "AB_",
  unp = "unp_",
  aban = "aban_",
  cen = "cen_",
  pa = "FEAR_",
  pru = "Pragm_Russia_",
  pkin = "PRG_CHINA_",
  sup = "Superiority_",
  sru = "SPR_Russia_",
  ski = "SPR_China_"
)

# Wide → canonical name map for the external validation batteries.
s5_valid_renames <- c(
  cmq = "CMQ__Bruder_2013_",
  pop = "Populist_attitudes_",
  ptrust = "Political_trust_",
  rwa = "VSA_",
  itt_imi_sym = "Symbolic_threatImmi_",
  itt_imi_real = "Realistic_threatImmi_",
  itt_lgbt_sym = "SymbolicThreatLGBT_",
  itt_lgbt_real = "RealisticThreatLGBTQ_",
  panas = "PANAS_1_"
)

s5_prepared <- raw_data$S5
# The raw_data import already passes column names through `clean_column_names()`, which
# (a) lower-cases and (b) collapses any run of non-alphanumeric characters to a single
# underscore. Mirror that here so the rename patterns match the actual column names
# (e.g. `The_LGBT__1` becomes `the_lgbt_1`, not `the_lgbt__1`).
snake_for_match <- function(x) {
  x <- stringr::str_replace_all(x, "[^A-Za-z0-9]+", "_")
  stringr::str_to_lower(x)
}
s5_dim_renames_lc <- setNames(vapply(s5_dim_renames, snake_for_match, character(1)), names(s5_dim_renames))
s5_valid_renames_lc <- setNames(vapply(s5_valid_renames, snake_for_match, character(1)), names(s5_valid_renames))

# Rebuild item names: each `<prefix>_<n>` (snake_cased) becomes `<short><n>` (e.g. `thr_1` → `th1`).
s5_rename_items <- function(df, map) {
  for (short in names(map)) {
    long <- map[[short]]
    pattern <- paste0("^", long, "(\\d+)$")
    matches <- stringr::str_detect(names(df), pattern)
    if (any(matches)) {
      names(df)[matches] <- stringr::str_replace(names(df)[matches], pattern, paste0(short, "\\1"))
    }
  }
  df
}

s5_prepared <- s5_prepared %>%
  rename_if_exists("responseid", "response_id") %>%
  rename_if_exists("startdate", "start_time_raw") %>%
  rename_if_exists("enddate", "end_time_raw") %>%
  rename_if_exists("recordeddate", "recorded_time_raw") %>%
  rename_if_exists("duration_in_seconds", "duration_seconds") %>%
  rename_if_exists("userlanguage", "lang") %>%
  rename_if_exists("response_bias", "response_bias") %>%
  rename_if_exists("nationality", "nationality") %>%
  rename_if_exists("gender", "gender") %>%
  rename_if_exists("age", "age") %>%
  rename_if_exists("id", "sona_id") %>%
  rename_if_exists("q44_1", "english_proficiency")

s5_prepared <- s5_rename_items(s5_prepared, s5_dim_renames_lc)
s5_prepared <- s5_rename_items(s5_prepared, s5_valid_renames_lc)

# Feeling-thermometer items get descriptive names (one-item scales).
s5_prepared <- s5_prepared %>%
  rename_if_exists("favourability1_1", "feel_eu") %>%
  rename_if_exists("favourability1_2", "feel_nato") %>%
  rename_if_exists("favourability1_3", "feel_ru") %>%
  rename_if_exists("favourability1_4", "feel_ch")

s5_prepared <- s5_prepared %>%
  mutate(
    start_time = lubridate::ymd_hms(start_time_raw, tz = "UTC"),
    end_time = lubridate::ymd_hms(end_time_raw, tz = "UTC"),
    recorded_time = lubridate::ymd_hms(recorded_time_raw, tz = "UTC"),
    start_time_local = lubridate::with_tz(start_time, "Europe/Vilnius"),  # field site time zone (VU SONA pool)
    end_time_local = lubridate::with_tz(end_time, "Europe/Vilnius"),
    recorded_date = as.Date(recorded_time),
    study = "S5_LT_2026",
    country = "LT"  # VU SONA pool — Lithuania
  )

s5_numeric_all <- s5_prepared %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(across(any_of(c("age", "duration_seconds")), as.numeric))  # ensure logical filters operate on plain numerics

# CONSORT-style sequential exclusions: status 0 = legitimate Qualtrics record (not preview/spam),
# finished = 1, response_bias = 1 ("yes, I answered seriously"), and pop5 (the Feb-30 birthday
# item) <= 2 = strongly disagreed with the implausible statement (passed the embedded attention
# check). Age 18–85 mirrors the S1 adult-panel window.
s5_conditions <- list(
  "Legitimate response (Status = 0)" = rlang::expr(status == 0),
  "Finished survey" = rlang::expr(finished == 1),
  "Confirmed honest responding" = rlang::expr(response_bias == 1),
  "Passed Feb-30 attention check" = rlang::expr(pop5 <= 2),
  "Age between 18 and 85" = rlang::expr(dplyr::between(age, 18, 85))
)

s5_flow_res <- run_flow(s5_numeric_all, s5_conditions, id_var = "response_id")
s5_flow <- format_flow_table(s5_flow_res$log)
s5_ids_keep <- s5_flow_res$data$response_id
s5_fast_cutoff <- stats::quantile(s5_numeric_all$duration_seconds, probs = 0.01, na.rm = TRUE)

s5_factor_vars <- c("gender", "nationality", "lang", "response_bias")

s5_clean <- s5_prepared %>%
  filter(response_id %in% s5_ids_keep) %>%
  distinct(response_id, .keep_all = TRUE) %>%
  mutate(across(all_of(s5_factor_vars), make_factor, .names = "{.col}_fct")) %>%
  mutate(across(where(haven::is.labelled), haven::zap_labels)) %>%
  mutate(
    age = as.numeric(age),
    duration_seconds = as.numeric(duration_seconds),
    duration_minutes = duration_seconds / 60,
    fast_completion = duration_seconds < s5_fast_cutoff,
    att_pass_pop5 = pop5 <= 2  # embedded attention check (already enforced; retained for transparency)
  ) %>%
  # Reverse-key items: RWA short scale (1-9 → 10 - x) and Populist subscale anchors (1-7 → 8 - x).
  # Originals are kept; recoded versions live in `_r` columns so downstream scoring is explicit.
  mutate(
    rwa1_r = 10 - rwa1,
    rwa4_r = 10 - rwa4,
    rwa5_r = 10 - rwa5,
    pop2_r = 8 - pop2,
    pop6_r = 8 - pop6,
    pop9_r = 8 - pop9
  ) %>%
  select(-any_of(c("start_time_raw", "end_time_raw", "recorded_time_raw"))) %>%
  relocate(any_of(c(
    "study", "response_id", "sona_id", "start_time", "end_time", "start_time_local",
    "end_time_local", "recorded_time", "recorded_date", "duration_seconds", "duration_minutes",
    "fast_completion", "progress", "finished", "status", "response_bias", "response_bias_fct",
    "att_pass_pop5", "country", "lang", "lang_fct", "nationality", "nationality_fct",
    "gender", "gender_fct", "age", "english_proficiency"
  )))

# Item registry — covers the DisInformeter long-form battery and every validation scale.
s5_item_columns <- names(s5_clean) %>%
  stringr::str_subset("^(gen|th|cet|exp|lgb|mi|ab|unp|aban|cen|pa|pru|pkin|sup|sru|ski|cmq|pop|ptrust|rwa|itt_|panas|feel_)[0-9_a-z]*$") %>%
  setdiff(c("english_proficiency"))  # english_proficiency is a single-item language-skill rating; expose separately
s5_item_columns <- intersect(s5_item_columns, names(s5_clean))

# Scale registry — DisInformeter long-form predictor battery + external validation batteries.
# Construct labels (and the subscale slicing) mirror the validation_hypotheses_prereg.csv
# nomenclature so downstream code in 04-cross-study/04e-nomological-network.qmd can map directly to it.
# NOTE: S5 does NOT administer the FIMI news items, so this study has no outcome (DV)
# entry — it is a pure measurement-model validation wave.
s5_scale_items <- list(
  # ---- DisInformeter scale predictors (long-form measurement model) -----------
  # Registry keys drive Layer-2 column naming (`scale_UNP`, `scale_ABAN_STATE`,
  # `scale_CEN`, `scale_SUP_GEN`) and are intentionally preserved on their
  # legacy spellings. Canonical SEM latent labels in R/sem_specs.R are:
  #   UNP        -> ABANF  (Russia / NATO unprotectedness)
  #   ABAN_STATE -> ABANH  (state-level homeland abandonment)
  #   CEN        -> ABANM  (media / press-freedom abandonment)
  #   SUP_GEN    -> SUPF / SUPFCH (canonical S5 spec splits the 8-item block
  #                                into 4 Russia + 4 China affective anchors)
  GEN = paste0("gen", 1:8),                                # General DisInformeter items (mapped to ESS/Feeling thermometers in the hypothesis CSV)
  THREAT = paste0("th", 1:12),                             # Threat [general] — long form
  THREAT_GAY = c("th9", "th10", "th11"),                   # Subset used in the Queer-Sentiment ITT hypothesis
  THREAT_MIGR = c("th7", "th8"),                           # Subset used in the Immigrant ITT hypothesis
  THREAT_FT = c("th4", "th5", "th6"),                      # Subset used in the EU/NATO feeling thermometer hypothesis
  CET = paste0("cet", 1:8),                                # Poisonous ethnocentrism
  EXPL = paste0("exp", 1:5),                               # Exploitation
  GAY = paste0("lgb", 1:6),                                # Queer Sentiment (long form)
  MIGR = paste0("mi", 1:7),                                # Immigration (long form)
  ABAND = paste0("ab", 1:8),                               # Abandoned [general]
  UNP = paste0("unp", 1:4),                                # Unprotected by international allies — SEM latent: ABANF
  ABAN_STATE = paste0("aban", 1:5),                        # Abandoned by own state — SEM latent: ABANH
  CEN = paste0("cen", 1:6),                                # Abandoned by media (censorship) — SEM latent: ABANM
  FEAR = paste0("pa", 1:6),                                # Fear [general]
  PRAGR = paste0("pru", 1:6),                              # Pragmatism Russia (long form)
  PRAGCH = paste0("pkin", 1:6),                            # Pragmatism China (long form)
  SUP_GEN = paste0("sup", 1:8),                            # Superiority [general] — 4 RU + 4 CN admiration anchors; SEM latents: SUPF (sup1–4) + SUPFCH (sup5–8)
  SUPR = paste0("sru", 1:12),                              # Superiority Russia (specific belief items)
  SUPCH = paste0("ski", 1:11),                             # Superiority China (specific belief items)
  # ---- Conspiracy mentality (Bruder et al., 2013) -----------------------------
  CMQ = paste0("cmq", 1:5),
  # ---- Populist attitudes (Silva, 2017) — pop5 is the attention check, excluded
  POP = c("pop1", "pop2_r", "pop3", "pop4", "pop6_r", "pop7", "pop8", "pop9_r", "pop10"),
  POP_PEOPLE = c("pop1", "pop2_r", "pop3"),                # People-centrism
  POP_ELITE = c("pop4", "pop6_r", "pop7"),                 # Anti-elitism
  POP_MANI = c("pop8", "pop9_r", "pop10"),                 # Manichaean outlook (SESOI hypothesis vs Overall)
  # ---- Institutional trust battery (ESS) --------------------------------------
  TRUST_LOCAL = paste0("ptrust", 1:5),                     # Parliament, Legal System, Police, Politicians, Parties
  TRUST_EU = "ptrust6",                                    # European Parliament
  TRUST_UN = "ptrust7",                                    # United Nations
  # ---- Feeling thermometers (single items, 0–100) -----------------------------
  FEEL_EU = "feel_eu",
  FEEL_NATO = "feel_nato",
  FEEL_RU = "feel_ru",
  FEEL_CH = "feel_ch",
  # ---- Right-wing authoritarianism (RWA short / VSA) --------------------------
  RWA = c("rwa1_r", "rwa2", "rwa3", "rwa4_r", "rwa5_r", "rwa6"),
  # ---- Intergroup threat (ITT) ------------------------------------------------
  ITT_IMI = c(paste0("itt_imi_sym", 1:3), paste0("itt_imi_real", 1:3)),
  ITT_IMI_SYM = paste0("itt_imi_sym", 1:3),
  ITT_IMI_REAL = paste0("itt_imi_real", 1:3),
  ITT_LGBT = c(paste0("itt_lgbt_sym", 1:4), paste0("itt_lgbt_real", 1:4)),
  ITT_LGBT_SYM = paste0("itt_lgbt_sym", 1:4),
  ITT_LGBT_REAL = paste0("itt_lgbt_real", 1:4),
  # ---- Trait affectivity (I-PANAS-SF) -----------------------------------------
  PANAS_PA = paste0("panas", 1:5),                         # Active, Determined, Attentive, Inspired, Alert
  PANAS_NA = paste0("panas", 6:10)                         # Afraid, Nervous, Upset, Hostile, Ashamed
)

# Three-way taxonomy for S5. No FIMI outcome by design — S5 is a validation-only wave
# that pairs the DisInformeter long-form predictors with the external validation batteries.
s5_construct_taxonomy <- list(
  outcome = character(),
  predictor = c("GEN", "THREAT", "THREAT_GAY", "THREAT_MIGR", "THREAT_FT",
                "CET", "EXPL", "GAY", "MIGR", "ABAND", "UNP", "ABAN_STATE",
                "CEN", "FEAR", "PRAGR", "PRAGCH", "SUP_GEN", "SUPR", "SUPCH"),
  validation = c("CMQ", "POP", "POP_PEOPLE", "POP_ELITE", "POP_MANI",
                 "TRUST_LOCAL", "TRUST_EU", "TRUST_UN",
                 "FEEL_EU", "FEEL_NATO", "FEEL_RU", "FEEL_CH",
                 "RWA",
                 "ITT_IMI", "ITT_IMI_SYM", "ITT_IMI_REAL",
                 "ITT_LGBT", "ITT_LGBT_SYM", "ITT_LGBT_REAL",
                 "PANAS_PA", "PANAS_NA")
)
Code
# render step-by-step exclusion log
s5_flow %>%
  kable(align = c("l", "r", "r"), caption = "Study 5 sampling flow") %>%
  kable_styling(full_width = FALSE)
Study 5 sampling flow
step n dropped
Imported cases 261 0
Legitimate response (Status = 0) 259 2
Finished survey 257 2
Confirmed honest responding 253 4
Passed Feb-30 attention check 250 3
Age between 18 and 85 248 2
Removed duplicate IDs 248 0

Key takeaways

  • Study 5 retains 248 respondents (raw 261); the bulk of attrition stems from the embedded “birthday = February 30” attention check and the honest-response self-report.
  • The sample is a young, predominantly Lithuanian VU SONA pool (median age ≈ 20), so generalisation beyond university students should be treated cautiously — the design is geared toward measurement-model validation rather than population estimates.
  • The DisInformeter long-form items reproduce the S1–S3 naming (gen*, th*, cet*, exp*, lgb*, mi*, ab*, unp*, aban*, cen*, pa*, pru*, pkin*, sru*, ski*) so the existing lavaan syntax in R/sem_specs.R and 03-models/ can ingest the dataset without further mapping.
  • Three new construct families are introduced relative to S1–S4:
    1. External validation scales — CMQ, populist attitudes (with three subscales), institutional trust (local/EU/UN), feeling thermometers (EU/NATO/RU/CH), RWA short scale (VSA), ITT (symbolic/realistic × Immigrant/LGBT), and I-PANAS-SF (PA/NA). Each is exposed via canonical short prefixes and pre-computed subscale scores.
    2. “General superiority” anchors (sup1–sup8) — short admiration items split 4 RU / 4 CN that map onto the Superiority [general] row in validation_hypotheses_prereg.csv.
    3. Reverse-keyed columns (rwa{1,4,5}_r, pop{2,6,9}_r) so downstream scoring is explicit and the originals remain inspectable for diagnostic plots.
  • No FIMI news items are administered in Study 5; the analysis pipeline therefore relies on S1–S4 for the FIMI-receptivity outcome and on S5 for external-validity and nomological-network evidence on its predictor constructs.

Variable Preparation

Below we compute score columns for all LISREL-relevant constructs, maintaining clear prefixes (scale_). These augment the cleaned item-level datasets without altering the raw indicators used in structural models. The same prefix is used for the FIMI outcome (e.g. scale_fimi) and for the DisInformeter scale predictors (e.g. scale_threat), so downstream code can grab any score by name — the construct_taxonomy.rds registry built below is the source-of-truth for deciding whether a given scale_* column is the DV, a predictor, or an external validation score.

Code
s1_scored <- score_scales(s1_clean, s1_scale_items)
s2_scored <- score_scales(s2_clean, s2_scale_items)
s3_scored <- score_scales(s3_clean, s3_scale_items)
s4_scored <- score_scales(s4_clean, s4_scale_items)
s5_scored <- score_scales(s5_clean, s5_scale_items)

# Override the two select-all-that-apply Study 4 scales with sum-counts.
# Q12_* (MISINFO_ACTION) and Q25_* (POL_PARTIC) come from Qualtrics as 1 (selected) / NA (not selected),
# so a rowMeans degenerates to 1 (or NaN). Sum-counts preserve respondent variation.
s4_sata_items <- list(
  scale_misinfo_action = s4_scale_items$MISINFO_ACTION,
  scale_pol_partic = s4_scale_items$POL_PARTIC
)
for (s in names(s4_sata_items)) {
  items <- intersect(s4_sata_items[[s]], names(s4_scored))
  if (length(items) > 0) {
    s4_scored[[s]] <- rowSums(s4_scored[items] == 1, na.rm = TRUE)  # treat unselected NA as 0
  }
}

s1_scale_cols <- names(s1_scored) %>% stringr::str_subset("^scale_")  # isolate derived scores
s2_scale_cols <- names(s2_scored) %>% stringr::str_subset("^scale_")
s3_scale_cols <- names(s3_scored) %>% stringr::str_subset("^scale_")
s4_scale_cols <- names(s4_scored) %>% stringr::str_subset("^scale_")
s5_scale_cols <- names(s5_scored) %>% stringr::str_subset("^scale_")

Dataframes

Items dataframes

We create compact item-level frames that include IDs, key covariates, and all questionnaire indicators required for LISREL replication.

Code
id_vars_common <- c(
  "study", "ppt", "response_id", "sona_id", "start_time", "end_time", "start_time_local", "end_time_local",
  "recorded_time", "recorded_date", "duration_seconds", "duration_minutes", "fast_completion",
  "progress", "finished", "status", "cons", "consent", "consentlt", "response_bias", "response_bias_fct",
  "gender", "gender_fct", "age", "age_fct",
  "age_bracket", "age_bracket_fct", "age_midpoint", "lang", "lang_fct", "language", "language_fct",
  "edu", "edu_fct", "education", "education_fct", "income", "income_fct",
  "country", "country_fct", "country_of_origin", "country_of_origin_fct",
  "nationality", "nationality_fct", "marital_st", "marital_st_fct",
  "cond", "cond_fct", "att", "att_q16", "att_pass_q16", "att_pass_embedded", "att_pass_q29", "att_pass_pop5",
  "english_proficiency"
)  # ensures every exported table carries the same identifier scaffold

s1_items <- s1_clean %>%
  select(any_of(id_vars_common), all_of(s1_item_columns))  # common metadata + items

s2_items <- s2_clean %>%
  select(any_of(id_vars_common), all_of(s2_item_columns))

s3_items <- s3_clean %>%
  select(any_of(id_vars_common), all_of(s3_item_columns))

s4_items <- s4_clean %>%
  select(any_of(id_vars_common), all_of(s4_item_columns))

s5_items <- s5_clean %>%
  select(any_of(id_vars_common), all_of(s5_item_columns))

Scale score dataframes

Code
s1_scales <- s1_scored %>% select(any_of(id_vars_common), all_of(s1_scale_cols))
s2_scales <- s2_scored %>% select(any_of(id_vars_common), all_of(s2_scale_cols))
s3_scales <- s3_scored %>% select(any_of(id_vars_common), all_of(s3_scale_cols))
s4_scales <- s4_scored %>% select(any_of(id_vars_common), all_of(s4_scale_cols))
s5_scales <- s5_scored %>% select(any_of(id_vars_common), all_of(s5_scale_cols))

cross_study_character_vars <- c("response_id", "sona_id", "lang", "country", "country_of_origin", "nationality")
s1_items <- s1_items %>% mutate(across(any_of(cross_study_character_vars), as.character))
s2_items <- s2_items %>% mutate(across(any_of(cross_study_character_vars), as.character))
s3_items <- s3_items %>% mutate(across(any_of(cross_study_character_vars), as.character))
s4_items <- s4_items %>% mutate(across(any_of(cross_study_character_vars), as.character))
s5_items <- s5_items %>% mutate(across(any_of(cross_study_character_vars), as.character))
s1_scales <- s1_scales %>% mutate(across(any_of(cross_study_character_vars), as.character))
s2_scales <- s2_scales %>% mutate(across(any_of(cross_study_character_vars), as.character))
s3_scales <- s3_scales %>% mutate(across(any_of(cross_study_character_vars), as.character))
s4_scales <- s4_scales %>% mutate(across(any_of(cross_study_character_vars), as.character))
s5_scales <- s5_scales %>% mutate(across(any_of(cross_study_character_vars), as.character))

# Combined frames stack the five studies; Studies 4–5 bring additional columns absent in
# earlier waves (Q*/COND/Convergent batteries) which are filled with NA for the older waves,
# and Study 5 carries the SONA `sona_id` instead of the legacy Norstat `ppt` identifier.
combined_items <- bind_rows(s1_items, s2_items, s3_items, s4_items, s5_items)  # stacked item-level frame
combined_scales <- bind_rows(s1_scales, s2_scales, s3_scales, s4_scales, s5_scales)

Item mapping QA

The hand-built data/item_mapping_mini.csv is treated as a cross-study dictionary, not as executable cleaning logic. The audit below canonicalises the mapped names the same way the cleaning code does, then checks whether mapped variables exist in the actual cleaned item exports. It also lists cleaned core DisInformeter items that are not represented in the dictionary so naming drift is visible before modelling.

Code
study_scale_items <- list(
  S1 = s1_scale_items,
  S2 = s2_scale_items,
  S3 = s3_scale_items,
  S4 = s4_scale_items,
  S5 = s5_scale_items
)

study_item_sets <- list(
  S1 = s1_item_columns,
  S2 = s2_item_columns,
  S3 = s3_item_columns,
  S4 = s4_item_columns,
  S5 = s5_item_columns
)

item_mapping_mini <- readr::read_csv(
  here::here("data", "item_mapping_mini.csv"),
  na = c("", "NA"),
  show_col_types = FALSE
)

canonicalize_mapping_item <- function(study_code, item) {
  out <- clean_column_names(item)
  out <- dplyr::case_when(
    study_code == "S1" ~ stringr::str_replace_all(out, s1_pattern_map),
    TRUE ~ out
  )
  out
}

item_mapping_specs <- tibble::tribble(
  ~study_code, ~primary_col, ~fallback_col, ~core_pattern,
  "S1", "S1", NA_character_, "^(poi|exp|dec|lgb|unp|aban|cen|pru|pkin|sru|ski|news)[0-9]",
  "S2", "S2_rename", "S2", "^(gen|th|cet|exp|lgb|mi|unp|aban|ab|cen|pru|pa|sru|fru|fch|ski|news|pch|pkin)[0-9]",
  "S3", "S3", NA_character_, "^(gen|th|cet|exp|lgb|mi|unp|aban|ab|cen|pru|pa|sru|fru|fch|ski|news|pch|pkin)[0-9]",
  "S4", "S4_vub", NA_character_, "^(gen|th|ab|pa|fru|fch|news)[0-9]",
  "S5", "S5_validation_rename", "S5_validation", "^(gen|th|cet|exp|lgb|mi|ab|unp|aban|cen|pa|pru|pkin|sup|sru|ski)[0-9]"
)

item_mapping_long <- purrr::pmap_dfr(item_mapping_specs, function(study_code, primary_col, fallback_col, core_pattern) {
  primary_items <- item_mapping_mini[[primary_col]]
  fallback_items <- if (!is.na(fallback_col) && fallback_col %in% names(item_mapping_mini)) item_mapping_mini[[fallback_col]] else NA_character_

  tibble(
    study_code = study_code,
    mapped_item = dplyr::coalesce(primary_items, fallback_items),
    scale_label = item_mapping_mini$scale
  ) %>%
    filter(!is.na(mapped_item), mapped_item != "") %>%
    mutate(mapped_item = canonicalize_mapping_item(study_code, mapped_item)) %>%
    filter(stringr::str_detect(mapped_item, core_pattern)) %>%
    distinct(study_code, mapped_item, .keep_all = TRUE)
})

item_mapping_audit <- item_mapping_specs %>%
  mutate(
    mapped_items = purrr::map(study_code, ~ sort(unique(item_mapping_long$mapped_item[item_mapping_long$study_code == .x]))),
    cleaned_core_items = purrr::map2(study_code, core_pattern, ~ sort(stringr::str_subset(study_item_sets[[.x]], .y))),
    missing_from_cleaned_data = purrr::map2(mapped_items, cleaned_core_items, setdiff),
    cleaned_items_missing_from_mapping = purrr::map2(cleaned_core_items, mapped_items, setdiff),
    mapped_n = purrr::map_int(mapped_items, length),
    cleaned_core_n = purrr::map_int(cleaned_core_items, length),
    missing_from_cleaned_data_n = purrr::map_int(missing_from_cleaned_data, length),
    cleaned_items_missing_from_mapping_n = purrr::map_int(cleaned_items_missing_from_mapping, length),
    missing_from_cleaned_data = purrr::map_chr(missing_from_cleaned_data, ~ paste(.x, collapse = ", ")),
    cleaned_items_missing_from_mapping = purrr::map_chr(cleaned_items_missing_from_mapping, ~ paste(.x, collapse = ", "))
  ) %>%
  select(
    study_code, mapped_n, cleaned_core_n,
    missing_from_cleaned_data_n, cleaned_items_missing_from_mapping_n,
    missing_from_cleaned_data, cleaned_items_missing_from_mapping
  )

item_mapping_audit %>%
  kable(
    align = c("l", rep("r", 4), "l", "l"),
    caption = "Audit of hand-built item mapping against cleaned core item columns"
  ) %>%
  kable_styling(full_width = FALSE)
Audit of hand-built item mapping against cleaned core item columns
study_code mapped_n cleaned_core_n missing_from_cleaned_data_n cleaned_items_missing_from_mapping_n missing_from_cleaned_data cleaned_items_missing_from_mapping
S1 94 94 0 0
S2 126 126 0 0
S3 126 126 0 0
S4 35 35 0 0
S5 118 118 0 0

Save dataframes

All cleaned artefacts are written in Parquet (lossless, columnar) and CSV formats inside data/wrangled_data/. Parquet is the recommended input for LISREL-style workflows because it preserves numeric precision and metadata.

Code
output_dir <- here::here("data", "wrangled_data")
if (!dir.exists(output_dir)) dir.create(output_dir, recursive = TRUE)  # create storage directory once

arrow::write_parquet(s1_items, here::here("data", "wrangled_data", "study1_items.parquet"))  # LISREL input (Study 1 items)
arrow::write_parquet(s2_items, here::here("data", "wrangled_data", "study2_items.parquet"))
arrow::write_parquet(s3_items, here::here("data", "wrangled_data", "study3_items.parquet"))
arrow::write_parquet(s4_items, here::here("data", "wrangled_data", "study4_items.parquet"))
arrow::write_parquet(s5_items, here::here("data", "wrangled_data", "study5_items.parquet"))
arrow::write_parquet(s1_scales, here::here("data", "wrangled_data", "study1_scales.parquet"))  # summary scores
arrow::write_parquet(s2_scales, here::here("data", "wrangled_data", "study2_scales.parquet"))
arrow::write_parquet(s3_scales, here::here("data", "wrangled_data", "study3_scales.parquet"))
arrow::write_parquet(s4_scales, here::here("data", "wrangled_data", "study4_scales.parquet"))
arrow::write_parquet(s5_scales, here::here("data", "wrangled_data", "study5_scales.parquet"))
arrow::write_parquet(combined_items, here::here("data", "wrangled_data", "disinformeter_items_all.parquet"))  # stacked items for pooled models
arrow::write_parquet(combined_scales, here::here("data", "wrangled_data", "disinformeter_scales_all.parquet"))
saveRDS(study_scale_items, here::here("data", "wrangled_data", "scale_blueprints.rds"))  # single source of truth for descriptives

construct_taxonomy <- list(
  S1 = s1_construct_taxonomy,
  S2 = s2_construct_taxonomy,
  S3 = s3_construct_taxonomy,
  S4 = s4_construct_taxonomy,
  S5 = s5_construct_taxonomy
)
saveRDS(construct_taxonomy, here::here("data", "wrangled_data", "construct_taxonomy.rds"))  # outcome / predictor / validation tagging used by 02-descriptives
readr::write_csv(item_mapping_audit, here::here("data", "wrangled_data", "item_mapping_audit.csv"))

readr::write_csv(s1_items, here::here("data", "wrangled_data", "study1_items.csv"))  # CSV mirrors for spreadsheet review
readr::write_csv(s2_items, here::here("data", "wrangled_data", "study2_items.csv"))
readr::write_csv(s3_items, here::here("data", "wrangled_data", "study3_items.csv"))
readr::write_csv(s4_items, here::here("data", "wrangled_data", "study4_items.csv"))
readr::write_csv(s5_items, here::here("data", "wrangled_data", "study5_items.csv"))
readr::write_csv(s1_scales, here::here("data", "wrangled_data", "study1_scales.csv"))
readr::write_csv(s2_scales, here::here("data", "wrangled_data", "study2_scales.csv"))
readr::write_csv(s3_scales, here::here("data", "wrangled_data", "study3_scales.csv"))
readr::write_csv(s4_scales, here::here("data", "wrangled_data", "study4_scales.csv"))
readr::write_csv(s5_scales, here::here("data", "wrangled_data", "study5_scales.csv"))

References