10 — Downward extension (Boston Children’s, ages 2–5): hand scores, official scores, and task selection

ImportantAI-generated — not for publication

The code and prose in this chapter were written by Claude Code (an AI coding agent) under human direction. They have not been line-by-line verified by a human and must not be published or cited as-is.

WarningPreliminary & unvetted analysis

These are exploratory analyses of preliminary, in-progress LEVANTE data. Results have not been reviewed or validated and require checking by the LEVANTE Data Coordinating Center before any use.

Purpose

Downward-extension data from Boston Children’s Hospital (Nelson site) — children aged 2–5, piloting the LEVANTE core tasks below the 5–12 range they were built for. Updated 2026-08-31: the sample has grown to 44 children / 378 runs (from 31/228 in the first June–August tranche), with collection through Aug 29. There are still no validated scoring models for this age band, so hand-scoring uses the raw per-site dataset (pilot_bostonchildrens_us_main_raw:7e6c, pinned to v5_0), not levante_data_latest.

Two aims: (A) hand-score the tasks from trial-level correctness with simple, transparent rules; (B) describe accuracy, time in task, and trials completed across tasks and ages. Along the way we log data-quality flags — this is a pilot, so what breaks is as informative as what works.

Code
source(here::here("common.R"))
library(tidyverse)
library(patchwork)

DS_REF <- "pilot_bostonchildrens_us_main_raw:7e6c"
DS_VER <- "v5_0"

# Raw tables are cached to data/ (git-ignored); pull via levante::get_raw_table()
# if a cache is missing. Pinned to DS_VER for reproducibility.
load_raw <- function(table) {
  path <- here("data", glue("downex_boston_{table}_{DS_VER}.rds"))
  if (!file_exists(path)) {
    dir_create(here("data"))
    write_rds(levante::get_raw_table(table, data_source = DS_REF,
                                     data_source_version = DS_VER), path)
  }
  read_rds(path)
}
users    <- load_raw("users")
runs     <- load_raw("runs")     |> filter(task_id != "schema_row")
trials   <- load_raw("trials")   |> filter(task_id != "schema_row")
variants <- load_raw("variants") |> filter(task_id != "schema_row")

task_labels <- c(`hearts-and-flowers` = "Hearts & Flowers", `memory-game` = "Memory",
                 `same-different-selection` = "Same & Different", `matrix-reasoning` = "Pattern Matching",
                 `mental-rotation` = "Shape Rotation", `egma-math` = "Math", vocab = "Vocabulary",
                 trog = "Sentence Underst.", pa = "ROAR-Phoneme")

1. Who and what: the sample and the runs

Ages come from birth_month/birth_year and each run’s time_started (month precision). Only children (user_type == "student") are kept.

Code
kids <- users |> filter(user_type == "student") |>
  select(user_id, birth_month, birth_year, sex)

runs <- runs |>
  inner_join(kids, by = "user_id") |>
  mutate(task = task_labels[task_id],
         age = (year(time_started) - birth_year) + (month(time_started) - birth_month) / 12,
         age_yr = floor(age),
         # time in task: run duration if finished, else first→last trial timestamp
         dur_min = as.numeric(difftime(time_finished, time_started, units = "mins")))

trial_span <- trials |> group_by(run_id) |>
  summarise(span_min = as.numeric(difftime(max(server_timestamp), min(server_timestamp), units = "mins")), .groups = "drop")
runs <- runs |> left_join(trial_span, by = "run_id") |>
  mutate(dur_min = coalesce(dur_min, span_min))

kids_age <- runs |> group_by(user_id, sex) |> summarise(age = min(age), .groups = "drop") |>
  mutate(age_yr = floor(age))
kids_age |> count(age_yr, sex) |> pivot_wider(names_from = sex, values_from = n, values_fill = 0) |>
  mutate(total = rowSums(across(-age_yr))) |>
  knitr::kable(caption = glue("Children by age (years) at first run — N = {nrow(kids_age)}"))
Children by age (years) at first run — N = 44
age_yr NA total
2 16 16
3 6 6
4 10 10
5 12 12
Code
runs |> group_by(task) |>
  summarise(runs = n(), children = n_distinct(user_id), completed = sum(completed),
            valid = sum(valid_run), adaptive = any(variant_id %in% variants$variant_id[variants$adaptive]),
            .groups = "drop") |>
  arrange(desc(runs)) |>
  knitr::kable(caption = "Runs per task (completed = dashboard flag; valid = passes the pipeline's run validation)")
Runs per task (completed = dashboard flag; valid = passes the pipeline’s run validation)
task runs children completed valid adaptive
Vocabulary 62 44 58 51 TRUE
Sentence Underst. 52 41 50 49 TRUE
Hearts & Flowers 50 36 46 40 FALSE
Pattern Matching 50 30 38 29 TRUE
Math 42 33 37 28 TRUE
ROAR-Phoneme 33 19 20 22 FALSE
Shape Rotation 32 24 28 25 TRUE
Same & Different 31 25 29 25 TRUE
Memory 26 22 22 13 FALSE
Code
runs |> count(completed, valid_run, validation_msg_run) |> arrange(desc(n)) |>
  knitr::kable(caption = "Run validity flags. `less_than_10_test_trials` is the pipeline's minimum-length rule.")
Run validity flags. less_than_10_test_trials is the pipeline’s minimum-length rule.
completed valid_run validation_msg_run n
TRUE TRUE NA 275
TRUE FALSE less_than_10_test_trials 45
FALSE FALSE less_than_10_test_trials 43
TRUE FALSE straightlining_10 8
FALSE TRUE NA 7

Most runs are complete and valid, but a sizeable minority end with fewer than 10 test trials — the pipeline’s validity floor. Where in the age range those short runs sit is one of the first things to check (§3).

2. Trial-level prep and hand-scoring rules

We keep test trials only (is_practice_trial == FALSE), and apply minimal, task-specific rules that mirror what levantemodels::recode_trials() does for the released data:

  • Hearts & Flowers: a response is correct only if 200 ms ≤ RT ≤ 2000 ms (too-fast/too-slow → incorrect); we report raw accuracy too. Blocks are hearts / flowers / hearts and flowers (mixed).
  • Vocabulary: catch trials (attention checks) are scored separately from test trials.
  • Memory (Corsi): span score = the longest sequence length answered correctly, forward and backward.
  • Math: accuracy overall and by subtask (corpus_trial_type).
  • Chance per trial is derived from the recorded distractors field (chance = 1 / (n distractors + 1)); Hearts & Flowers is 2-AFC by design; Memory is free recall (chance 0); ROAR-Phoneme records no distractors (chance left NA).
  • Everything else: proportion correct on test trials.
