Psychometric research in R (Federdeck endpoints)

This guide shows a practical workflow for doing CTT + IRT work in R using Federdeck’s indexed endpoints. It focuses on fetching data, handling pagination, shaping into matrices, and fitting models.

What you need

  • Federdeck instance base URL (example: https://federdeck.com)
  • Deck AT-URI or Item AT-URI
  • R packages: httr2, jsonlite, dplyr, tidyr, tibble, plus mirt (recommended)

Endpoints used

  • /xrpc/com.federdeck.getResponsesByDeck?deck=AT_URI&limit=200&cursor=...
  • /xrpc/com.federdeck.getResponsesByItem?item=AT_URI&limit=200&cursor=...

1) Install packages

install.packages(c("httr2", "jsonlite", "dplyr", "tidyr", "tibble"))
install.packages("mirt")

2) Fetch all pages from an endpoint (cursor pagination)

library(httr2)
library(jsonlite)
library(dplyr)
library(tidyr)
library(tibble)

ds_fetch_all <- function(url, query, max_pages = Inf, verbose = TRUE) {
  out <- list()
  cursor <- NULL
  page <- 1

  repeat {
    q <- query
    if (!is.null(cursor)) q$cursor <- cursor

    resp <- request(url) |>
      req_url_query(!!!q) |>
      req_perform()

    txt <- resp_body_string(resp)
    obj <- fromJSON(txt, simplifyVector = TRUE)

    if (!is.null(obj$responses) && length(obj$responses) > 0) {
      out[[length(out) + 1]] <- as_tibble(obj$responses)
    }

    cursor <- obj$cursor
    if (verbose) {
      n <- ifelse(is.null(obj$responses), 0, nrow(as_tibble(obj$responses)))
      message("page=", page, " rows=", n)
    }

    page <- page + 1
    if (is.null(cursor)) break
    if (page > max_pages) break
  }

  if (length(out) == 0) return(tibble())
  bind_rows(out)
}

3) Example: fetch responses by deck

BASE <- "https://federdeck.com"
DECK <- "at://did:plc:.../com.federdeck.deck/3k...."

responses <- ds_fetch_all(
  url = paste0(BASE, "/xrpc/com.federdeck.getResponsesByDeck"),
  query = list(deck = DECK, limit = 200)
)

glimpse(responses)

4) Clean and decide attempt policy

Psychometrics usually needs one scored response per person×item. If a person answered the same item multiple times, choose a policy. Common: first attempt for “initial difficulty” or last attempt for “final performance”.

responses_clean <- responses |>
  transmute(
    user = userDid,
    item = itemId,
    correct = as.integer(correct),
    answered_at = answeredAt,
    response_time = responseTime
  ) |>
  filter(!is.na(user), user != "", !is.na(item), item != "")

# last attempt policy
responses_one <- responses_clean |>
  arrange(user, item, answered_at) |>
  group_by(user, item) |>
  slice_tail(n = 1) |>
  ungroup()

5) Create person × item matrix (0/1/NA)

mat_df <- responses_one |>
  select(user, item, correct) |>
  distinct() |>
  pivot_wider(names_from = item, values_from = correct)

person_id <- mat_df$user
mat <- as.matrix(select(mat_df, -user))
storage.mode(mat) <- "numeric"

6) Quick CTT checks

# Item p-values (difficulty proxies)
p_values <- colMeans(mat, na.rm = TRUE)

# Missingness per item
missing_rate <- colMeans(is.na(mat))

ctt <- tibble(
  item = colnames(mat),
  p = p_values,
  missing = missing_rate
) |>
  arrange(p)

print(ctt, n = 25)

7) Fit Rasch (1PL) / 2PL using mirt

library(mirt)

mod_rasch <- mirt(mat, 1, itemtype = "Rasch", verbose = FALSE)
mod_2pl   <- mirt(mat, 1, itemtype = "2PL", verbose = FALSE)

summary(mod_rasch)
summary(mod_2pl)

8) Extract parameters and scores

# Item parameters (IRT parameterization)
ip_rasch <- coef(mod_rasch, IRTpars = TRUE, simplify = TRUE)$items
ip_2pl   <- coef(mod_2pl,   IRTpars = TRUE, simplify = TRUE)$items

# Person scores (EAP)
theta_rasch <- fscores(mod_rasch, method = "EAP")
theta_2pl   <- fscores(mod_2pl, method = "EAP")

Reproducibility checklist (recommended)

  • Record the queried deck/item AT-URI(s).
  • Record the instance base URL (index coverage differs by instance).
  • Record retrieval timestamp and your attempt policy (first vs last).
  • Keep the raw JSON pages if you need full auditability.