9  Appendix

9.1 Questionnaries

9.2 Randomization design check: simulated data

9.2.1 Purpose

The distributive conjoint’s identification argument (see Questionnaire design A: Distributive Conjoint Survey Experiment, Causal identification) rests on complete, independent randomization of attribute levels: every attribute is drawn uniformly and independently of every other attribute and of the competing profile, subject only to a pairwise restriction that the two profiles in a task differ on at least two attributes. A natural concern is whether this restriction, or any implementation detail of the randomizer, inadvertently introduces correlation between attributes, which would threaten the orthogonality the AMCE estimator assumes.

This appendix answers that concern empirically rather than asserting it. It simulates data using the exact randomization procedure implemented in surveys/pre-piloto/sin_comentarios/app.R (the pre-pilot Shiny/surveydown app), and then checks (a) how densely the design space is covered and (b) whether attribute levels are correlated with one another, both within a profile and across the two profiles of a task.

9.2.2 Design parameters, as implemented in app.R

The parameters below are read directly from build_default_conjoint_design() and make_cbc_table() in app.R, not reconstructed from the design write-up, so that the simulation cannot silently drift from what the app actually does.

  • Seven attributes. Six are explicit rows in the profile table — need, identity, control, effort, reciprocity, attitude — plus applicant sex, signaled only through a first name (never an explicit row).
  • Levels and marginal probabilities. Every level within an attribute is drawn with sample(lvls, 1), i.e. uniformly: 1/2 for the five binary attributes (need, control, reciprocity, attitude, sex) and 1/3 for the two three-level attributes (identity, effort). The exact level strings coded in niveles are reproduced in Table 9.1 below.
  • Pairwise restriction. generate_pair() draws the two profiles’ six explicit attributes (identity/need/control/effort/reciprocity/attitude — not sex) independently and redraws the whole pair — both profiles, all six attributes — whenever they differ on fewer than min_diff = 2 attributes. This is a rejection sampler operating on the joint distribution of the pair, not a constraint on any single attribute’s marginal.
  • Attribute display order / NICERA code. Once per respondent, sample() draws a random permutation of the six explicit attribute names; this order is held constant across that respondent’s six tasks and is persisted as a 6-letter code (Need, Identity, Control, Effort, Reciprocity, Attitude) via attr_code. Sex is not part of this code — it always occupies the profile header, never a randomized row position.
  • Six tasks, two profiles per task, one respondent draws one respondent-level design row (respID) from the pre-generated design table, or, when the newer profile columns are absent, falls back to build_default_conjoint_design() — the function reproduced here.
niveles <- list(
  need        = c("Dificultad", "Holgura"),
  identity    = c("Chile", "Venezuela", "Perú"),
  control     = c("Postuló a otras becas pero no obtuvo financiamiento",
                   "No alcanzó a postular a tiempo a otras becas"),
  effort      = c("Más que sus compañeros",
                   "Igual que sus compañeros",
                   "Menos que sus compañeros"),
  reciprocity = c("Ha hecho voluntariado", "No ha hecho voluntariado"),
  attitude    = c("Una ayuda que agradece", "Algo que se merece")
)

attr_names <- names(niveles)
n_levels   <- lengths(niveles)

library(kableExtra)
data.frame(
  Attribute  = attr_names,
  N_levels   = n_levels,
  P_level    = paste0("1/", n_levels),
  Levels     = sapply(niveles, paste, collapse = " | ")
) |>
  kbl(row.names = FALSE,
      col.names = c("Attribute", "N levels", "P(level)", "Levels (as coded)")) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Table 9.1: Attributes and levels exactly as coded in app.R’s niveles list
Attribute N levels P(level) Levels (as coded)
need 2 1/2 Dificultad | Holgura
identity 3 1/3 Chile | Venezuela | Perú
control 2 1/2 Postuló a otras becas pero no obtuvo financiamiento | No alcanzó a postular a tiempo a otras becas
effort 3 1/3 Más que sus compañeros | Igual que sus compañeros | Menos que sus compañeros
reciprocity 2 1/2 Ha hecho voluntariado | No ha hecho voluntariado
attitude 2 1/2 Una ayuda que agradece | Algo que se merece