Code
test <- trials |>
  filter(!is_practice_trial %in% TRUE) |>
  inner_join(runs |> select(run_id, user_id, task, age, age_yr, valid_run), by = c("run_id", "user_id")) |>
  mutate(n_distr = if_else(is.na(distractors) | distractors %in% c("", "{}"), NA_integer_,
                           str_count(distractors, ",") + 1L),
         chance = case_when(task_id == "hearts-and-flowers" ~ 0.5,
                            task_id == "memory-game" ~ 0,
                            !is.na(n_distr) ~ 1 / (n_distr + 1),
                            TRUE ~ NA_real_),
         # H&F RT recode (mirrors levantemodels::recode_hf)
         correct_hand = if_else(task_id == "hearts-and-flowers",
                                correct & !is.na(rt_numeric) & rt_numeric >= 200 & rt_numeric <= 2000,
                                correct))

test |> group_by(task) |>
  summarise(test_trials = n(), runs = n_distinct(run_id),
            chance = paste(sort(unique(round(chance, 2))), collapse = "/"),
            rt_missing = mean(is.na(rt_numeric)), .groups = "drop") |>
  knitr::kable(digits = 2, caption = "Test trials per task, implied chance level(s), and RT missingness (Memory records no RT)")
Test trials per task, implied chance level(s), and RT missingness (Memory records no RT)
task test_trials runs chance rt_missing
Hearts & Flowers 1854 45 0.5 0.08
Math 741 39 0.25/0.33/0.5 0.00
Memory 227 21 0 1.00
Pattern Matching 649 41 0.2/0.25 0.01
ROAR-Phoneme 659 22 0.00
Same & Different 810 30 0.17/0.2/0.25/0.33 0.00
Sentence Underst. 2208 51 0.25 0.00
Shape Rotation 724 26 0.33 0.00
Vocabulary 2829 53 0.25 0.00
Code
run_sum <- test |> group_by(run_id, user_id, task, task_id, age, age_yr, valid_run) |>
  summarise(n_trials = n(), acc = mean(correct_hand), acc_raw = mean(correct),
            chance = mean(chance), med_rt = median(rt_numeric, na.rm = TRUE) / 1000,
            .groups = "drop") |>
  left_join(runs |> select(run_id, dur_min, completed, num_attempted, num_correct), by = "run_id")

3. Basic descriptives across tasks and ages

Each point is one run; lines are loess smooths (age is continuous, 2.0–5.8).

3.1 Trials completed

Code
ggplot(run_sum, aes(age, n_trials)) +
  geom_point(aes(colour = valid_run), alpha = 0.7) +
  geom_smooth(method = "loess", se = FALSE, colour = "grey30", span = 1) +
  facet_wrap(~task, scales = "free_y") +
  scale_colour_manual(values = c(`TRUE` = "#268bd2", `FALSE` = "#dc322f"), name = "valid run") +
  labs(x = "Age (years)", y = "Test trials completed",
       title = "Trials completed per run, by age") +
  theme(legend.position = "top")
Figure 16.1

3.2 Time in task

Code
ggplot(run_sum |> filter(!is.na(dur_min)), aes(age, dur_min)) +
  geom_point(aes(colour = valid_run), alpha = 0.7) +
  geom_smooth(method = "loess", se = FALSE, colour = "grey30", span = 1) +
  facet_wrap(~task, scales = "free_y") +
  scale_colour_manual(values = c(`TRUE` = "#268bd2", `FALSE` = "#dc322f"), name = "valid run") +
  labs(x = "Age (years)", y = "Minutes in task",
       title = "Time in task per run, by age",
       subtitle = "Run duration (time_finished − time_started); first→last trial span for unfinished runs") +
  theme(legend.position = "top")
Figure 16.2

3.3 Accuracy

Dashed line = chance (task-average of the per-trial chance; Math and Same & Different mix response formats).

Code
chance_df <- run_sum |> group_by(task) |> summarise(chance = mean(chance, na.rm = TRUE), .groups = "drop")
ggplot(run_sum, aes(age, acc)) +
  geom_hline(data = chance_df, aes(yintercept = chance), linetype = "dashed", colour = "grey50") +
  geom_point(aes(colour = valid_run, size = n_trials), alpha = 0.7) +
  geom_smooth(method = "loess", se = FALSE, colour = "grey30", span = 1) +
  facet_wrap(~task) +
  scale_colour_manual(values = c(`TRUE` = "#268bd2", `FALSE` = "#dc322f"), name = "valid run") +
  scale_size_area(max_size = 4, name = "test trials") +
  scale_y_continuous(limits = c(0, 1)) +
  labs(x = "Age (years)", y = "Proportion correct (hand-scored)",
       title = "Accuracy per run, by age") +
  theme(legend.position = "top")
Figure 16.3

3.4 Summary table by task × age year

Code
run_sum |> group_by(task, age_yr) |>
  summarise(runs = n(), pct_valid = mean(valid_run), trials = median(n_trials),
            minutes = median(dur_min, na.rm = TRUE), acc = mean(acc), .groups = "drop") |>
  mutate(across(c(pct_valid, acc), ~ round(.x, 2)), minutes = round(minutes, 1)) |>
  pivot_wider(names_from = age_yr, values_from = c(runs, pct_valid, trials, minutes, acc),
              names_glue = "{.value}_{age_yr}y") |>
  select(task, starts_with("runs"), starts_with("pct_valid"), starts_with("trials"),
         starts_with("minutes"), starts_with("acc")) |>
  knitr::kable(caption = "Per task × age-year: runs, share valid, median trials, median minutes, mean accuracy")
