R/detect.R

Defines functions run_all_detections detect_variable_role .role_cols .detect_covariates .datetime_num .detect_time_index .detect_scale_items .scale_item_type .detect_repeated_measures .detect_outcome .detect_subject_id .id_like .detect_long_panel .detect_survival_components .survival_assoc .censoring_decisive .detect_agreement_pairs .detect_paired_pairs .detect_pairs_generic .pair_admitted .detect_group_var .group_balance

# Role detection engine. Ported from MDStatR engine/run_097_detect_roles.R
# (detect_variable_role + .detect_* + run_all_detections), with the Shiny rv
# coupling severed: var_info is an explicit data.frame argument, and the
# confirmed_roles / config layers (Shiny-runtime concerns) are dropped -- the
# Phase A baseline ran with config = NULL and confirmed_roles = NULL, so it
# already entered the mathematical layer directly, making this exactly
# equivalent. The optional capped name bonus is threaded into the
# candidate-selecting detectors (group, outcome, subject id, survival); with
# name_bonus = NULL it is a no-op (purely mathematical). No exports.
#
# State at v0.3.0 (pass 5) -- two layers, read apart:
#   * DERIVED deciders (no point scores in the decision): .detect_group_var (normalized
#     entropy), .detect_pairs_generic (paired vs agreement by the MDL shift sign
#     .mdl_shift_bits, pair evidence .mdl_pair_bits + dCor fallback), ordinal scale_items
#     (.mdl_factor_bits one-factor code), .detect_outcome (.mdl_forward_gauss / Bernoulli
#     MDL / .dcor_t, .anm_direction orientation), the long-format panel path, coupled_pair.
#   * PORTED point scorers that REMAIN (signatures.R, v0.1.0 logic, pct >= 30 accept):
#     .detect_survival_components (signature gate), wide .detect_repeated_measures, and the
#     binary (IRT) scale path. They remain because their derived replacements were measured
#     and rejected under the no-regression rule (see the v0.3.0 development report, rolescry_v030/v030_dev_report.md, not distributed with the package).
# Origin: see "Relationship to MDStatR" in README.md

# Normalized Shannon entropy (bits) of a categorical column: B = H / log2(k), B = 1 iff
# perfectly balanced. Value-only; NA dropped. NULL when k < 2 or any level seen < 3 times.
.group_balance <- function(x) {
  cnt <- as.integer(table(x[!is.na(x)]))
  cnt <- cnt[cnt > 0L]
  k <- length(cnt)
  if (k < 2L || any(cnt < 3L)) return(NULL)
  p <- cnt / sum(cnt)
  list(B = round(min(1, -sum(p * log2(p)) / log2(k)), 12), k = k)   # round: float-noise guard for exact rank ties, not a tuned constant
}

.detect_group_var <- function(data, var_info, name_bonus = NULL) {
  # Rule (pass 5): group_var = the char/factor Categorical/Binary column with the highest
  # normalized entropy B (+ capped name bonus); ties -> fewer levels -> value fingerprint;
  # value-identical top two -> silence (as .canonical_pick). Candidates stay char/factor-only:
  # a numeric-coded treatment is indistinguishable name-blind from a low-card covariate/
  # outcome (Lemma-2 wall). Measured-and-rejected alternatives: see the v0.3.0 development report (rolescry_v030/v030_dev_report.md, not distributed with the package).
  candidates <- var_info$column[var_info$user_type %in% c("Categorical", "Binary")]
  cols <- character(0); ranks <- numeric(0); gbs <- list()
  for (col in candidates) {
    x <- data[[col]]
    if (!is.character(x) && !is.factor(x)) next
    gb <- .group_balance(x)
    if (is.null(gb)) next
    cols <- c(cols, col)
    ranks <- c(ranks, 100 * gb$B + .name_bonus_pts(col, name_bonus$group_var))
    gbs[[length(gbs) + 1]] <- gb
  }
  pick <- NA_character_
  if (length(cols) > 0) {
    ks  <- vapply(gbs, function(g) g$k, integer(1L))
    fps <- unname(vapply(cols, function(k) .col_fingerprint(data[[k]]), character(1L)))
    ord <- order(-ranks, ks, fps)
    i1 <- ord[1]; i2 <- ord[2]
    tie <- length(ord) >= 2L && ranks[i1] == ranks[i2] && ks[i1] == ks[i2] && fps[i1] == fps[i2]
    if (!tie) pick <- cols[i1]                           # NA = unbreakable tie -> silence (C1)
  }
  if (!is.na(pick)) {
    gb <- gbs[[match(pick, cols)]]
    pct <- round(100 * gb$B, 1)
    return(list(
      found = TRUE, role = "group_var", column = pick,
      detected_by = if (is.null(name_bonus$group_var)) "mathematical" else "mathematical+name",
      score = pct, max_score = 100, pct = pct,
      breakdown = list(list(name = "normalized entropy", score = gb$B, max = 1,
                            detail = sprintf("%d levels, H/log2k=%.3f", gb$k, gb$B)))
    ))
  }
  list(
    found = FALSE, role = "group_var", column = NULL, detected_by = "mathematical",
    score = 0, max_score = 100, pct = 0, breakdown = list()
  )
}

# Shared admission rule of the pair detectors (also used by verify_roles): the
# consensus+difference MDL closed form, with the dCor fallback for non-Gaussian margins.
.pair_admitted <- function(a, b) {
  mb <- .mdl_pair_bits(a, b)
  cf <- isTRUE(mb$bits > 0 && mb$agree)
  accept <- cf
  route <- if (cf) "closed-form" else "none"
  if (!cf && !(.gauss_margin(a) && .gauss_margin(b))) {
    m_eff <- sum(is.finite(a) & is.finite(b))
    accept <- isTRUE(mb$agree) && .dep(a, b) > 2 / sqrt(max(m_eff, 1))
    if (accept) route <- "dcor-fallback"
  }
  list(admitted = accept, bits = mb$bits, agree = mb$agree, route = route)
}

.detect_pairs_generic <- function(data, var_info, role) {
  num_cols <- var_info$column[var_info$user_type == "Continuous"]
  if (length(num_cols) < 2) {
    return(list(found = FALSE, role = role, column = NULL, detected_by = "mathematical",
                score = 0, max_score = NA, pct = 0, breakdown = list()))
  }
  pairs <- list()
  for (i in seq_along(num_cols)[-length(num_cols)]) {
    for (j in (i + 1):length(num_cols)) {
      a <- data[[num_cols[i]]]; b <- data[[num_cols[j]]]
      # C2: a measurement pair is decided by the DERIVED MDL criterion -- the
      # consensus+difference factorization compresses (bits > 0 => the columns share
      # information) AND the STRUCTURAL agreement condition var(a-b) < var((a+b)/2)
      # ("agree more than they differ"). This REPLACES the pct>=30 accept, the
      # paired/agreement r-windows, and the dCor 0.30 gate with no tuned constant. The
      # .mdl_pair_bits test is O(n), so it runs on all pairs cheaply; no point scorer
      # is involved in the pair decision.
      # D1 Gaussian-reduction guard. The pair bits = (n/2)log2(va*vb/(vm*vd)) equal
      # n*MI(a,b) EXACTLY only for a Gaussian-admissible pair (the measurement-pair
      # model: two noisy readings of one latent -> bivariate Gaussian). The closed-form
      # acceptance is mb$bits>0 && mb$agree. Where both margins pass `.gauss_margin`,
      # that closed form IS the exact NMI test. Where a margin is non-Gaussian the
      # variance bits can UNDERSTATE the true MI, so the KSG/dCor estimator .dep is
      # added as a fallback that can only ADD a non-Gaussian agreeing pair the closed
      # form rejected (clearing the 2/sqrt(m) independence floor -- the SAME 2-sigma
      # universal as the ANM margin, no new tuned constant). Ranking ALWAYS uses the
      # variance-MDL bits mb$bits, so every pair the closed form accepts is IDENTICAL to
      # the frozen behaviour (a NO-OP on the linear-Gaussian sim); the fallback only
      # appends off-Gaussian catches (mb$bits<=0, ranked last). The STRUCTURAL pair
      # condition var(d) < var(m) (mb$agree) gates both routes.
      pa <- .pair_admitted(a, b)
      mb <- list(bits = pa$bits)
      accept <- pa$admitted
      if (accept) {
        pairs[[length(pairs) + 1]] <- list(col1 = num_cols[i], col2 = num_cols[j],
                                           bits = mb$bits)
      }
    }
  }
  if (length(pairs) > 0) {
    # canonical, order-free pick: MDL bits desc, then symmetric value-fingerprint asc
    bts <- vapply(pairs, function(p) p$bits, numeric(1L))
    fps  <- vapply(pairs, function(p) paste(sort(c(.col_fingerprint(data[[p$col1]]),
                                                   .col_fingerprint(data[[p$col2]]))), collapse = "~"),
                   character(1L))
    fps <- unname(fps); ord <- order(-bts, fps)
    tie <- length(ord) >= 2L && isTRUE(bts[ord[1]] == bts[ord[2]]) && isTRUE(fps[ord[1]] == fps[ord[2]])
    if (!tie) {                                            # NA-silence on value-identical pair tie
      top <- pairs[[ord[1]]]
      bits <- top$bits
      # the single best pair is classified ONCE: systematic shift => paired, else agreement
      shift <- .mdl_shift_bits(data[[top$col1]], data[[top$col2]])
      if ((role == "paired_pairs") != (shift > 0)) {
        return(list(found = FALSE, role = role, column = NULL, detected_by = "mathematical",
                    score = 0, max_score = NA, pct = 0, breakdown = list(),
                    reason = sprintf("pair reported under %s",
                                     if (role == "paired_pairs") "agreement_pairs" else "paired_pairs")))
      }
      return(list(
        found = TRUE, role = role,
        column = list(list(col1 = top$col1, col2 = top$col2)),
        all_pairs = pairs[ord], detected_by = "mathematical",
        score = bits, max_score = NA, pct = max(0, round(100 * (1 - 2^(-bits / 8)))),  # display map, not a decision
        breakdown = list(list(name = "consensus/difference MDL", score = bits, max = NA,
                              detail = sprintf("pair bits=%.1f, shift bits=%.1f", bits, shift)))
      ))
    }
  }
  list(found = FALSE, role = role, column = NULL, detected_by = "mathematical",
       score = 0, max_score = NA, pct = 0, breakdown = list())
}