Sex is not in niveles (it is generated separately), so it is added here only for reference: two levels, drawn uniformly, from an 8-name pool per sex (male_names, female_names in app.R).

male_names   <- c("Mateo", "Lucas", "Benjamin", "Nicolas", "Daniel",
                   "Santiago", "Tomas", "Joaquin")
female_names <- c("Sofia", "Valentina", "Isidora", "Martina", "Camila",
                   "Florencia", "Catalina", "Antonia")
attr_code    <- c(need = "N", identity = "I", control = "C",
                   effort = "E", reciprocity = "R", attitude = "A")

With seven attributes at these level counts, the profile space has \(2 \times 3 \times 2 \times 3 \times 2 \times 2 \times 2 = 288\) unique combinations, matching the main design chapter.

9.2.3 Step 1 — Simulating respondents

The simulation targets \(N = 4{,}000\) respondents, 6 tasks each, 2 profiles per task. Pair generation replicates generate_pair() exactly (independent uniform draw per attribute for each profile, redraw the whole pair if it differs on fewer than 2 of the six explicit attributes), implemented here as a vectorized rejection sampler for speed rather than the app’s per-pair repeat loop; the acceptance rule is identical, only the batching differs.

if (! require("pacman")) install.packages("pacman")

pacman::p_load(dplyr,
               tidyr, 
               purrr,
               tibble,
               jsonlite,
               stringr,
               psych,
               corrplot)

set.seed(2026)

N        <- 4000
n_tasks  <- 6
n_pairs  <- N * n_tasks  # one pair of profiles per task

# Vectorized version of generate_pair(): draw a batch of candidate pairs,
# keep only those differing on >= 2 of the 6 explicit attributes, repeat
# until enough admissible pairs have been collected.
gen_batch <- function(n) {
  m1 <- sapply(n_levels, function(L) sample.int(L, n, replace = TRUE))
  m2 <- sapply(n_levels, function(L) sample.int(L, n, replace = TRUE))
  keep <- rowSums(m1 != m2) >= 2
  list(m1 = m1[keep, , drop = FALSE], m2 = m2[keep, , drop = FALSE])
}

acc1 <- matrix(nrow = 0, ncol = 6)
acc2 <- acc1
while (nrow(acc1) < n_pairs) {
  batch <- gen_batch(ceiling((n_pairs - nrow(acc1)) * 1.15) + 10)
  acc1  <- rbind(acc1, batch$m1)
  acc2  <- rbind(acc2, batch$m2)
}
acc1 <- acc1[seq_len(n_pairs), ]
acc2 <- acc2[seq_len(n_pairs), ]
colnames(acc1) <- attr_names
colnames(acc2) <- attr_names

stopifnot(min(rowSums(acc1 != acc2)) >= 2)  # restriction holds for every task

# Map integer level codes back to the label strings coded in niveles
to_labels <- function(m) {
  as.data.frame(
    lapply(attr_names, function(a) niveles[[a]][m[, a]]),
    col.names = attr_names, stringsAsFactors = FALSE
  )
}
lab1 <- to_labels(acc1)
lab2 <- to_labels(acc2)

# Sex/name: independent per profile-slot, exactly as in make_cbc_table()
sex_draw <- function(n) {
  is_male <- runif(n) < 0.5
  nm <- character(n)
  nm[is_male]  <- sample(male_names,   sum(is_male),  replace = TRUE)
  nm[!is_male] <- sample(female_names, sum(!is_male), replace = TRUE)
  nm
}
lab1$nombre <- sex_draw(n_pairs)
lab2$nombre <- sex_draw(n_pairs)

session_id <- rep(seq_len(N), each = n_tasks)
task_id    <- rep(seq_len(n_tasks), times = N)
lab1$session_id <- session_id; lab1$task_id <- task_id
lab2$session_id <- session_id; lab2$task_id <- task_id

# Attribute display order per respondent -> NICERA code, exactly as in app.R
attr_order_mat <- t(replicate(N, sample(attr_names)))
nicera_code    <- apply(attr_order_mat, 1, function(o) paste(attr_code[o], collapse = ""))