Per task × age-year: runs, share valid, median trials, median minutes, mean accuracy
task runs_2y runs_3y runs_4y runs_5y pct_valid_2y pct_valid_3y pct_valid_4y pct_valid_5y trials_2y trials_3y trials_4y trials_5y minutes_2y minutes_3y minutes_4y minutes_5y acc_2y acc_3y acc_4y acc_5y
Hearts & Flowers 12 11 9 13 0.67 0.91 1.00 1.00 16.5 38 44.0 76.0 4.2 5.7 4.3 5.1 0.31 0.59 0.69 0.84
Math 8 7 10 14 0.38 0.57 0.80 0.93 8.5 10 15.0 29.0 3.2 1.7 3.1 5.7 0.27 0.28 0.41 0.55
Memory 2 4 7 8 0.00 0.00 1.00 0.75 3.0 8 12.0 11.5 17.0 6.7 5.7 5.8 0.00 0.20 0.50 0.50
Pattern Matching 11 9 11 10 0.55 1.00 0.91 0.40 18.0 21 21.0 7.0 6.1 4.1 4.1 1.8 0.39 0.49 0.49 0.36
ROAR-Phoneme NA NA 8 14 NA NA 1.00 1.00 NA NA 29.0 31.5 NA NA 10.7 10.1 NA NA 0.49 0.60
Same & Different 8 9 6 7 0.75 0.78 0.83 1.00 19.0 29 31.5 34.0 6.2 7.9 6.2 6.1 0.48 0.67 0.74 0.91
Sentence Underst. 19 9 10 13 0.89 1.00 1.00 1.00 37.0 49 49.0 42.0 4.2 4.1 4.0 4.1 0.35 0.64 0.63 0.75
Shape Rotation NA 7 6 13 NA 0.86 1.00 1.00 NA 18 20.5 35.0 NA 3.5 3.7 4.0 NA 0.52 0.52 0.73
Vocabulary 17 11 11 14 0.88 1.00 1.00 1.00 47.0 66 52.0 56.5 4.2 4.1 4.1 4.1 0.38 0.58 0.61 0.70

4. Hand scores per task

4.1 Sanity check: hand accuracy vs the dashboard’s own count

The runs table carries the dashboard’s num_correct/num_attempted. Our hand-scored accuracy should agree except where our rules deliberately differ (H&F RT recode; vocab catch trials excluded).

Code
run_sum |> filter(!is.na(num_attempted), num_attempted > 0) |>
  mutate(dash_acc = num_correct / num_attempted) |>
  group_by(task) |>
  summarise(runs = n(), r = cor(acc_raw, dash_acc, use = "complete.obs"),
            mean_diff = mean(acc_raw - dash_acc, na.rm = TRUE),
            trials_match = mean(n_trials == num_attempted), .groups = "drop") |>
  knitr::kable(digits = 2, caption = "Raw hand accuracy vs dashboard num_correct/num_attempted (r, mean difference, share of runs with identical trial counts)")
Raw hand accuracy vs dashboard num_correct/num_attempted (r, mean difference, share of runs with identical trial counts)
task runs r mean_diff trials_match
Hearts & Flowers 45 1.00 0.00 1.00
Math 39 1.00 0.00 1.00
Memory 21 1.00 0.23 0.00
Pattern Matching 41 1.00 0.00 1.00
ROAR-Phoneme 22 1.00 0.00 1.00
Same & Different 30 0.89 -0.01 0.07
Sentence Underst. 51 1.00 0.00 1.00
Shape Rotation 26 1.00 0.00 1.00
Vocabulary 53 1.00 0.00 1.00

Where the two are directly comparable (Shape Rotation, ROAR-Phoneme, Sentence Understanding, Vocabulary) the hand score reproduces the dashboard count exactly (r = 1, identical trial counts). Memory and Same & Different count “trials” differently (sequences / multi-response stages), so they don’t match by construction; the remaining tasks have some runs with no dashboard count.

4.2 Task-specific scores

Code
# Vocabulary: test vs catch
vocab_h <- test |> filter(task_id == "vocab") |>
  group_by(run_id, user_id, age, kind = corpus_trial_type) |>
  summarise(n = n(), acc = mean(correct), .groups = "drop")

# Hearts & Flowers: raw vs RT-recoded, by block
hf_h <- test |> filter(task_id == "hearts-and-flowers") |>
  group_by(run_id, user_id, age, block = corpus_trial_type) |>
  summarise(n = n(), acc_raw = mean(correct), acc_rt = mean(correct_hand),
            med_rt = median(rt_numeric, na.rm = TRUE), .groups = "drop") |>
  mutate(block = factor(block, c("hearts", "flowers", "hearts and flowers")))

# Memory: longest correct span, forward / backward
mem_h <- test |> filter(task_id == "memory-game") |>
  separate(corpus_trial_type, into = c("direction", "span"), sep = "-", convert = TRUE) |>
  group_by(run_id, user_id, age, direction) |>
  summarise(n = n(), max_span = if (any(correct)) max(span[correct]) else 0, .groups = "drop")

# Math: by subtask
math_h <- test |> filter(task_id == "egma-math") |>
  group_by(run_id, user_id, age, subtask = corpus_trial_type) |>
  summarise(n = n(), acc = mean(correct), chance = mean(chance), .groups = "drop")

# Same & Different: by trial type
sds_h <- test |> filter(task_id == "same-different-selection") |>
  group_by(run_id, user_id, age, type = corpus_trial_type) |>
  summarise(n = n(), acc = mean(correct), .groups = "drop")
Code
p_vocab <- ggplot(vocab_h, aes(age, acc, colour = kind)) + geom_point(alpha = 0.7) +
  geom_smooth(method = "loess", se = FALSE, span = 1) + geom_hline(yintercept = 0.25, linetype = "dashed", colour = "grey50") +
  ylim(0, 1) + labs(title = "Vocabulary: test vs catch trials", x = "Age", y = "Prop. correct", colour = NULL)
p_hf <- hf_h |> pivot_longer(c(acc_raw, acc_rt), names_to = "scoring", values_to = "acc") |>
  ggplot(aes(age, acc, colour = scoring)) + geom_point(alpha = 0.6) +
  geom_smooth(method = "loess", se = FALSE, span = 1) + geom_hline(yintercept = 0.5, linetype = "dashed", colour = "grey50") +
  facet_wrap(~block) + ylim(0, 1) + labs(title = "Hearts & Flowers: raw vs RT-recoded (200–2000 ms), by block", x = "Age", y = "Prop. correct", colour = NULL)
p_mem <- ggplot(mem_h, aes(age, max_span, colour = direction)) + geom_jitter(width = 0.05, height = 0.08, alpha = 0.7) +
  geom_smooth(method = "loess", se = FALSE, span = 1) + labs(title = "Memory: longest correct span", x = "Age", y = "Max span", colour = NULL)
p_math <- ggplot(math_h, aes(age, acc)) + geom_point(alpha = 0.6, aes(size = n)) +
  geom_smooth(method = "loess", se = FALSE, span = 1.2, colour = "grey30") + facet_wrap(~subtask, nrow = 2) +
  ylim(0, 1) + scale_size_area(max_size = 3) + labs(title = "Math: accuracy by subtask", x = "Age", y = "Prop. correct")