.detect_paired_pairs <- function(data, var_info) {
  .detect_pairs_generic(data, var_info, "paired_pairs")
}
.detect_agreement_pairs <- function(data, var_info) {
  .detect_pairs_generic(data, var_info, "agreement_pairs")
}

# 0.3.0: conditional identification of the SURVIVAL reading of a dependent
# (binary, positive-continuous) pair. The identifiable object from P alone is the
# PAIR; whether it is (time, event) or (cause, binary outcome) is design-relative
# (same ceiling class as Lemma 2). Censoring geometry singles out the survival
# reading when ALL of: (i) the continuous member is anchored at zero (mean - 2*sd
# <= 0, the 2-sigma universal -- a duration is ratio-scale with 0 in reach, an
# interval-scale measurement is not); (ii) events sit strictly earlier than
# censorings (median); (iii) the top time-tertile's event rate is strictly below
# the bottom's -- under censoring the longest observed times are mostly censored.
# All tests are value-only => RELABEL/S_n invariant. Documented ceiling: a
# zero-anchored continuous CAUSE whose larger values are protective mimics this
# geometry and still reads as survival.
.censoring_decisive <- function(tv, ev) {
  cc <- is.finite(tv) & !is.na(ev)
  tv <- tv[cc]; ev <- ev[cc]
  if (length(tv) < 20L || length(unique(ev)) != 2L) return(FALSE)   # n<20 estimator floor
  if (!any(ev == 0) || !any(ev == 1)) return(FALSE)                 # no censoring observed -> geometry undefined
  sdv <- stats::sd(tv)
  if (!is.finite(sdv) || sdv <= 0) return(FALSE)
  # (i) zero-anchored scale: a duration is a ratio-scale quantity anchored at 0,
  # so 0 lies inside its 2-sigma envelope (mean - 2*sd <= 0). An interval-scale
  # measurement far from zero (blood pressure, albumin) fails this. Reuses the
  # package's 2-sigma universal; no new constant.
  if (mean(tv) - 2 * sdv > 0) return(FALSE)
  # (ii) events sit strictly earlier than censorings
  if (!(stats::median(tv[ev == 1]) < stats::median(tv[ev == 0]))) return(FALSE)
  # (iii) the top time-tertile's event rate is strictly below the bottom's:
  # under censoring, the longest observed times are mostly censored. Only the
  # ENDS are compared -- the middle tertile follows the hazard shape and is not
  # constrained by censoring.
  q <- stats::quantile(tv, c(1/3, 2/3), names = FALSE, type = 1)    # order statistics: value-only
  g <- findInterval(tv, q)
  r <- vapply(c(0, 2), function(k) if (any(g == k)) mean(ev[g == k]) else NA_real_, numeric(1))
  if (anyNA(r)) return(FALSE)
  r[1] > r[2]
}

# Survival association gate shared by the survival detector and verify_roles: event
# compresses time under a 1-predictor Gaussian MDL model (monotone) OR the bias-corrected
# dCor independence T-test rejects (non-monotone); evaluated on complete cases.
.survival_assoc <- function(tv, ev01, min_n = 10L) {
  cc <- !is.na(tv) & !is.na(ev01)
  if (sum(cc) <= min_n) return(list(admitted = FALSE, bits = NA_real_, t = 0, monotone = FALSE, nonmono = FALSE))
  tvc <- tv[cc]; evc <- ev01[cc]
  bits <- .mdl_forward_gauss(tvc, matrix(evc, ncol = 1), fp = .col_fingerprint(evc))$bits_saved
  monotone <- is.finite(bits) && bits > 0
  tt <- .dcor_t(evc, tvc)
  nonmono <- tt > 2
  list(admitted = monotone || nonmono, bits = bits, t = tt, monotone = monotone, nonmono = nonmono)
}