9.2.4 Step 2 — Assembling the simulated cbc_profiles database

app.R stores one JSON blob per respondent (cbc_profiles) with the six tasks nested under keys q1–q6, each holding the two alternatives under a1/a2, each alternative holding the six attribute values plus nombre. It also stores the display order as cbc_attr_order (the NICERA code) and — via surveydown’s own input persistence — one slider answer per task (cbc_q1-cbc_q6).

The slider/share values are not simulated from any effect model here: this appendix checks randomization structure, not treatment effects.

build_json_for_session <- function(df1, df2) {
  tasks <- vector("list", n_tasks)
  for (t in seq_len(n_tasks)) {
    tasks[[t]] <- list(
      a1 = c(as.list(df1[t, attr_names, drop = FALSE]), nombre = df1$nombre[t]),
      a2 = c(as.list(df2[t, attr_names, drop = FALSE]), nombre = df2$nombre[t])
    )
  }
  names(tasks) <- paste0("q", seq_len(n_tasks))
  as.character(toJSON(tasks, auto_unbox = TRUE))
}

split1 <- split(lab1, lab1$session_id)
split2 <- split(lab2, lab2$session_id)
profiles_json <- map2_chr(split1, split2, build_json_for_session)

# Placeholder share: a random even split per task, complementary by
# construction (share_a1 + share_a2 = 100) so the fixed-sum shape of the
# real outcome is preserved structurally. NOT used to estimate anything.
share_a1 <- matrix(runif(n_pairs, 0, 100), nrow = N, ncol = n_tasks, byrow = TRUE)

sim_db <- tibble(
  session_id     = as.integer(names(split1)),
  cbc_attr_order = nicera_code,
  cbc_profiles   = profiles_json
) |>
  bind_cols(as.data.frame(share_a1) |> setNames(paste0("cbc_q", seq_len(n_tasks))))

glimpse(sim_db[1, ])
Rows: 1
Columns: 9
$ session_id     <int> 1
$ cbc_attr_order <chr> "CNAEIR"
$ cbc_profiles   <chr> "{\"q1\":{\"a1\":{\"need\":\"Dificultad\",\"identity\":…
$ cbc_q1         <dbl> 13.83732
$ cbc_q2         <dbl> 64.06149
$ cbc_q3         <dbl> 95.98121
$ cbc_q4         <dbl> 38.31619
$ cbc_q5         <dbl> 32.36069
$ cbc_q6         <dbl> 4.203217

sim_db now has the same shape as the real response table would.

9.2.5 Step 3 — Reshape to long format

This is the pipeline that will be applied to the real pilot export: parse each respondent’s cbc_profiles JSON, unnest tasks and alternatives into one row per session_id × task × altID, and recover sex from nombre via the same name pools app.R draws from.

sex_lookup <- setNames(
  c(rep("male", length(male_names)), rep("female", length(female_names))),
  c(male_names, female_names)
)

reshape_one <- function(session_id, json_str) {
  parsed <- fromJSON(json_str, simplifyVector = FALSE)
  imap_dfr(parsed, function(task_data, qname) {
    imap_dfr(task_data, function(alt_data, altname) {
      as_tibble(alt_data) |>
        mutate(task  = as.integer(sub("q", "", qname)),
               altID = as.integer(sub("a", "", altname)))
    })
  }) |>
    mutate(session_id = session_id, .before = 1)
}

long_df <- map2_dfr(sim_db$session_id, sim_db$cbc_profiles, reshape_one) |>
  mutate(sex = unname(sex_lookup[nombre]))

attr_cols <- c("need", "identity", "control", "effort", "reciprocity", "attitude", "sex")
long_df |> select(session_id, task, altID, all_of(attr_cols)) |> head(4) |>
  kbl(row.names = FALSE) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE) |>
  scroll_box(width = "100%")