(p_vocab + p_mem) / p_hf / p_math + plot_layout(heights = c(1, 1, 1.4))
Figure 17.1
Code
bind_rows(
  vocab_h |> mutate(task = "Vocabulary", part = kind, score = acc),
  hf_h |> mutate(task = "Hearts & Flowers", part = as.character(block), score = acc_rt),
  mem_h |> mutate(task = "Memory (max span)", part = direction, score = max_span),
  math_h |> mutate(task = "Math", part = subtask, score = acc),
  sds_h |> mutate(task = "Same & Different", part = type, score = acc),
  run_sum |> filter(task_id %in% c("matrix-reasoning", "mental-rotation", "trog", "pa")) |>
    mutate(part = "all", score = acc)
) |>
  mutate(age_yr = floor(age)) |>
  group_by(task, part, age_yr) |>
  summarise(n_runs = n(), score = mean(score), .groups = "drop") |>
  mutate(score = round(score, 2)) |>
  pivot_wider(names_from = age_yr, values_from = c(n_runs, score), names_glue = "{.value}_{age_yr}y") |>
  select(task, part, starts_with("n_runs"), starts_with("score")) |>
  knitr::kable(caption = "Hand scores by task component × age year (mean over runs)")
Hand scores by task component × age year (mean over runs)
task part n_runs_2y n_runs_3y n_runs_4y n_runs_5y score_2y score_3y score_4y score_5y
Hearts & Flowers flowers 8 11 9 13 0.19 0.58 0.64 0.88
Hearts & Flowers hearts 12 11 9 13 0.38 0.67 0.85 0.96
Hearts & Flowers hearts and flowers 2 6 9 12 0.19 0.42 0.56 0.78
Math Addition 8 7 10 10 0.18 0.17 0.24 0.37
Math Counting AFC 7 7 10 NA 0.29 0.29 0.60 NA
Math Missing Number NA NA 4 13 NA NA 0.46 0.22
Math Multiplication NA NA NA 1 NA NA NA 0.00
Math Non-symbolic Number Identification 7 7 10 NA 0.45 0.29 0.63 NA
Math Number Comparison 8 7 10 13 0.33 0.47 0.52 0.72
Math Number Identification 3 4 8 14 0.14 0.36 0.29 0.66
Math Subtraction NA NA NA 5 NA NA NA 0.35
Memory (max span) backward NA 4 7 7 NA 1.50 1.14 2.00
Memory (max span) forward 2 4 7 8 0.00 1.00 2.00 2.88
Pattern Matching all 11 9 11 10 0.39 0.49 0.49 0.36
ROAR-Phoneme all NA NA 8 14 NA NA 0.49 0.60
Same & Different 2-match NA 1 3 7 NA 0.50 0.56 0.84
Same & Different 3-match NA NA NA 3 NA NA NA 0.88
Same & Different 4-match NA 3 2 2 NA 0.28 0.88 0.45
Same & Different instructions NA 4 5 7 NA 0.50 0.60 1.00
Same & Different something-same-1 NA 5 6 NA NA 0.00 0.00 NA
Same & Different something-same-2 NA 5 5 NA NA 0.64 0.88 NA
Same & Different test-dimensions 8 9 6 7 0.48 0.81 0.96 0.99
Sentence Underst. all 19 9 10 13 0.35 0.64 0.63 0.75
Shape Rotation all NA 7 6 13 NA 0.52 0.52 0.73
Vocabulary catch 15 11 6 3 0.50 0.89 0.83 1.00
Vocabulary test 17 11 11 14 0.38 0.57 0.60 0.70

4.3 The CAT’s running θ as an (unvalidated) provisional score

Several tasks ran as adaptive variants, so each trial carries the CAT’s running theta_estimate on the 5–12 calibrated item bank. That bank is out of range for 2–5-year-olds (see the flags below), so this is not a score to use — but its relation to hand accuracy tells us whether the CAT is discriminating at all down here.

Code
theta_last <- test |> filter(!is.na(theta_estimate)) |>
  group_by(run_id) |> slice_max(trial_index, n = 1, with_ties = FALSE) |>
  select(run_id, theta_last = theta_estimate) |>
  inner_join(run_sum, by = "run_id") |>
  group_by(task) |> filter(n() >= 5) |> ungroup()   # drop tasks with a handful of CAT runs

ggplot(theta_last, aes(acc, theta_last)) +
  geom_point(aes(colour = age), alpha = 0.8) +
  geom_smooth(method = "lm", se = FALSE, colour = "grey30") +
  facet_wrap(~task, scales = "free") +
  scale_colour_viridis_c(name = "age") +
  labs(x = "Hand-scored proportion correct", y = "CAT final θ estimate",
       title = "CAT running θ (5–12 item bank) vs hand accuracy, adaptive tasks only")

Code
theta_last |> group_by(task) |>
  summarise(runs = n(), r_theta_acc = cor(theta_last, acc), r_theta_age = cor(theta_last, age),
            mean_theta = mean(theta_last), .groups = "drop") |>
  knitr::kable(digits = 2, caption = "Final CAT θ: agreement with hand accuracy and age, and mean level (on the 5–12 scale)")
Final CAT θ: agreement with hand accuracy and age, and mean level (on the 5–12 scale)
task runs r_theta_acc r_theta_age mean_theta
Math 39 0.68 0.67 -5.00
Same & Different 30 -0.01 -0.09 -1.02
Sentence Underst. 51 0.64 0.68 -2.84
Shape Rotation 26 0.86 0.66 -4.12
Vocabulary 53 0.62 0.49 -3.08

The CAT does discriminate down here on Math, Shape Rotation, Sentence Understanding, and Vocabulary (θ–accuracy r ≈ 0.5–0.85), but the θ values are far below the 5–12 scale (means ≈ −2.7 to −5.2), and a whole row of runs — mostly the 2–3-year-olds — sits at exactly θ = −6, the estimator’s hard lower bound: those are floor codes, not measurements. Same & Different’s θ is unrelated to accuracy (r ≈ 0.1) and many of its runs sit at exactly 0 (the prior mean, i.e. θ never updated), consistent with its scoring being under revision.

4.4 Vocabulary: item-level accuracy

Vocabulary is a CAT, so each child sees a different subset of the 126 items and each item at most once — per-item Ns are small (mostly 10–23) and these estimates are very noisy. Bars are Wilson 95% intervals; the dashed line is 4-AFC chance.