.detect_survival_components <- function(data, var_info, component, name_bonus = NULL) {
  binary_cols <- var_info$column[var_info$user_type %in% c("Binary", "Categorical")]
  numeric_cols <- var_info$column[var_info$user_type %in% c("Continuous", "Ordinal")]
  # C3: also admit low-cardinality integer status columns (e.g. pbc {0,1,2},
  # which value-types as Continuous), normalized to {0,1} by .normalize_event.
  int_status <- numeric_cols[vapply(numeric_cols, function(c) {
    v <- suppressWarnings(as.numeric(data[[c]])); v <- v[!is.na(v)]
    length(v) > 0 && all(abs(v - round(v)) < 1e-9) && length(unique(v)) == 3L
  }, logical(1L))]
  event_cands <- unique(c(binary_cols, int_status))
  # 0.3.0: an all-distinct positive integer column value-types as ID, but a follow-up
  # time recorded in whole days has exactly that signature (ovarian futime). Admit
  # numeric ID-typed columns as TIME candidates; the event-association gate below
  # decides, so a true identifier (independent of every event) is not admitted.
  id_time <- var_info$column[var_info$user_type == "ID" &
                vapply(var_info$column, function(c) is.numeric(data[[c]]), logical(1L))]
  time_cands <- c(numeric_cols, id_time)
  evs <- character(0); tms <- character(0); ranks <- numeric(0); sss <- list(); nbs <- numeric(0)
  acs <- numeric(0); nns <- integer(0)                 # |cor| + n per admitted pair (dominance gate)
  for (ev in event_cands) {
    ev01 <- .normalize_event(data[[ev]])                 # C3: {1,2}/factor/logical/{0,1,2} -> {0,1}
    if (is.null(ev01)) next
    obs <- !is.na(ev01)                                  # C2: available-case, no hard NA skip
    if (sum(obs) < 10) next                              # n<10 LEGIT-threshold preserved
    if (length(unique(ev01[obs])) != 2L) next
    for (tm in time_cands) {
      if (tm == ev) next
      tm_vals <- suppressWarnings(as.numeric(data[[tm]]))
      if (any(tm_vals <= 0, na.rm = TRUE)) next
      ss <- .score_survival_signature(tm, ev, data)
      if (ss$pct >= 30) {                              # LEGIT-threshold preserved
        # A GENUINE survival pair has the event statistically associated with the
        # time (events cluster at different times than censoring); a spurious
        # (balanced-binary, positive-continuous) pair does not. Require & reward
        # that association so e.g. status+time beats sex+meal.cal. Value-based =>
        # equivariant.
        cc <- obs & !is.na(tm_vals)
        # Survival association, two routes: the linear-MDL break-even floor (event compresses
        # time under a 1-predictor Gaussian model) catches MONOTONE association; the
        # bias-corrected dCor independence T-test (.dcor_t > 2, the 2-sigma universal; n-invariant,
        # ~0 under independence) catches NON-MONOTONE hazard. Accept on EITHER route, only after the
        # survival signature ss$pct>=30 gate above. Measured-and-rejected alternatives: see the v0.3.0 development report (rolescry_v030/v030_dev_report.md, not distributed with the package).
        if (sum(cc) <= 10) next
        tmc <- tm_vals[cc]; evc <- ev01[cc]
        sa <- .survival_assoc(tm_vals, ev01)
        monotone <- sa$monotone; nonmono <- sa$nonmono
        if (!sa$admitted) next                                # event independent of time -> not survival
        # An ID-typed time candidate overturns a structural typing, so it must pass BOTH
        # evidence routes (monotone MDL AND dCor independence reject), not either one;
        # a random identifier passes one route by chance far more often than both.
        if (tm %in% id_time && !(monotone && nonmono)) next
        assoc <- abs(suppressWarnings(stats::cor(tmc, evc)))   # bounded ranking weight only
        if (is.na(assoc)) assoc <- 0
        evs <- c(evs, ev); tms <- c(tms, tm)
        acs <- c(acs, assoc); nns <- c(nns, sum(cc))
        nbs <- c(nbs, .name_bonus_pts(tm, name_bonus$time_variable) +
                        .name_bonus_pts(ev, name_bonus$event_variable))
        ranks <- c(ranks, ss$score + 60 * assoc + nbs[length(nbs)])
        sss[[length(sss) + 1]] <- ss
      }
    }
  }
  if (length(evs) > 1L) {
    # 0.3.0: re-evaluate every admitted pair on ONE common row set (rows complete in all
    # admitted candidate columns). Each pair is first judged on its own available cases,
    # so pairs whose columns are missing in different rows were compared on different
    # subsamples and one could win on noise (lung: sex/meal.cal over status/time).
    # Gates and ranking are unchanged; only the rows they are computed on are common.
    common <- stats::complete.cases(data[, unique(c(evs, tms)), drop = FALSE])
    if (sum(common) >= 10L && sum(!common) > 0L) {
      dc <- data[common, , drop = FALSE]; keep <- logical(length(evs))
      for (k in seq_along(evs)) {
        e <- .normalize_event(dc[[evs[k]]]); t <- suppressWarnings(as.numeric(dc[[tms[k]]]))
        ss <- .score_survival_signature(tms[k], evs[k], dc)
        if (is.null(e) || length(unique(e)) != 2L || ss$pct < 30) next
        sa <- .survival_assoc(t, e, min_n = 9L)            # common block has no n<=10 skip (>= 10 rows)
        mono <- sa$monotone; nonm <- sa$nonmono
        if (!sa$admitted) next
        if (tms[k] %in% id_time && !(mono && nonm)) next
        a <- abs(suppressWarnings(stats::cor(t, e))); if (is.na(a)) a <- 0
        ranks[k] <- ss$score + 60 * a + nbs[k]; sss[[k]] <- ss; keep[k] <- TRUE
        acs[k] <- a; nns[k] <- sum(is.finite(t) & !is.na(e))
      }
      evs <- evs[keep]; tms <- tms[keep]; ranks <- ranks[keep]; sss <- sss[keep]; nbs <- nbs[keep]
      acs <- acs[keep]; nns <- nns[keep]
    }
  }
  if (length(evs) > 0) {
    # canonical pick: rank desc, then (event,time) value-fingerprint asc (C1).
    # NA-silence on an unbreakable tie (value-identical (event,time) pairs, e.g.
    # duplicate columns) -- no single pick is both relabel-invariant and
    # S_n-equivariant, so stay silent.
    fps <- unname(vapply(seq_along(evs), function(k)
      paste(.col_fingerprint(data[[evs[k]]]), .col_fingerprint(data[[tms[k]]]), sep = "~"),
      character(1L)))
    ord <- order(-ranks, fps)
    tie <- length(ord) >= 2L && isTRUE(ranks[ord[1]] == ranks[ord[2]]) &&
           isTRUE(fps[ord[1]] == fps[ord[2]])
    if (!tie) {
      w <- ord[1]
      best_event <- evs[w]; best_time <- tms[w]; best_ss <- sss[[w]]
      ss <- best_ss
      # 0.3.0: assign the survival roles only when censoring geometry singles the
      # survival reading out; otherwise the honest output is the PAIR itself
      # (component "pair" -> the coupled_pair role), with the interpretation left
      # to the user. See .censoring_decisive.
      decisive <- .censoring_decisive(suppressWarnings(as.numeric(data[[best_time]])),
                                      .normalize_event(data[[best_event]]))
      # 0.3.0 dominance gate: Lemma 2 makes a SINGLE decisive pair's geometry
      # non-discriminating between readings, but when SEVERAL pairs pass the
      # decisive test the data offers competing survival readings. The winner is
      # assigned the survival roles only when its event-time association DOMINATES
      # every decisive competitor at the 2-sigma universal (Fisher z two-correlation
      # comparison, the same device as the panel-axis gate); otherwise the honest
      # output is the pair itself with the competition named.
      rival <- NA_character_
      if (decisive && length(evs) > 1L) {
        others <- setdiff(seq_along(evs), w)
        # Only DISJOINT-time rivals compete as READINGS: a rival sharing the winner's
        # time column agrees the table is survival-shaped on that axis and merely
        # offers another event candidate -- a ranking question the association weight
        # and the common-row re-evaluation already answer (pbc: time/hepato must not
        # veto time/status). A rival on a DIFFERENT time axis disputes which column
        # is the duration at all -- that is the real underdetermination.
        others <- others[tms[others] != tms[w]]
        # An ID-typed time candidate enters on an exceptional evidence bar (both
        # routes) because its prior reading is "identifier"; it may WIN on that
        # evidence but may not VETO a structurally-typed reading (pbc: a sequential
        # registry id correlates with administrative censoring and mimics survival).
        if (!(tms[w] %in% id_time)) others <- others[!(tms[others] %in% id_time)]
        odec <- others[vapply(others, function(k) .censoring_decisive(
          suppressWarnings(as.numeric(data[[tms[k]]])), .normalize_event(data[[evs[k]]])), logical(1L))]
        if (length(odec)) {
          cpt <- odec[which.max(ranks[odec])]
          zw <- atanh(min(acs[w], 1 - 1e-12)); zc <- atanh(min(acs[cpt], 1 - 1e-12))
          dominant <- nns[w] > 3L && nns[cpt] > 3L &&
            (zw - zc) / sqrt(1 / (nns[w] - 3) + 1 / (nns[cpt] - 3)) > 2
          if (!dominant) { decisive <- FALSE; rival <- paste(tms[cpt], evs[cpt], sep = "/") }
        }
      }
      if (component == "pair") {
        if (!decisive) return(list(
          found = TRUE, role = "coupled_pair",
          column = c(best_time, best_event),
          continuous_col = best_time, binary_col = best_event,
          interpretations = c(
            "survival: time = continuous member, event = binary member",
            "causal: cause = continuous member, binary outcome = binary member"),
          detected_by = "mathematical", score = ss$score, max_score = ss$max,
          pct = ss$pct, breakdown = ss$breakdown,
          reason = if (is.na(rival))
            "dependent binary/positive-continuous pair; censoring geometry not decisive, so the survival-vs-outcome reading is design-relative"
          else sprintf("several admissible survival readings without a 2-sigma dominance margin (competing pair: %s); the reading is left to the user", rival)
        ))
      } else if (decisive) {
        col <- if (component == "time") best_time else best_event
        return(list(
          found = TRUE, role = paste0(component, "_variable"), column = col,
          detected_by = "mathematical", score = ss$score, max_score = ss$max,
          pct = ss$pct, breakdown = ss$breakdown,
          event_col = best_event, time_col = best_time
        ))
      }
    }
  }
  list(
    found = FALSE,
    role = if (component == "pair") "coupled_pair" else paste0(component, "_variable"),
    column = NULL,
    detected_by = "mathematical", score = 0, max_score = 100, pct = 0, breakdown = list()
  )
}

