Skip to content

Commit 3948285

Browse files
authored
Merge pull request #893 from rasmi/balanced-strategy
Add BALANCED_ASSIGNMENT variable config
2 parents ddd6514 + f1279ad commit 3948285

File tree

16 files changed

+1618
-290
lines changed

16 files changed

+1618
-290
lines changed

frontend/src/components/experiment_builder/variable_editor.ts

Lines changed: 375 additions & 146 deletions
Large diffs are not rendered by default.

frontend/src/shared/templates/policy.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ import {
4444
VariableConfigType,
4545
VariableType,
4646
RandomPermutationVariableConfig,
47-
StaticVariableConfig,
48-
createStaticVariableConfig,
49-
VariableScope,
47+
BalancedAssignmentVariableConfig,
48+
BalanceStrategy,
49+
BalanceAcross,
50+
createBalancedAssignmentVariableConfig,
5051
createShuffleConfig,
5152
} from '@deliberation-lab/utils';
5253

@@ -196,17 +197,23 @@ const PolicySchema = VariableType.object({
196197
),
197198
});
198199

199-
// Create a static variable config with the complete policy object
200-
const POLICY_STATIC_CONFIG: StaticVariableConfig = createStaticVariableConfig({
201-
id: 'policy-static-config',
202-
scope: VariableScope.EXPERIMENT,
203-
definition: {
204-
name: 'policy',
205-
description: 'Policy debate topic',
206-
schema: PolicySchema,
207-
},
208-
value: JSON.stringify(EXAMPLE_POLICY_A),
209-
});
200+
// Create a balanced assignment config for multi-policy experiments
201+
// Each participant is randomly assigned one policy with even distribution
202+
const POLICY_BALANCED_ASSIGNMENT_CONFIG: BalancedAssignmentVariableConfig =
203+
createBalancedAssignmentVariableConfig({
204+
id: 'policy-balanced-assignment',
205+
definition: {
206+
name: 'policy',
207+
description: 'Randomly assigned policy for balanced conditions',
208+
schema: PolicySchema,
209+
},
210+
values: [
211+
JSON.stringify(EXAMPLE_POLICY_A),
212+
JSON.stringify(EXAMPLE_POLICY_B),
213+
],
214+
balanceStrategy: BalanceStrategy.ROUND_ROBIN,
215+
balanceAcross: BalanceAcross.EXPERIMENT,
216+
});
210217

