PsyCloud

Counterbalancing & Assignment

Within-subjects structure — factors, crossing, sampling, shuffling — decides what one participant sees and in what order (see Experiment Design). Counterbalancing is the orthogonal question: across many participants, who gets which condition, kept balanced and auditable. In PsyCloud it's declared, not left to chance. Stateful hosted modes record assignments in the server ledger; cb_hash is the separate stateless mode for teams that prefer per-run reproducibility over active balancing.

Between-subjects, specifically

This page is about between-subjects factors — a condition a participant is assigned once and keeps for the whole session (e.g. a key mapping, a stimulus set, an instruction framing). Within-subject ordering/randomization lives in the design pipeline; see Experiment Design.

Hosted status

Ledger-backed allocation is a hosted-backend behavior. Preview and SDK simulation exercise the assignment logic without persisted server state; live balance is created and audited through the run/session backend described in Hosted Backend.

Two layers that work together

Counterbalancing in PsyCloud has two cooperating pieces:

  1. A per-trial assignment step — assign_between. Inside a phase's pipeline, it filters the trial table down to the rows matching this participant's assigned cell. Methods:
    • hash — deterministic from the participant id; no server state required.
    • quota — balanced toward target counts (server-tracked).
    • random — seeded random pick.
  2. A bundle-level allocation policy — cb_hash / cb_random / cb_rotation. This declares the factor's levels and the algorithm the server uses to allocate each new participant to a cell. cb_hash is stateless; cb_random and cb_rotation use the allocation ledger to balance as people arrive.

The policy decides which cell a participant is in; assign_between applies that cell to the trial table at run time. Most studies use both (as below); a quick pilot can use assign_between(method = "hash") alone.

Declare it

build-bundle.R
# Per-trial: filter the practice/main rows to this participant's mapping cell.
exp |>
  phase("practice", label = "Practice") |>
  trials(both_mappings(practice_rows)) |>
  assign_between(factor = "mapping", method = "hash")
 
# ... later, at the bundle level: declare the stateless hash policy.
exp |>
  bundle(
    with_auto_ids = TRUE,
    counterbalance = cb_hash(mapping = c("A", "B"))
  )

Hash, random, rotation, minimization

The policy's algorithm decides how participants are spread across cells:

  • cb_hash derives the cell from the run id, assignment key, policy hash, namespace, and strata. It requires no state read and no allocation lock, so it scales well and is reproducible per run. It is balanced only in expectation and cannot enforce quotas, streak-avoidance, balance targets, or online item rotation.
  • cb_random records each participant in the hosted allocation ledger and samples toward the target proportions as people arrive. Assignment is unpredictable; at small N a run can be uneven — it's balanced in expectation, not exactly.
  • cb_rotation assigns by cycling through the cells (the order reshuffled each full cycle), so the cells are evened out after each complete cycle — the tightest balance, best at small/medium N. If the next scheduled cell is unavailable (e.g. a hard quota is full) it falls back to the balancing (minimization) algorithm.
  • Minimization is the lower-level adaptive allocator for finite-sample balancing against explicit objectives, quotas, and recent-assignment penalties.

Rule of thumb: reach for cb_hash when reproducible, stateless assignment is more important than active balance; cb_rotation when you want maximally even cells throughout; and cb_random when you want unpredictable assignment and N is large enough that balance-in-expectation is fine.

# Hash: reproducible per run, no server state needed for the decision
bundle(counterbalance = cb_hash(mapping = c("A", "B")))
 
# Random: ledger-backed, ~50/50 in expectation
bundle(counterbalance = cb_random(mapping = c("A", "B")))
 
# Rotation: as-even-as-possible at every point (tighter at small N)
bundle(counterbalance = cb_rotation(mapping = c("A", "B")))
 
# Multi-factor (2x2): the two axes are crossed into 4 cells
bundle(counterbalance = cb_rotation(
  mapping = c("A", "B"),
  framing = c("gain", "loss")
))

For weighted splits, quotas, balance targets, or streak-avoidance, drop to the lower-level cb_policy() builder. Those stateful constraints are not allowed with cb_hash. Cells are counted by assigned participants by default (accounting_unit can count started / completed / passed_qc instead).

The both_mappings(...) / practice_rows helpers above are illustrative — see the full runnable study in the repo's examples/high-level/task-switching-gate.

How balance actually happens (hosted vs. preview)

First — what “simulate” and “preview” mean