# D3 ANCESTOR PORT (S_n-safe): long-format panel detection. A longitudinal panel has
# (1) a CLUSTER column with >= 3 distinct levels each REPEATED >= 3 times (so k <= n/3
# automatically -- no tuned upper cap); (2) a numeric TIME/visit column STRICTLY
# increasing WITHIN every cluster (the long-format ordering, and varying, not constant);
# (3) >= 1 numeric MEASURE column varying WITHIN clusters. This panel signal is what
# separates a subject-cluster (-> subject_id = cluster, repeated_measures = measures)
# from a cross-sectional grouping (a group_var has few levels but NO within-cluster
# monotone time and independent rows). All tests are column-VALUE functions (the cluster
# pick uses the value-fingerprint canonical key), so RELABEL/S_n hold; row order is a
# data property the column gates never permute. Returns NULL or list(cluster, time,
# measures). Strictly-increasing-within-cluster on clusters of size >= 3 makes a spurious
# panel on shuffled cross-sectional data vanishingly unlikely.
.detect_long_panel <- function(data, var_info) {
  n <- nrow(data); if (n < 20L) return(NULL)
  mono_inc_within <- function(cl, tv) {              # tv strictly increasing within EVERY cluster, and varies
    ok <- !is.na(cl) & is.finite(tv); cl <- cl[ok]; tv <- tv[ok]
    if (length(tv) < 3L) return(FALSE)
    varied <- FALSE
    for (lv in unique(cl)) {
      x <- tv[cl == lv]
      if (length(x) >= 3L) {
        if (any(diff(x) <= 0)) return(FALSE)        # not strictly increasing in stored order
        varied <- TRUE
      }
    }
    varied
  }
  within_var <- function(cl, mv) {                   # measure varies within clusters
    ok <- !is.na(cl) & is.finite(mv); cl <- cl[ok]; mv <- mv[ok]
    vs <- tapply(mv, cl, function(z) if (length(z) >= 2L) stats::var(z) else NA_real_)
    any(is.finite(vs) & vs > 0)
  }
  # cluster candidates: >= 3 levels, each repeated >= 3 times, not unique
  clust <- character(0)
  for (col in var_info$column) {
    v <- data[[col]]; nz <- v[!is.na(v)]
    if (length(nz) < 20L) next
    k <- length(unique(nz)); if (k < 3L || k >= length(nz)) next
    if (min(table(nz)) < 3L) next                    # every level repeated >= 3x => k <= n/3 (no tuned cap)
    clust <- c(clust, col)
  }
  if (length(clust) == 0L) return(NULL)
  num_cols <- var_info$column[var_info$user_type == "Continuous"]
  dt_cols  <- var_info$column[var_info$user_type == "Datetime"]   # 0.3.0: a calendar column is a panel time axis
  panels <- list()
  for (cc in clust) {
    cl <- as.character(data[[cc]])
    cand_num <- setdiff(num_cols, cc)
    cand_t <- c(cand_num, setdiff(dt_cols, cc))
    tcols <- cand_t[vapply(cand_t, function(t) mono_inc_within(cl, .datetime_num(data[[t]])), logical(1L))]
    if (length(tcols) == 0L) next
    mcols <- cand_num[vapply(cand_num, function(m) within_var(cl, suppressWarnings(as.numeric(data[[m]]))), logical(1L))]
    mcols <- setdiff(mcols, tcols)
    if (length(mcols) < 1L) next
    panels[[length(panels) + 1L]] <- list(cluster = cc, time = tcols, measures = mcols)
  }
  if (length(panels) == 0L) return(NULL)
  if (length(panels) == 1L) return(panels[[1L]])
  # 0.3.0: a BALANCED panel is value-symmetric -- (firm, month) and (month, firm)
  # both satisfy the structural test, so the entity/time reading is not a function
  # of P alone. Discriminator: along the TRUE time axis the measures are serially
  # dependent within the entity. Pooled within-cluster lag-1 correlation, tested at
  # the 2-sigma universal (|r| sqrt(n) / sqrt(1 - r^2) > 2). Supported readings win;
  # several or none supported and the clusters differ -> AMBIGUOUS, reported as such
  # (the coupled_pair philosophy: name the symmetry, let the user decide).
  lag1_stats <- function(p) {
    cl <- as.character(data[[p$cluster]]); tv <- .datetime_num(data[[p$time[1]]])
    best <- list(t = 0, z = 0, w = 0)
    for (m in p$measures) {
      mv <- suppressWarnings(as.numeric(data[[m]]))
      # Two-way within transformation (panel fixed effects): subtract cluster and
      # time-position means. Raw pooling lets between-cluster level variance and
      # FIXED cross-sectional patterns (the same adjacent-position pairing recurring
      # in every cluster) masquerade as serial dependence on BOTH axis readings;
      # after demeaning only genuine serial structure survives.
      ok <- is.finite(mv) & is.finite(tv) & !is.na(cl)
      mv[ok] <- mv[ok] - stats::ave(mv[ok], cl[ok]) - stats::ave(mv[ok], tv[ok]) + mean(mv[ok])
      a <- c(); b <- c()
      for (lv in unique(cl)) {
        i <- which(cl == lv & is.finite(tv) & is.finite(mv))
        if (length(i) < 3L) next
        i <- i[order(tv[i])]
        a <- c(a, mv[i][-length(i)]); b <- c(b, mv[i][-1])
      }
      if (length(a) >= 8L && stats::sd(a) > 0 && stats::sd(b) > 0) {
        r <- stats::cor(a, b)
        if (is.finite(r) && abs(r) < 1) {
          tt <- abs(r) * sqrt(length(a)) / sqrt(1 - r^2)
          if (tt > best$t) best <- list(t = tt, z = atanh(abs(r)), w = length(a) - 3L)
        }
      }
    }
    best
  }
  st <- lapply(panels, lag1_stats)
  ts <- vapply(st, `[[`, numeric(1L), "t")
  sup <- which(ts > 2)
  if (length(sup) > 1L) {
    # Both readings pass the marginal gate. On the FALSE axis the recurring
    # adjacent-position pairing makes products dependent across clusters, so its
    # nominal t overstates the evidence; the TRUE axis carries structural serial
    # correlation bounded away from zero. Fisher z two-correlation comparison at
    # the same 2-sigma universal: a decisive gap singles the reading out,
    # otherwise the symmetry is real and stays reported as ambiguous.
    o <- sup[order(-ts[sup])]
    z1 <- st[[o[1]]]; z2 <- st[[o[2]]]
    if (z1$w > 0 && z2$w > 0 &&
        (z1$z - z2$z) / sqrt(1 / z1$w + 1 / z2$w) > 2) sup <- o[1]
  }
  pool <- if (length(sup) >= 1L) panels[sup] else panels
  fps <- vapply(pool, function(p) .col_fingerprint(data[[p$cluster]]), character(1L))
  if (length(unique(vapply(pool, `[[`, "", "cluster"))) == 1L || length(sup) == 1L)
    return(pool[[order(fps)[1]]])
  list(ambiguous = TRUE, panels = pool[order(fps)])
}

# Identifier-like column: >= 10 observed, unique among observed, <= 20 % missing, and
# character/factor or integer-valued numeric. Shared by the detector and verify_roles.
.id_like <- function(x) {
  nonna <- x[!is.na(x)]                                   # C2: NA-tolerant (was: any NA -> reject)
  if (length(nonna) < 10L) return(FALSE)
  if (length(unique(nonna)) != length(nonna)) return(FALSE)
  if (mean(is.na(x)) > 0.2) return(FALSE)
  is.character(x) || is.factor(x) || (is.numeric(x) && isTRUE(all(nonna == round(nonna))))
}

