Adherence across daytime, sleep, and pre-sleep

This exploratory analysis separates two questions: whether a participant’s higher-than-usual daytime adherence accompanies different sleep or pre-sleep adherence, and whether participants with higher observed average daytime adherence differ from other participants. The exposure contrast is 10 percentage points. Neither comparison establishes causation or stable participant types. The day-type analysis and definitions of valid minutes are in Light recommendations.

Inputs and reusable model functions

The previous analysis derives window periods from minute-level light measurements and sleep diaries. This page uses those freshly generated periods and the normalized diary to join each daytime period to its preceding sleep and following pre-sleep period. The files in scripts/models/brown-association/ provide reusable frame construction, the endpoint-inflated beta-binomial likelihood, optimization, predictions, and diagnostics. The analysis choices and execution are shown below. Compiling the likelihood requires a C++17 compiler supported by the installed R and TMB versions.

source("scripts/project.R")
analysis_setup()
library(data.table)

Attaching package: 'data.table'
The following object is masked from 'package:base':

    %notin%
for (helper in c("io", "model", "frames", "fit", "effects", "primary-effects", "diagnostics", "deletion", "likelihood", "display")) {
  source(file.path("scripts/models/brown-association", paste0(helper, ".R")))
}
site_levels <- as.character(data.table::fread("config/site_display_registry.csv")[order(display_order)]$site)
window_periods <- readRDS("results/intermediate/model_data/brown/frames/window_periods.rds")
diary_raw <- data.table::as.data.table(readRDS("results/intermediate/model_data/normalized_inputs/sleepdiaries.rds"))

Align the windows and separate within- and between-participant exposure

Each eligible daytime period supplies the wake-start date and Work or Free day classification. Sleep is the preceding sleep interval; pre-sleep is the following three-hour interval. Pairing uses diary row identifiers plus actual dates and UTC boundaries, so gaps are not silently bridged. The primary sample permits any valid period, and the sensitivity sample requires at least 80% coverage for each included window. Expected duration determines coverage; valid measured minutes are the binomial denominator.

periods <- data.table::copy(data.table::as.data.table(window_periods$near_eye))
data.table::setindex(periods, NULL)
periods <- periods[linkage_variant == "C_previous_presleep_sleep_then_wake" & !is.na(day_type) & valid_minutes > 0]
required_samples <- c("primary_any_valid", "support_80")
samples <- list(
  primary_any_valid = prepare_frame(periods, "primary_any_valid"),
  support_80 = prepare_frame(periods[support_fraction >= 0.80], "support_80")
)
diary <- diary_raw[, .(
  site = as.character(site),
  Id = as.character(Id),
  source_row = as.integer(source_row),
  diary_wake_date = as.Date(wake_wall),
  diary_wake_utc = wake_utc,
  diary_sleepprep_utc = sleepprep_utc,
  diary_day_type = data.table::fcase(
    as.character(daytype2) == "a work day", "Work day",
    as.character(daytype2) == "a free day", "Free day",
    default = NA_character_
  )
)]
if (
  anyDuplicated(diary[, .(site, Id, source_row)]) ||
    anyDuplicated(diary[, .(site, Id, diary_wake_utc)])
) {
  stop("The normalized diary chronology key is duplicated.", call. = FALSE)
}
data.table::setorder(diary, site, Id, diary_wake_utc, source_row)
diary[, `:=`(
  chronological_next_source_row = data.table::shift(source_row, type = "lead"),
  chronological_next_wake_date = data.table::shift(diary_wake_date, type = "lead"),
  chronological_next_wake_utc = data.table::shift(diary_wake_utc, type = "lead")
), by = .(site, Id)]


pair_objects <- lapply(samples, build_pairs, diary = diary)
pairs <- data.table::rbindlist(lapply(pair_objects, `[[`, "pair"), use.names = TRUE, fill = TRUE)
mapping_checks <- pairs[, .(
  rows = .N,
  participants = data.table::uniqueN(participant_id),
  cycles = data.table::uniqueN(source_cycle_key),
  missing_diary = sum(is.na(diary_anchor_date) | is.na(diary_next_date)),
  nonforward_date_gap = sum(date_gap_days <= 0L, na.rm = TRUE),
  anchor_date_mismatch = sum(!anchor_date_match, na.rm = TRUE),
  anchor_day_type_mismatch = sum(!anchor_day_type_match, na.rm = TRUE),
  chronological_source_mismatch = sum(!next_source_is_chronological, na.rm = TRUE),
  chronological_date_mismatch = sum(!next_date_is_chronological, na.rm = TRUE),
  chronological_time_mismatch = sum(!next_time_is_chronological, na.rm = TRUE),
  wake_start_mismatch = sum(!wake_start_match, na.rm = TRUE),
  wake_end_mismatch = sum(!wake_end_match, na.rm = TRUE),
  target_date_mismatch = sum(!target_date_match, na.rm = TRUE),
  target_start_mismatch = sum(!target_start_match, na.rm = TRUE),
  target_end_mismatch = sum(!target_end_match, na.rm = TRUE),
  target_site_mismatch = sum(target_site != site),
  target_id_mismatch = sum(target_Id != Id),
  count_identity_failure = sum(!valid_count_identity),
  duplicate_pair_key = .N - data.table::uniqueN(
    paste(participant_id, source_cycle_key, target_state)
  ),
  minimum_date_gap = min(date_gap_days, na.rm = TRUE),
  maximum_date_gap = max(date_gap_days, na.rm = TRUE),
  minimum_source_row_delta = min(source_row_delta, na.rm = TRUE),
  maximum_source_row_delta = max(source_row_delta, na.rm = TRUE)
), by = .(sample_id, target_state)]
mapping_failure_columns <- c(
  "missing_diary",
  "nonforward_date_gap",
  "anchor_date_mismatch",
  "anchor_day_type_mismatch",
  "chronological_source_mismatch",
  "chronological_date_mismatch",
  "chronological_time_mismatch",
  "wake_start_mismatch",
  "wake_end_mismatch",
  "target_date_mismatch",
  "target_start_mismatch",
  "target_end_mismatch",
  "target_site_mismatch",
  "target_id_mismatch",
  "count_identity_failure",
  "duplicate_pair_key"
)
if (any(unlist(mapping_checks[, ..mapping_failure_columns]) != 0L)) {
  stop("The exact date-based association mapping failed.", call. = FALSE)
}
cs_write(mapping_checks, "date_mapping_checks.csv")


knitr::kable(mapping_checks[, .(sample_id, target_state, rows, participants, cycles)])
sample_id target_state rows participants cycles
primary_any_valid Sleep 758 140 758
primary_any_valid Pre-sleep 618 139 618
support_80 Sleep 697 140 697
support_80 Pre-sleep 502 137 502

The participant mean uses every eligible daytime period in the corresponding sample. A within-participant deviation compares each daytime fraction with that mean. The between-participant predictor is the centered participant mean, and the participant’s fraction of Free days is included separately. All three predictors are scaled so one unit is ten percentage points.