Two things in this section run your design without a server:

Both are offline tools for checking that a design runs and reproduces — no recruited participants and no stored data. (There's no separate "simulate" page; it lives in the SDK workbench and Studio Preview.)

This distinction matters and is easy to get wrong:

  • Hosted runs (real participants). At session start the server allocates the participant using the policy's algorithm. With cb_hash, it derives the cell from the run id, assignment key, policy hash, namespace, and strata, then records the result. With cb_rotation, cb_random, or minimization, it reads the allocation ledger, chooses from the current counts/history, and records the decision. The chosen cell is fixed for the session and passed to the runtime, where assign_between applies it.
  • Preview / simulate (no server). There's no ledger, so PsyCloud makes a stable, seeded per-participant pick across the declared levels — enough to exercise the design and reproduce a given participant, but not cross-participant balanced.
  • Compiled bundles. A compiled bundle has no participant, so compilation pins one representative cell (the first level of each between-subject axis) consistently across all scopes. Inspect a compiled artifact and you'll see that single cell — not the live per-participant allocation.
Simulate is not a balance check

Simulate and Preview tell you a design runs and that a given participant id reproduces — but with no server-side ledger offline, they can't tell you whether conditions are balanced across participants. Real balance is enforced only on a hosted run; inspect it in Monitor / Data.

Reproducibility

There are two different guarantees, depending on where assignment happens:

  • Preview / simulate is seeded — assignment and trial order derive from a deterministic chain (study + participant + namespace), so they reproduce offline from the seed alone.
  • Hosted stateful assignment is ledger-backed — for cb_random, cb_rotation, and minimization, reconstruct from the stored assignment plus the policy, not a seed alone. The decision depends on arrival order and counts.
  • Hosted cb_hash is stateless per run — the first assignment is derived from run id, assignment key, policy hash, namespace, and strata. The recorded assignment still wins for a returning participant, so a mid-study policy or namespace edit does not silently reassign someone already recorded. Re-launches produce fresh assignments because the run id is part of the hash input.

The within-subject trial order for a given participant is seed-reproducible in both cases. Pair the stored assignment with a preregistered analysis script for fully reproducible results. Changing randomNamespace in the design's determinism settings deliberately invalidates all previously generated streams.

Order counterbalancing (Latin square)

For within-session order effects (e.g. block order), PsyCloud's allocators include Latin-square schemes — cyclic rotation and balanced Williams designs (which control first-order carryover). These are selected through the allocation policy rather than assign_between.

In Studio

When a phase contains an assign_between step, Studio surfaces a between-subjects factor banner in Trial Design with a per-factor method selector — Balanced quota (recommended), Deterministic hash, or Random. Advanced balancing (targets, Latin-square / balanced blocks, sequential effects) is configured through the counterbalance wizard reached from the assignment inspector.

Discoverability

Counterbalancing UI appears after you add an assign_between step (or open the wizard); there is no standalone "Counterbalancing" tab yet. If you author in code, the SDK fields above are the source of truth.

Validation & checking allocation

  • psycloud validate <bundle-or-study> and psycloud inspect <bundle-or-study> --section counterbalance check the policy structure (schema 0.1.1 / 0.1.2): factor ids, non-empty levels, proportions, quotas. Hash policies reject stateful fields such as quotas, streak-avoidance, balance targets, and online item rotation.
  • On a live run, Monitor shows allocation status. Data exports carry an assignment_hash per session, and the participant's cell shows up as the filtered factor values in the trial rows (after assign_between) — there's no separate assigned-cell column, so confirm balance from those.

Known limits (alpha)

What's not wired yet
  • Item rotation (rotating which items a participant sees, balanced across the pool) is expressible in the policy and SDKs (cb_item_rotation_*) but is not yet executed by the design pipeline — treat it as roadmap.
  • TypeScript authoring exposes the low-level policy builders but not the high-level cb_hash/cb_random/cb_rotation helpers — author counterbalancing in Python or R (or construct the policy object directly).
  • Dropout rebalancing depends on the allocation path. Simple conditions-based allocation counts live sessions, so a participant who abandons or whose lease expires frees their cell and the next arrival refills it. The cb_* policy path (v0.1.1) keeps a persistent assigned-count that is not decremented when a participant drops out, so abandoned cells in a policy-based run are not auto-refilled. Over-recruit slightly and check balance in Monitor / Data.

Next