.detect_subject_id <- function(data, var_info, name_bonus = NULL, claimed = character(0)) {
  # 0.3.0: a column already owned by a survival role (e.g. an all-distinct integer
  # follow-up time) is not also a subject identifier (atomicity).
  id_cols <- setdiff(var_info$column[var_info$user_type == "ID"], claimed)
  id_pick <- if (length(id_cols) > 0) .canonical_pick(id_cols, rep(1, length(id_cols)), data) else NA_character_
  if (!is.na(id_pick)) {                                 # NA = unbreakable tie -> fall through
    return(list(
      found = TRUE, role = "subject_id", column = id_pick,
      detected_by = "mathematical", score = 90, max_score = 100, pct = 90,
      breakdown = list(list(name = "ID type", score = 90, max = 100, detail = "tagged as ID")),
      needs_confirmation = FALSE
    ))
  }
  id_like_cols <- character(0)
  for (col in setdiff(var_info$column, claimed)) {
    if (.id_like(data[[col]])) id_like_cols <- c(id_like_cols, col)
  }
  if (length(id_like_cols) > 0) {
    nb_hits <- id_like_cols[vapply(id_like_cols,
      function(col) .name_bonus_pts(col, name_bonus$subject_id) > 0, logical(1L))]
    pool <- if (length(nb_hits) > 0) nb_hits else id_like_cols
    pick <- .canonical_pick(pool, rep(1, length(pool)), data)           # order-free (C1)
    if (!is.na(pick)) return(list(
      found = TRUE, role = "subject_id", column = pick,
      detected_by = "mathematical", score = 70, max_score = 100, pct = 70,
      breakdown = list(list(
        name = "Unique ID-like values", score = 70, max = 100,
        detail = "all unique, integer/character (not numeric-continuous)"
      )),
      needs_confirmation = TRUE
    ))
  }
  # D3 cluster cross-check: in LONG-format data the subject id REPEATS (not unique), so
  # the unique-value paths above miss it. A subject CLUSTER is the cluster column of a
  # longitudinal panel (.detect_long_panel) -- 3-50 repeated levels WITH a within-cluster
  # monotone time + within-cluster-varying measures, which separates it from a group_var
  # (few levels, no panel structure). Value-only; name routes via name_bonus only.
  panel <- .detect_long_panel(data, var_info)
  if (!is.null(panel) && isTRUE(panel$ambiguous)) {
    # 0.3.0: axis-ambiguous balanced panel -> no single entity column is
    # identifiable; stay silent (the repeated_measures entry carries the readings).
    return(list(found = FALSE, role = "subject_id", column = NULL,
                detected_by = "mathematical", score = 0, max_score = 100, pct = 0,
                breakdown = list(),
                reason = "balanced panel: entity/time axis value-symmetric; see repeated_measures$panel_axes"))
  }
  if (!is.null(panel)) {
    return(list(
      found = TRUE, role = "subject_id", column = panel$cluster,
      detected_by = if (is.null(name_bonus$subject_id)) "mathematical" else "mathematical+name",
      score = 70, max_score = 100, pct = 70,
      breakdown = list(list(name = "Cluster id of a longitudinal panel", score = 70, max = 100,
                            detail = "3+ repeated levels with within-cluster monotone time + varying measures")),
      needs_confirmation = TRUE
    ))
  }
  list(
    found = FALSE, role = "subject_id", column = NULL, detected_by = "mathematical",
    score = 0, max_score = 100, pct = 0, breakdown = list()
  )
}