Code
wilson <- function(p, n, z = 1.96) {
  c <- (p + z^2 / (2 * n)) / (1 + z^2 / n)
  h <- z * sqrt(p * (1 - p) / n + z^2 / (4 * n^2)) / (1 + z^2 / n)
  tibble(lo = pmax(0, c - h), hi = pmin(1, c + h))
}
vi <- test |> filter(task_id == "vocab", corpus_trial_type == "test") |>
  mutate(grp = if_else(age < 3, "2-year-olds", "3+"))

item_all <- vi |> group_by(item) |> summarise(n = n(), acc = mean(correct), .groups = "drop") |>
  filter(n >= 10) |> mutate(wilson(acc, n)) |> mutate(item = fct_reorder(item, acc))

ggplot(item_all, aes(acc, item)) +
  geom_vline(xintercept = 0.25, linetype = "dashed", colour = "grey50") +
  geom_errorbarh(aes(xmin = lo, xmax = hi), height = 0, colour = "grey60") +
  geom_point(aes(size = n), colour = "#268bd2") +
  scale_size_area(max_size = 3.5, breaks = c(10, 15, 20), name = "n children") +
  scale_x_continuous(limits = c(0, 1), breaks = seq(0, 1, 0.25)) +
  labs(x = "Proportion correct (all ages, 2–5)", y = NULL,
       title = glue("Vocabulary items with ≥10 responses (n = {nrow(item_all)} of 126)"),
       subtitle = "Sorted by accuracy; Wilson 95% intervals; dashed = 4-AFC chance") +
  theme(axis.text.y = element_text(size = 7), legend.position = "top")

Code
bind_rows(item_all |> slice_max(acc, n = 10) |> mutate(end = "easiest"),
          item_all |> slice_min(acc, n = 10) |> mutate(end = "hardest")) |>
  select(end, item, n, acc, lo, hi) |> mutate(across(c(acc, lo, hi), ~ round(.x, 2))) |>
  knitr::kable(caption = "Ten easiest and ten hardest vocabulary items (≥10 responses), all ages")
Ten easiest and ten hardest vocabulary items (≥10 responses), all ages
end item n acc lo hi
easiest cake 35 0.86 0.71 0.94
easiest sunflower 33 0.85 0.69 0.93
easiest ship 12 0.83 0.55 0.95
easiest acorn 31 0.77 0.60 0.89
easiest shower 35 0.77 0.61 0.88
easiest buffet 13 0.77 0.50 0.92
easiest carousel 13 0.77 0.50 0.92
easiest wetland 13 0.77 0.50 0.92
easiest blender 25 0.76 0.57 0.89
easiest sprinkler 37 0.76 0.60 0.87
hardest oil 33 0.18 0.09 0.34
hardest diamond 34 0.24 0.12 0.40
hardest latch 20 0.25 0.11 0.47
hardest kazoo 11 0.27 0.10 0.57
hardest sandbag 25 0.28 0.14 0.48
hardest spatula 25 0.28 0.14 0.48
hardest gourmet 20 0.30 0.15 0.52
hardest cork 21 0.33 0.17 0.55
hardest saddle 36 0.33 0.20 0.50
hardest chat 35 0.34 0.21 0.51

The easy end is what you’d expect for preschoolers (sunflower, sprinkler, cake, marshmallow); the hard end mixes genuinely late-acquired words (gourmet, silverware, cork) with a couple sitting below chance (latch 0.15, oil 0.20). Not significant at these Ns (both intervals include 0.25), but below-chance accuracy is the same key/picture-defect signal we use in the 5–12 data — worth a look at those two items’ pictures/distractors.

By age — 2-year-olds vs 3+. Only items with ≥4 responses in each group are shown (the 2-year-olds contribute just 688 test trials in total), so the 2-year-old estimates in particular are extremely noisy.

Code
item_age <- vi |> group_by(item, grp) |> summarise(n = n(), acc = mean(correct), .groups = "drop") |>
  group_by(item) |> filter(n_distinct(grp) == 2, all(n >= 4)) |> ungroup()
ord_items <- item_age |> filter(grp == "3+") |> arrange(acc) |> pull(item)
item_age <- item_age |> mutate(item = factor(item, levels = ord_items))
wide <- item_age |> select(item, grp, acc, n) |>
  pivot_wider(names_from = grp, values_from = c(acc, n))
r_age <- cor(wide$`acc_2-year-olds`, wide$`acc_3+`)

ggplot(item_age, aes(acc, item)) +
  geom_vline(xintercept = 0.25, linetype = "dashed", colour = "grey50") +
  geom_line(aes(group = item), colour = "grey70") +
  geom_point(aes(colour = grp, size = n)) +
  scale_colour_manual(values = c(`2-year-olds` = "#dc322f", `3+` = "#268bd2"), name = NULL) +
  scale_size_area(max_size = 4, name = "n children") +
  scale_x_continuous(limits = c(0, 1), breaks = seq(0, 1, 0.25)) +
  labs(x = "Proportion correct", y = NULL,
       title = glue("Vocabulary items with ≥4 responses in both age groups (n = {nrow(wide)} items)"),
       subtitle = glue("Sorted by 3+ accuracy; item-level correlation between groups r = {round(r_age, 2)}")) +
  theme(axis.text.y = element_text(size = 8), legend.position = "top")

Code
ggplot(wide, aes(`acc_3+`, `acc_2-year-olds`)) +
  geom_abline(slope = 1, intercept = 0, colour = "grey70") +
  geom_hline(yintercept = 0.25, linetype = "dashed", colour = "grey50") +
  geom_vline(xintercept = 0.25, linetype = "dashed", colour = "grey50") +
  geom_point(aes(size = `n_2-year-olds`), colour = "#dc322f", alpha = 0.7) +
  ggrepel::geom_text_repel(aes(label = item), size = 2.6, max.overlaps = 20) +
  scale_size_area(max_size = 4, name = "n 2-yr-olds") +
  coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
  labs(x = "Accuracy, 3+", y = "Accuracy, 2-year-olds",
       title = "Same items, two age groups", subtitle = "Diagonal = equal; dashed = chance")

The 2-year-olds sit near chance on nearly every item regardless of how easy it is for the 3+ group — items the 3+ children get 80–90% right (panda, carrot, turtle, map) are at 0–0.35 for the 2s, and the item-level correlation between the groups is only r ≈ 0.38. If the 2-year-olds were simply weaker vocabulary learners we’d expect the same difficulty ordering, shifted down; instead the ordering is largely lost, which is what non-engagement / task-incomprehension looks like — consistent with their catch- trial failures above. A handful of items (sunflower, telescope, net, hedgehog) show some 2-year-old signal, but on 4–10 children each.