model_frames <- setNames(lapply(required_samples, function(id) {
  build_model_frame(pairs, id, site_levels)
}), required_samples)
sample_support <- data.table::rbindlist(lapply(model_frames, function(frame) {
  value <- data.table::as.data.table(frame)
  value[, .(
    rows = .N,
    participants = data.table::uniqueN(participant_cluster),
    cycles = data.table::uniqueN(association_cycle_cluster),
    complete_target_cycles = data.table::uniqueN(
      association_cycle_cluster[complete_target_pair]
    ),
    sites = data.table::uniqueN(site),
    valid_minutes = sum(target_valid_minutes),
    exact_zero_rows = sum(target_exact_zero),
    exact_one_rows = sum(target_exact_one)
  ), by = .(sample_id, target_state)]
}))
cs_write(sample_support, "sample_support.csv")

cell_support <- data.table::rbindlist(lapply(model_frames, function(frame) {
  data.table::as.data.table(frame)[, .(
    rows = .N,
    participants = data.table::uniqueN(participant_cluster),
    cycles = data.table::uniqueN(association_cycle_cluster),
    valid_minutes = sum(target_valid_minutes),
    exact_zero_rows = sum(target_exact_zero),
    exact_one_rows = sum(target_exact_one)
  ), by = .(sample_id, target_state, site, day_type)]
}))
cs_write(cell_support, "cell_support.csv")


cs_save(model_frames, "association_model_frames.rds")
knitr::kable(sample_support)
sample_id target_state rows participants cycles complete_target_cycles sites valid_minutes exact_zero_rows exact_one_rows
primary_any_valid Sleep 758 140 758 615 9 364575 6 298
primary_any_valid Pre-sleep 618 139 618 615 9 103322 11 66
support_80 Sleep 697 140 697 486 9 336581 6 269
support_80 Pre-sleep 502 137 502 486 9 89084 8 49

Likelihood and model specification

The model represents adherent and non-adherent minute counts and permits extra all-zero and all-one periods. The mean contains target-window by site by day-type interactions, target-specific within- and between-participant daytime associations, and the participant Free-day fraction. The all-zero component depends on target and day type, the all-one component on their interaction, and dispersion on target. A participant random intercept accounts for repeated observations. We also fit a cycle intercept to check whether it is identifiable.

compiled_dir <- "results/models/brown-association/compiled"
dir.create(compiled_dir, recursive = TRUE, showWarnings = FALSE)
cpp_path <- file.path(compiled_dir, "cross_state_endpoint_model.cpp")
file.copy("scripts/models/brown-association/cross_state_endpoint_model.cpp", cpp_path, overwrite = TRUE)
[1] TRUE
TMB::compile(cpp_path, flags = "-O2 -std=gnu++17", safebounds = FALSE, safeunload = TRUE)
Note: Using Makevars in /Users/zauner/Projects/ZaunerEtAl_reproducible_NH/config/Makevars 
using C++ compiler: 'Apple clang version 17.0.0 (clang-1700.0.13.3)'
using SDK: 'MacOSX15.4.sdk'
[1] 0
dll_path <- TMB::dynlib(tools::file_path_sans_ext(cpp_path))
dyn.load(dll_path)
knitr::kable(cs_formula_registry)
component rung formula
mean_fixed F3 ~target_state * site * day_type + target_state * wake_within_10pp + target_state * wake_between_centered_10pp + target_state * wake_free_fraction_centered_10pp
mean_fixed F2 ~target_state * site + target_state * day_type + site * day_type + target_state * wake_within_10pp + target_state * wake_between_centered_10pp + target_state * wake_free_fraction_centered_10pp
mean_fixed F1 ~target_state * site + target_state * day_type + target_state * wake_within_10pp + target_state * wake_between_centered_10pp + target_state * wake_free_fraction_centered_10pp
mean_fixed F0 ~target_state + site + day_type + target_state * wake_within_10pp + target_state * wake_between_centered_10pp + target_state * wake_free_fraction_centered_10pp
mean_random R0 (1 | participant_cluster) + (1 | association_cycle_cluster)
mean_random R3 (1 | participant_cluster)
extra_all_zero Q2 ~target_state + day_type
extra_all_one Q1 ~target_state * day_type
dispersion D0 ~target_state

Before fitting, direct probability calculations in R check the compiled likelihood, its endpoint masses, mean, variance, cumulative distribution, derivatives, and random-number generator. A limiting beta-binomial case is compared with glmmTMB.

synthetic <- data.frame(
  y = c(0L, 1L, 4L, 7L, 10L, 2L, 8L, 3L),
  n = c(10L, 10L, 10L, 10L, 10L, 8L, 8L, 6L)
)
glmm_reference <- glmmTMB::glmmTMB(
  cbind(y, n - y) ~ 1,
  data = synthetic,
  family = glmmTMB::betabinomial(link = "logit"),
  dispformula = ~1,
  ziformula = ~0,
  REML = FALSE
)
eta_mu <- unname(glmmTMB::fixef(glmm_reference)$cond)
eta_disp <- unname(glmmTMB::fixef(glmm_reference)$disp)
reduction <- make_synthetic(
  synthetic$y,
  synthetic$n,
  eta_mu,
  -30,
  -30,
  eta_disp
)
reduction_object <- cs_make_object(
  reduction$design,
  reduction$parameters,
  silent = TRUE
)
reference_log_likelihood <- as.numeric(stats::logLik(glmm_reference))
custom_reduction_log_likelihood <- -reduction_object$fn()

grid <- expand.grid(
  eta_zero = c(-4, -0.5, 2.5),
  eta_one = c(-3, 0.25, 3),
  eta_mu = c(-1.2, 0.7),
  eta_disp = c(log(4), log(15))
)
direct_cases <- data.table::rbindlist(lapply(seq_len(nrow(grid)), function(index) {
  value <- grid[index, ]
  y <- c(0L, 2L, 7L, 10L)
  n <- rep(10L, length(y))
  design <- make_synthetic(
    y,
    n,
    value$eta_mu,
    value$eta_zero,
    value$eta_one,
    value$eta_disp
  )
  objective <- cs_make_object(design$design, design$parameters, silent = TRUE)
  weights <- cs_component_weights(value$eta_zero, value$eta_one)
  support <- cs_mixture_support(
    10L,
    stats::plogis(value$eta_mu),
    exp(value$eta_disp),
    weights$pi_zero,
    weights$pi_one,
    weights$pi_beta
  )
  probability <- support$probability[match(y, support$y)]
  direct_log_likelihood <- sum(log(probability))
  mean_direct <- sum(support$y / 10 * support$probability)
  variance_direct <- sum(
    (support$y / 10 - mean_direct)^2 * support$probability
  )
  report <- objective$report()
  data.table::data.table(
    case = index,
    probability_sum = sum(support$probability),
    minimum_probability = min(support$probability),
    cdf_terminal = tail(support$cdf, 1L),
    likelihood_difference = -objective$fn() - direct_log_likelihood,
    mean_difference = mean_direct - report$conditional_mean[[1L]],
    variance_difference = variance_direct - report$conditional_variance[[1L]]
  )
}))

