From 2fb55725403534c93f2773720010b0efe3781874 Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 13 Aug 2026 12:14:28 +0000 Subject: [PATCH 1/2] feat: implement dual-seed PRNG isolation across TypeScript core and target language strategies (R, Python, SAS, STATA) --- Validation_Traceability_Matrix.md | 2 +- .../core/minimization-algorithm.ts | 12 +- .../core/randomization-algorithm.spec.ts | 23 +++ .../core/randomization-algorithm.ts | 24 ++- .../services/generation/base.strategy.ts | 4 + .../services/generation/ir/templates.ts | 158 ++++++++++++++++++ .../services/generation/ir/transpiler.ts | 8 +- .../services/generation/stata.strategy.ts | 2 +- 8 files changed, 217 insertions(+), 16 deletions(-) diff --git a/Validation_Traceability_Matrix.md b/Validation_Traceability_Matrix.md index 0e9b2672..ccdf2ca1 100644 --- a/Validation_Traceability_Matrix.md +++ b/Validation_Traceability_Matrix.md @@ -1,6 +1,6 @@ # Validation Traceability Matrix -> **Generated:** 2026-08-13T11:53:12.740Z +> **Generated:** 2026-08-13T12:13:03.920Z > **Status:** Test results not provided — status shown as UNKNOWN > **Requirements covered:** 14 / 16 > **Tagged test cases:** 39 diff --git a/src/app/domain/randomization-engine/core/minimization-algorithm.ts b/src/app/domain/randomization-engine/core/minimization-algorithm.ts index 946de983..f82b2325 100644 --- a/src/app/domain/randomization-engine/core/minimization-algorithm.ts +++ b/src/app/domain/randomization-engine/core/minimization-algorithm.ts @@ -3,6 +3,7 @@ import { RandomizationConfig, GeneratedSchema, TreatmentArm } from '../../core/m import { PRECISION_SCALE } from '../../../core/constants/precision.config'; import { generateSubjectId } from './subject-id-engine'; import { SubjectRegistry } from './subject-registry'; +import { MT19937Internal } from './mt19937'; import { MathUtil } from '../../core/utils/math.util'; @@ -87,8 +88,15 @@ export function generateMinimization( config: RandomizationConfig, rng: () => number, registry: SubjectRegistry, - siteWeights?: Record + siteWeights?: Record, + rngId?: () => number ): GeneratedSchema[] { + const resolvedRngId = rngId ?? (config.seed ? (() => { + const secondarySeed = config.seed + '-id'; + const mtSecondary = new MT19937Internal(MT19937Internal.get31BitSeed(secondarySeed)); + return () => mtSecondary.random(); + })() : rng); + const { arms, strata, sites, minimizationConfig } = config; const p = minimizationConfig?.p ?? 0.8; const totalSampleSize = minimizationConfig?.totalSampleSize ?? 100; @@ -348,7 +356,7 @@ export function generateMinimization( config.subjectIdMask, { site, stratumCode, sequence: siteSeq }, usedSubjectIds, - rng + resolvedRngId ); schema.push({ diff --git a/src/app/domain/randomization-engine/core/randomization-algorithm.spec.ts b/src/app/domain/randomization-engine/core/randomization-algorithm.spec.ts index 541322f8..c0e5a184 100644 --- a/src/app/domain/randomization-engine/core/randomization-algorithm.spec.ts +++ b/src/app/domain/randomization-engine/core/randomization-algorithm.spec.ts @@ -993,5 +993,28 @@ describe('generateRandomizationSchema – hierarchical block strategy', () => { expect(() => generateRandomizationSchema(config)).toThrow(/marginalCap/i); }); }); + + describe('Dual Seeded PRNG State Isolation', () => { + it('results in identical treatment assignments when changing subject ID mask from sequential to random', () => { + const configSequential: RandomizationConfig = { + ...BASE_CONFIG, + subjectIdMask: '{SITE}-{SEQ:4}' + }; + + const configRandom: RandomizationConfig = { + ...BASE_CONFIG, + subjectIdMask: '{SITE}-{RND:6}' + }; + + const resultSequential = generateRandomizationSchema(configSequential); + const resultRandom = generateRandomizationSchema(configRandom); + + // Verify subject IDs are indeed different + expect(resultSequential.schema.map(r => r.subjectId)).not.toEqual(resultRandom.schema.map(r => r.subjectId)); + + // Verify treatment assignments are completely identical + expect(resultSequential.schema.map(r => r.treatmentArmId)).toEqual(resultRandom.schema.map(r => r.treatmentArmId)); + }); + }); }); diff --git a/src/app/domain/randomization-engine/core/randomization-algorithm.ts b/src/app/domain/randomization-engine/core/randomization-algorithm.ts index 19534077..b1ffc8a9 100644 --- a/src/app/domain/randomization-engine/core/randomization-algorithm.ts +++ b/src/app/domain/randomization-engine/core/randomization-algorithm.ts @@ -135,7 +135,8 @@ function generateStandard( totalRatio: number, schema: GeneratedSchema[], usedSubjectIds: Set, - registry: SubjectRegistry + registry: SubjectRegistry, + rngId: () => number ): void { const stateMap = new Map(); const siteSubjectCounts = new Map(); @@ -177,7 +178,7 @@ function generateStandard( resolvedConfig.subjectIdMask, { site, stratumCode, sequence: siteSubjectCounts.get(site)! }, usedSubjectIds, - rng + rngId ); schema.push({ subjectId, site, stratum, stratumCode, blockNumber: state.blockNumber, blockSize, treatmentArm: arm.name, treatmentArmId: arm.id }); @@ -209,7 +210,8 @@ function generateMarginalOnly( totalRatio: number, schema: GeneratedSchema[], usedSubjectIds: Set, - registry: SubjectRegistry + registry: SubjectRegistry, + rngId: () => number ): void { for (const site of resolvedConfig.sites) { let siteSubjectCount = 0; @@ -249,7 +251,7 @@ function generateMarginalOnly( resolvedConfig.subjectIdMask, { site, stratumCode, sequence: siteSubjectCount }, usedSubjectIds, - rng + rngId ); schema.push({ @@ -298,9 +300,15 @@ export function generateRandomizationSchema( ? config : { ...config, seed: generateCryptoSeed() }; - const mt = new MT19937Internal(MT19937Internal.get31BitSeed(resolvedConfig.seed)); + 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(); + // Generate all strata combinations let strataCombinations: Record[] = [{}]; for (const factor of resolvedConfig.strata) { @@ -355,12 +363,12 @@ export function generateRandomizationSchema( const usedSubjectIds = new Set(); if (internalConfig.randomizationMethod === 'MINIMIZATION') { - schema.push(...generateMinimization(internalConfig, rng, registry, siteWeights)); + schema.push(...generateMinimization(internalConfig, rng, registry, siteWeights, rngId)); } else if (internalConfig.capStrategy === 'MARGINAL_ONLY') { - generateMarginalOnly(internalConfig, rng, strataCombinations, totalRatio, schema, usedSubjectIds, registry); + generateMarginalOnly(internalConfig, rng, strataCombinations, totalRatio, schema, usedSubjectIds, registry, rngId); } else { // Both 'MANUAL_MATRIX' (default) and 'PROPORTIONAL' use intersection caps. - generateStandard(internalConfig, rng, strataCombinations, totalRatio, schema, usedSubjectIds, registry); + generateStandard(internalConfig, rng, strataCombinations, totalRatio, schema, usedSubjectIds, registry, rngId); } return { diff --git a/src/app/domain/schema-management/services/generation/base.strategy.ts b/src/app/domain/schema-management/services/generation/base.strategy.ts index 02f083fc..a1643d0f 100644 --- a/src/app/domain/schema-management/services/generation/base.strategy.ts +++ b/src/app/domain/schema-management/services/generation/base.strategy.ts @@ -90,12 +90,16 @@ export class BaseOrchestrator implements CodeGenerationStrategy { const dateStr = DateUtil.getIsoTimestamp(); const algorithm = method === 'MINIMIZATION' ? 'Pocock-Simon Minimization' : 'PRNG Algorithm: MT19937'; + const secondarySeed = resolvedConfig.seed + '-id'; + const seedHashSecondary = MT19937Internal.get31BitSeed(secondarySeed); + const data: Record = { protocolId: config.protocolId, appVersion: APP_VERSION, dateStr, algorithm, seedHash: ir.seedHash, + seedHashSecondary: seedHashSecondary, validationVector: valVec.join(', '), validationVectorSpace: valVec.join(' '), precisionScale: PRECISION_SCALE, diff --git a/src/app/domain/schema-management/services/generation/ir/templates.ts b/src/app/domain/schema-management/services/generation/ir/templates.ts index 2f604982..cdf7ec7b 100644 --- a/src/app/domain/schema-management/services/generation/ir/templates.ts +++ b/src/app/domain/schema-management/services/generation/ir/templates.ts @@ -18,6 +18,45 @@ source("mt19937_v1.0.0.r") init_mt({{seedHash}}) +# --- Secondary MT19937 PRNG for Subject ID --- +mt_state_id <- numeric(624) +mt_idx_id <- 624 + +init_mt_id <- function(seed) { + mt_state_id[1] <<- seed %% 4294967296 + for (i in 2:624) { + prev <- mt_state_id[i - 1] + val <- u32_xor(prev, u32_shr(prev, 30)) + val <- u32_mul(val, 1812433253) + (i - 1) + mt_state_id[i] <<- val %% 4294967296 + } + mt_idx_id <<- 624 +} + +random_int_id <- function() { + if (mt_idx_id >= 624) { + for (kk in 1:624) { + y <- u32_or(u32_and(mt_state_id[kk], 2147483648), u32_and(mt_state_id[(kk %% 624) + 1], 2147483647)) + nxt <- mt_state_id[((kk + 396) %% 624) + 1] + mt_state_id[kk] <<- u32_xor(nxt, u32_shr(y, 1)) + if ((y %% 2) != 0) mt_state_id[kk] <<- u32_xor(mt_state_id[kk], 2567483615) + } + mt_idx_id <<- 0 + } + + y <- mt_state_id[mt_idx_id + 1] + mt_idx_id <<- mt_idx_id + 1 + + y <- u32_xor(y, u32_shr(y, 11)) + y <- u32_xor(y, u32_and(u32_shl(y, 7), 2636928640)) + y <- u32_xor(y, u32_and(u32_shl(y, 15), 4022730752)) + y <- u32_xor(y, u32_shr(y, 18)) + + return(y) +} + +init_mt_id({{seedHashSecondary}}) + # --- SINGLE-SOURCE TRANSPILED LOGIC --- {{minimizationParam}} schema_list <- list() @@ -36,6 +75,7 @@ export const SAS_TEMPLATE = ` /* Generated At: {{dateStr}} */ /* Algorithm: {{algorithm}} */ %let seed = {{seedHash}}; +%let seed_id = {{seedHashSecondary}}; %let arms = {{arms}}; %let arms_names = {{armsNames}}; %let strata_factors = {{strataFactors}}; @@ -59,6 +99,21 @@ data RandomizationSchema; /* --- MT19937 PRNG --- */ %mt19937_init(&seed); + array mt_id[0:623] _temporary_; + mti_id = 624; + + mt_id[0] = &seed_id; + do i = 1 to 623; + prev_id = mt_id[i-1]; + val_id = mod(bxor(prev_id, brshift(prev_id, 30)), 4294967296); + if val_id < 0 then val_id = val_id + 4294967296; + a = 1812433253; + a_hi = int(a / 65536); a_lo = mod(a, 65536); + b_hi = int(val_id / 65536); b_lo = mod(val_id, 65536); + prod_id = mod(mod(a_hi * b_lo + a_lo * b_hi, 65536) * 65536 + a_lo * b_lo, 4294967296); + mt_id[i] = mod(prod_id + i, 4294967296); + end; + /* --- RUNTIME PARITY VALIDATION --- */ array val_vec[100] _temporary_ ({{validationVectorSpace}}); do v_idx = 1 to 100; @@ -76,6 +131,42 @@ data RandomizationSchema; /* MT19937 Generator Macro-Equivalent */ %mt19937_label(); + + get_rand_int_id: + if mti_id >= 624 then do; + do kk = 0 to 226; + y_id = mod(bor(band(mt_id[kk], 2147483648), band(mt_id[kk+1], 2147483647)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + mt_id[kk] = mod(bxor(bxor(mt_id[kk+397], brshift(y_id, 1)), ifn(band(y_id, 1), 2567483615, 0)), 4294967296); + if mt_id[kk] < 0 then mt_id[kk] = mt_id[kk] + 4294967296; + end; + do kk = 227 to 622; + y_id = mod(bor(band(mt_id[kk], 2147483648), band(mt_id[kk+1], 2147483647)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + mt_id[kk] = mod(bxor(bxor(mt_id[kk-227], brshift(y_id, 1)), ifn(band(y_id, 1), 2567483615, 0)), 4294967296); + if mt_id[kk] < 0 then mt_id[kk] = mt_id[kk] + 4294967296; + end; + y_id = mod(bor(band(mt_id[623], 2147483648), band(mt_id[0], 2147483647)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + mt_id[623] = mod(bxor(bxor(mt_id[396], brshift(y_id, 1)), ifn(band(y_id, 1), 2567483615, 0)), 4294967296); + if mt_id[623] < 0 then mt_id[623] = mt_id[623] + 4294967296; + mti_id = 0; + end; + + y_id = mt_id[mti_id]; + mti_id = mti_id + 1; + + y_id = mod(bxor(y_id, brshift(y_id, 11)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + y_id = mod(bxor(y_id, band(blshift(y_id, 7), 2636928640)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + y_id = mod(bxor(y_id, band(blshift(y_id, 15), 4022730752)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + y_id = mod(bxor(y_id, brshift(y_id, 18)), 4294967296); + if y_id < 0 then y_id = y_id + 4294967296; + + rand_int_id = y_id; + return; run; `; @@ -126,6 +217,7 @@ class MT19937: return y & 0xffffffff rng = MT19937({{seedHash}}) +rng_id = MT19937({{seedHashSecondary}}) # Arms: {{arms}} # Ratios: {{ratios}} @@ -191,7 +283,73 @@ do "mt19937_v1.0.0.do" mata: +real rowvector mt_state_id +real scalar mt_idx_id + +void init_mt_id(real scalar seed) { + mt_state_id = J(1, 624, 0) + mt_state_id[1] = seed + for (i=2; i<=624; i++) { + prev = mt_state_id[i-1] + val = mod(bitxor(prev, bitrshift(prev, 30)), 4294967296) + if (val < 0) val = val + 4294967296 + + a = 1812433253 + a_hi = trunc(a / 65536) + a_lo = mod(a, 65536) + b_hi = trunc(val / 65536) + b_lo = mod(val, 65536) + prod = mod(mod(a_hi * b_lo + a_lo * b_hi, 65536) * 65536 + a_lo * b_lo, 4294967296) + + mt_state_id[i] = mod(prod + (i-1), 4294967296) + } + mt_idx_id = 624 +} + +real scalar random_int_id() { + if (mt_idx_id >= 624) { + for (kk=1; kk<=227; kk++) { + y = mod(bitor(bitand(mt_state_id[kk], 2147483648), bitand(mt_state_id[kk+1], 2147483647)), 4294967296) + if (y < 0) y = y + 4294967296 + mt_state_id[kk] = mod(bitxor(mt_state_id[kk+397], bitrshift(y, 1)), 4294967296) + if (mt_state_id[kk] < 0) mt_state_id[kk] = mt_state_id[kk] + 4294967296 + if (bitand(y, 1) != 0) mt_state_id[kk] = mod(bitxor(mt_state_id[kk], 2567483615), 4294967296) + if (mt_state_id[kk] < 0) mt_state_id[kk] = mt_state_id[kk] + 4294967296 + } + for (kk=228; kk<=623; kk++) { + y = mod(bitor(bitand(mt_state_id[kk], 2147483648), bitand(mt_state_id[kk+1], 2147483647)), 4294967296) + if (y < 0) y = y + 4294967296 + mt_state_id[kk] = mod(bitxor(mt_state_id[kk-227], bitrshift(y, 1)), 4294967296) + if (mt_state_id[kk] < 0) mt_state_id[kk] = mt_state_id[kk] + 4294967296 + if (bitand(y, 1) != 0) mt_state_id[kk] = mod(bitxor(mt_state_id[kk], 2567483615), 4294967296) + if (mt_state_id[kk] < 0) mt_state_id[kk] = mt_state_id[kk] + 4294967296 + } + y = mod(bitor(bitand(mt_state_id[624], 2147483648), bitand(mt_state_id[1], 2147483647)), 4294967296) + if (y < 0) y = y + 4294967296 + mt_state_id[624] = mod(bitxor(mt_state_id[397], bitrshift(y, 1)), 4294967296) + if (mt_state_id[624] < 0) mt_state_id[624] = mt_state_id[624] + 4294967296 + if (bitand(y, 1) != 0) mt_state_id[624] = mod(bitxor(mt_state_id[624], 2567483615), 4294967296) + if (mt_state_id[624] < 0) mt_state_id[624] = mt_state_id[624] + 4294967296 + mt_idx_id = 0 + } + + y = mt_state_id[mt_idx_id+1] + mt_idx_id = mt_idx_id + 1 + + y = mod(bitxor(y, bitrshift(y, 11)), 4294967296) + if (y < 0) y = y + 4294967296 + y = mod(bitxor(y, bitand(bitlshift(y, 7), 2636928640)), 4294967296) + if (y < 0) y = y + 4294967296 + y = mod(bitxor(y, bitand(bitlshift(y, 15), 4022730752)), 4294967296) + if (y < 0) y = y + 4294967296 + y = mod(bitxor(y, bitrshift(y, 18)), 4294967296) + if (y < 0) y = y + 4294967296 + + return(mod(y, 4294967296)) +} + init_mt({{seedHash}}) +init_mt_id({{seedHashSecondary}}) // --- RUNTIME PARITY VALIDATION --- real rowvector val_vec diff --git a/src/app/domain/schema-management/services/generation/ir/transpiler.ts b/src/app/domain/schema-management/services/generation/ir/transpiler.ts index 7376293d..85a30ae6 100644 --- a/src/app/domain/schema-management/services/generation/ir/transpiler.ts +++ b/src/app/domain/schema-management/services/generation/ir/transpiler.ts @@ -265,7 +265,7 @@ export class CodeTranspiler { } else if (token.type === 'seq') { baseBuilder += `sprintf("%0${token.length}d", ${seqVar}), `; } else if (token.type === 'rnd') { - baseBuilder += `paste0(ALPHANUMERIC[floor((random_int() / 4294967296) * 36) + 1][1:${token.length}], collapse=""), `; + baseBuilder += `paste0(ALPHANUMERIC[floor((random_int_id() / 4294967296) * 36) + 1][1:${token.length}], collapse=""), `; } else if (token.type === 'checksum') { hasChecksum = true; baseBuilder += `"{CHECKSUM}", `; @@ -308,7 +308,7 @@ export class CodeTranspiler { } else if (token.type === 'seq') { baseBuilder += `str(${seqVar}).zfill(${token.length}) + `; } else if (token.type === 'rnd') { - baseBuilder += `''.join(ALPHANUMERIC[int((rng.random_int() / 4294967296) * 36)] for _ in range(${token.length})) + `; + baseBuilder += `''.join(ALPHANUMERIC[int((rng_id.random_int() / 4294967296) * 36)] for _ in range(${token.length})) + `; } else if (token.type === 'checksum') { hasChecksum = true; baseBuilder += `"{CHECKSUM}" + `; @@ -352,8 +352,8 @@ export class CodeTranspiler { code += ` length rnd_str_${rndCounter} $ ${token.length};\n`; code += ` rnd_str_${rndCounter} = "";\n`; code += ` do _k = 1 to ${token.length};\n`; - code += ` link get_rand_int;\n`; - code += ` char_idx = int((rand_int / 4294967296) * 36) + 1;\n`; + code += ` link get_rand_int_id;\n`; + code += ` char_idx = int((rand_int_id / 4294967296) * 36) + 1;\n`; code += ` rnd_str_${rndCounter} = trim(rnd_str_${rndCounter}) || substr(ALPHANUMERIC, char_idx, 1);\n`; code += ` end;\n`; baseBuilder += `trim(rnd_str_${rndCounter}) || `; diff --git a/src/app/domain/schema-management/services/generation/stata.strategy.ts b/src/app/domain/schema-management/services/generation/stata.strategy.ts index a7f11660..023a6bcc 100644 --- a/src/app/domain/schema-management/services/generation/stata.strategy.ts +++ b/src/app/domain/schema-management/services/generation/stata.strategy.ts @@ -76,7 +76,7 @@ export const STATA_CONFIG: LanguageConfig = { utils += `string scalar stata_rnd_str(real scalar len) {\n`; utils += ` string scalar res; res = "";\n`; utils += ` real scalar k;\n`; - utils += ` for (k=1; k<=len; k++) { res = res + substr("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", trunc((random_int() / 4294967296) * 36) + 1, 1); }\n`; + utils += ` for (k=1; k<=len; k++) { res = res + substr("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", trunc((random_int_id() / 4294967296) * 36) + 1, 1); }\n`; utils += ` return(res);\n`; utils += `}\n\n`; From 716e8f8e5d73e735b54fcb7bdfc6a64f82b94122 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" Date: Thu, 13 Aug 2026 15:04:12 +0000 Subject: [PATCH 2/2] chore: update Validation_Traceability_Matrix.md generated timestamp --- Validation_Traceability_Matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Validation_Traceability_Matrix.md b/Validation_Traceability_Matrix.md index ccdf2ca1..ea4be915 100644 --- a/Validation_Traceability_Matrix.md +++ b/Validation_Traceability_Matrix.md @@ -1,6 +1,6 @@ # Validation Traceability Matrix -> **Generated:** 2026-08-13T12:13:03.920Z +> **Generated:** 2026-08-13T15:02:54.350Z > **Status:** Test results not provided — status shown as UNKNOWN > **Requirements covered:** 14 / 16 > **Tagged test cases:** 39