session_id task altID need identity control effort reciprocity attitude sex
1 1 1 Dificultad Perú Postuló a otras becas pero no obtuvo financiamiento Igual que sus compañeros No ha hecho voluntariado Una ayuda que agradece female
1 1 2 Dificultad Chile Postuló a otras becas pero no obtuvo financiamiento Igual que sus compañeros Ha hecho voluntariado Una ayuda que agradece female
1 2 1 Dificultad Chile No alcanzó a postular a tiempo a otras becas Más que sus compañeros Ha hecho voluntariado Algo que se merece male
1 2 2 Holgura Perú No alcanzó a postular a tiempo a otras becas Igual que sus compañeros Ha hecho voluntariado Una ayuda que agradece female

long_df has 48000 rows — 4000 respondents × 6 tasks × 2 alternatives — one row per presented profile, with the seven attributes as columns. This is the table format the pilot analysis (and the AMCE models in the main design chapter) will be estimated on.

9.2.6 Coverage

long_df <- long_df |>
  mutate(profile_id = paste(need, identity, control, effort,
                             reciprocity, attitude, sex, sep = "|"))

profile_counts <- long_df |> count(profile_id, name = "n_appearances")
n_unique_profiles <- nrow(profile_counts)

tibble(
  Quantity = c("Unique profiles observed", "Possible profiles (2·3·2·3·2·2·2)",
               "Mean appearances / profile", "Min appearances / profile",
               "Max appearances / profile"),
  Value = c(n_unique_profiles, 288,
            round(mean(profile_counts$n_appearances), 1),
            min(profile_counts$n_appearances),
            max(profile_counts$n_appearances))
) |>
  kbl(row.names = FALSE) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Quantity Value
Unique profiles observed 288.0
Possible profiles (2·3·2·3·2·2·2) 288.0
Mean appearances / profile 166.7
Min appearances / profile 134.0
Max appearances / profile 212.0
level_balance <- purrr::map_dfr(attr_cols, function(a) {
  long_df |> count(level = .data[[a]]) |>
    mutate(attribute = a, .before = 1)
})

level_balance |>
  kbl(row.names = FALSE, col.names = c("Attribute", "Level", "N observed")) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Attribute Level N observed
need Dificultad 23988
need Holgura 24012
identity Chile 16027
identity Perú 15905
identity Venezuela 16068
control No alcanzó a postular a tiempo a otras becas 23848
control Postuló a otras becas pero no obtuvo financiamiento 24152
effort Igual que sus compañeros 15943
effort Menos que sus compañeros 16180
effort Más que sus compañeros 15877
reciprocity Ha hecho voluntariado 24039
reciprocity No ha hecho voluntariado 23961
attitude Algo que se merece 23930
attitude Una ayuda que agradece 24070
sex female 23811
sex male 24189

Every level of every attribute lands close to its expected count (\(N \times 6 \times 2\) divided by the number of levels), consistent with uniform, independent draws.

9.2.7 Orthogonality: correlation matrix

Within-profile. For each presented profile (48,000 of them: 4,000 respondents × 6 tasks × 2 alternatives). Under complete independent randomization these correlations should be indistinguishable from zero.

dummy_df <- long_df |>
  mutate(across(all_of(attr_cols), factor)) |>
  select(all_of(attr_cols))

#mm <- model.matrix(~ ., data = dummy_df)[, -1]

mm <- dummy_df |> mutate_all(~as.numeric(.))

cor_within <- cor(mm)
diag(cor_within) <- NA

rownames(cor_within) <- c("A. Need:",
                     "B. Identity",
                     "C. Control",
                     "D. Effort",
                     "E. Reciprocity",
                     "F. Attitude",
                     "G. Sex")

#set Column names of the matrix
colnames(cor_within) <-c("(A)", "(B)","(C)","(D)","(E)","(F)","(G)")

testp <- cor.mtest(cor_within, conf.level = 0.95)

#Plot the matrix using corrplot
corrplot::corrplot(cor_within,
                   method = "color",
                   addCoef.col = "black",
                   type = "upper",
                   tl.col = "black",
                   col = colorRampPalette(c("#E16462", "white", "#0D0887"))(12),
                   bg = "white",
                   na.label = "-") 

max_r_within <- max(cor_within, na.rm = T)

Maximum \(|r|\) across all atributtes: 0.0107.

n_profiles_total <- nrow(long_df)
se_r  <- 1 / sqrt(n_profiles_total)
band3 <- 3 * se_r