derivative <- make_synthetic(
  c(0L, 2L, 5L, 8L, 10L),
  rep(10L, 5L),
  -0.2,
  -1.1,
  -0.8,
  log(7)
)
derivative_object <- cs_make_object(
  derivative$design,
  derivative$parameters,
  silent = TRUE
)
theta <- derivative_object$par
automatic_gradient <- derivative_object$gr(theta)
finite_gradient <- vapply(seq_along(theta), function(index) {
  step <- 1e-6 * (1 + abs(theta[[index]]))
  upper <- lower <- theta
  upper[[index]] <- upper[[index]] + step
  lower[[index]] <- lower[[index]] - step
  (derivative_object$fn(upper) - derivative_object$fn(lower)) / (2 * step)
}, numeric(1))
maximum_gradient_difference <- max(abs(automatic_gradient - finite_gradient))

simulation_draws <- 20000L
simulation_n <- 20L
simulation_mu <- 0.65
simulation_phi <- 12
simulation_pi <- c(zero = 0.20, one = 0.30, beta = 0.50)
simulation_design <- make_synthetic(
  rep(1L, simulation_draws),
  rep(simulation_n, simulation_draws),
  stats::qlogis(simulation_mu),
  log(simulation_pi[["zero"]] / simulation_pi[["beta"]]),
  log(simulation_pi[["one"]] / simulation_pi[["beta"]]),
  log(simulation_phi)
)
simulation_object <- cs_make_object(
  simulation_design$design,
  simulation_design$parameters,
  silent = TRUE
)
set.seed(20260814)
simulated_y <- as.numeric(simulation_object$simulate()$y_simulated)
expected_support <- cs_mixture_support(
  simulation_n,
  simulation_mu,
  simulation_phi,
  simulation_pi[["zero"]],
  simulation_pi[["one"]],
  simulation_pi[["beta"]]
)
expected_zero <- expected_support$probability[[1L]]
expected_one <- expected_support$probability[[nrow(expected_support)]]
expected_mean <- sum(
  expected_support$y / simulation_n * expected_support$probability
)
expected_variance <- sum(
  (expected_support$y / simulation_n - expected_mean)^2 *
    expected_support$probability
)
observed_zero <- mean(simulated_y == 0)
observed_one <- mean(simulated_y == simulation_n)
observed_mean <- mean(simulated_y / simulation_n)
zero_tolerance <- 6 * sqrt(expected_zero * (1 - expected_zero) / simulation_draws)
one_tolerance <- 6 * sqrt(expected_one * (1 - expected_one) / simulation_draws)
mean_tolerance <- 6 * sqrt(expected_variance / simulation_draws)


likelihood_checks <- data.table::data.table(
  check = c("probability_sum", "beta_binomial_reduction", "direct_likelihood", "mean", "variance", "cdf", "gradient", "simulation"),
  passed = c(
    max(abs(direct_cases$probability_sum - 1)) < 1e-12 && min(direct_cases$minimum_probability) >= 0,
    abs(reference_log_likelihood - custom_reduction_log_likelihood) < 1e-8,
    max(abs(direct_cases$likelihood_difference)) < 1e-10,
    max(abs(direct_cases$mean_difference)) < 1e-12,
    max(abs(direct_cases$variance_difference)) < 1e-12,
    max(abs(direct_cases$cdf_terminal - 1)) < 1e-12,
    maximum_gradient_difference < 1e-4,
    abs(observed_zero - expected_zero) <= zero_tolerance && abs(observed_one - expected_one) <= one_tolerance && abs(observed_mean - expected_mean) <= mean_tolerance
  )
)
stopifnot(all(likelihood_checks$passed))
cs_write(likelihood_checks, "likelihood_checks.csv")
cs_write(direct_cases, "likelihood_probability_grid.csv")
knitr::kable(likelihood_checks)
check passed
probability_sum TRUE
beta_binomial_reduction TRUE
direct_likelihood TRUE
mean TRUE
variance TRUE
cdf TRUE
gradient TRUE
simulation TRUE

Fit the association models

The initial model includes participant and cycle intercepts. If the cycle intercept is not identifiable, the participant-intercept model provides the estimable analysis. Its full target-site-day interaction is retained. Convergence, gradients, Hessian definiteness, endpoint separation, covariance, and estimability are checked rather than inferred from optimizer completion alone.

cycle_model <- cs_fit(model_frames, "primary_any_valid", "CS-ANY-F3-R0-Q2-Q1-D0", random_rung = "R0")
cs_save_fit(cycle_model)
if (!isTRUE(cycle_model$fit_check$association_cycle_random_nonidentifiable) && !isTRUE(cycle_model$fit_check$association_cycle_random_boundary)) {
  stop("The cycle random intercept is identifiable; reassess the participant-only specification before interpreting results.")
}
bundles <- list()
bundles$primary_any_valid <- cs_fit(model_frames, "primary_any_valid", "CS-ANY-F3-R3-Q2-Q1-D0", warm_bundle = cycle_model)
bundles$support_80 <- cs_fit(model_frames, "support_80", "CS-80-F3-R3-Q2-Q1-D0", warm_bundle = bundles$primary_any_valid)
invisible(lapply(bundles, cs_save_fit))
stopifnot(all(vapply(bundles, function(x) !x$fit_check$structural_failure, logical(1))))
fit_summary <- data.table::rbindlist(lapply(bundles, `[[`, "fit_check"))
knitr::kable(fit_summary[, .(sample_id, rows, participants, cycles, fit_status, maximum_absolute_gradient, participant_standard_deviation)])
sample_id rows participants cycles fit_status maximum_absolute_gradient participant_standard_deviation
primary_any_valid 1376 140 761 acceptable_with_cautions 0.0047898 0.7808732
support_80 1199 140 713 acceptable_with_cautions 0.0032443 0.8089699

Four primary associations and the coverage sensitivity

Each response-scale contrast changes the relevant daytime predictor from minus five to plus five percentage points around its reference value. Predictions integrate over the participant intercept and average equally across sites and 50:50 across Work and Free days. The delta method uses the complete fixed-parameter covariance, including uncertainty in the participant standard deviation. The four primary conditional-logit tests form one Benjamini-Hochberg FDR family. Sensitivity fits do not add tests to this family.

association_effects <- data.table::rbindlist(Map(cs_derive_primary_effects, names(bundles), bundles))
effects <- association_effects
target_order <- c("Sleep", "Pre-sleep")
level_order <- c("within", "between")
effects[, order_key :=
  match(association_level, level_order) * 10L + match(target_state, target_order)]
data.table::setorder(effects, sample_id, order_key)
effects[, order_key := NULL]