211218
const NO_SHUFFLE: ShuffleConfig = createShuffleConfig({
212219
shuffle: false,
@@ -222,11 +229,11 @@ const PARTICIPANT_SHUFFLE: ShuffleConfig = createShuffleConfig({
222229
// ****************************************************************************
223230
export function getPolicyExperimentTemplate(): ExperimentTemplate {
224231
const stageConfigs = getPolicyStageConfigs();
225-
const variableTemplates: VariableConfig[] = [POLICY_STATIC_CONFIG];
232+
const variableConfigs: VariableConfig[] = [POLICY_BALANCED_ASSIGNMENT_CONFIG];
226233
return createExperimentTemplate({
227234
experiment: createExperimentConfig(stageConfigs, {
228235
metadata: POLICY_METADATA,
229-
variableConfigs: variableTemplates,
236+
variableConfigs,
230237
}),
231238
stageConfigs,
232239
agentMediators: POLICY_MEDIATOR_AGENTS,

functions/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010
"deploy": "firebase deploy --only functions",
1111
"logs": "firebase functions:log",
1212
"test": "npm run test:unit && npm run test:firestore",
13-
"test:firestore": "firebase -c firebase-test.json emulators:exec --only firestore,functions --project=demo-deliberate-lab \"npx jest $npm_package_config_firestore_tests\"",
13+
"test:firestore": "firebase -c firebase-test.json emulators:exec --only firestore,functions --project=demo-deliberate-lab \"npx jest --runInBand $npm_package_config_firestore_tests\"",
1414
"test:unit": "npx jest --testPathIgnorePatterns=$npm_package_config_firestore_tests",
1515
"typecheck": "tsc --noEmit"
1616
},
1717
"engines": {
1818
"node": "22"
1919
},
2020
"config": {
21-
"firestore_tests": "src/log.utils.test.ts src/dl_api/experiments.dl_api.integration.test.ts"
21+
"firestore_tests": "src/log.utils.test.ts src/dl_api/experiments.dl_api.integration.test.ts src/variables.utils.test.ts"
2222
},
2323
"main": "lib/index.js",
2424
"dependencies": {

functions/src/cohort.utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import {
44
MediatorProfileExtended,
55
StageConfig,
66
createPublicDataFromStageConfigs,
7-
generateVariablesForScope,
87
VariableScope,
98
} from '@deliberation-lab/utils';
9+
import {generateVariablesForScope} from './variables.utils';
1010
import {createMediatorsForCohort} from './mediator.utils';
1111
import {app} from './app';
1212

@@ -67,7 +67,7 @@ export async function createCohortInternal(
6767
}
6868

6969
// Add variable values at the cohort level
70-
cohortConfig.variableMap = generateVariablesForScope(
70+
cohortConfig.variableMap = await generateVariablesForScope(
7171
experiment.variableConfigs ?? [],
7272
{scope: VariableScope.COHORT, experimentId, cohortId: cohortConfig.id},
7373
);

functions/src/dl_api/dl_api_key.utils.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
* Handles generation, hashing, storage, and verification of API keys
44
*/
55

6-
import * as admin from 'firebase-admin';
76
import {randomBytes, scrypt, createHash, timingSafeEqual} from 'crypto';
87
import {promisify} from 'util';
98
import {
109
DeliberateLabAPIKeyPermission,
1110
DeliberateLabAPIKeyData,
1211
} from '@deliberation-lab/utils';
12+
import {app} from '../app';
1313

1414
const scryptAsync = promisify(scrypt);
1515

@@ -67,7 +67,6 @@ export async function createDeliberateLabAPIKey(
6767
DeliberateLabAPIKeyPermission.WRITE,
6868
],
6969
): Promise<{apiKey: string; keyId: string}> {
70-
const app = admin.app();
7170
const firestore = app.firestore();
7271

7372
// Generate the API key
@@ -104,7 +103,6 @@ export async function createDeliberateLabAPIKey(
104103
export async function verifyDeliberateLabAPIKey(
105104
apiKey: string,
106105
): Promise<{valid: boolean; data?: DeliberateLabAPIKeyData}> {
107-
const app = admin.app();
108106
const firestore = app.firestore();
109107

110108
// Get key ID to look up the document
@@ -151,7 +149,6 @@ export async function revokeDeliberateLabAPIKey(
151149
keyId: string,
152150
experimenterId: string,
153151
): Promise<boolean> {
154-
const app = admin.app();
155152
const firestore = app.firestore();
156153

157154
const doc = await firestore
@@ -195,7 +192,6 @@ export async function listDeliberateLabAPIKeys(experimenterId: string): Promise<
195192
permissions: DeliberateLabAPIKeyPermission[];
196193
}>
197194
> {
198-
const app = admin.app();
199195
const firestore = app.firestore();
200196

201197
const snapshot = await firestore

functions/src/dl_api/experiments.dl_api.integration.test.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,14 @@
44
* These tests verify that experiments created via the REST API are equivalent
55
* to experiments created using the traditional template system.
66
*
7-
* This test assumes that a Firestore emulator is running.
8-
* Run using: npm run test:firestore experiments.api.integration.test.ts
9-
* Or: firebase emulators:exec --only firestore "npx jest experiments.api.integration.test.ts"
7+
* This test requires a Firestore emulator running. Run via:
8+
* npm run test:firestore
109
*/
1110

12-
// Don't override GCLOUD_PROJECT - let firebase emulators:exec set it
13-
// If not set, use a demo project ID that the emulator will accept
14-
if (!process.env.GCLOUD_PROJECT) {
15-
process.env.GCLOUD_PROJECT = 'demo-deliberate-lab';
16-
}
17-
1811
import {
1912
initializeTestEnvironment,
2013
RulesTestEnvironment,
2114
} from '@firebase/rules-unit-testing';
22-
import * as admin from 'firebase-admin';
2315
import {
2416
createExperimentConfig,
2517
StageConfig,
@@ -53,8 +45,7 @@ describe('API Experiment Creation Integration Tests', () => {
5345
const createdExperimentIds: string[] = [];
5446

5547
beforeAll(async () => {
56-
// Use the same project ID that the emulator will use
57-
const projectId = process.env.GCLOUD_PROJECT || 'demo-deliberate-lab';
48+
const projectId = 'demo-deliberate-lab';
5849

5950
testEnv = await initializeTestEnvironment({
6051
projectId,
@@ -68,13 +59,6 @@ describe('API Experiment Creation Integration Tests', () => {
6859
firestore = testEnv.unauthenticatedContext().firestore();
6960
firestore.settings({ignoreUndefinedProperties: true, merge: true});
7061

71-
// Initialize Firebase Admin SDK (will use emulator via FIRESTORE_EMULATOR_HOST environment variable)
72-
if (!admin.apps.length) {
73-
admin.initializeApp({
74-
projectId,
75-
});
76-
}
77-
7862
// Create test API key (this will be stored in the emulator)
7963
console.log('Creating API key...');
8064
const {apiKey} = await createDeliberateLabAPIKey(

functions/src/experiment.endpoints.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
StageConfig,
1414
createExperimentConfig,
1515
createExperimentTemplate,
16-
generateVariablesForScope,
1716
VariableScope,
1817
} from '@deliberation-lab/utils';
18+
import {generateVariablesForScope} from './variables.utils';
1919
import {getExperimentDownload} from './data';
2020

2121
import {onCall, HttpsError} from 'firebase-functions/v2/https';
@@ -71,7 +71,7 @@ export const writeExperiment = onCall(async (request) => {
7171
}
7272

7373
// Add variable values at the experiment level
74-
experimentConfig.variableMap = generateVariablesForScope(
74+
experimentConfig.variableMap = await generateVariablesForScope(
7575
experimentConfig.variableConfigs ?? [],
7676
{scope: VariableScope.EXPERIMENT, experimentId: experimentConfig.id},
7777
);

functions/src/participant.endpoints.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
StageConfig,
1616
TransferStageConfig,
1717
createParticipantProfileExtended,
18-
generateVariablesForScope,
1918
setProfile,
2019
VariableScope,
2120
} from '@deliberation-lab/utils';
@@ -24,6 +23,7 @@ import {
2423
updateParticipantNextStage,
2524
handleAutomaticTransfer,
2625
} from './participant.utils';
26+
import {generateVariablesForScope} from './variables.utils';
2727

2828
import {onCall, HttpsError} from 'firebase-functions/v2/https';
2929

@@ -77,7 +77,13 @@ export const createParticipant = onCall(async (request) => {
7777
.collection('participants')
7878
.doc(participantConfig.privateId);
7979

80-
// Set random timeout to avoid data contention with transaction
80+
// Set random timeout to avoid data contention with transaction.
81+
// Note: This also mitigates (but doesn't eliminate) a race condition with
82+
// BalancedAssignment variables. The count/query used to determine assignment
83+
// happens inside the transaction, but Firestore transactions only lock
84+
// documents that are read—not aggregation queries. Two concurrent participants
85+
// could see the same count and receive the same assignment. For most experiments
86+
// with moderate join rates, the random delay provides sufficient distribution.
8187
await new Promise((resolve) => {
8288
setTimeout(resolve, Math.random() * 2000);
8389
});
@@ -129,7 +135,7 @@ export const createParticipant = onCall(async (request) => {
129135
participantConfig.currentStageId = experiment.stageIds[0];
130136

131137
// Add variable values at the participant level
132-
participantConfig.variableMap = generateVariablesForScope(
138+
participantConfig.variableMap = await generateVariablesForScope(
133139
experiment.variableConfigs ?? [],
134140
{
135141
scope: VariableScope.PARTICIPANT,

0 commit comments

Comments
 (0)