The problem
A trial randomization schedule has to be reproducible: anyone holding the seed and the study configuration should get the same subject-by-arm list, and a statistician should be able to read the allocation logic in a language they already use. Equipose is a free, open-source Angular app for designing stratified block and Pocock–Simon minimization schedules in the browser. It is deployed at equipose.org.
The approach: A seeded MT19937 generator drives every random choice, so a schedule can be regenerated from its seed. The same configuration can be exported as an R, Python, SAS, or Stata script, either with the generated list embedded (static) or with the allocation logic re-implemented in the target language (dynamic).
What the repository shows, and where it stops:
- Schedules are generated in the browser. A Playwright suite fails if generation, CSV, PDF, or code export makes a request to any non-local host.
- A golden-fixture suite checks the TypeScript engine against stored schedules and, in CI, runs the exported R and Python scripts against the same fixtures.
- The project README states bit-for-bit parity between the web app and all four exports. Treat that as project-reported: the project’s own SAS/Stata exception report says dynamic SAS and Stata scripts do not guarantee sequence parity, and only static exports, which embed the list, match exactly in those languages.
- No timing benchmarks are published. The only enforced performance budget is bundle size: the production build fails if the initial bundle exceeds 2.2 MB.
Equipose is a design and inspection tool, not a validated system. Its documentation marks the in-browser schedule as a draft and directs organisations to run exported scripts inside their own validated statistical environment.
How it works
Three bounded contexts. randomization-engine holds the pure TypeScript algorithms, the Web Worker, and the facade the UI talks to. study-builder holds the configuration form and an NgRx SignalStore (@ngrx/signals). schema-management holds the results grid, CSV/PDF export, and code generation. ESLint forbids fetch, XMLHttpRequest, WebSocket, and Angular’s HttpClient everywhere under src/app/domain. The app is built on Angular 22 (package.json; the README badge still says 21).
Seeding. If the user gives no seed, 128 bits from crypto.getRandomValues become one. The seed string is hashed with SHA-256, the first 32 hex characters are folded into a 31-bit integer with an FNV-style loop, and that integer seeds MT19937. A second generator, seeded from the seed plus -id, keeps random subject-ID tokens on a separate stream.
Allocation. Block randomization builds each block from the arm ratios and shuffles it with Fisher–Yates. Enrollment caps follow one of three strategies: a manual matrix, proportional caps distributed by largest remainder in the study builder, or marginal-only caps. Minimization tracks counts per site; for each candidate arm it sums, over the subject’s factor levels, the range of ratio-normalised arm counts, then assigns the lowest-scoring arm with probability p (default 0.8, accepted range 0.5 to 1.0).
Off-main-thread work. RandomizationEngineFacade sends START_GENERATION and START_MONTE_CARLO commands to a module Web Worker. If the worker cannot be constructed, generation falls back to the same pure function on the main thread. A Monte Carlo run is 10,000 regenerations of the design with a fresh random seed each time, progress every 500 iterations, and an optional 0–50% attrition filter.
Code export. CodeGeneratorService registers four strategies (R, Python, SAS, Stata). Each builds an intermediate representation (LogicIR: seed hash, simplified ratios, per-site and per-stratum tasks, subject-ID tokens) and renders a language template; a static-mapping guard checks the output. Dynamic scripts carry MT19937 ports for R, SAS, and Stata and a Python class. For minimization designs the export dialog offers static mode only.
Audit hash and ADaM-lite view. The facade attaches a SHA-256 hash over the configuration, timestamp, and schedule, computed from key-sorted JSON; Python and R scripts recompute it independently, and a Playwright test checks that Chromium, Firefox, and WebKit produce the same hash for the same seed. The results view also reshapes a schedule into an “ADaM-lite” dataset of labelled variables and records. That is the project’s own lightweight shape; it is not a CDISC ADaM dataset and is not checked for conformance.
How the pieces connect
flowchart TD
Form["Config form (study-builder)"] --> Store["StudyBuilderStore (NgRx SignalStore)"]
Store --> Facade["RandomizationEngineFacade"]
Facade -->|"START_GENERATION / START_MONTE_CARLO"| Worker["Module Web Worker"]
Facade -.->|"worker unavailable"| Inline["Same pure function on main thread"]
Worker --> Engine["generateRandomizationSchema (MT19937)"]
Inline --> Engine
Engine --> Block["Block path: Fisher-Yates per block"]
Engine --> Min["Minimization path: range imbalance, biased coin"]
Worker -->|"GENERATION_SUCCESS"| Facade
Facade --> Hash["computeAuditHash (SHA-256)"]
Hash --> Results["Results grid, CSV/PDF, ADaM-lite view"]
Results --> Codegen["CodeGeneratorService: R, Python, SAS, Stata"]
Implementation notes
The excerpts below are copied verbatim from the main branch.
Seed resolution (src/app/domain/randomization-engine/core/randomization-algorithm.ts)
const resolvedConfig = config.seed
? config
: { ...config, seed: generateCryptoSeed() };
const primarySeed = resolvedConfig.seed;
const secondarySeed = primarySeed + '-id';
const mt = new MT19937Internal(MT19937Internal.get31BitSeed(primarySeed));
const rng = () => mt.random();
const mtSecondary = new MT19937Internal(MT19937Internal.get31BitSeed(secondarySeed));
const rngId = () => mtSecondary.random();
Minimization imbalance and biased coin (src/app/domain/randomization-engine/core/minimization-algorithm.ts)
for (const arm of arms) {
const count = (levelMarginals.get(arm.id) ?? 0) + (arm.id === candidateArmId ? 1 : 0);
const mult = ratioMultipliers.get(arm.id) ?? 1;
const normalizedCount = count * mult;
if (min === null || normalizedCount < min) min = normalizedCount;
if (max === null || normalizedCount > max) max = normalizedCount;
}
if (min !== null && max !== null) {
totalScore += (max - min);
}
if (preferred.length === arms.length || nonPreferred.length === 0) {
assignedArm = selectWeightedArm(preferred, rng);
} else {
const r = Math.floor(rng() * PRECISION_SCALE);
const pScaled = Math.round(p * PRECISION_SCALE);
if (r < pScaled) {
assignedArm = selectWeightedArm(preferred, rng);
} else {
assignedArm = selectWeightedArm(nonPreferred, rng);
}
}
Monte Carlo in the worker (src/app/domain/randomization-engine/worker/randomization-engine.worker.ts)
function runMonteCarlo(id: string, { config, attritionRate, siteWeights }: MonteCarloPayload): void {
const TOTAL_ITERATIONS = 10_000;
const PROGRESS_INTERVAL = 500;
// NaN guard: non-finite values (e.g. NaN from empty input) are normalized to 0.
const normalizedAttritionRate = Number.isFinite(attritionRate) ? attritionRate : 0;
const clampedAttritionRate = Math.max(0, Math.min(50, normalizedAttritionRate));
const dropoutProbability = clampedAttritionRate / 100;
Tradeoffs and lessons
- Client-side execution over a backend: keeping generation in the browser means trial design parameters are never sent to a server, which a network-interception test enforces. The cost is that nothing is stored centrally; reproducibility depends on the user keeping the seed, the configuration, and the audit hash.
- A hand-written MT19937 over the platform generator:
crypto.getRandomValuescannot be seeded, so it only supplies a default seed. Every allocation step uses MT19937, which can be ported to R, SAS, Stata, and Python. - Static and dynamic exports: a static script reproduces the web schedule exactly because it contains it. A dynamic script shows the method but, for SAS and Stata, may not reproduce the same sequence, and the generated SAS and Stata scripts carry a warning comment saying so.
- Minimization exports: the export dialog does not yet offer dynamic export for minimization, so those designs are exported as an embedded list.
Sources
Every claim above is checked against the fderuiter/equipose-randomization repository on main:
- README.md: live site, bounded contexts, and the project-reported parity claim.
- package.json: Angular 22 and
@ngrx/signalsversions. - core/mt19937.ts and SEED_COMPLIANCE_EXPLAINER.md: MT19937 and the SHA-256 to 31-bit seed fold.
- core/minimization-algorithm.ts and its spec: Pocock–Simon minimization.
- worker/randomization-engine.worker.ts and randomization-engine.facade.ts: Web Worker, main-thread fallback, and Monte Carlo.
- study-builder/store/study-builder.store.ts: NgRx SignalStore.
- code-generator.service.ts and generation/ir/transpiler.ts: R, Python, SAS, and Stata export.
- SAS_Stata_Exception_Report.md: the limits on SAS and Stata sequence parity.
- randomization-algorithm-golden.spec.ts and code-generation-fixture.spec.ts: golden fixtures and R/Python output comparison.
- core/crypto-hash.ts and determinism.spec.ts: the audit hash and its cross-browser check.
- adam-lite.mapper.ts: the ADaM-lite view.
- zero-trust.spec.ts: the no-outbound-request check.
- PERFORMANCE_BUDGETS.md: the bundle-size budget.