With 48,000 simulated profiles, the sampling standard error of a correlation under the null of true independence is \(1/\sqrt{48,000} \approx 0.0046\), so correlations within \(\pm0.0137\) (three standard errors) are indistinguishable from sampling noise. The observed maximum, 0.0107, falls inside that band.

Robustness across sample sizes. The check above uses the full \(N = 4{,}000\) simulated sample. To confirm that the near-zero correlations are a small-sample-noise phenomenon that shrinks with \(N\) — rather than an artifact of averaging over a large sample — the same within-profile check (one numeric column per attribute, off-diagonal correlations) is repeated at \(N =\) 500, 1,000, 1,500 and 4,000 respondents, 6 tasks each. Each run reuses gen_batch() for the pairwise-restricted draws and adds sex independently per profile, exactly as in the rest of this appendix.

check_orthogonality_at_N <- function(N_resp, n_tasks_check = 6, seed = 2026) {
  set.seed(seed)
  n_pairs_check <- N_resp * n_tasks_check

  acc1_n <- matrix(nrow = 0, ncol = length(n_levels))
  acc2_n <- acc1_n
  while (nrow(acc1_n) < n_pairs_check) {
    batch  <- gen_batch(ceiling((n_pairs_check - nrow(acc1_n)) * 1.15) + 10)
    acc1_n <- rbind(acc1_n, batch$m1)
    acc2_n <- rbind(acc2_n, batch$m2)
  }
  acc1_n <- acc1_n[seq_len(n_pairs_check), ]
  acc2_n <- acc2_n[seq_len(n_pairs_check), ]
  colnames(acc1_n) <- attr_names
  colnames(acc2_n) <- attr_names

  lab1_n <- to_labels(acc1_n)
  lab2_n <- to_labels(acc2_n)
  lab1_n$sex <- ifelse(runif(n_pairs_check) < 0.5, "male", "female")
  lab2_n$sex <- ifelse(runif(n_pairs_check) < 0.5, "male", "female")

  mm_n <- bind_rows(lab1_n[, attr_cols], lab2_n[, attr_cols]) |>
    mutate(across(everything(), factor)) |>
    mutate_all(~ as.numeric(.))

  cor_n <- cor(mm_n)
  diag(cor_n) <- NA

  n_profiles_check <- nrow(mm_n)
  max_r_check <- max(abs(cor_n), na.rm = TRUE)
  band3_check <- 3 / sqrt(n_profiles_check)

  tibble(
    N_respondents = N_resp,
    N_profiles    = n_profiles_check,
    Max_abs_r     = round(max_r_check, 4),
    Band_3SE      = round(band3_check, 4),
    Within_band   = ifelse(max_r_check <= band3_check, "yes", "no")
  )
}

N_grid <- c(500, 1000, 1500, 4000)
orthogonality_by_N <- purrr::map_dfr(N_grid, check_orthogonality_at_N)

orthogonality_by_N |>
  kbl(row.names = FALSE,
      col.names = c("N respondents", "N profiles (N×6×2)", "Max |r| (cross-attribute)",
                    "3-SE band (±)", "Within band?")) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
N respondents N profiles (N×6×2) Max |r| (cross-attribute) 3-SE band (±) Within band?
500 6000 0.0260 0.0387 yes
1000 12000 0.0192 0.0274 yes
1500 18000 0.0164 0.0224 yes
4000 48000 0.0124 0.0137 yes

The 3-SE band widens as \(N\) shrinks — from $$0.0137 at \(N=4{,}000\) to $$0.0387 at \(N=500\) — and the observed maximum \(|r|\) widens along with it (0.026 at \(N=500\) down to 0.0124 at \(N=4{,}000\)), staying inside the band at every \(N\) tested. That the maximum tracks the shrinking band rather than sitting at a fixed floor is the signature of sampling noise around a true zero correlation, not of a residual structural dependence a larger sample would eventually reveal.

Between profiles, within the same task. The restriction (differ on ≥2 attributes) is a constraint on the pair, so some negative dependence between the two profiles of a task is expected and is not a threat to identification — it is orthogonal to (does not touch) the within-profile independence checked above.