primary <- effects[sample_id == "primary_any_valid"]
if (nrow(primary) != 4L || anyNA(primary$raw_p_value)) {
  stop("BA-CS-M1 requires exactly four estimable primary contrasts.", call. = FALSE)
}
primary[, `:=`(
  multiplicity_family = "BA-CS-M1",
  multiplicity_method = "Benjamini-Hochberg FDR",
  adjusted_p_value = stats::p.adjust(raw_p_value, method = "BH")
)]
primary[, adjusted_significant_0_05 := adjusted_p_value < 0.05]
effects <- merge(
  effects,
  primary[, .(
    sample_id,
    target_state,
    association_level,
    multiplicity_family,
    multiplicity_method,
    adjusted_p_value,
    adjusted_significant_0_05
  )],
  by = c("sample_id", "target_state", "association_level"),
  all.x = TRUE,
  sort = FALSE
)

coverage_check <- merge(
  effects[sample_id == "primary_any_valid", .(
    target_state,
    association_level,
    primary_direction = direction,
    primary_interval_excludes_zero = interval_excludes_zero,
    primary_response_effect_percentage_points = response_effect_percentage_points
  )],
  effects[sample_id == "support_80", .(
    target_state,
    association_level,
    support_80_direction = direction,
    support_80_interval_excludes_zero = interval_excludes_zero,
    support_80_response_effect_percentage_points = response_effect_percentage_points
  )],
  by = c("target_state", "association_level"),
  sort = FALSE
)
coverage_check[, `:=`(
  direction_preserved = primary_direction == support_80_direction,
  interval_exclusion_status_preserved =
    primary_interval_excludes_zero == support_80_interval_excludes_zero,
  absolute_response_shift_percentage_points = abs(
    support_80_response_effect_percentage_points -
      primary_response_effect_percentage_points
  )
)]
coverage_check[, response_shift_within_2pp :=
  absolute_response_shift_percentage_points <= 2]
coverage_check[, coverage_check_passed :=
  direction_preserved & interval_exclusion_status_preserved &
    response_shift_within_2pp]


stopifnot(all(effects$quadrature_absolute_difference_percentage_points <= 0.05))
association_effects <- effects
cs_write(effects, "association_effects.csv")
cs_write(primary, "multiplicity.csv")
cs_write(coverage_check, "coverage_comparison.csv")
effect_source <- primary[, .(association_level, target_state, wake_contrast_percentage_points,
  conditional_logit_estimate, conditional_logit_conf_low, conditional_logit_conf_high,
  raw_p_value, adjusted_p_value, response_effect_percentage_points,
  response_conf_low_percentage_points, response_conf_high_percentage_points,
  interval_excludes_zero, direction, reference_weighting)]
effect_source[, association_order := match(paste(association_level, target_state), c("within Sleep", "within Pre-sleep", "between Sleep", "between Pre-sleep"))]
setorder(effect_source, association_order)
dir.create("results/csv/source_data/brown", recursive = TRUE, showWarnings = FALSE)
data.table::fwrite(effect_source, "results/csv/source_data/brown/table_association_effects.csv")
effect_source[, .(
  Association = association_level, Target = target_state,
  `Difference, pp (95% CI)` = sprintf("%.2f (%.2f, %.2f)", response_effect_percentage_points, response_conf_low_percentage_points, response_conf_high_percentage_points),
  `FDR-adjusted p` = signif(adjusted_p_value, 3)
)] |> knitr::kable()
Table 1: Exploratory associations per 10 percentage points higher daytime adherence.
Association Target Difference, pp (95% CI) FDR-adjusted p
within Sleep -0.26 (-0.95, 0.43) 4.53e-01
within Pre-sleep -1.09 (-2.75, 0.56) 2.58e-01
between Sleep -2.52 (-3.55, -1.48) 3.50e-06
between Pre-sleep -3.59 (-5.94, -1.25) 5.08e-03

The stricter coverage comparison examines effect direction, whether intervals exclude zero, and whether the response-scale estimate changes by more than two percentage points. These checks qualify interpretation rather than define a new significance test.

knitr::kable(coverage_check)
Table 2: Association stability under at least 80% window coverage.
target_state association_level primary_direction primary_interval_excludes_zero primary_response_effect_percentage_points support_80_direction support_80_interval_excludes_zero support_80_response_effect_percentage_points direction_preserved interval_exclusion_status_preserved absolute_response_shift_percentage_points response_shift_within_2pp coverage_check_passed
Sleep within negative FALSE -0.2631128 negative FALSE -0.2759279 TRUE TRUE 0.0128151 TRUE TRUE
Pre-sleep within negative FALSE -1.0942850 negative FALSE -1.1479848 TRUE TRUE 0.0536998 TRUE TRUE
Sleep between negative TRUE -2.5169903 negative TRUE -2.5146772 TRUE TRUE 0.0023130 TRUE TRUE
Pre-sleep between negative TRUE -3.5934041 negative TRUE -2.9951519 TRUE TRUE 0.5982522 TRUE TRUE

Calibration, endpoints, and temporal dependence

Conditional predictions are checked against observed adherence and endpoint frequencies, using 250 reproducible predictive simulations. Randomized quantile residuals use the exact mixture distribution. Temporal correlations pair observations by actual diary date within participant and target, rather than assuming that adjacent rows are adjacent days.

diagnostics <- list(
  primary_any_valid = cs_diagnose(
    "primary_any_valid", bundles[["primary_any_valid"]], 0L
  ),
  support_80 = cs_diagnose(
    "support_80", bundles[["support_80"]], 1000L
  )
)

bind_component <- function(name) {
  data.table::rbindlist(lapply(diagnostics, `[[`, name), fill = TRUE)
}
calibration <- bind_component("calibration")
boundary_prediction <- bind_component("boundary_prediction")
residual_summary <- bind_component("residual_summary")
residual_patterns <- bind_component("residual_patterns")
linearity <- bind_component("linearity")
temporal_correlation <- bind_component("temporal_correlation")
temporal_gap_summary <- bind_component("temporal_gap_summary")
influence_summary <- bind_component("influence_summary")
assessment <- bind_component("assessment")

validation <- data.table::rbindlist(list(
  data.table::data.table(
    check = "predictive_draw_dimensions",
    passed = all(vapply(diagnostics, function(value) {
      ncol(value$simulations) == 250L &&
        nrow(value$simulations) == nrow(value$row_data)
    }, logical(1))),
    detail = "250 conditional draws per sample"
  ),
  data.table::data.table(
    check = "predictive_draw_bounds",
    passed = all(vapply(diagnostics, function(value) {
      all(value$simulations >= 0L) &&
        all(value$simulations <= value$row_data$target_valid_minutes)
    }, logical(1))),
    detail = "all simulated counts in [0,n]"
  ),
  data.table::data.table(
    check = "randomized_cdf_order",
    passed = all(vapply(diagnostics, function(value) {
      all(value$row_data$lower_cdf <= value$row_data$upper_cdf) &&
        all(value$row_data$lower_cdf >= 0) &&
        all(value$row_data$upper_cdf <= 1)
    }, logical(1))),
    detail = "exact mixture CDF bounds"
  ),
  data.table::data.table(
    check = "target_endpoint_check_complete",
    passed = all(
      boundary_prediction[grouping == "target", .N, by = sample_id]$N == 6L
    ),
    detail = "three boundary classes for each of two targets"
  ),
  data.table::data.table(
    check = "actual_date_temporal_series_complete",
    passed = all(
      temporal_correlation[residual_type == "Pearson", .N, by = sample_id]$N == 2L
    ),
    detail = "Sleep and Pre-sleep ordered by Wake-anchor diary date"
  ),
  data.table::data.table(
    check = "bounded_influence_selection_defined",
    passed = all(influence_summary$selected_participants == 5L),
    detail = "five anonymous participants selected per sample"
  ),
  data.table::data.table(
    check = "no_identifier_in_durable_csv_outputs",
    passed = TRUE,
    detail = "row data and anonymous selections retained only inside RDS"
  )
))
if (!all(validation$passed)) {
  stop("Diagnostic validation failed.", call. = FALSE)
}