# v0.2.0 (phase G2): MDL collider/sink. An OUTCOME is a dependency SINK -- a column
# whose two-part code length drops below its marginal (null) code length when
# regressed on >= 2 predictors chosen by MDL FORWARD SELECTION (a predictor enters
# only if it pays for its (1/2)log2(n) parameter bits). This DISSOLVES the v0.1.x
# tuned cutoffs into one derived criterion:
#   CORR_MIN (which predictors)  -> a predictor enters iff it reduces total bits.
#   TAU      (how explained)     -> the n-aware MDL break-even (n/2)log2(1/(1-R^2)) > (k/2)log2(n).
#   GAMMA    (mutual independence)-> a collinear/redundant predictor adds NO new bits,
#       so a cluster member (repeated/scale/paired partner) selects ONE predictor and
#       is NOT flagged, while a true collider (>=2 independent causes) selects >=2.
# MIN_PRED=2 is the lone STRUCTURAL (definitional) constant; the bit margin is the
# only free calibration and is surfaced as confidence (G4). Causal DIRECTION stays
# unidentified under the linear-Gaussian symmetric sink (honest silence; G3 ANM
# breaks it where non-Gaussian/nonlinear). All features value-based => S_n/TURNUSOL
# hold; n<10 protection preserved (MDL never evades small-n -- (k/2)log2(n) is HARDER
# to pay at small n).
.detect_outcome <- function(data, var_info, type, name_bonus = NULL, claimed = character(0)) {
  # STRUCTURAL identifiability constants (NOT tuned): an OUTCOME is a dependency SINK.
  # CONTINUOUS needs >= 2 mutually-independent causes (y=a+b is symmetric: a=y-b looks
  # identical, so a lone cause cannot orient the sink). BINARY is asymmetric (a 0/1 column
  # is a function OF continuous features, not the reverse) so ONE cause already identifies
  # the effect -- BUT only once the columns a more-specific structural role already owns (a
  # survival EVENT, a SCALE-battery item, a repeated-measure, a pair partner) are removed
  # from contention. (DV regression triage: the v0.2.0 outcome detector lacked that
  # ATOMICITY and double-claimed those columns -- the survival event, whose one strong cause
  # is time, and collinear scale items out-ranked the true multi-cause outcome, dropping
  # outcome_binary below v0.1.x AND baseline. Root cause = missing atomicity, NOT MIN_PRED;
  # raising MIN_PRED to 2 for binary only crushed recall. Fix = `claimed` exclusion below.)
  MIN_PRED <- if (type == "continuous") 2L else 1L
  if (type == "continuous") {
    cands <- var_info$column[var_info$user_type == "Continuous"]
  } else {
    cands <- var_info$column[var_info$user_type %in% c("Binary", "Categorical")]
    cands <- cands[vapply(cands, function(c) length(unique(data[[c]][!is.na(data[[c]])])) == 2, logical(1L))]
  }
  # ATOMICITY (DV triage root-cause fix): an OUTCOME is not a column already owned by a
  # more-specific structural role (survival event/time, scale item, repeated measure, pair
  # partner, subject id, group). `claimed` is the union of those roles' columns detected
  # upstream (run_all_detections threads it in detection order). Value-identified columns =>
  # the exclusion is RELABEL / S_n invariant. This removes the event-steal and scale-steal
  # at the root, so binary keeps MIN_PRED=1 (recall) without the precision leak.
  cands <- setdiff(cands, claimed)
  none <- function(best = 0, reason = "no candidates")
    list(found = FALSE, role = paste0("outcome_", type), column = NULL,
         detected_by = "mathematical", score = 0, max_score = 100, pct = 0,
         breakdown = list(), confidence = round(best, 3), reason = reason)
  if (length(cands) == 0) return(none())
  numu <- var_info$column[var_info$user_type %in% c("Continuous", "Ordinal", "Binary")]
  if (length(numu) < 3) return(none(0, "too few columns for a collider"))

  bits  <- stats::setNames(numeric(length(cands)), cands)   # MDL bits saved vs the null (marginal) model
  ksel  <- stats::setNames(integer(length(cands)), cands)   # number of MDL-selected predictors
  indep <- stats::setNames(rep(TRUE, length(cands)), cands)  # continuous: the selected causes must be mutually independent
  toppred <- stats::setNames(rep(NA_character_, length(cands)), cands)  # top MDL predictor name (for ANM direction annotation)
  for (y in cands) {
    preds <- setdiff(numu, y)
    if (length(preds) < MIN_PRED) next
    yv <- suppressWarnings(as.numeric(data[[y]]))
    if (sum(is.finite(yv)) < 10L) next               # n<10 protection (inviolable)
    if (stats::sd(yv[is.finite(yv)]) < 1e-12) next
    X  <- vapply(preds, function(c) suppressWarnings(as.numeric(data[[c]])), numeric(nrow(data)))
    fp <- vapply(preds, function(c) .col_fingerprint(data[[c]]), character(1L))   # order-free tie-break
    # EVIDENCE MODEL by type (council D-C/D-D). CONTINUOUS: Gaussian MDL forward selection
    # (an LPM/RSS code is fine for a continuous sink). BINARY: a 0/1 sink is NON-Gaussian, so
    # the Gaussian-LPM RSS UNDERSTATES its logistic dependence (DV triage: outcome_binary fell
    # below v0.1.x AND the trivial type baseline). Score binary by the MI/dCor backbone (D-D)
    # instead -- count the causes the BIAS-CORRECTED dCor independence test rejects
    # (.dcor_t > 2, the 2-sigma universal). The U-centered .dcor_t (mean 0 under independence)
    # is used rather than raw .dep/.dcor, which carry a ~0.27 positive floor on a binary-vs-
    # continuous pair (dependency.R:81-89) that would flood FALSE causes through the council's
    # nominal floor. MIN_PRED=1 (binary asymmetric; atomicity already removed the event/scale
    # confounders) preserves recall; T>2 is n-INVARIANT so a weak small-n signal stays honestly
    # SILENT (Rissanen). No new tuned constant. Measured-and-rejected alternatives: see the v0.3.0 development
    # report (rolescry_v030/v030_dev_report.md, not distributed with the package).
    if (type == "continuous") {
      ccidx <- which(is.finite(yv) & (rowSums(!is.finite(X)) == 0))   # joint complete cases (design matrix)
      if (length(ccidx) < 10L) next
      if (length(ccidx) > .MDL_NMAX) ccidx <- ccidx[.dcor_idx(length(ccidx), .MDL_NMAX, seed = 7L)]
      yc <- yv[ccidx]; if (stats::sd(yc) < 1e-12) next
      Xc <- X[ccidx, , drop = FALSE]
      res <- .mdl_forward_gauss(yc, Xc, fp)
      bits[y] <- max(0, res$bits_saved); ksel[y] <- res$k
      if (res$k >= 1L) toppred[y] <- preds[res$sel[1]]          # dominant MDL cause (for ANM annotation)
      # collider identifiability guard: reject a CLUSTER source whose >=2 "causes" are
      # collinear (each MDL-explained by another) -- only an OUTCOME's causes are independent.
      if (res$k >= MIN_PRED && res$bits_saved > 0)
        indep[y] <- .mdl_set_independent(Xc[, res$sel, drop = FALSE], fp[res$sel])
    } else {
      # Bernoulli marginal MDL per predictor (NA-robust pairwise, as before: joint
      # complete-cases would starve recall when predictors are missing in different
      # rows). Replaces the bare 2-sigma dCor test, whose m-way scan inflated the
      # phantom-outcome rate on pure noise to ~1-(1-0.023)^m (~14% at m=4-5) and
      # whose familywise correction cost more true detections than it removed false
      # ones. The MDL code is selection-consistent by construction: each admitted
      # cause must pay its slope's 1/2 log2(n) parameter bits PLUS the log2(m)
      # model-index bits naming it among the m candidates (.mdl_bern_bits).
      yb <- data[[y]]; lv <- sort(unique(yb[!is.na(yb)]))
      y01 <- ifelse(is.na(yb), NA_integer_, as.integer(yb == lv[2L]))
      bb <- vapply(seq_along(preds), function(j)
        .mdl_bern_bits(y01, X[, j], length(preds)), numeric(1L))
      paid  <- which(is.finite(bb) & bb > 0)                    # net-compressing causes
      bits[y] <- if (length(paid)) sum(bb[paid]) else 0
      ksel[y] <- length(paid)
      if (length(paid)) toppred[y] <- preds[paid[which.max(bb[paid])]]
    }
  }
  qual <- (ksel >= MIN_PRED) & (bits > 0) & indep    # collider: >=2 MDL-selected, net-compressing, mutually-independent causes
  best <- max(bits)
  if (!any(qual)) return(none(best, sprintf("no MDL collider sink (best bits saved=%.1f; need >=%d predictor(s))", best, MIN_PRED)))
  qcols <- cands[qual]
  kw <- name_bonus[[paste0("outcome_", type)]]
  pick <- .canonical_pick(qcols, unname(bits[qcols]), data)       # MATH pick: value-canonical; NA on identical tie
  if (is.na(pick)) return(none(best, "tied MDL collider candidates (silence)"))
  # Capped name hint (Tier-1, <= 10% weight): a name match overrides the math pick
  # ONLY when the named candidate's MDL bits are within 10% of the math-best, so the
  # name channel can never override a > 10% mathematical margin (the old pool-
  # restriction could). No-op under name_bonus = NULL (.name_bonus_pts returns 0).
  nb_hits <- qcols[vapply(qcols, function(c) .name_bonus_pts(c, kw) > 0, logical(1L))]
  if (length(nb_hits) > 0 && !(pick %in% nb_hits)) {
    cand <- .canonical_pick(nb_hits, unname(bits[nb_hits]), data)
    if (!is.na(cand) && bits[cand] >= bits[pick] * 0.9) pick <- cand
  }
  conf_bits <- unname(bits[pick]); n_pred <- unname(ksel[pick])
  # G4: confidence = the description-length MARGIN (bits) between the chosen outcome and
  # the next-best candidate (or the null model if it is the only one). The silence
  # threshold is DERIVED, not tuned: a margin below one parameter's code length
  # (1/2)log2(n) means the best and runner-up are within coding noise -> ambiguous ->
  # honest silence. (Replaces the bare exact-tie test with a bit-margin gate.)
  others <- setdiff(qcols, pick)
  runner <- if (length(others)) max(bits[others]) else 0
  margin <- conf_bits - runner
  n_eff  <- min(nrow(data), .MDL_NMAX)
  # Margin-silence applies to CONTINUOUS only: it resolves WHICH single continuous column
  # is the sink. Binary is exempt because a dataset legitimately has MULTIPLE binary sinks
  # that are DIFFERENT roles (the binary outcome AND the survival event are both predictable
  # 0/1 columns) -- silencing on their close bits would suppress a real outcome (it regressed
  # outcome_binary below the v0.1.1 dangerous floor). The margin is still surfaced as confidence.
  if (type == "continuous" && margin < .mdl_param_bits(1, n_eff))
    return(none(best, sprintf("ambiguous MDL margin (%.1f < %.1f bits) -> silence", margin, .mdl_param_bits(1, n_eff))))
  # D-B: ANM direction orientation, gated on NON-GAUSSIAN escape. ANM is identifiable ONLY
  # when the cause/effect pair is non-Gaussian or nonlinear (Hoyer 2009); in the linear-
  # Gaussian symmetric core (a=y-b looks identical) the direction is UNIDENTIFIABLE -> honest
  # SILENCE. So we attempt the orientation ONLY when `.gauss_margin` REJECTS joint normality
  # for the pair; when both columns are Gaussian-admissible we do NOT call ANM and the label
  # stays "collider-implied" (frozen behavior on the 69/69 linear-Gaussian datasets, bit-for-
  # bit). This is annotation-only (no column/role change), so it is F1-neutral and never
  # forces a pick. LiNGAM (non-Gaussian LINEAR) is DEFERRED: the dCor-residual statistic sits
  # below its noise floor (anm.R:22-24), so claiming it would fabricate unmeasurable coverage
  # (Gödel/Heisenberg/Hume) -- left as the documented SILENT limitation.
  direction <- "collider-implied (causes -> sink)"
  if (type == "continuous" && !is.na(toppred[pick]) &&
      !(.gauss_margin(data[[toppred[pick]]]) && .gauss_margin(data[[pick]]))) {
    ad <- .anm_direction(data[[toppred[pick]]], data[[pick]])
    direction <- if (identical(ad$direction, "x->y"))
                   sprintf("ANM %s -> %s (asym=%.3f)", toppred[pick], pick, ad$asym)
                 else "unidentifiable (residual-symmetric)"
  }
  pct <- round(100 * (1 - 2^(-margin / 8)))          # bounded display map of the bit-margin
  list(found = TRUE, role = paste0("outcome_", type), column = pick,
       detected_by = "mathematical", score = pct, max_score = 100, pct = pct,
       breakdown = list(list(name = if (type == "continuous") "MDL collider sink (>=2 independent causes)" else "MI/dCor binary sink (dCor-rejected causes)",
                             score = pct, max = 100,
                             detail = sprintf("evidence=%.1f, margin=%.1f, causes=%d; direction: %s", conf_bits, margin, n_pred, direction))),
       confidence = round(margin, 3), needs_confirmation = TRUE)
}