Does the 5–12 difficulty ordering hold for preschoolers?

The vocabulary CAT’s item bank was calibrated on 5–12-year-olds (by_language en-US Rasch with a 0.25 guessing floor; levante_metadata_scoring v1_14, via load_item_parameters()). Every item the Boston children saw is in that calibration, so we can ask whether the 5–12 difficulty ordering survives down here — and, treating each age group as if it were a single 5–12-scale person, what θ its item accuracies imply. Curves are the model-implied P(correct) = 0.25 + 0.75·logistic(θ − b) at the group’s best-fitting θ (binomial deviance minimised over items).

Code
ip <- load_item_parameters() |>
  filter(task_id == "vocab", subset == "en-US") |>
  select(item_uid, difficulty)

fit_theta <- function(k, n, b) {
  optimize(\(th) -sum(dbinom(k, n, 0.25 + 0.75 * plogis(th - b), log = TRUE)), c(-8, 6))$minimum
}

grp_items <- vi |> mutate(grp = "all ages (2–5)") |>
  bind_rows(vi) |>
  group_by(grp, item, item_uid) |> summarise(n = n(), k = sum(correct), acc = mean(correct), .groups = "drop") |>
  inner_join(ip, by = "item_uid") |>
  filter((grp == "2-year-olds" & n >= 4) | (grp != "2-year-olds" & n >= 10)) |>
  mutate(grp = factor(grp, c("all ages (2–5)", "3+", "2-year-olds")))

grp_stats <- grp_items |> group_by(grp) |>
  summarise(items = n(), rho = cor(acc, difficulty, method = "spearman"),
            theta = fit_theta(k, n, difficulty), .groups = "drop")
curves <- grp_stats |> reframe(difficulty = seq(-5, 5, 0.1), .by = c(grp, theta)) |>
  mutate(p = 0.25 + 0.75 * plogis(theta - difficulty))

ggplot(grp_items, aes(difficulty, acc)) +
  geom_hline(yintercept = 0.25, linetype = "dashed", colour = "grey50") +
  geom_line(data = curves, aes(y = p), colour = "grey30") +
  geom_point(aes(size = n), colour = "#268bd2", alpha = 0.7) +
  geom_text(data = grp_stats, aes(x = -4.8, y = 0.06,
            label = glue("{items} items\nSpearman ρ = {round(rho, 2)}\nbest θ = {round(theta, 1)}")),
            hjust = 0, size = 3.2) +
  facet_wrap(~grp) +
  scale_size_area(max_size = 4, name = "n children") +
  scale_y_continuous(limits = c(0, 1)) +
  labs(x = "5–12 calibrated item difficulty (en-US Rasch, logits)", y = "Preschool proportion correct",
       title = "Vocabulary: preschool item accuracy vs. the 5–12 calibrated difficulty",
       subtitle = "Curve = Rasch + 0.25 guessing floor at the group's best-fitting single θ") +
  theme(legend.position = "top")

Code
grp_stats |> mutate(across(c(rho, theta), ~ round(.x, 2))) |>
  knitr::kable(caption = "Per group: items, Spearman correlation between preschool accuracy and 5–12 difficulty, and the best-fitting single θ on the 5–12 scale")
Per group: items, Spearman correlation between preschool accuracy and 5–12 difficulty, and the best-fitting single θ on the 5–12 scale
grp items rho theta
all ages (2–5) 80 -0.37 -2.63
3+ 79 -0.47 -2.04
2-year-olds 54 -0.33 -4.65

For the 3+ children the 5–12 ordering carries down: accuracy falls with calibrated difficulty (Spearman ρ = -0.47), and the item points sit along the Rasch curve at a single θ ≈ -2 — the 3–5 group behaves like a low-ability 5–12-scale person, which is what a downward extension of the same construct should look like. For the 2-year-olds the relation is much weaker (ρ = -0.33, fitted θ ≈ -4.6 near the guessing floor) — but with the larger sample it is no longer zero (the June tranche gave ρ ≈ −0.16): the oldest/most-engaged 2-year-olds are starting to show the same ordering. The practical implication is that the existing en-US bank plus its calibration is a plausible starting point for 3–5 — the CAT was already routing these children to the bank’s easy tail (median difficulty of items seen ≈ −1.9), and the 3+ group’s average θ ≈ -2 sits right there — but the bank thins out fast below b ≈ −3 (only 9% of the 146 en-US items; easiest −4.4). So the average 3–5-year-old is measurable, while younger/weaker children in the band (and certainly the 2-year-olds, θ ≈ -4.6) fall below where items exist. The bank needs easier items to cover this band, and 2-year-olds need something other than 4-AFC picture naming.

5. Data-quality flags to carry forward

Code
# How far down the age range does each Math subtask reach, and where is it above chance?
math_h |> group_by(subtask) |>
  summarise(kids = n_distinct(user_id), min_age = min(age), acc_under4 = mean(acc[age < 4]),
            acc_4plus = mean(acc[age >= 4]), chance = mean(chance), .groups = "drop") |>
  arrange(min_age) |>
  knitr::kable(digits = 2, caption = "Math subtasks: youngest child served, and mean accuracy below vs. above age 4 (chance shown). Under 4 nothing reliably clears chance; from 4 on the number subtasks do, while Addition stays at chance throughout")
Math subtasks: youngest child served, and mean accuracy below vs. above age 4 (chance shown). Under 4 nothing reliably clears chance; from 4 on the number subtasks do, while Addition stays at chance throughout
subtask kids min_age acc_under4 acc_4plus chance
Addition 30 2.00 0.17 0.31 0.25
Number Comparison 33 2.00 0.40 0.63 0.50
Counting AFC 21 2.17 0.29 0.60 0.33
Non-symbolic Number Identification 21 2.17 0.37 0.63 0.33
Number Identification 25 2.25 0.27 0.53 0.25
Missing Number 15 4.58 NaN 0.28 0.25
Subtraction 4 5.17 NaN 0.35 0.25
Multiplication 1 5.83 NaN 0.00 0.25
Code
# Vocabulary catch trials as an engagement gate
vocab_h |> mutate(age_yr = floor(age)) |> group_by(age_yr, kind) |>
  summarise(runs = n(), acc = mean(acc), .groups = "drop") |>
  pivot_wider(names_from = kind, values_from = c(runs, acc)) |>
  knitr::kable(digits = 2, caption = "Vocabulary: catch-trial vs test accuracy by age year")