cs_write(calibration, "diagnostic_calibration.csv")
cs_write(boundary_prediction, "diagnostic_boundary_prediction.csv")
cs_write(residual_summary, "diagnostic_residual_summary.csv")
cs_write(residual_patterns, "diagnostic_residual_patterns.csv")
cs_write(linearity, "diagnostic_linearity_bins.csv")
cs_write(temporal_correlation, "diagnostic_temporal_correlation.csv")
cs_write(temporal_gap_summary, "diagnostic_temporal_gap_summary.csv")
cs_write(influence_summary, "diagnostic_influence_screen_summary.csv")
cs_write(assessment, "diagnostic_assessment.csv")
cs_write(validation, "diagnostic_validation.csv")
cs_save(list(diagnostics = diagnostics, validation = validation), "diagnostics.rds")
knitr::kable(assessment)
sample_id target status detail
primary_any_valid overall_adherence acceptable_with_limitations maximum absolute target-site-day-type calibration difference among cells with at least 10 rows: 0.063
primary_any_valid endpoint_probabilities acceptable 6 of 6 target-level endpoint envelopes passed
primary_any_valid actual_date_temporal_dependence triggered 2 target Pearson series reached the specified trigger
support_80 overall_adherence acceptable_with_limitations maximum absolute target-site-day-type calibration difference among cells with at least 10 rows: 0.064
support_80 endpoint_probabilities acceptable 6 of 6 target-level endpoint envelopes passed
support_80 actual_date_temporal_dependence triggered 2 target Pearson series reached the specified trigger

Sensitivity models

The same model is refitted to cycles with both targets, an equal-Work/Free-day definition of the participant daytime mean, observations at least two days apart, and a target-specific participant-centered date trend. The latter two examine residual temporal dependence. Models failing identifiability or convergence checks remain visible as failures and do not contribute effect estimates.

sensitivity_registry <- data.table::data.table(
  model_id = c("CS-ANY-F3-R3-COMPLETE", "CS-80-F3-R3-COMPLETE", "CS-ANY-F3-R3-EQUALDAY", "CS-80-F3-R3-EQUALDAY", "CS-ANY-F3DATE-R3", "CS-80-F3DATE-R3", "CS-ANY-F3-R3-THIN2", "CS-80-F3-R3-THIN2"),
  sample_id = rep(c("primary_any_valid", "support_80"), 4),
  frame_variant = rep(c("complete_pair", "equal_daytype", "date_trend", "thinned"), each = 2),
  sensitivity = rep(c("complete_target_pair", "equal_daytype_between", "participant_centered_date_trend", "two_day_thinning"), each = 2)
)
sensitivity_models <- setNames(lapply(seq_len(nrow(sensitivity_registry)), function(i) {
  spec <- sensitivity_registry[i]
  warm <- if (spec$frame_variant == "date_trend" || (spec$frame_variant == "thinned" && spec$sample_id == "primary_any_valid")) NULL else bundles[[spec$sample_id]]
  fit <- cs_fit(model_frames, spec$sample_id, spec$model_id,
    fixed_rung = if (spec$frame_variant == "date_trend") "F3_DATE" else "F3",
    frame_variant = spec$frame_variant, warm_bundle = warm)
  cs_save_fit(fit)
}), sensitivity_registry$model_id)
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
fit_registry <- data.table::rbindlist(lapply(seq_len(nrow(sensitivity_registry)), function(i) {
  spec <- sensitivity_registry[i]
  value <- data.table::copy(sensitivity_models[[spec$model_id]]$fit_check)
  value[, sensitivity := spec$sensitivity]
  value
}))
passed_models <- fit_registry[structural_failure == FALSE, model_id]
sensitivity_effects <- data.table::rbindlist(lapply(passed_models, function(id) {
  result <- cs_summarize_four_effects(sensitivity_models[[id]], id)
  spec <- sensitivity_registry[model_id == id]
  result[, `:=`(sample_id = spec$sample_id, sensitivity = spec$sensitivity)]
  result
}), fill = TRUE)
knitr::kable(fit_registry[, .(sample_id, sensitivity, rows, fit_status, failure_components)])
sample_id sensitivity rows fit_status failure_components
primary_any_valid complete_target_pair 1230 structural_failure endpoint_separation
support_80 complete_target_pair 972 acceptable_with_cautions
primary_any_valid equal_daytype_between 1267 acceptable_with_cautions
support_80 equal_daytype_between 1096 acceptable_with_cautions
primary_any_valid participant_centered_date_trend 1376 structural_failure hard_fit_check | endpoint_separation | participant_random_nonidentifiable | wake_association_nonestimable
support_80 participant_centered_date_trend 1199 structural_failure hard_fit_check | endpoint_separation | participant_random_nonidentifiable | wake_association_nonestimable
primary_any_valid two_day_thinning 774 acceptable_with_cautions
support_80 two_day_thinning 704 structural_failure hard_fit_check
primary <- association_effects[
  sample_id == "primary_any_valid",
  .(
    target_state,
    association_level,
    primary_conditional_logit_estimate = conditional_logit_estimate,
    primary_direction = direction,
    primary_interval_excludes_zero = interval_excludes_zero,
    primary_response_effect_percentage_points = response_effect_percentage_points
  )
]
comparison <- merge(
  sensitivity_effects,
  primary,
  by = c("target_state", "association_level"),
  all.x = TRUE,
  sort = FALSE
)
comparison[, `:=`(
  direction_preserved_vs_primary = direction == primary_direction,
  interval_status_preserved_vs_primary =
    interval_excludes_zero == primary_interval_excludes_zero,
  absolute_response_shift_vs_primary_percentage_points = abs(
    response_effect_percentage_points -
      primary_response_effect_percentage_points
  )
)]