wide_between <- long_df |>
  select(session_id, task, altID, all_of(attr_cols)) |>
  pivot_wider(id_cols = c(session_id, task), names_from = altID,
              values_from = all_of(attr_cols), names_sep = "_")

dummy_a <- wide_between |> select(ends_with("_1")) |> mutate(across(everything(), factor))
dummy_b <- wide_between |> select(ends_with("_2")) |> mutate(across(everything(), factor))
mm_a <- dummy_a |> mutate_all(~as.numeric(.))
mm_b <- dummy_b |> mutate_all(~as.numeric(.))
cross_cor <- cor(mm_a, mm_b)

max_r_between  <- max(abs(cross_cor))
mean_r_between <- mean(cross_cor)

Mean cross-profile correlation: -0.006 (slightly negative, as expected from the restriction). Maximum \(|r|\): 0.0545. This dependence is between the two profiles of a task, not between attributes within a profile — it plays no role in AMCE identification, which conditions on one profile’s own attribute vector (see main design chapter, Causal identification and Restrictions).

9.2.8 Summary

Under the randomization exactly as coded in app.R — independent uniform draws per attribute, with a pair redrawn only when its six CARIN/NICER attributes differ on fewer than two of them, and sex assigned independently afterward — the simulated data show: (i) near-complete coverage of the 288-profile space (288/288 profiles observed, 134–212 appearances each); (ii) uniform level balance within every attribute; (iii) within-profile attribute correlations indistinguishable from sampling noise (max \(|r|\) = 0.0107, against a 0.0137 three-SE band); and (iv) a between-profile, within-task correlation that is mildly negative, as the pairwise restriction predicts, but structurally separate from within-profile orthogonality.

9.3 Attribute-order diagnostic (mechanics only)

app.R persists the display order of the six explicit attributes per respondent as a 6-letter NICERA code (Section 9.2.4). Hainmueller et al. (2014), sec. 5.3.4, recommend testing whether an attribute’s estimated effect depends on the screen position it was shown in, via a joint Wald test on an attribute-level × position interaction. The code below implements that test end to end: parsing the NICERA code back into a per-attribute, per-respondent position, joining it to the long-format data, and running the joint interaction test.

code_to_attr <- c(N = "need", I = "identity", C = "control",
                   E = "effort", R = "reciprocity", A = "attitude")

attr_position <- function(nicera_code, attribute) {
  map_int(str_split(nicera_code, ""), function(letters_seq) {
    which(code_to_attr[letters_seq] == attribute)
  })
}

position_df <- purrr::map_dfc(attr_names, function(a) {
  tibble(!!paste0("pos_", a) := attr_position(sim_db$cbc_attr_order, a))
}) |>
  mutate(session_id = sim_db$session_id, .before = 1)

head(position_df)
# A tibble: 6 × 7
  session_id pos_need pos_identity pos_control pos_effort pos_reciprocity
       <int>    <int>        <int>       <int>      <int>           <int>
1          1        2            5           1          4               6
2          2        4            2           6          3               1
3          3        6            5           3          2               1
4          4        3            5           2          4               6
5          5        6            3           2          4               1
6          6        3            1           2          4               6
# ℹ 1 more variable: pos_attitude <int>

Round-trip validation (single case). Before either pipeline (reshape or NICERA decoding) is trusted on the real pilot export, both are checked end to end on one randomly chosen simulated respondent: (a) the raw cbc_profiles JSON for that respondent is parsed independently of the reshape_one() pipeline and compared, value by value, against what long_df produced for that respondent’s first task/first alternative; (b) the NICERA code stored in sim_db$cbc_attr_order is decoded with code_to_attr and compared against the original attribute-order permutation drawn for that respondent in attr_order_mat — the ground truth, which is otherwise never consulted again once the code is generated. Passing both is a necessary (not sufficient) condition for trusting the pipeline on real data: it catches encoding/decoding bugs that a check run only on the simulator’s own generative assumptions would not.

set.seed(99)
check_session <- sample(sim_db$session_id, 1)