Vocabulary: catch-trial vs test accuracy by age year
age_yr runs_catch runs_test acc_catch acc_test
2 15 17 0.50 0.38
3 11 11 0.89 0.57
4 6 11 0.83 0.60
5 3 14 1.00 0.70
  • Short / invalid runs concentrate at the young end (see §3.1, §3.4): the <10 test trials rule removes many 2-year-old runs — for this age band the validity floor itself needs re-thinking (a 2-year-old’s 8 valid trials may be the whole point).
  • 2–2.5-year-olds sit at chance on the core tasks (Vocabulary 0.26, Sentence Understanding 0.31, Math at/below chance) and pass the Vocabulary catch trials only ≈ 50% (vs ≈ 90–100% from age 3) — much of the youngest data reflects partial engagement rather than measured ability. But the 2026-08 sample softens the June picture: by 2.5–3 every language/ reasoning task clears chance (Vocab 0.49, TROG 0.46, Matrix 0.51, SDS 0.74), so the practical cut for the current battery is ~2.5, not 3. Catch-trial accuracy remains a usable engagement gate.
  • CAT item banks are calibrated for 5–12: the top of the routing behaves sensibly (Subtraction / Multiplication reached only 5-year-olds), but the bank has no floor for this band — Addition items reach 2-year-olds yet sit at chance across the whole 2–5 range, and below age 4 no Math subtask reliably clears chance (the number subtasks do from 4 on). Running θ is not interpretable at this age.
  • Memory records no RT (rt_numeric all NA) — known from the main pipeline; fine for accuracy/span, no timing possible.
  • Same & Different staging is irregular in the raw table (first_response/second_response/… plus blank assessment_stage), and its scoring is under revision anyway (see LEVANTE.md); the SDS numbers here are proportion-correct by trial type only.
  • Shape Rotation trials record 2 distractors (3-AFC) in this dataset, whereas the calibrated models use a 0.5 guessing floor — check which variant ran before comparing to 5–12 data.
  • stop_type is entirely NA in this tranche (no early-stopping info).

6. Official IRT scores (processed dataset)

Since the first draft of this notebook, the scoring pipeline has run: the processed dataset pilot_bostonchildrens_us_main:2fj2 (unreleased draft, pulled 2026-08-31) now carries EAP θ scores from the calibrated models for 42 of the 44 children (multigroup_dataset Rasch/2PL for the core tasks; by_language en-US for vocab/pa). These are still 5–12-calibrated models applied to 2–5-year-olds — the θs inherit every floor problem shown above — but they’re the pipeline’s official numbers.

Code
SC_REF <- "pilot_bostonchildrens_us_main:2fj2"; SC_VER <- "next-2026-08-31"
sc_path <- here("data", glue("downex_boston_scores_2fj2_{SC_VER}.rds"))
if (!file_exists(sc_path)) write_rds(levante::get_scores(glue("{SC_REF}:{SC_VER}")), sc_path)
scores <- read_rds(sc_path) |> mutate(task = task_labels[task_id])

scores |> group_by(task) |>
  summarise(runs = n(), scored = sum(!is.na(score)),
            few_valid_trials = sum(exclusion %in% "few_valid_trials"),
            incomplete = sum(exclusion %in% "incomplete"),
            no_data = sum(exclusion %in% "no_data"), .groups = "drop") |>
  arrange(scored / runs) |>
  knitr::kable(caption = "Scored vs excluded runs per task. Pattern Matching remains ~unscorable (4 of 50 runs; 26 few_valid_trials + 15 no_data): the downward-extension variant's items don't map into the calibrated bank.")
Scored vs excluded runs per task. Pattern Matching remains ~unscorable (4 of 50 runs; 26 few_valid_trials + 15 no_data): the downward-extension variant’s items don’t map into the calibrated bank.
task runs scored few_valid_trials incomplete no_data
Pattern Matching 50 4 26 5 15
Memory 26 13 7 1 5
ROAR-Phoneme 33 20 0 2 11
Math 42 26 11 2 3
Shape Rotation 32 24 1 1 6
Same & Different 31 24 5 1 1
Hearts & Flowers 50 39 5 1 5
Vocabulary 62 51 2 0 9
Sentence Underst. 52 48 2 1 1
Code
sc_ok <- scores |> filter(!is.na(score)) |> group_by(task) |> filter(n() >= 5) |> ungroup()
ggplot(sc_ok, aes(age, score)) +
  geom_errorbar(aes(ymin = score - score_se, ymax = score + score_se), width = 0, colour = "grey60") +
  geom_point(colour = "#268bd2") +
  geom_smooth(method = "lm", se = FALSE, colour = "grey30") +
  facet_wrap(~task, scales = "free_y") +
  labs(x = "Age (years)", y = "EAP θ (5–12 scale)",
       title = "Official IRT scores vs age",
       subtitle = "±1 SE; linear fit. Pattern Matching omitted (1 scored run).")
Figure 19.1
Code
sc_hand <- sc_ok |> inner_join(run_sum |> select(run_id, acc, n_trials), by = "run_id")
sc_hand |> group_by(task) |>
  summarise(n = n(), r_theta_acc = cor(score, acc), r_theta_age = cor(score, age),
            mean_se = mean(score_se, na.rm = TRUE), .groups = "drop") |>
  knitr::kable(digits = 2, caption = "Official θ vs our hand accuracy and age; mean posterior SE")
Official θ vs our hand accuracy and age; mean posterior SE
task n r_theta_acc r_theta_age mean_se
Hearts & Flowers 39 0.94 0.84 0.76
Math 26 0.84 0.61 0.60
Memory 13 0.90 0.75 0.53
ROAR-Phoneme 20 0.98 0.45 0.64
Same & Different 24 0.91 0.81 0.86
Sentence Underst. 48 0.93 0.82 0.44
Shape Rotation 24 0.87 0.53 0.47
Vocabulary 51 0.82 0.75 0.48

The official θs track our hand scores closely everywhere (r ≈ 0.8–1.0 with hand accuracy) — the pipeline and the hand scoring agree about ordering. The problems are the scale and the coverage: mean posterior SEs are large (≈ 0.4–0.9 vs ≈ 0.3 typical in the 5–12 data), Math θ is compressed against the bottom of its scale (all runs between −5.3 and −1.8), and the exclusion rules built for 5–12 administration (≥10 valid trials) remove most of what the youngest children produced — so the scored subset over-represents the 3+ band. Within it, Sentence Understanding and Same & Different θs show the strongest age relations (r ≈ 0.85 and 0.81).

7. Which tasks work for 2-year-olds?