temporal_trigger <- temporal_correlation[
  residual_type == "Pearson",
  .(
    temporal_triggered = any(temporal_trigger),
    triggered_targets = sum(temporal_trigger)
  ),
  by = sample_id
]
temporal_fit <- fit_registry[
  sensitivity %in% c("participant_centered_date_trend", "two_day_thinning"),
  .(
    temporal_fits = .N,
    temporal_fits_passed = sum(!structural_failure),
    failed_models = paste(model_id[structural_failure], collapse = " | ")
  ),
  by = sample_id
]
temporal_stability <- comparison[
  sensitivity == "two_day_thinning" & association_level == "within",
  .(
    thinned_within_effects = .N,
    directions_preserved = all(direction_preserved_vs_primary),
    interval_statuses_preserved = all(interval_status_preserved_vs_primary),
    maximum_response_shift_percentage_points = max(
      absolute_response_shift_vs_primary_percentage_points
    )
  ),
  by = sample_id
]
temporal_assessment <- merge(
  merge(temporal_trigger, temporal_fit, by = "sample_id", all = TRUE),
  temporal_stability,
  by = "sample_id",
  all = TRUE
)
temporal_assessment[, day_level_claim_status := data.table::fcase(
  !temporal_triggered, "eligible_after_other_diagnostics",
  temporal_fits_passed < temporal_fits,
    "withhold_unresolved_temporal_dependence",
  !directions_preserved | !interval_statuses_preserved |
    maximum_response_shift_percentage_points > 2,
    "withhold_temporally_unstable",
  default = "eligible_with_temporal_limitation"
)]

cs_write(fit_registry, "sensitivity_fit_summary.csv")
cs_write(sensitivity_effects, "sensitivity_effects.csv")
cs_write(comparison, "sensitivity_comparison.csv")
cs_write(temporal_assessment, "temporal_assessment.csv")
knitr::kable(temporal_assessment)
sample_id temporal_triggered triggered_targets temporal_fits temporal_fits_passed failed_models thinned_within_effects directions_preserved interval_statuses_preserved maximum_response_shift_percentage_points day_level_claim_status
primary_any_valid TRUE 2 2 1 CS-ANY-F3DATE-R3 2 TRUE TRUE 0.8220722 withhold_unresolved_temporal_dependence
support_80 TRUE 2 2 0 CS-80-F3DATE-R3 | CS-80-F3-R3-THIN2 NA NA NA NA withhold_unresolved_temporal_dependence

The temporal assessment determines whether within-participant associations can support an inferential claim. A failed temporal sensitivity is not evidence that dependence has been resolved.

Site and participant influence

Leave-one-site-out fits examine dependence on each site. Five participant deletions are selected by the largest percentile across denominator share, absolute residual, and fixed-design variance diagnostics. These are influence checks, not additional hypothesis tests. Every fit is recomputed in this render.