# (a) Reshape: raw JSON for q1/a1 vs. what long_df produced
raw_q1a1 <- fromJSON(
  sim_db$cbc_profiles[sim_db$session_id == check_session],
  simplifyVector = FALSE
)$q1$a1
raw_q1a1_vec <- unlist(raw_q1a1)[c(attr_names, "nombre")]

long_q1a1 <- long_df |>
  filter(session_id == check_session, task == 1, altID == 1) |>
  select(all_of(attr_names), nombre) |>
  unlist()

reshape_match <- identical(unname(raw_q1a1_vec), unname(long_q1a1))

# (b) NICERA: decoded stored code vs. the original permutation drawn for this respondent
original_perm  <- attr_order_mat[check_session, ]
stored_code    <- sim_db$cbc_attr_order[sim_db$session_id == check_session]
recovered_perm <- unname(code_to_attr[str_split(stored_code, "")[[1]]])

nicera_match <- identical(unname(original_perm), recovered_perm)

roundtrip_results <- tibble(
  Check   = c("Reshape (JSON -> long_df)", "NICERA (code -> attr_order_mat)"),
  Session = check_session,
  Detail  = c(
    paste0("q1/a1 attributes (", paste(attr_names, collapse = ", "), ", nombre)"),
    paste0("stored code '", stored_code, "' decoded vs. original permutation")
  ),
  Pass = c(reshape_match, nicera_match)
)

roundtrip_results |>
  kbl(row.names = FALSE) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = TRUE)
Check Session Detail Pass
Reshape (JSON -> long_df) 1456 q1/a1 attributes (need, identity, control, effort, reciprocity, attitude, nombre) TRUE
NICERA (code -> attr_order_mat) 1456 stored code 'RCENAI' decoded vs. original permutation TRUE
stopifnot(reshape_match, nicera_match)

Part (a) verifies that unnesting the JSON into one row per session × task × alternative did not drop, shift, or relabel any attribute along the way; part (b) verifies that the NICERA code is a lossless encoding of the display-order permutation. Checking one concrete, real case round-trip end to end catches indexing and ordering bugs that a check written against the simulator’s own assumptions (e.g. re-deriving the answer with the same logic used to produce it) would not; the stopifnot() above makes the render fail loudly rather than let either pipeline silently drift once this appendix is pointed at the real pilot export.

library(car)

analysis_df <- long_df |>
  left_join(position_df, by = "session_id") |>
  left_join(
    wide_share <- long_df |> distinct(session_id, task) |>
      mutate(shareA = share_a1[cbind(session_id, task)]) |>
      mutate(shareB = 100 - shareA),
    by = c("session_id", "task")
  ) |>
  mutate(share = ifelse(altID == 1, shareA, shareB))

order_test_one <- function(attribute) {
  df <- analysis_df |>
    mutate(lvl = factor(.data[[attribute]]),
           pos = .data[[paste0("pos_", attribute)]])
  fit <- lm(share ~ lvl * pos, data = df)
  interaction_terms <- grep(":", names(coef(fit)), value = TRUE)
  wt <- linearHypothesis(fit, interaction_terms)
  tibble(attribute = attribute,
         df_num = wt$Df[2],
         F_stat = round(wt$F[2], 3),
         p_value = round(wt$`Pr(>F)`[2], 3))
}

order_test_results <- map_dfr(attr_names, order_test_one)

order_test_results |>
  kbl(row.names = FALSE,
      col.names = c("Attribute", "Df", "F statistic", "p value")) |>
  kable_styling(bootstrap_options = c("striped", "condensed"), full_width = FALSE)
Attribute Df F statistic p value
need 1 0.013 0.909
identity 2 0.909 0.403
control 1 0.449 0.503
effort 2 2.819 0.060
reciprocity 1 0.006 0.940
attitude 1 0.090 0.764

These results are not evidence of anything about the real instrument. The outcome fed into this test is the placeholder share generated in Section 9.2.4 — random noise, uncorrelated by construction with position or attribute level — so a null result here is guaranteed and validates only the mechanics of the parsing and testing code (NICERA decoding, position joining, interaction specification, joint Wald test), not the absence of order effects. The substantive version of this test will be run once on real pilot data, where cbc_attr_order reflects what respondents actually saw and share reflects what they actually allocated.