The design decision this pilot feeds is which tasks to keep for the youngest band. Two views: accuracy in fine age bins, and the trial-level logistic slope of accuracy on age fit to children under 3.5 (the “lift-off” test — is there a within-band age gradient at all?).

Code
fine <- test |>
  filter(!(task_id == "vocab" & corpus_trial_type == "catch")) |>
  mutate(bin = cut(age, c(2, 2.5, 3, 3.5, 4, 5, 6), right = FALSE,
                   labels = c("2–2.5", "2.5–3", "3–3.5", "3.5–4", "4–5", "5–6")))
fine |> group_by(task, bin) |>
  summarise(acc = mean(correct_hand), kids = n_distinct(user_id), .groups = "drop") |>
  mutate(cell = glue("{sprintf('%.2f', acc)} ({kids})")) |>
  select(task, bin, cell) |>
  pivot_wider(names_from = bin, values_from = cell) |>
  left_join(chance_df |> mutate(chance = round(chance, 2)), by = "task") |>
  knitr::kable(caption = "Hand-scored accuracy (n children) by fine age bin; rightmost column = task-average chance")
Hand-scored accuracy (n children) by fine age bin; rightmost column = task-average chance
task 2–2.5 2.5–3 3–3.5 3.5–4 4–5 5–6 chance
Hearts & Flowers 0.33 (6) 0.42 (4) 0.48 (3) 0.67 (4) 0.69 (8) 0.86 (10) 0.50
Math 0.35 (4) 0.29 (4) 0.12 (2) 0.47 (3) 0.52 (9) 0.59 (11) 0.31
Memory NA 0.00 (2) 0.00 (1) 0.28 (3) 0.51 (6) 0.57 (7) 0.00
Pattern Matching 0.25 (4) 0.51 (5) 0.45 (2) 0.50 (3) 0.53 (9) 0.48 (7) 0.25
ROAR-Phoneme NA NA NA NA 0.58 (7) 0.61 (12) NaN
Same & Different 0.42 (5) 0.74 (3) 0.64 (2) 0.70 (4) 0.74 (6) 0.91 (5) 0.30
Sentence Underst. 0.31 (10) 0.46 (5) 0.61 (3) 0.66 (4) 0.62 (10) 0.75 (10) 0.25
Shape Rotation NA NA 0.55 (2) 0.55 (4) 0.54 (6) 0.74 (11) 0.33
Vocabulary 0.26 (9) 0.49 (5) 0.50 (3) 0.60 (4) 0.57 (10) 0.69 (12) 0.25
Code
liftoff <- fine |> filter(age < 3.5) |>
  group_by(task) |> filter(n_distinct(user_id) >= 4) |>
  group_modify(\(d, ...) {
    co <- summary(glm(correct_hand ~ age, binomial, d))$coefficients
    tibble(kids = n_distinct(d$user_id), trials = nrow(d),
           slope = co[2, 1], se = co[2, 2], p = co[2, 4])
  }) |> ungroup() |> arrange(desc(slope))
liftoff |> mutate(across(c(slope, se), ~ round(.x, 2)), p = signif(p, 2)) |>
  knitr::kable(caption = "Lift-off under 3.5: logistic slope of trial correctness on age (logits/year), tasks with ≥4 children under 3.5")
Lift-off under 3.5: logistic slope of trial correctness on age (logits/year), tasks with ≥4 children under 3.5
task kids trials slope se p
Vocabulary 16 891 1.44 0.20 0.0000
Sentence Underst. 17 884 1.41 0.21 0.0000
Pattern Matching 10 203 1.32 0.46 0.0037
Same & Different 10 195 1.17 0.40 0.0032
Hearts & Flowers 13 343 0.91 0.32 0.0040
Math 10 91 -0.78 0.69 0.2600

Verdict by task, for the 2s specifically:

  • Vocabulary — keep. Clear lift-off (+1.4 logits/yr under 3.5, p < .001, now on 16 kids / 891 trials): 2–2.5s sit at chance (0.26) but 2.5–3s are at 0.49, and 2-year-olds pass the easy catch items at 50% vs 25% chance — the format works once items are easy enough; the bank, not the task, is the problem.
  • Sentence Understanding — keep. Clear lift-off (+1.4 logits/yr, p < .001); 0.31 at 2–2.5 (chance .25) rising to 0.46 by 2.5–3, on the most under-3 data of any task (17 kids under 3.5).
  • Math — drop for 2s. Still the clearest negative: slope −0.8 (p = .26) under 3.5, at/below chance in every bin below 3.5, and the item bank has no items down there (§5).
  • Memory (Corsi) — drop for 2s. Complete floor: the under-3s who reached test trials got literally zero sequences correct (max span 0), and most young runs died before producing 10 valid trials.
  • Hearts & Flowers — borderline, leaning drop below ~3. Lift-off is real (+0.9 logits/yr, p = .004, 13 kids) but rises from below chance (0.33 at 2–2.5 on a 2-AFC — systematic perseveration/too-slow responding, not noise); it doesn’t clear chance until ~3.5.
  • Pattern Matching — behaviorally promising, blocked by scoring (changed verdict). With the larger sample the downward-extension variant shows clear lift-off (+1.3 logits/yr, p = .004; chance 0.25 at 2–2.5 → 0.51 at 2.5–3) — but §6 shows it is still ~unscorable against the current bank (4 of 50 runs). Calibrating the downex items is now clearly worth it.
  • Same & Different — promising from ~2.5 (changed verdict). Now has under-3 data (10 kids): lift-off +1.2 logits/yr (p = .003), 0.42 at 2–2.5 (chance ≈ 0.30) rising to 0.74 at 2.5–3.
  • Shape Rotation / ROAR-Phoneme: still almost no under-3 data; no basis to judge.

Notes & next steps

  • Data: pilot_bostonchildrens_us_main_raw:7e6c v5_0 (raw, per-site) + the unreleased processed draft (pulled 2026-08-31). Cached under data/ (git-ignored). Two other downward-extension raw datasets exist on Redivis — pilot_langcog_us_downex_raw:a6kb (Stanford) and partner_sparklab_us_downex_raw:4n9e — natural comparison sets once the Boston picture is clear.
  • In the published book (since 2026-08-17, by explicit decision); sample now N = 44.
  • Next: (i) decide a validity floor appropriate for 2–5; (ii) look at practice-trial performance as an engagement gate; (iii) once ≥ 2 sites of downward-extension data exist, item-level difficulty for a 2–5 bank; (iv) compare with the Stanford / Sparklab downex tranches.