Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Validation_Traceability_Matrix.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Validation Traceability Matrix

> **Generated:** 2026-08-13T15:02:47.631Z
> **Generated:** 2026-08-13T15:02:54.350Z
> **Status:** Test results not provided — status shown as UNKNOWN
> **Requirements covered:** 14 / 16
> **Tagged test cases:** 39
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,8 +88,15 @@ export function generateMinimization(
config: RandomizationConfig,
rng: () => number,
registry: SubjectRegistry,
siteWeights?: Record<string, number>
siteWeights?: Record<string, number>,
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;
Expand Down Expand Up @@ -348,7 +356,7 @@ export function generateMinimization(
config.subjectIdMask,
{ site, stratumCode, sequence: siteSeq },
usedSubjectIds,
rng
resolvedRngId
);

schema.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ function generateStandard(
totalRatio: number,
schema: GeneratedSchema[],
usedSubjectIds: Set<string>,
registry: SubjectRegistry
registry: SubjectRegistry,
rngId: () => number
): void {
const stateMap = new Map<string, { blockNumber: number; blockState: any; rule: any }>();
const siteSubjectCounts = new Map<string, number>();
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -209,7 +210,8 @@ function generateMarginalOnly(
totalRatio: number,
schema: GeneratedSchema[],
usedSubjectIds: Set<string>,
registry: SubjectRegistry
registry: SubjectRegistry,
rngId: () => number
): void {
for (const site of resolvedConfig.sites) {
let siteSubjectCount = 0;
Expand Down Expand Up @@ -249,7 +251,7 @@ function generateMarginalOnly(
resolvedConfig.subjectIdMask,
{ site, stratumCode, sequence: siteSubjectCount },
usedSubjectIds,
rng
rngId
);

schema.push({
Expand Down Expand Up @@ -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<string, string>[] = [{}];
for (const factor of resolvedConfig.strata) {
Expand Down Expand Up @@ -355,12 +363,12 @@ export function generateRandomizationSchema(
const usedSubjectIds = new Set<string>();

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number> = {
protocolId: config.protocolId,
appVersion: APP_VERSION,
dateStr,
algorithm,
seedHash: ir.seedHash,
seedHashSecondary: seedHashSecondary,
validationVector: valVec.join(', '),
validationVectorSpace: valVec.join(' '),
precisionScale: PRECISION_SCALE,
Expand Down
158 changes: 158 additions & 0 deletions src/app/domain/schema-management/services/generation/ir/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}};
Expand All @@ -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;
Expand All @@ -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;
`;

Expand Down Expand Up @@ -126,6 +217,7 @@ class MT19937:
return y & 0xffffffff

rng = MT19937({{seedHash}})
rng_id = MT19937({{seedHashSecondary}})

# Arms: {{arms}}
# Ratios: {{ratios}}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}", `;
Expand Down Expand Up @@ -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}" + `;
Expand Down Expand Up @@ -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}) || `;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down
Loading