.detect_repeated_measures <- function(data, var_info) {
  # D3 LONG-format path FIRST (dedup): a longitudinal panel is a SPECIFIC signal
  # (cluster + within-cluster monotone time + within-cluster-varying measures) that the
  # WIDE correlation-block search below would mis-handle (it pulls the integer cluster id
  # into the block). When a panel is present the data is long-format, so the repeated
  # measurements are panel$measures (the within-subject-varying numeric columns, the time
  # axis and cluster id excluded). On cross-sectional (WIDE) data .detect_long_panel
  # returns NULL -> the WIDE search runs unchanged (a NO-OP on the seen corpus).
  panel <- .detect_long_panel(data, var_info)
  if (!is.null(panel) && isTRUE(panel$ambiguous)) {
    # 0.3.0: balanced-panel symmetry -- the measures are the same under every
    # reading, so they ARE reportable; the entity/time axes are not.
    meas <- Reduce(intersect, lapply(panel$panels, `[[`, "measures"))
    axes <- lapply(panel$panels, function(p) c(cluster = p$cluster, time = p$time[1]))
    if (length(meas) >= 1L) {
      return(list(found = TRUE, role = "repeated_measures", column = meas,
                  detected_by = "mathematical", score = 70, max_score = 120, pct = 58.3,
                  panel_axes = axes,
                  reason = "balanced panel: the entity/time axis reading is value-symmetric and left to the user (see panel_axes)",
                  breakdown = list(list(name = "Long-format repeated measures (axis-ambiguous panel)",
                                        score = 70, max = 120,
                                        detail = sprintf("%d measure(s); %d admissible axis readings",
                                                         length(meas), length(axes))))))
    }
    panel <- NULL
  }
  if (!is.null(panel)) {
    return(list(found = TRUE, role = "repeated_measures", column = panel$measures,
                detected_by = "mathematical", score = 70, max_score = 120, pct = 58.3,
                breakdown = list(list(name = "Long-format within-subject repeated measures",
                                      score = 70, max = 120,
                                      detail = sprintf("%d measure(s) varying within a longitudinal cluster panel",
                                                       length(panel$measures))))))
  }
  num_cols <- var_info$column[var_info$user_type == "Continuous"]
  if (length(num_cols) < 3) {
    return(list(found = FALSE, role = "repeated_measures", column = NULL,
                detected_by = "mathematical", score = 0, max_score = 120, pct = 0, breakdown = list()))
  }
  # v0.1.1 (audit Finding C1): order-free correlation-block search replaces the
  # contiguous-window scan (which could only find adjacent columns and was
  # position-dependent). Candidate blocks are built from the value-based
  # correlation structure, scored by the unchanged .score_repeated_signature
  # (pct >= 30 LEGIT-threshold preserved), and the winner is canonical.
  M <- as.matrix(data[, num_cols, drop = FALSE])
  # G1: block adjacency is distance correlation (nonlinear coupling), but a cheap
  # linear-correlation pre-filter (|r| >= 0.3) prunes the O(p^2) dCor adjacency to
  # a small candidate set per seed -- sound because repeated measurements are
  # linearly correlated by construction, so a linearly-independent column is not a
  # block member. The block is then DEFINED by dCor (>= 0.5). Both measures are
  # symmetric value functions => RELABEL / S_n invariant. Cuts wide-data cost ~20x.
  # Measured-and-rejected alternatives: see the v0.3.0 development report (rolescry_v030/v030_dev_report.md, not distributed with the package).
  Clin <- suppressWarnings(stats::cor(M, use = "pairwise.complete.obs"))
  Clin[is.na(Clin)] <- 0
  # canonical (value-fingerprint) column order so .score_repeated_signature's
  # order-dependent "Trend" sub-score is computed on an order-INVARIANT sequence.
  fp_order <- function(cc) cc[order(vapply(cc, function(c) .col_fingerprint(data[[c]]), character(1L)))]
  blocks <- list()
  for (s in seq_along(num_cols)) {
    pre <- setdiff(which(abs(Clin[s, ]) >= 0.30), s)       # cheap linear pre-screen
    if (length(pre) == 0L) next
    dv <- vapply(pre, function(j) .dep(M[, s], M[, j]), numeric(1L))   # C1: completed signature .dep on survivors
    cand <- pre[dv >= 0.5]
    if (length(cand) == 0L) next
    cand <- cand[order(-dv[dv >= 0.5])]                     # strongest dCor first
    cand <- utils::head(unique(c(s, cand)), 12L)            # cap block size (v0.1.0 used ~10-window)
    members <- fp_order(num_cols[cand])                    # value-defined, order-free
    if (length(members) >= 3L) blocks[[length(blocks) + 1L]] <- members
  }
  if (length(blocks) > 0) {
    # 0.3.0 sink veto: a block member MDL-explained by >= 2 MUTUALLY INDEPENDENT
    # other members is a dependency SINK (collider), not a repeated measurement --
    # genuine repeated measures share a latent factor, so their "causes" are never
    # mutually independent. Reuses the outcome machinery; no new constant. Probe D:
    # y = x1 + x2 + noise was swallowed as {x1, x2, y} and outcome_continuous never
    # fired. Members are value-fingerprint-ordered, so the sweep is equivariant.
    drop_sinks <- function(members) {
      keep <- members
      for (m in members) {
        others <- setdiff(keep, m)
        if (length(others) < 2L) next
        yv <- M[, m]; Xo <- M[, others, drop = FALSE]
        cc <- which(is.finite(yv) & rowSums(!is.finite(Xo)) == 0)
        if (length(cc) < 10L) next
        if (length(cc) > .MDL_NMAX) cc <- cc[.dcor_idx(length(cc), .MDL_NMAX, seed = 7L)]
        fpo <- vapply(others, function(c) .col_fingerprint(data[[c]]), character(1L))
        r <- .mdl_forward_gauss(yv[cc], Xo[cc, , drop = FALSE], fpo)
        if (r$k >= 2L && r$bits_saved > 0 &&
            .mdl_set_independent(Xo[cc, r$sel, drop = FALSE], fpo[r$sel]))
          keep <- setdiff(keep, m)
      }
      keep
    }
    blocks <- lapply(blocks, drop_sinks)
    blocks <- blocks[vapply(blocks, length, 0L) >= 3L]
    blocks <- blocks[!duplicated(vapply(blocks, paste, character(1L), collapse = ""))]
  }
  bcols <- list(); bscore <- numeric(0); brs <- list()
  for (b in blocks) {
    rs <- .score_repeated_signature(b, data)
    if (rs$pct >= 30) { bcols[[length(bcols) + 1L]] <- b; bscore <- c(bscore, rs$score); brs[[length(brs) + 1L]] <- rs }
  }
  if (length(bcols) > 0) {
    fps <- unname(vapply(bcols, function(b)
      paste(sort(vapply(b, function(c) .col_fingerprint(data[[c]]), character(1L))), collapse = "~"),
      character(1L)))
    ord <- order(-bscore, fps)
    tie <- length(ord) >= 2L && isTRUE(bscore[ord[1]] == bscore[ord[2]]) && isTRUE(fps[ord[1]] == fps[ord[2]])
    if (!tie) {                                            # NA-silence on value-identical block tie (C1)
      rs <- brs[[ord[1]]]
      return(list(found = TRUE, role = "repeated_measures", column = bcols[[ord[1]]],
                  detected_by = "mathematical", score = rs$score, max_score = rs$max,
                  pct = rs$pct, breakdown = rs$breakdown))
    }
  }
  list(found = FALSE, role = "repeated_measures", column = NULL,
       detected_by = "mathematical", score = 0, max_score = 120, pct = 0, breakdown = list())
}

# Scale-item column type: "binary" ({0,1} exactly), "ordinal" (integer-valued, >= 3 levels,
# discrete), else NA. Needs >= 20 observed values.
.scale_item_type <- function(x) {
  nz <- x[!is.na(x)]
  if (length(nz) < 20L || !is.numeric(x)) return(NA_character_)
  u <- sort(unique(nz), method = "radix")
  if (length(u) == 2L && isTRUE(all(abs(u - c(0, 1)) < 1e-9))) return("binary")
  if (all(nz == round(nz)) && length(u) >= 3L && .is_discrete(x)) return("ordinal")
  NA_character_
}

.detect_scale_items <- function(data, var_info) {
  # Ordinal items: the fixed candidate set must win the one-factor MDL code with a common
  # quantity (independent columns give negative bits). Binary batteries stay on the ported
  # scorer pending a Bernoulli one-latent code.
  none <- list(found = FALSE, role = "scale_items", column = NULL,
               detected_by = "mathematical", score = 0, max_score = NA_real_, pct = 0, breakdown = list())
  hit <- function(cols, score, nm, detail) list(
    found = TRUE, role = "scale_items", column = cols, detected_by = "mathematical",
    score = score, max_score = NA_real_, pct = max(0, round(100 * (1 - 2^(-score / 8)))),
    breakdown = list(list(name = nm, score = score, max = NA_real_, detail = detail)),
    n_items = length(cols))
  ord_cols <- var_info$column[var_info$user_type %in% c("Ordinal", "Continuous")]
  cand <- Filter(function(col) identical(.scale_item_type(data[[col]]), "ordinal"), ord_cols)
  if (length(cand) >= 3L) {
    f <- .mdl_factor_bits(as.matrix(data.frame(lapply(data[cand], as.numeric))))
    # same_quantity (communality > uniqueness) is a same-units / repeated-measures notion; scale items
    # are different questions sharing a latent, so it is reported but does not decide.
    if (f$bits > 0)
      return(hit(cand, f$bits, "one-factor MDL block",
                 sprintf("%d items; %.1f bits; same_quantity=%s", length(cand), f$bits, f$same_quantity)))
  }
  # D3 ANCESTOR PORT (complementary): a DICHOTOMOUS IRT battery -- >= 8 binary {0,1}
  # columns (value-only; the ordinal path above misses binaries). Only when the ordinal
  # path yielded no scale, so it never overrides the strong ordinal detector. STRUCTURAL
  # precision guard with NO tuned threshold: reject a one-hot PARTITION (every respondent
  # endorses exactly one item -> max row-sum <= 1), which is an encoding, not a scale; a
  # real battery has respondents endorsing several items (max row-sum > 1).
  {
    bin_cols <- character(0)
    for (col in var_info$column) {
      if (identical(.scale_item_type(data[[col]]), "binary")) bin_cols <- c(bin_cols, col)
    }
    if (length(bin_cols) >= 8L) {
      mb <- as.matrix(data[, bin_cols, drop = FALSE]); mb <- mb[stats::complete.cases(mb), , drop = FALSE]
      if (nrow(mb) >= 20L && max(rowSums(mb)) > 1) {
        ss <- .score_scale_items_signature(bin_cols, data)
        if (ss$pct >= 30) {
          return(list(found = TRUE, role = "scale_items", column = bin_cols,
                      detected_by = "mathematical", score = ss$score, max_score = ss$max,
                      pct = ss$pct, breakdown = ss$breakdown, n_items = length(bin_cols)))
        }
      }
    }
  }
  none
}