primary_bundle <- bundles$primary_any_valid
primary_frame <- primary_bundle$design_object$frame
selected_participants <- diagnostics$primary_any_valid$influence[selected_for_bounded_refit == TRUE, participant_cluster]
sites <- levels(primary_frame$site)
refits <- list()
for (site in sites) {
  id <- paste0("LOSO-", site)
  frame <- droplevels(primary_frame[as.character(primary_frame$site) != site, ])
  refits[[id]] <- cs_fit_deletion(frame, id, primary_bundle)
}
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in sqrt(diag(object$cov.fixed)): NaNs produced
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in stats::nlminb(start = start, objective = objective$fn, gradient =
objective$gr, : NA/NaN function evaluation
Warning in sqrt(diag(object$cov.fixed)): NaNs produced
for (i in seq_along(selected_participants)) {
  id <- sprintf("INFLUENCE-CASE-%02d", i)
  frame <- droplevels(primary_frame[as.character(primary_frame$participant_cluster) != selected_participants[[i]], ])
  refits[[id]] <- cs_fit_deletion(frame, id, primary_bundle)
}
checks <- data.table::rbindlist(lapply(refits, `[[`, "check"), fill = TRUE)
effects <- data.table::rbindlist(lapply(refits, `[[`, "effects"), fill = TRUE)
effects[, refit_type := ifelse(
  grepl("^LOSO-", model_id), "leave_one_site_out", "bounded_participant"
)]
effects[, omitted_site := ifelse(
  refit_type == "leave_one_site_out",
  sub("^LOSO-", "", model_id),
  NA_character_
)]

primary <- association_effects[
  sample_id == "primary_any_valid",
  .(
    target_state,
    association_level,
    primary_conditional_logit_estimate = conditional_logit_estimate,
    primary_direction = direction,
    primary_interval_excludes_zero = interval_excludes_zero,
    primary_response_effect_percentage_points = response_effect_percentage_points
  )
]
effects <- merge(
  effects,
  primary,
  by = c("target_state", "association_level"),
  all.x = TRUE,
  sort = FALSE
)
effects[, `:=`(
  direction_preserved = direction == primary_direction,
  interval_status_preserved =
    interval_excludes_zero == primary_interval_excludes_zero,
  absolute_logit_shift = abs(
    conditional_logit_estimate - primary_conditional_logit_estimate
  ),
  absolute_response_shift_percentage_points = abs(
    response_effect_percentage_points -
      primary_response_effect_percentage_points
  )
)]

loso <- effects[refit_type == "leave_one_site_out"]
influence <- effects[refit_type == "bounded_participant"]
influence_summary <- influence[, .(
  successful_refits = .N,
  conditional_logit_minimum = min(conditional_logit_estimate),
  conditional_logit_maximum = max(conditional_logit_estimate),
  maximum_absolute_logit_shift = max(absolute_logit_shift),
  response_effect_minimum_percentage_points = min(response_effect_percentage_points),
  response_effect_maximum_percentage_points = max(response_effect_percentage_points),
  maximum_absolute_response_shift_percentage_points = max(
    absolute_response_shift_percentage_points
  ),
  all_directions_preserved = all(direction_preserved),
  all_interval_statuses_preserved = all(interval_status_preserved)
), by = .(target_state, association_level)]

check_summary <- data.table::data.table(
  refit_set = c("nine_LOSO", "bounded_five_participant"),
  requested_refits = c(9L, 5L),
  successful_refits = c(
    sum(grepl("^LOSO-", checks$refit_id) & !checks$structural_failure),
    sum(grepl("^INFLUENCE-", checks$refit_id) & !checks$structural_failure)
  ),
  failed_refits = c(
    sum(grepl("^LOSO-", checks$refit_id) & checks$structural_failure),
    sum(grepl("^INFLUENCE-", checks$refit_id) & checks$structural_failure)
  )
)
assessment <- data.table::data.table(
  check = c("LOSO", "bounded_participant_influence"),
  status = c(
    if (
      check_summary[refit_set == "nine_LOSO", failed_refits] > 0L ||
        any(!loso$direction_preserved) ||
        any(!loso$interval_status_preserved)
    ) {
      "acceptable_with_limitations"
    } else {
      "acceptable"
    },
    if (
      check_summary[refit_set == "bounded_five_participant", failed_refits] > 0L ||
        any(!influence$direction_preserved) ||
        any(!influence$interval_status_preserved)
    ) {
      "acceptable_with_limitations"
    } else {
      "acceptable"
    }
  ),
  detail = c(
    sprintf(
      "%d/9 fits succeeded; maximum response shift %.3f percentage points",
      check_summary[refit_set == "nine_LOSO", successful_refits],
      max(loso$absolute_response_shift_percentage_points, na.rm = TRUE)
    ),
    sprintf(
      "%d/5 fits succeeded; maximum response shift %.3f percentage points",
      check_summary[refit_set == "bounded_five_participant", successful_refits],
      max(influence$absolute_response_shift_percentage_points, na.rm = TRUE)
    )
  )
)

cs_write(check_summary, "bounded_refit_summary.csv")
cs_write(loso, "leave_one_site_out_effects.csv")
cs_write(influence_summary, "participant_influence_summary.csv")
cs_write(assessment, "bounded_refit_assessment.csv")
cs_save(refits, "deletion_models.rds")
knitr::kable(assessment)
check status detail
LOSO acceptable_with_limitations 5/9 fits succeeded; maximum response shift 0.910 percentage points
bounded_participant_influence acceptable_with_limitations 4/5 fits succeeded; maximum response shift 0.177 percentage points

Descriptive daytime groups

For illustration only, daytime deviations are divided at the sample’s one-third and two-third empirical quantiles (type 1). Predictions are evaluated at each group’s observed median deviation. These labels describe recorded daytime periods, not participant categories, and no inferential comparison of groups is performed.

day_groups <- data.table::rbindlist(Map(function(id, bundle) cs_day_groups(id, bundle, samples), names(bundles), bundles))
cs_write(day_groups, "descriptive_daytime_groups.csv")
knitr::kable(day_groups[, .(sample_id, target_state, wake_group, wake_cycles, paired_target_periods, adjusted_target_adherence)])
Table 3: Descriptive target adherence at low, middle, and high observed daytime deviations.
sample_id target_state wake_group wake_cycles paired_target_periods adjusted_target_adherence
primary_any_valid Sleep High 253 254 0.8733624
primary_any_valid Sleep Low 254 251 0.8795232
primary_any_valid Sleep Middle 254 253 0.8765703
primary_any_valid Pre-sleep High 253 220 0.6290602
primary_any_valid Pre-sleep Low 254 185 0.6546805
primary_any_valid Pre-sleep Middle 254 213 0.6423541
support_80 Sleep High 239 231 0.8723458
support_80 Sleep Low 240 234 0.8787969
support_80 Sleep Middle 239 232 0.8756261
support_80 Pre-sleep High 239 178 0.6383259
support_80 Pre-sleep Low 240 151 0.6651601
support_80 Pre-sleep Middle 239 173 0.6519346

Participant profiles

For participants represented in all three windows, each point is the participant’s equal-cycle mean adherence. The lines connect the same participant’s three summaries and are not time trajectories. The density shapes, median, and interquartile range describe this sample. Sleep reflects the bedside environment, rather than ocular exposure.

primary <- data.table::as.data.table(model_frames$primary_any_valid)
wake_consistency <- primary[, .(
  values = data.table::uniqueN(wake_fraction),
  minimum = min(wake_fraction),
  maximum = max(wake_fraction)
), by = .(participant_cluster, association_cycle_cluster)]
if (any(wake_consistency$values != 1L)) {
  stop("Wake adherence is not unique within participant-cycle.", call. = FALSE)
}

wake_cycle <- unique(primary[, .(
  participant_cluster,
  association_cycle_cluster,
  state = "Wake",
  adherence = wake_fraction
)])
target_cycle <- primary[, .(
  participant_cluster,
  association_cycle_cluster,
  state = as.character(target_state),
  adherence = target_fraction
)]

cycle_states <- data.table::rbindlist(list(wake_cycle, target_cycle))
profile_internal <- cycle_states[, .(
  mean_adherence = mean(adherence),
  valid_cycles = .N
), by = .(participant_cluster, state)]

state_levels <- c("Sleep", "Wake", "Pre-sleep")
state_display_labels <- c(
  "Sleep" = "Sleep",
  "Wake" = "Daytime",
  "Pre-sleep" = "Pre-sleep"
)
profile_internal[, state := factor(state, levels = state_levels)]
state_support_all <- profile_internal[, .(
  profiles = data.table::uniqueN(participant_cluster)
), by = .(state = as.character(state))]
profile_support <- profile_internal[, .(
  states_observed = data.table::uniqueN(state),
  sleep_observed = any(state == "Sleep"),
  wake_observed = any(state == "Wake"),
  pre_sleep_observed = any(state == "Pre-sleep")
), by = participant_cluster]
complete_clusters <- profile_support[states_observed == 3L, participant_cluster]
profile_internal <- profile_internal[participant_cluster %in% complete_clusters]
if (
  nrow(profile_internal) != length(complete_clusters) * 3L ||
    any(profile_internal[, .N, by = participant_cluster]$N != 3L)
) {
  stop("The complete profile display does not contain three-state profiles.", call. = FALSE)
}

# The sequential display key is assigned after a fixed arbitrary permutation.
# No cluster key or permutation map is written to disk.
set.seed(20260820L)
permuted_clusters <- sample(sort(complete_clusters), length(complete_clusters))
anonymous_key <- data.table::data.table(
  participant_cluster = permuted_clusters,
  profile_key = sprintf("profile_%03d", seq_along(permuted_clusters))
)
set.seed(20260821L)
anonymous_key[, point_offset := stats::runif(.N, min = 0.075, max = 0.255)]
profile_internal <- merge(
  profile_internal,
  anonymous_key,
  by = "participant_cluster",
  all.x = TRUE,
  sort = FALSE
)
profile_internal[, state_order := match(as.character(state), state_levels)]
profile_internal[, point_x := state_order + point_offset]
data.table::setorder(profile_internal, profile_key, state_order)

profile_source <- profile_internal[, .(
  profile_key,
  state = as.character(state),
  state_order,
  mean_adherence,
  valid_cycles,
  point_offset,
  point_x
)]
if (
  any(grepl("participant|cluster|site|rank|tertile", names(profile_source))) ||
    !all(grepl("^profile_[0-9]{3}$", profile_source$profile_key)) ||
    data.table::uniqueN(profile_source$profile_key) != length(complete_clusters) ||
    nrow(profile_source) != length(complete_clusters) * 3L
) {
  stop("The profile source failed its privacy or shape check.", call. = FALSE)
}
cs_write(profile_source, "participant_state_profiles_points.csv")

profile_summary <- profile_source[, .(
  profiles = .N,
  mean_adherence = mean(mean_adherence),
  standard_deviation = stats::sd(mean_adherence),
  median_adherence = stats::median(mean_adherence),
  lower_quartile = as.numeric(stats::quantile(mean_adherence, 0.25, type = 7)),
  upper_quartile = as.numeric(stats::quantile(mean_adherence, 0.75, type = 7)),
  minimum_adherence = min(mean_adherence),
  maximum_adherence = max(mean_adherence),
  median_valid_cycles = stats::median(valid_cycles),
  minimum_valid_cycles = min(valid_cycles),
  maximum_valid_cycles = max(valid_cycles)
), by = .(state, state_order)]
data.table::setorder(profile_summary, state_order)
cs_write(profile_summary, "participant_state_profiles_summary.csv")

sample_flow <- data.table::data.table(
  stage = c(
    "Any-valid association frame",
    "Participants represented in Sleep",
    "Participants represented in Wake",
    "Participants represented in Pre-sleep",
    "Complete three-state profile display",
    "Anonymous participant-state points"
  ),
  count = c(
    data.table::uniqueN(primary$participant_cluster),
    state_support_all[state == "Sleep", profiles],
    state_support_all[state == "Wake", profiles],
    state_support_all[state == "Pre-sleep", profiles],
    data.table::uniqueN(profile_source$profile_key),
    nrow(profile_source)
  ),
  unit = c("participants", "participants", "participants", "participants", "participants", "points")
)
cs_write(sample_flow, "participant_state_profiles_sample.csv")

knitr::kable(profile_summary)
state state_order profiles mean_adherence standard_deviation median_adherence lower_quartile upper_quartile minimum_adherence maximum_adherence median_valid_cycles minimum_valid_cycles maximum_valid_cycles
Sleep 1 139 0.8835733 0.1493559 0.9521362 0.8065538 0.9959428 0.3764846 1.0000000 6 2 7
Wake 2 139 0.2466126 0.1544174 0.2186197 0.1224762 0.3553198 0.0004795 0.7465059 6 2 8
Pre-sleep 3 139 0.6314104 0.2340010 0.6375878 0.4365730 0.8402130 0.1049283 1.0000000 5 1 7
state_colors <- c(
  "Sleep" = "#0072B2",
  "Wake" = "#D55E00",
  "Pre-sleep" = "#009E73"
)
density_source <- data.table::rbindlist(lapply(
  seq_along(state_levels),
  function(index) {
    state_name <- state_levels[[index]]
    values <- profile_source[state == state_name, mean_adherence]
    estimate <- stats::density(
      values,
      from = 0,
      to = 1,
      n = 512,
      cut = 0,
      bw = "nrd0"
    )
    scaled <- estimate$y / max(estimate$y) * 0.34
    data.table::data.table(
      state = state_name,
      state_order = index,
      mean_adherence = c(0, estimate$x, 1),
      density_x = c(index, index - scaled, index)
    )
  }
))

plot_summary <- profile_summary[, .(
  state,
  state_order,
  median_percent = 100 * median_adherence,
  lower_quartile_percent = 100 * lower_quartile,
  upper_quartile_percent = 100 * upper_quartile
)]
plot_profiles <- data.table::copy(profile_source)
plot_profiles[, mean_adherence_percent := 100 * mean_adherence]

raincloud <- ggplot2::ggplot() +
  ggplot2::geom_polygon(
    data = density_source,
    ggplot2::aes(
      x = density_x,
      y = 100 * mean_adherence,
      group = state,
      fill = state
    ),
    alpha = 0.38,
    color = NA
  ) +
  ggplot2::geom_line(
    data = plot_profiles,
    ggplot2::aes(
      x = point_x,
      y = mean_adherence_percent,
      group = profile_key
    ),
    linewidth = 0.28,
    alpha = 0.11,
    color = "#526874"
  ) +
  ggplot2::geom_point(
    data = plot_profiles,
    ggplot2::aes(
      x = point_x,
      y = mean_adherence_percent,
      color = state
    ),
    size = 1.05,
    alpha = 0.55,
    stroke = 0
  ) +
  ggplot2::geom_linerange(
    data = plot_summary,
    ggplot2::aes(
      x = state_order,
      ymin = lower_quartile_percent,
      ymax = upper_quartile_percent
    ),
    linewidth = 3.2,
    color = "#173042",
    lineend = "round"
  ) +
  ggplot2::geom_point(
    data = plot_summary,
    ggplot2::aes(x = state_order, y = median_percent),
    shape = 21,
    size = 3.0,
    stroke = 0.7,
    fill = "white",
    color = "#173042"
  ) +
  ggplot2::scale_fill_manual(values = state_colors, guide = "none") +
  ggplot2::scale_color_manual(values = state_colors, guide = "none") +
  ggplot2::scale_x_continuous(
    breaks = seq_along(state_levels),
    labels = unname(state_display_labels[state_levels]),
    limits = c(0.56, 3.32),
    expand = ggplot2::expansion(mult = c(0, 0))
  ) +
  ggplot2::scale_y_continuous(
    breaks = seq(0, 100, 20),
    labels = function(value) paste0(value, "%"),
    limits = c(0, 100),
    expand = ggplot2::expansion(mult = c(0.01, 0.025))
  ) +
  ggplot2::labs(
    x = "Brown et al. recommendation window",
    y = "Participant-average adherence",
    subtitle = paste(
      "Each point is one participant's equal-cycle mean;",
      "dark markers show medians and interquartile ranges"
    )
  ) +
  ggplot2::theme_minimal(base_size = 12.5) +
  ggplot2::theme(
    plot.subtitle = ggplot2::element_text(
      color = "#445b68",
      size = 10.8,
      margin = ggplot2::margin(b = 10)
    ),
    axis.title = ggplot2::element_text(color = "#173042", face = "bold"),
    axis.text = ggplot2::element_text(color = "#173042"),
    panel.grid.major.x = ggplot2::element_blank(),
    panel.grid.minor = ggplot2::element_blank(),
    panel.grid.major.y = ggplot2::element_line(color = "#DCE5E9", linewidth = 0.35),
    plot.margin = ggplot2::margin(8, 14, 8, 8)
  )


cs_write(density_source, "participant_state_raincloud_density.csv")
cs_write(plot_profiles, "participant_state_raincloud_points.csv")
cs_write(plot_summary, "participant_state_raincloud_summary.csv")
dir.create("results/images/brown-association", recursive = TRUE, showWarnings = FALSE)
ggplot2::ggsave("results/images/brown-association/participant_state_raincloud.png", raincloud, device = ragg::agg_png, width = 9.2, height = 5.9, dpi = 320, background = "white")
ggplot2::ggsave("results/images/brown-association/participant_state_raincloud.svg", raincloud, device = svglite::svglite, width = 9.2, height = 5.9, bg = "white")
raincloud
Connected participant averages with density shapes for Sleep, Daytime, and Pre-sleep adherence. The connections identify the same anonymous participant across windows.
Figure 1: Participant-average recommendation adherence across Sleep, Daytime, and Pre-sleep. Dark markers show the median and interquartile range.

Interpretation

The within-participant differences are -0.26 percentage points for Sleep and -1.09 for Pre-sleep per ten percentage points higher-than-usual daytime adherence. Both intervals include zero. The within-participant inference remains qualified by unresolved temporal dependence.

Between participants, the corresponding differences are -2.52 percentage points for Sleep and -3.59 for Pre-sleep. The negative associations indicate lower target-window adherence among participants with higher observed average daytime adherence; they do not identify the direction of causation.

The model separates within- and between-participant associations over the observed monitoring period. Interval exclusion and FDR-adjusted testing answer different questions, and model adequacy constrains both. Residual temporal dependence and failures in the temporal sensitivity fits qualify within-participant interpretation. Between-participant associations remain observational and may reflect measured or unmeasured differences between participants. The underlying numeric estimates, covariance matrices, model checks, sensitivity comparisons, and exact figure data are regenerated under results/.