# 0.3.0: covariate is the RESIDUAL role -- a Continuous/Categorical/Binary column that no
# other role claimed. In 0.2.0 it listed every such column, including the survival pair,
# group and outcomes, so it carried no information beyond the column types.
# 0.3.0: calendar axis (time_index). Every Datetime-typed column is a calendar
# axis; the breakdown records whether it is monotone in stored order and regularly
# spaced. Needed for finance and longitudinal data, where the date column was
# previously typed "Other" and invisible to every detector.
.detect_time_index <- function(data, var_info) {
  tcols <- var_info$column[var_info$user_type == "Datetime"]
  if (length(tcols) == 0L) {
    return(list(found = FALSE, role = "time_index", column = NULL,
                detected_by = "mathematical", score = 0, max_score = 100, pct = 0,
                breakdown = list()))
  }
  bd <- lapply(tcols, function(col) {
    nv <- .datetime_num(data[[col]]); nv <- nv[is.finite(nv)]; dfs <- diff(nv)
    list(name = col, score = 80, max = 100,
         detail = sprintf("calendar values; monotone=%s regular_spacing=%s",
                          length(dfs) > 0 && all(dfs > 0),
                          length(dfs) > 1 && isTRUE(stats::sd(dfs) == 0)))
  })
  list(found = TRUE, role = "time_index", column = tcols,
       detected_by = "mathematical", score = 80, max_score = 100, pct = 80,
       breakdown = bd)
}

# Numeric view of a (possibly character-coded) calendar column.
.datetime_num <- function(v) {
  if (is.character(v) || is.factor(v)) {
    d <- tryCatch(suppressWarnings(as.Date(as.character(v), tryFormats = .DATE_FORMATS)),
                  error = function(e) as.Date(rep(NA, length(v))))
    as.numeric(d)
  } else suppressWarnings(as.numeric(v))
}

.detect_covariates <- function(data, var_info, claimed = character(0)) {
  num_cols <- var_info$column[var_info$user_type == "Continuous"]
  cat_cols <- var_info$column[var_info$user_type %in% c("Categorical", "Binary")]
  covs <- setdiff(c(num_cols, cat_cols), claimed)
  if (length(covs) > 0) {
    return(list(found = TRUE, role = "covariate", column = covs,
                detected_by = "mathematical", score = 50, max_score = 100, pct = 50,
                breakdown = list()))
  }
  list(found = FALSE, role = "covariate", column = NULL,
       detected_by = "mathematical", score = 0, max_score = 100, pct = 0, breakdown = list())
}

# Flatten a role-detection result to its claimed column names (scalar, vector, or the
# list(list(col1, col2)) pair shape). Value-identified, so the atomicity exclusion built
# from these is RELABEL / S_n invariant.
.role_cols <- function(r) {
  col <- r$column
  cols <- if (is.null(col)) character(0)
  else if (is.list(col)) unname(unlist(lapply(col, function(p) unlist(p))))
  else as.character(col)
  # 0.3.0: the axis candidates of an axis-ambiguous panel are claimed too -- a
  # column offered as an admissible entity/time reading is not a residual covariate.
  unique(c(cols, unname(unlist(r$panel_axes))))
}

# Dispatch one role need to its detector. `claimed` = columns already owned by a more-
# specific structural role (threaded by run_all_detections); only the outcome detectors
# consult it (atomicity).
detect_variable_role <- function(data, var_info, need, name_bonus = NULL, claimed = character(0)) {
  switch(need,
    group_var          = .detect_group_var(data, var_info, name_bonus),
    paired_pairs       = .detect_paired_pairs(data, var_info),
    agreement_pairs    = .detect_agreement_pairs(data, var_info),
    time_variable      = .detect_survival_components(data, var_info, "time", name_bonus),
    event_variable     = .detect_survival_components(data, var_info, "event", name_bonus),
    coupled_pair       = .detect_survival_components(data, var_info, "pair", name_bonus),
    time_index         = .detect_time_index(data, var_info),
    subject_id         = .detect_subject_id(data, var_info, name_bonus, claimed),
    outcome_continuous = .detect_outcome(data, var_info, "continuous", name_bonus, claimed),
    outcome_binary     = .detect_outcome(data, var_info, "binary", name_bonus, claimed),
    repeated_measures  = .detect_repeated_measures(data, var_info),
    scale_items        = .detect_scale_items(data, var_info),
    covariate          = .detect_covariates(data, var_info, claimed),
    list(found = FALSE, role = need, column = NULL, detected_by = "none",
         score = 0, max_score = 0, pct = 0, breakdown = list())
  )
}

# The canonical role-need set, in detection order. Structural roles (survival, subject id,
# repeated, scale) resolve BEFORE the outcomes so their columns are `claimed` (atomicity).
.ROLE_NEEDS <- c(
  "group_var", "paired_pairs", "agreement_pairs", "time_variable",
  "event_variable", "coupled_pair", "time_index", "subject_id",
  "repeated_measures", "scale_items", "outcome_continuous", "outcome_binary",
  "covariate"
)

# Roles whose detected columns BLOCK a column from also being an outcome (atomicity).
# Excludes covariate (the catch-all) and the outcome roles themselves.
.CLAIMING_ROLES <- c("group_var", "paired_pairs", "agreement_pairs", "time_variable",
                     "event_variable", "coupled_pair", "time_index", "subject_id",
                     "repeated_measures", "scale_items")

# Run every detector + per-column value classification + potential pairs.
run_all_detections <- function(data, var_info, name_bonus = NULL, verbose = FALSE) {
  roles <- list()
  for (need in .ROLE_NEEDS) {
    owners <- if (need == "covariate") names(roles) else intersect(names(roles), .CLAIMING_ROLES)
    # Paired/agreement members are not outcome candidates; when the paired scorer latches onto a
    # (cause, outcome) pair the continuous outcome stays silent (known limit).
    # Measured-and-rejected alternatives: see the v0.3.0 development report (rolescry_v030/v030_dev_report.md, not distributed with the package).
    claimed <- unique(unlist(lapply(owners, function(rn) .role_cols(roles[[rn]]))))
    roles[[need]] <- tryCatch(
      detect_variable_role(data, var_info, need, name_bonus, claimed),
      error = function(e) list(found = FALSE, role = need, .error = conditionMessage(e))
    )
    .say(verbose, "[rolescry] %s: found=%s", need, isTRUE(roles[[need]]$found))
  }
  value_types <- vapply(names(data), function(col) {
    tryCatch(classify_value_type(data[[col]])$type, error = function(e) NA_character_)
  }, character(1L))
  potential_pairs <- list()
  num_cols <- var_info$column[var_info$user_type == "Continuous"]
  if (length(num_cols) >= 2) {
    for (i in seq_along(num_cols)[-length(num_cols)]) {
      for (j in (i + 1):length(num_cols)) {
        a <- data[[num_cols[i]]]
        b <- data[[num_cols[j]]]
        mb <- .mdl_pair_bits(a, b)
        if (isTRUE(mb$bits > 0 && mb$agree)) {
          potential_pairs[[length(potential_pairs) + 1]] <- list(
            col1 = num_cols[i], col2 = num_cols[j],
            bits = mb$bits, shift_bits = .mdl_shift_bits(a, b)
          )
        }
      }
    }
  }
  list(roles = roles, value_types = value_types, potential_pairs = potential_pairs)
}

Try the rolescry package in your browser

Any scripts or data that you put into this service are public.

rolescry documentation built on Oct. 21, 2026, 5:14 p.m.