diff --git a/packages/backend/src/api/services/MoocletExperimentService.ts b/packages/backend/src/api/services/MoocletExperimentService.ts index b91b84317..88170787e 100644 --- a/packages/backend/src/api/services/MoocletExperimentService.ts +++ b/packages/backend/src/api/services/MoocletExperimentService.ts @@ -394,6 +394,16 @@ export class MoocletExperimentService extends ExperimentService { updatedExperiment.moocletPolicyParameters = policyParameterResponse.parameters; + // Transform prior keys from Mooclet version IDs back to UpGrade condition codes, + // so the PUT response is consistent with the GET response from attachPolicyParamsToExperimentDTO. + const updatedTsParams = updatedExperiment.moocletPolicyParameters as MoocletTSConfigurablePolicyParametersDTO; + if (updatedTsParams?.prior) { + updatedTsParams.prior = this.translateVersionIdsToConditionCodes( + updatedTsParams.prior, + currentMoocletExperimentRef.versionConditionMaps + ); + } + // --------- update versions ---------------------- if (!versionEdits) { @@ -522,6 +532,18 @@ export class MoocletExperimentService extends ExperimentService { currentMoocletExperimentRef: MoocletExperimentRef, logger: UpgradeLogger ): Promise { + const tsParams = newPolicyParameters as MoocletTSConfigurablePolicyParametersDTO; + if (tsParams.prior) { + // Translate conditionCode keys to Mooclet version IDs before sending to the Mooclet API + newPolicyParameters = { + ...tsParams, + prior: this.translateConditionCodesToVersionIds( + tsParams.prior, + currentMoocletExperimentRef.versionConditionMaps + ), + } as MoocletPolicyParametersDTO; + } + return this.moocletDataService.updatePolicyParameters( currentMoocletExperimentRef.policyParametersId, { @@ -1045,10 +1067,9 @@ export class MoocletExperimentService extends ExperimentService { moocletExperimentRef.variableId = moocletVariableResponse?.id; moocletExperimentRef.policyId = newMoocletRequest.policy; - moocletExperimentRef.outcomeVariableName = - upgradeExperiment.assignmentAlgorithm === ASSIGNMENT_ALGORITHM.MOOCLET_TS_CONFIGURABLE - ? (moocletPolicyParameters as MoocletTSConfigurablePolicyParametersDTO).outcome_variable_name - : undefined; + moocletExperimentRef.outcomeVariableName = ( + moocletPolicyParameters as MoocletTSConfigurablePolicyParametersDTO + ).outcome_variable_name; } catch (err) { await this.orchestrateDeleteMoocletResources(moocletExperimentRef, logger); throw err; @@ -1168,24 +1189,20 @@ export class MoocletExperimentService extends ExperimentService { logger ); - // Transform current_posteriors keys from Mooclet version IDs to UpGrade condition codes + // Transform current_posteriors and prior keys from Mooclet version IDs to UpGrade condition codes const tsConfigurableParams = policyParameters.parameters as MoocletTSConfigurablePolicyParametersDTO; if (tsConfigurableParams.current_posteriors) { - const transformedPosteriors = {}; - for (const [versionId, posteriorData] of Object.entries(tsConfigurableParams.current_posteriors)) { - const versionConditionMap = moocletExperimentRef.versionConditionMaps.find( - (map) => map.moocletVersionId === parseInt(versionId, 10) - ); - - if (versionConditionMap?.experimentCondition?.conditionCode) { - transformedPosteriors[versionConditionMap.experimentCondition.conditionCode] = posteriorData; - } else { - logger.warn({ - message: `No condition mapping found for Mooclet version ${versionId} in experiment ${experiment.id}`, - }); - } - } - tsConfigurableParams.current_posteriors = transformedPosteriors; + tsConfigurableParams.current_posteriors = this.translateVersionIdsToConditionCodes( + tsConfigurableParams.current_posteriors, + moocletExperimentRef.versionConditionMaps + ); + } + + if (tsConfigurableParams.prior) { + tsConfigurableParams.prior = this.translateVersionIdsToConditionCodes( + tsConfigurableParams.prior, + moocletExperimentRef.versionConditionMaps + ); } experiment.moocletPolicyParameters = policyParameters.parameters; @@ -1350,6 +1367,37 @@ export class MoocletExperimentService extends ExperimentService { return SUPPORTED_MOOCLET_ALGORITHMS.includes(assignmentAlgorithm); } + private translateConditionCodesToVersionIds( + record: Record, + versionConditionMaps: MoocletVersionConditionMap[] + ): Record { + const result: Record = {}; + for (const [conditionCode, value] of Object.entries(record)) { + const map = versionConditionMaps?.find((m) => m.experimentCondition?.conditionCode === conditionCode); + if (map?.moocletVersionId) { + result[String(map.moocletVersionId)] = value; + } + } + return result; + } + + private translateVersionIdsToConditionCodes( + record: Record, + versionConditionMaps: MoocletVersionConditionMap[] + ): Record { + const result: Record = {}; + for (const [versionId, value] of Object.entries(record)) { + const map = versionConditionMaps?.find((m) => String(m.moocletVersionId) === versionId); + if (!map?.experimentCondition?.conditionCode) { + throw new MoocletError( + `Reward feedback summary could not be processed: no condition mapping found for Mooclet version ${versionId}` + ); + } + result[map.experimentCondition.conditionCode] = value; + } + return result; + } + /** * Generate a unique outcome variable name based on experiment name and timestamp. * Returns a string in this format: diff --git a/packages/backend/src/api/services/MoocletRewardsService.ts b/packages/backend/src/api/services/MoocletRewardsService.ts index 592683b54..f8af8efe4 100644 --- a/packages/backend/src/api/services/MoocletRewardsService.ts +++ b/packages/backend/src/api/services/MoocletRewardsService.ts @@ -1,5 +1,11 @@ import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; -import { EXPERIMENT_STATE, SERVER_ERROR, BinaryRewardValueMap } from 'upgrade_types'; +import { + EXPERIMENT_STATE, + SERVER_ERROR, + BinaryRewardValueMap, + MoocletTSConfigurablePolicyParametersDTO, + Prior, +} from 'upgrade_types'; import { RequestedExperimentUser } from '../controllers/validators/ExperimentUserValidator'; import { MoocletExperimentRef } from '../models/MoocletExperimentRef'; import { MoocletDataService } from './MoocletDataService'; @@ -243,7 +249,24 @@ export class MoocletRewardsService { } } - return this.createExperimentRewardsSummary(moocletExperimentRef, rewards, logger); + let tsConfigurableParams: MoocletTSConfigurablePolicyParametersDTO | undefined; + if (moocletExperimentRef.policyParametersId) { + try { + const policyParametersResponse = await this.moocletDataService.getPolicyParameters( + moocletExperimentRef.policyParametersId, + logger + ); + tsConfigurableParams = policyParametersResponse.parameters as MoocletTSConfigurablePolicyParametersDTO; + } catch (policyError) { + logger.warn({ + message: 'Could not fetch policy parameters for Thompson sampling estimate', + experimentId, + error: policyError, + }); + } + } + + return this.createExperimentRewardsSummary(moocletExperimentRef, rewards, logger, tsConfigurableParams); } catch (error) { logger.error({ message: 'Error fetching rewards summary for experiment', experimentId, error }); throw error; @@ -266,7 +289,8 @@ export class MoocletRewardsService { public async createExperimentRewardsSummary( moocletExperimentRef: MoocletExperimentRef, rewardsData: MoocletValueResponseDetails[], - logger: UpgradeLogger + logger: UpgradeLogger, + policyParameters?: MoocletTSConfigurablePolicyParametersDTO ): Promise { const rewards: MoocletValueResponseDetails[] = rewardsData; @@ -278,8 +302,21 @@ export class MoocletRewardsService { return []; } + const versionConditionPairs = moocletExperimentRef.versionConditionMaps.map( + ({ experimentCondition, moocletVersionId }) => ({ + conditionCode: experimentCondition.conditionCode, + moocletVersionId, + }) + ); + const DEFAULT_PRIOR: Prior = { success: 1, failure: 1 }; + const estimatedWeightMap = policyParameters + ? this.computeThompsonWeightsMap(versionConditionPairs, policyParameters) + : null; + const rewardsSummaries = moocletExperimentRef.versionConditionMaps.map( ({ experimentCondition, moocletVersionId }) => { + const conditionCode = experimentCondition.conditionCode; + const versionIdKey = String(moocletVersionId); const versionRewards = rewards.filter((reward) => reward.version === moocletVersionId); const successes = versionRewards.filter((reward) => reward.value === 1.0).length; const failures = versionRewards.filter((reward) => reward.value === 0.0).length; @@ -287,13 +324,20 @@ export class MoocletRewardsService { const percentSuccess = total > 0 ? (successes / total) * 100 : 0.0; const successRate = percentSuccess.toFixed(1) + '%'; + const conditionPrior: Prior = policyParameters?.prior?.[versionIdKey] ?? DEFAULT_PRIOR; + const conditionPosteriors = policyParameters?.current_posteriors?.[versionIdKey]; + const rewardsForCondition: ExperimentRewardsByCondition = { - conditionCode: experimentCondition.conditionCode, + conditionCode, successes, failures, - total, successRate, order: experimentCondition.order, + estimatedWeight: estimatedWeightMap?.get(conditionCode), + priorSuccess: conditionPrior.success, + priorFailure: conditionPrior.failure, + posteriorSuccesses: conditionPosteriors?.successes ?? 0, + posteriorFailures: conditionPosteriors?.failures ?? 0, }; return rewardsForCondition; } @@ -303,6 +347,89 @@ export class MoocletRewardsService { return orderedRewardsSummary; } + /** + * Computes Thompson Sampling estimated weight percentages per condition. + * Combines per-condition priors with current_posteriors as Beta distribution inputs. + * Returns a Map of conditionCode → integer estimated weight (all values sum to 100). + */ + private computeThompsonWeightsMap( + versionConditionPairs: { conditionCode: string; moocletVersionId: number }[], + params: MoocletTSConfigurablePolicyParametersDTO + ): Map { + const DEFAULT_PRIOR: Prior = { success: 1, failure: 1 }; + + const arms = versionConditionPairs.map(({ conditionCode, moocletVersionId }) => { + const versionIdKey = String(moocletVersionId); + const posteriors = params.current_posteriors?.[versionIdKey]; + return { + conditionCode, + alpha: posteriors?.successes ?? DEFAULT_PRIOR.success, + beta: posteriors?.failures ?? DEFAULT_PRIOR.failure, + }; + }); + + const iterations = 10_000; + const wins = new Array(arms.length).fill(0); + for (let i = 0; i < iterations; i++) { + let maxSample = -1; + let maxIdx = 0; + for (let j = 0; j < arms.length; j++) { + const sample = this.randBeta(arms[j].alpha, arms[j].beta); + if (sample > maxSample) { + maxSample = sample; + maxIdx = j; + } + } + wins[maxIdx]++; + } + + // Largest Remainder Method: normalize to integer percentages summing to exactly 100 + const raw = arms.map((_, i) => (wins[i] / iterations) * 100); + const floored = raw.map(Math.floor); + const remainder = 100 - floored.reduce((a, b) => a + b, 0); + const indices = raw + .map((v, i) => ({ diff: v - floored[i], i })) + .sort((a, b) => b.diff - a.diff) + .map(({ i }) => i); + for (let k = 0; k < remainder; k++) floored[indices[k]]++; + + return new Map(arms.map((arm, i) => [arm.conditionCode, floored[i]])); + } + + // --- Beta distribution sampling (Marsaglia-Tsang / Box-Muller) --- + + private randNormal(): number { + const u = Math.random() || Number.EPSILON; // guard against log(0) + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * Math.random()); + } + + private randGamma(shape: number): number { + if (shape < 1) { + return this.randGamma(1 + shape) * Math.pow(Math.random(), 1 / shape); + } + const d = shape - 1 / 3; + const c = 1 / Math.sqrt(9 * d); + let x: number; + let v: number; + let u: number; + do { + do { + x = this.randNormal(); + v = 1 + c * x; + } while (v <= 0); + v = v * v * v; + u = Math.random(); + // eslint-disable-next-line no-constant-condition + } while (!(u < 1 - 0.0331 * x * x * x * x) && !(Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))); + return d * v; + } + + private randBeta(alpha: number, beta: number): number { + const g1 = this.randGamma(alpha); + const g2 = this.randGamma(beta); + return g1 / (g1 + g2); + } + /** * Throws a 409 data-conflict error for most unexpected cases */ diff --git a/packages/backend/test/unit/services/MoocletExperimentService.test.ts b/packages/backend/test/unit/services/MoocletExperimentService.test.ts index 0ff960f04..89df0f1eb 100644 --- a/packages/backend/test/unit/services/MoocletExperimentService.test.ts +++ b/packages/backend/test/unit/services/MoocletExperimentService.test.ts @@ -1404,7 +1404,10 @@ describe('#MoocletExperimentService', () => { const mockPolicyParams = { parameters: { assignmentAlgorithm: ASSIGNMENT_ALGORITHM.MOOCLET_TS_CONFIGURABLE, - prior: { success: 1, failure: 1 }, + prior: { + '1': { success: 1, failure: 1 }, + '2': { success: 1, failure: 1 }, + }, }, }; @@ -1667,7 +1670,16 @@ describe('#MoocletExperimentService', () => { mockMoocletExperimentRef.id = 'ref-123'; mockMoocletExperimentRef.policyParametersId = 1; mockMoocletExperimentRef.variableId = 2; - mockMoocletExperimentRef.versionConditionMaps = []; + mockMoocletExperimentRef.versionConditionMaps = [ + { + moocletVersionId: 10, + experimentCondition: { conditionCode: 'control' } as any, + } as MoocletVersionConditionMap, + { + moocletVersionId: 20, + experimentCondition: { conditionCode: 'treatment' } as any, + } as MoocletVersionConditionMap, + ]; const mockExperiment = { id: 'exp-123', @@ -1713,7 +1725,13 @@ describe('#MoocletExperimentService', () => { updateSpy.mockResolvedValue(updatedExperiment); jest.spyOn(moocletExperimentService as any, 'doRevertablePolicyParameterChange').mockResolvedValue({ - parameters: mockExperiment.moocletPolicyParameters, + parameters: { + ...mockExperiment.moocletPolicyParameters, + prior: { + '10': { success: 1, failure: 1 }, + '20': { success: 1, failure: 1 }, + }, + }, }); const result = await (moocletExperimentService as any).handleEditMoocletTransaction(manager, params); diff --git a/packages/backend/test/unit/services/MoocletRewardsService.test.ts b/packages/backend/test/unit/services/MoocletRewardsService.test.ts index 2f5b5ec5a..472dbbfa0 100644 --- a/packages/backend/test/unit/services/MoocletRewardsService.test.ts +++ b/packages/backend/test/unit/services/MoocletRewardsService.test.ts @@ -595,17 +595,25 @@ describe('MoocletRewardsService', () => { conditionCode: 'Control', successes: 2, failures: 1, - total: 3, successRate: '66.7%', order: 0, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); expect(result[1]).toEqual({ conditionCode: 'Treatment', successes: 1, failures: 1, - total: 2, successRate: '50.0%', order: 1, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); }); @@ -638,9 +646,13 @@ describe('MoocletRewardsService', () => { conditionCode: 'Control', successes: 0, failures: 0, - total: 0, successRate: '0.0%', order: 0, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); }); @@ -698,18 +710,26 @@ describe('MoocletRewardsService', () => { conditionCode: 'Control', successes: 2, failures: 1, - total: 3, successRate: '66.7%', order: 0, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); // Treatment (version 200): page1 has id3 (success), page2 has id5 (failure) + id6 (success) => 2 successes, 1 failure expect(result[1]).toEqual({ conditionCode: 'Treatment', successes: 2, failures: 1, - total: 3, successRate: '66.7%', order: 1, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); }); @@ -817,9 +837,9 @@ describe('MoocletRewardsService', () => { const result = await service.getRewardsSummaryForExperiment('experiment-123', mockLogger); expect(mockMoocletDataService.getRewardsForExperiment).toHaveBeenCalledTimes(3); - expect(result[0].total).toBe(3); expect(result[0].successes).toBe(2); expect(result[0].failures).toBe(1); + expect(result[0].successes + result[0].failures).toBe(3); }); it('should log error and re-throw when mooclet service fails', async () => { @@ -985,17 +1005,25 @@ describe('MoocletRewardsService', () => { conditionCode: 'Control', successes: 2, failures: 1, - total: 3, successRate: '66.7%', order: 0, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); expect(result[1]).toEqual({ conditionCode: 'Treatment', successes: 1, failures: 3, - total: 4, successRate: '25.0%', order: 1, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); }); @@ -1115,8 +1143,8 @@ describe('MoocletRewardsService', () => { ); // Version 999 should not be counted - expect(result[0].total).toBe(2); // Only version 100 - expect(result[1].total).toBe(2); // Only version 200 + expect(result[0].successes + result[0].failures).toBe(2); // Only version 100 + expect(result[1].successes + result[1].failures).toBe(2); // Only version 200 }); it('should handle condition with no rewards', async () => { @@ -1153,9 +1181,13 @@ describe('MoocletRewardsService', () => { conditionCode: 'Treatment', successes: 0, failures: 0, - total: 0, successRate: '0.0%', order: 1, + estimatedWeight: undefined, + priorSuccess: 1, + priorFailure: 1, + posteriorSuccesses: 0, + posteriorFailures: 0, }); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts index 612d6585b..452d65d38 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -60,6 +60,7 @@ import { map, take, tap } from 'rxjs/operators'; import { LocalStorageService } from '../local-storage/local-storage.service'; import { ExperimentSegmentListRequest } from '../segments/store/segments.model'; import { ConditionWeightUpdate } from '../../features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; +import { MoocletTSConfigurablePolicyParametersDTO, Prior } from 'upgrade_types'; import { selectCurrentUserEmail } from '../auth/store/auth.selectors'; @Injectable() @@ -299,6 +300,22 @@ export class ExperimentService { ); } + updateExperimentConditionprior(experiment: ExperimentVM, prior: Record): void { + const updatedExperiment: ExperimentVM = { + ...experiment, + moocletPolicyParameters: { + ...experiment.moocletPolicyParameters, + prior, + } as MoocletTSConfigurablePolicyParametersDTO, + }; + this.store$.dispatch( + experimentAction.actionUpsertExperiment({ + experiment: updatedExperiment, + actionType: UpsertExperimentType.UPDATE_EXPERIMENT, + }) + ); + } + setIsLoadingImportExperiment(isLoadingImportExperiment: boolean) { this.store$.dispatch(experimentAction.actionSetIsLoadingImportExperiment({ isLoadingImportExperiment })); } diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.spec.ts index 976eddfe5..dacc5c746 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.spec.ts @@ -62,7 +62,6 @@ describe('MoocletAlgorithmHelperService', () => { expect(result.batch_size).toBe(expected.batch_size); expect(result.uniform_threshold).toBe(expected.uniform_threshold); expect(result.tspostdiff_thresh).toBe(expected.tspostdiff_thresh); - expect(result.prior).toEqual(expected.prior); expect(result.max_rating).toBe(expected.max_rating); expect(result.min_rating).toBe(expected.min_rating); }); @@ -81,8 +80,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: defaults.batch_size, uniform_threshold: defaults.uniform_threshold, tspostdiff_thresh: defaults.tspostdiff_thresh, - prior_success: defaults.prior.success, - prior_failure: defaults.prior.failure, }); }); @@ -91,7 +88,6 @@ describe('MoocletAlgorithmHelperService', () => { const defaults = new MoocletTSConfigurablePolicyParametersDTO(); expect(result.batch_size).toBe(defaults.batch_size); - expect(result.prior_success).toBe(defaults.prior.success); }); it('should extract editable fields from existing params', () => { @@ -99,7 +95,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 50, uniform_threshold: 100, tspostdiff_thresh: 5, - prior: { success: 10, failure: 10 }, }); const result = service.deriveEditableParametersForTSConfigurable(existingParams); @@ -108,23 +103,9 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 50, uniform_threshold: 100, tspostdiff_thresh: 5, - prior_success: 10, - prior_failure: 10, }); }); - it('should flatten nested prior object into prior_success and prior_failure', () => { - const existingParams = createMockTSConfigurableParams({ - prior: { success: 20, failure: 30 }, - }); - - const result = service.deriveEditableParametersForTSConfigurable(existingParams); - - expect(result.prior_success).toBe(20); - expect(result.prior_failure).toBe(30); - expect((result as any).prior).toBeUndefined(); - }); - it('should not include non-editable fields in result', () => { const existingParams = createMockTSConfigurableParams({ assignmentAlgorithm: ASSIGNMENT_ALGORITHM.MOOCLET_TS_CONFIGURABLE, @@ -160,8 +141,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 50, uniform_threshold: 100, tspostdiff_thresh: 5, - prior_success: 10, - prior_failure: 10, }; const result = service.buildTSConfigurablePolicyParametersDTO(editableParams); @@ -169,7 +148,6 @@ describe('MoocletAlgorithmHelperService', () => { expect(result.batch_size).toBe(50); expect(result.uniform_threshold).toBe(100); expect(result.tspostdiff_thresh).toBe(5); - expect(result.prior).toEqual({ success: 10, failure: 10 }); }); it('should set assignmentAlgorithm to MOOCLET_TS_CONFIGURABLE', () => { @@ -196,22 +174,6 @@ describe('MoocletAlgorithmHelperService', () => { expect(result.min_rating).toBe(defaults.min_rating); }); - it('should convert flat prior fields to nested prior object', () => { - const editableParams: EditableTSConfigurablePolicyParameters = { - batch_size: 30, - uniform_threshold: 50, - tspostdiff_thresh: 3, - prior_success: 15, - prior_failure: 20, - }; - - const result = service.buildTSConfigurablePolicyParametersDTO(editableParams); - - expect(result.prior).toBeDefined(); - expect(result.prior.success).toBe(15); - expect(result.prior.failure).toBe(20); - }); - it('should not include outcome_variable_name for any experiment name', () => { const editableParams = createMockEditableParams(); @@ -234,8 +196,6 @@ describe('MoocletAlgorithmHelperService', () => { expect(validators.batch_size).toBeDefined(); expect(validators.uniform_threshold).toBeDefined(); expect(validators.tspostdiff_thresh).toBeDefined(); - expect(validators.prior_success).toBeDefined(); - expect(validators.prior_failure).toBeDefined(); }); it('should include required validator for all fields', () => { @@ -252,9 +212,6 @@ describe('MoocletAlgorithmHelperService', () => { expect(validators.batch_size.length).toBe(4); expect(validators.uniform_threshold.length).toBe(4); - expect(validators.prior_success.length).toBe(4); - expect(validators.prior_failure.length).toBe(4); - expect(validators.tspostdiff_thresh.length).toBe(3); // Test that min validator works correctly for batch_size @@ -351,7 +308,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 30, uniform_threshold: 50, tspostdiff_thresh: 3, - prior: { success: 1, failure: 1 }, outcome_variable_name: 'test_outcome', max_rating: 5, min_rating: 1, @@ -368,7 +324,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 30, uniform_threshold: 50, tspostdiff_thresh: 3, - prior: { success: 1, failure: 1 }, outcome_variable_name: 'test', max_rating: 5, min_rating: 1, @@ -385,7 +340,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 30, uniform_threshold: 50, tspostdiff_thresh: 3, - prior: { success: 1, failure: 1 }, outcome_variable_name: '', // Empty string should not cause validation errors max_rating: 5, min_rating: 1, @@ -402,7 +356,6 @@ describe('MoocletAlgorithmHelperService', () => { batch_size: 30, uniform_threshold: 50, tspostdiff_thresh: 3, - prior: { success: 1, failure: 1 }, // outcome_variable_name property completely missing max_rating: 5, min_rating: 1, @@ -461,7 +414,6 @@ describe('formatTSConfigurablePolicyParamDetails (pure function)', () => { batch_size: 50, uniform_threshold: 100, tspostdiff_thresh: 5, - prior: { success: 10, failure: 10 }, }), }); @@ -469,22 +421,7 @@ describe('formatTSConfigurablePolicyParamDetails (pure function)', () => { expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(5); - }); - - it('should include prior values in formatted output', () => { - environment.moocletToggle = true; - const experiment = createMockExperimentVM({ - assignmentAlgorithm: ASSIGNMENT_ALGORITHM.MOOCLET_TS_CONFIGURABLE, - moocletPolicyParameters: createMockTSConfigurableParams({ - prior: { success: 15, failure: 20 }, - }), - }); - - const result = formatTSConfigurablePolicyParamDetails(experiment); - - expect(result.some((param) => param.value === 15)).toBe(true); - expect(result.some((param) => param.value === 20)).toBe(true); + expect(result.length).toBe(3); }); it('should include all configurable parameters', () => { @@ -495,7 +432,6 @@ describe('formatTSConfigurablePolicyParamDetails (pure function)', () => { batch_size: 50, uniform_threshold: 100, tspostdiff_thresh: 5, - prior: { success: 10, failure: 10 }, }), }); @@ -505,7 +441,6 @@ describe('formatTSConfigurablePolicyParamDetails (pure function)', () => { expect(values).toContain(50); expect(values).toContain(100); expect(values).toContain(5); - expect(values).toContain(10); }); }); @@ -521,8 +456,6 @@ function createMockEditableParams( batch_size: defaults.batch_size, uniform_threshold: defaults.uniform_threshold, tspostdiff_thresh: defaults.tspostdiff_thresh, - prior_success: defaults.prior.success, - prior_failure: defaults.prior.failure, ...overrides, }; } diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.ts index 056cc1f5c..904ef20c2 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/mooclet-helper.service.ts @@ -39,17 +39,6 @@ export function formatTSConfigurablePolicyParamDetails( const params = experiment.moocletPolicyParameters; const formattedParams: BullettedListKeyValueFormat[] = []; - if (params.prior) { - formattedParams.push({ - labelKey: TS_CONFIGURABLE_OVERVIEW_PARAM_LABELS.PRIOR_SUCCESS, - value: params.prior.success, - }); - formattedParams.push({ - labelKey: TS_CONFIGURABLE_OVERVIEW_PARAM_LABELS.PRIOR_FAILURE, - value: params.prior.failure, - }); - } - formattedParams.push({ labelKey: TS_CONFIGURABLE_OVERVIEW_PARAM_LABELS.BATCH_SIZE, value: params.batch_size, @@ -85,8 +74,6 @@ export interface EditableTSConfigurablePolicyParameters { batch_size: number; uniform_threshold: number; tspostdiff_thresh: number; - prior_success: number; - prior_failure: number; } /** @@ -174,8 +161,6 @@ export class MoocletExperimentHelperService { batch_size: source.batch_size, uniform_threshold: source.uniform_threshold, tspostdiff_thresh: source.tspostdiff_thresh, - prior_success: source.prior?.success, - prior_failure: source.prior?.failure, }; } @@ -193,10 +178,6 @@ export class MoocletExperimentHelperService { batch_size: editableParams.batch_size, uniform_threshold: editableParams.uniform_threshold, tspostdiff_thresh: editableParams.tspostdiff_thresh, - prior: { - success: editableParams.prior_success, - failure: editableParams.prior_failure, - }, // System-managed fields assignmentAlgorithm: ASSIGNMENT_ALGORITHM.MOOCLET_TS_CONFIGURABLE, max_rating: defaults.max_rating, @@ -225,15 +206,24 @@ export class MoocletExperimentHelperService { CommonFormHelpersService.integerValidator(), ], tspostdiff_thresh: [Validators.required, Validators.min(defaults.tspostdiff_thresh), Validators.max(1.0)], - prior_success: [ + }; + } + + /** + * Get field validators for per-condition prior success/failure inputs used in the prior editor. + */ + getpriorFieldValidators(): Record { + const priorDefault = 1; + return { + successes: [ Validators.required, - Validators.min(defaults.prior.success), + Validators.min(priorDefault), Validators.max(DEFAULT_MAX_NUMBER_INPUT), CommonFormHelpersService.integerValidator(), ], - prior_failure: [ + failures: [ Validators.required, - Validators.min(defaults.prior.failure), + Validators.min(priorDefault), Validators.max(DEFAULT_MAX_NUMBER_INPUT), CommonFormHelpersService.integerValidator(), ], diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts index a89cf858a..632169489 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts @@ -598,8 +598,6 @@ export const EXPERIMENT_OVERVIEW_LABELS = { export const TS_CONFIGURABLE_OVERVIEW_PARAM_LABELS = { BATCH_SIZE: 'home.new-experiment.design.ts-configurable-policy.batch-size.label.text', - PRIOR_SUCCESS: 'home.new-experiment.design.ts-configurable-policy.prior-success.label.text', - PRIOR_FAILURE: 'home.new-experiment.design.ts-configurable-policy.prior-failure.label.text', UNIFORM_THRESHOLD: 'home.new-experiment.design.ts-configurable-policy.uniform-threshold.label.text', TSPOSTDIFF_THRESH: 'home.new-experiment.design.ts-configurable-policy.tspostdiff-thresh.label.text', }; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.html new file mode 100644 index 000000000..b95dff5b7 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.html @@ -0,0 +1,80 @@ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'experiments.edit-condition-prior-modal.condition-header.text' | translate }} + + + {{ condition.conditionCode }} + + + {{ 'experiments.edit-condition-prior-modal.successes-header.text' | translate }} + + + + @if (getSuccessesControl(i).hasError('min')) { + {{ 'experiments.edit-condition-prior-modal.min-error.text' | translate }} + } @if (getSuccessesControl(i).hasError('max')) { + {{ 'experiments.edit-condition-prior-modal.max-error.text' | translate }} + } @if (getSuccessesControl(i).hasError('integer')) { + {{ 'experiments.edit-condition-prior-modal.integer-error.text' | translate }} + } + + + {{ 'experiments.edit-condition-prior-modal.failures-header.text' | translate }} + + + + @if (getFailuresControl(i).hasError('min')) { + {{ 'experiments.edit-condition-prior-modal.min-error.text' | translate }} + } @if (getFailuresControl(i).hasError('max')) { + {{ 'experiments.edit-condition-prior-modal.max-error.text' | translate }} + } @if (getFailuresControl(i).hasError('integer')) { + {{ 'experiments.edit-condition-prior-modal.integer-error.text' | translate }} + } + +
+ {{ 'experiments.edit-condition-prior-modal.no-data.text' | translate }} +
+
+
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.scss new file mode 100644 index 000000000..8205cb82e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.scss @@ -0,0 +1,99 @@ +.prior-input { + width: 90px; + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } +} + +.table-container { + position: relative; + overflow: auto; + width: 100%; + + ::ng-deep .no-data tbody:before { + display: block; + line-height: 8px; + content: '\200C'; + } + + .conditions-table { + input[type='number'] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + ::ng-deep thead { + background-color: var(--zircon); + + tr.mat-mdc-header-row { + height: 48px; + border: 0; + + th { + padding-left: 0; + color: var(--darker-grey); + + &:first-child { + border-top-left-radius: 4px; + } + + &:last-child { + border-top-right-radius: 4px; + } + } + } + } + + ::ng-deep tbody { + tr.mat-mdc-row { + height: 56px; + + td { + min-width: 96px; + padding-left: 0; + color: var(--black-2); + } + } + + tr.mat-mdc-no-data-row { + height: 48px; + + td { + text-align: center; + border: 1.5px dashed var(--light-grey-2); + color: var(--dark-grey); + } + } + } + + .condition-column { + width: 50%; + padding-left: 32px; + } + + .prior-column { + width: 25%; + text-align: right; + padding-right: 16px; + + .prior-input { + width: 90px; + + input { + text-align: right; + } + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + } + } +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.ts new file mode 100644 index 000000000..02ddf5935 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component.ts @@ -0,0 +1,113 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, ReactiveFormsModule, FormArray, FormControl } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable, combineLatest, map, startWith } from 'rxjs'; +import { CommonModalComponent } from '@shared-component-lib'; +import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service'; +import { CommonModalConfig } from '@shared-component-lib/common-modal/common-modal.types'; +import { MoocletExperimentHelperService } from '../../../../../core/experiments/mooclet-helper.service'; +import { Prior } from 'upgrade_types'; +import { SharedModule } from '../../../../../shared/shared.module'; + +export interface ConditionPriorUpdate { + conditionCode: string; + successes: number; + failures: number; +} + +@Component({ + selector: 'app-edit-condition-prior-modal', + imports: [ + CommonModalComponent, + MatTableModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + CommonModule, + ReactiveFormsModule, + TranslateModule, + SharedModule, + ], + templateUrl: './edit-condition-prior-modal.component.html', + styleUrl: './edit-condition-prior-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditConditionpriorModalComponent implements OnInit { + isPrimaryButtonDisabled$: Observable; + priorForm: FormGroup; + displayedColumns: string[] = ['condition', 'successes', 'failures']; + + conditions: ConditionPriorUpdate[] = []; + + constructor( + @Inject(MAT_DIALOG_DATA) + public config: CommonModalConfig<{ conditions: ConditionPriorUpdate[] }>, + public dialogRef: MatDialogRef, + private readonly formBuilder: FormBuilder, + private readonly moocletHelperService: MoocletExperimentHelperService + ) {} + + ngOnInit(): void { + this.conditions = this.config.params.conditions; + this.createpriorForm(); + } + + createpriorForm(): void { + const validators = this.moocletHelperService.getpriorFieldValidators(); + + const conditionsFormArray = this.formBuilder.array( + this.conditions.map((condition) => + this.formBuilder.group({ + conditionCode: [condition.conditionCode], + successes: [condition.successes, validators.successes], + failures: [condition.failures, validators.failures], + }) + ) + ); + + this.priorForm = this.formBuilder.group({ conditions: conditionsFormArray }); + + this.isPrimaryButtonDisabled$ = combineLatest([ + this.priorForm.statusChanges.pipe(startWith(this.priorForm.status)), + this.priorForm.valueChanges.pipe(startWith(this.priorForm.value)), + ]).pipe(map(([status]) => status === 'INVALID' || this.priorForm.pristine)); + } + + get conditionsFormArray(): FormArray { + return this.priorForm.get('conditions') as FormArray; + } + + getSuccessesControl(index: number): FormControl { + return this.conditionsFormArray.at(index).get('successes') as FormControl; + } + + getFailuresControl(index: number): FormControl { + return this.conditionsFormArray.at(index).get('failures') as FormControl; + } + + onPrimaryActionBtnClicked(): void { + if (this.priorForm.valid) { + const result: Record = {}; + this.conditionsFormArray.controls.forEach((control) => { + const conditionCode = control.get('conditionCode')?.value; + result[conditionCode] = { + success: control.get('successes')?.value, + failure: control.get('failures')?.value, + }; + }); + this.dialogRef.close(result); + } else { + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.priorForm); + } + } + + closeModal(): void { + this.dialogRef.close(); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.html index 29b68fe52..85acb7ff5 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.html @@ -10,52 +10,6 @@ >
- - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-success.label.text' | translate - }} - - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-success.hint.text' | translate - }} - @if (policyForm.get('prior_success')?.hasError('min')) { - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-success.min-error.text' | translate - }} - } @if (policyForm.get('prior_success')?.hasError('max')) { - - {{ 'home.new-experiment.design.ts-configurable-policy.general.max-error.text' | translate }} - - } @if (policyForm.get('prior_success')?.hasError('integer')) { - - {{ 'home.new-experiment.design.ts-configurable-policy.general.integer-error.text' | translate }} - - } - - - - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-failure.label.text' | translate - }} - - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-failure.hint.text' | translate - }} - @if (policyForm.get('prior_failure')?.hasError('min')) { - {{ - 'home.new-experiment.design.ts-configurable-policy.prior-failure.min-error.text' | translate - }} - } @if (policyForm.get('prior_failure')?.hasError('max')) { - - {{ 'home.new-experiment.design.ts-configurable-policy.general.max-error.text' | translate }} - - } @if (policyForm.get('prior_failure')?.hasError('integer')) { - - {{ 'home.new-experiment.design.ts-configurable-policy.general.integer-error.text' | translate }} - - } - - {{ 'home.new-experiment.design.ts-configurable-policy.batch-size.label.text' | translate diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.ts index a6872789f..cc28d0b6c 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/ts-configurable-policy-parameters-form/ts-configurable-policy-parameters-form.component.ts @@ -80,8 +80,6 @@ export class TsConfigurablePolicyParametersFormComponent implements OnInit, OnDe batch_size: [params.batch_size, validators.batch_size], uniform_threshold: [params.uniform_threshold, validators.uniform_threshold], tspostdiff_thresh: [params.tspostdiff_thresh, validators.tspostdiff_thresh], - prior_success: [params.prior_success, validators.prior_success], - prior_failure: [params.prior_failure, validators.prior_failure], }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html index ecd5671b2..cbf5e5829 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html @@ -35,8 +35,10 @@ [actionsDisabled]="vm.restriction.isDisabled" [actionsTooltip]="restrictionTooltip" [isMoocletExperiment]="isMoocletExperiment(vm.experiment)" + [prior]="vm.experiment.moocletPolicyParameters?.prior" (rowAction)="onRowAction($event, vm.experiment.id, appContext)" (editWeights)="onEditWeights($event, vm.experiment)" + (editprior)="onEditprior($event, vm.experiment)" > } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts index 60194d98d..d4d298c2b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts @@ -22,6 +22,7 @@ import { UserPermission } from '../../../../../../../core/auth/store/auth.models import { AuthService } from '../../../../../../../core/auth/auth.service'; import { DialogService } from '../../../../../../../shared/services/common-dialog.service'; import { ConditionWeightUpdate } from '../../../../modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; +import { Prior } from 'upgrade_types'; import { ConditionHelperService } from '../../../../../../../core/experiments/condition-helper.service'; import { selectConditionWeightsValid } from '../../../../../../../core/experiments/store/experiments.selectors'; import { Store } from '@ngrx/store'; @@ -128,9 +129,19 @@ export class ExperimentConditionsSectionCardComponent implements OnInit { .openEditConditionWeightsModal(conditions, experiment.weightingMethod) .subscribe((result: ConditionWeightUpdate[] | undefined) => { if (result) { - // Update the experiment with new condition weights this.experimentService.updateExperimentConditionWeights(experiment, result); } }); } + + onEditprior(conditions: ExperimentCondition[], experiment: ExperimentVM): void { + const existingprior = experiment.moocletPolicyParameters?.prior; + this.dialogService + .openEditConditionpriorModal(conditions, existingprior) + .subscribe((result: Record | undefined) => { + if (result) { + this.experimentService.updateExperimentConditionprior(experiment, result); + } + }); + } } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.html index 061124d8c..e3ca6b532 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.html @@ -71,6 +71,52 @@ + + + + {{ CONDITION_TRANSLATION_KEYS.prior_SUCCESSES | translate }} + + + {{ getprioruccesses(condition) }} + + + + + + + {{ CONDITION_TRANSLATION_KEYS.prior_FAILURES | translate }} + + + {{ getPriorFailures(condition) }} + + + + + + + @if (showActions && isMoocletExperiment) { +
+ +
+ } + + + + +
+ diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.scss index 472e6f47c..d03a8501b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.scss @@ -73,7 +73,14 @@ text-align: right; } - .weight-edit-column { + .prior-column { + width: 10%; + min-width: 72px; + text-align: right; + } + + .weight-edit-column, + .prior-edit-column { width: 10%; text-align: left; @@ -92,6 +99,10 @@ } } + .prior-edit-column { + padding-left: 20px; + } + .description-column { width: 40%; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts index c2107def0..c606bdfcc 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts @@ -13,6 +13,7 @@ import { EXPERIMENT_ROW_ACTION, } from '../../../../../../../../core/experiments/store/experiments.model'; import { SharedModule } from '../../../../../../../../shared/shared.module'; +import { Prior } from 'upgrade_types'; @Component({ selector: 'app-experiment-conditions-table', @@ -37,20 +38,35 @@ export class ExperimentConditionsTableComponent { @Input() actionsDisabled?: boolean = false; @Input() actionsTooltip?: string = ''; @Input() isMoocletExperiment = false; + @Input() prior?: Record; @Output() rowAction = new EventEmitter(); @Output() editWeights = new EventEmitter(); + @Output() editPrior = new EventEmitter(); - displayedColumns: string[] = ['condition', 'weight', 'weightEdit', 'description', 'actions']; - - // Make enum accessible in template + get displayedColumns(): string[] { + if (this.isMoocletExperiment) { + return ['condition', 'priorSuccesses', 'priorFailures', 'priorEdit', 'description', 'actions']; + } + return ['condition', 'weight', 'weightEdit', 'description', 'actions']; + } CONDITION_TRANSLATION_KEYS = { CONDITION: 'experiments.details.conditions.condition.text', DESCRIPTION: 'experiments.details.conditions.description.text', WEIGHT: 'experiments.details.conditions.weight.text', + PRIOR_SUCCESSES: 'experiments.details.conditions.prior-successes.text', + PRIOR_FAILURES: 'experiments.details.conditions.prior-failures.text', ACTIONS: 'experiments.details.conditions.actions.text', }; + getPriorSuccesses(condition: ExperimentCondition): number { + return this.prior?.[condition.conditionCode]?.success ?? 1; + } + + getPriorFailures(condition: ExperimentCondition): number { + return this.prior?.[condition.conditionCode]?.failure ?? 1; + } + onEditButtonClick(condition: ExperimentCondition): void { this.rowAction.emit({ action: EXPERIMENT_ROW_ACTION.EDIT, condition }); } @@ -62,4 +78,8 @@ export class ExperimentConditionsTableComponent { onEditWeightsClick(): void { this.editWeights.emit(this.conditions); } + + onEditPriorClick(): void { + this.editPrior.emit(this.conditions); + } } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-enrollment-data-section-card/enrollment-condition-table/enrollment-condition-expandable-row/enrollment-condition-expandable-row.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-enrollment-data-section-card/enrollment-condition-table/enrollment-condition-expandable-row/enrollment-condition-expandable-row.component.html index 96f5e3763..d09cc9b7c 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-enrollment-data-section-card/enrollment-condition-table/enrollment-condition-expandable-row/enrollment-condition-expandable-row.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-enrollment-data-section-card/enrollment-condition-table/enrollment-condition-expandable-row/enrollment-condition-expandable-row.component.html @@ -4,7 +4,9 @@ @for (key of displayedColumns; track key) { - {{ key.includes('Icon') ? '' : columnHeaders[key] }} + {{ key.includes('Icon') || (key === 'weight' && isMoocletExperiment(experiment)) ? '' : columnHeaders[key] }} - + {{ 'experiments.details.posteriors.successes.text' | translate }} - + {{ row.successes }} + + + + {{ 'experiments.details.posteriors.prior.text' | translate }} + + ({{ row.priorSuccess ?? 1 }}) + + + + + + {{ 'experiments.details.posteriors.posterior.text' | translate }} + + + {{ row.successes + (row.priorSuccess ?? 1) }} + + + - + {{ 'experiments.details.posteriors.failures.text' | translate }} - + {{ row.failures }} - - - - {{ 'experiments.details.posteriors.total.text' | translate }} + + + + {{ 'experiments.details.posteriors.prior.text' | translate }} - - {{ row.total }} - + ({{ row.priorFailure ?? 1 }}) - - - - {{ 'experiments.details.posteriors.successRate.text' | translate }} + + + + {{ 'experiments.details.posteriors.posterior.text' | translate }} - - {{ row.successRate }} + + {{ row.failures + (row.priorFailure ?? 1) }} - - - - + + + + {{ 'experiments.details.posteriors.estimated-weight.text' | translate }} + + + {{ row.estimatedWeight != null ? '≈' + row.estimatedWeight + '%' : '—' }} + diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.scss index 74e799edc..25fb221df 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.scss @@ -63,31 +63,33 @@ } .condition-column { - width: 350px; text-align: left; padding-left: 32px; + width: 8%; } - .successes-column, - .failures-column, - .total-column, - .success-rate-column, - .spacer-column { - width: auto; + .prior-column, + .posterior-column { + width: 6%; + } + + .count-column { + width: 20%; + } + + .count-column, + .prior-column, + .posterior-column, + .estimated-weight-column { text-align: right; - padding-right: 16px; } &.no-data .condition-column { width: auto; } - .successes-column { - width: 64px; - } - - .spacer-column { - min-width: 64px; + .prior-column { + color: var(--darker-grey); } } } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.ts index 579ace171..ccad7317b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-reward-feedback-section-card/ts-configurable-reward-count-table/ts-configurable-reward-count-table.component.ts @@ -2,13 +2,14 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatTableModule } from '@angular/material/table'; import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; -import { ExperimentRewardsSummary } from 'upgrade_types'; +import { ExperimentRewardsByCondition, ExperimentRewardsSummary } from 'upgrade_types'; @Component({ selector: 'app-ts-configurable-reward-count-table', standalone: true, - imports: [CommonModule, MatTableModule, MatProgressBarModule, TranslateModule], + imports: [CommonModule, MatTableModule, MatProgressBarModule, MatTooltipModule, TranslateModule], templateUrl: './ts-configurable-reward-count-table.component.html', styleUrl: './ts-configurable-reward-count-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -17,5 +18,20 @@ export class TSConfigurableRewardCountTableComponent { @Input() dataSource: ExperimentRewardsSummary = []; @Input() isLoading = false; - displayedColumns = ['conditionCode', 'successes', 'failures', 'total', 'successRate', 'spacer']; + displayedColumns = [ + 'conditionCode', + 'successes', + 'successPrior', + 'successPosterior', + 'failures', + 'failurePrior', + 'failurePosterior', + 'estimatedWeight', + ]; + + getEstimatedWeightTooltip(row: ExperimentRewardsByCondition): string { + const alpha = row.posteriorSuccesses ?? 0; + const beta = row.posteriorFailures ?? 0; + return `α: posteriors(${alpha})\nβ: posteriors(${beta})\nbeta(${alpha}, ${beta})`; + } } diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index 8784b655c..3cbc07f41 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -61,6 +61,11 @@ import { ConditionWeightUpdate, EditConditionWeightsModalComponent, } from '../../features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; +import { + ConditionPriorUpdate, + EditConditionpriorModalComponent, +} from '../../features/dashboard/experiments/modals/edit-condition-prior-modal/edit-condition-prior-modal.component'; +import { Prior } from 'upgrade_types'; import { EditPayloadModalComponent, EditPayloadModalParams, @@ -613,6 +618,35 @@ export class DialogService { return dialogRef.afterClosed(); } + openEditConditionpriorModal( + conditions: ExperimentCondition[], + existingprior?: Record + ): Observable> { + const conditionPriorUpdates: ConditionPriorUpdate[] = conditions.map((condition) => ({ + conditionCode: condition.conditionCode, + successes: existingprior?.[condition.conditionCode]?.success ?? 1, + failures: existingprior?.[condition.conditionCode]?.failure ?? 1, + })); + + const dialogRef = this.dialog.open(EditConditionpriorModalComponent, { + panelClass: ['experiment-modal', 'modal-shadow'], + hasBackdrop: true, + autoFocus: false, + restoreFocus: false, + backdropClass: 'modal-backdrop', + width: ModalSize.STANDARD, + data: { + title: 'experiments.edit-condition-prior-modal.title.text', + primaryActionBtnLabel: 'Save', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { conditions: conditionPriorUpdates }, + }, + }); + + return dialogRef.afterClosed(); + } + openAddListModal(appContext: string, segmentId: string, segmentType: SEGMENT_TYPE) { const commonModalConfig: CommonModalConfig = { title: segmentType === SEGMENT_TYPE.GLOBAL_EXCLUDE ? 'Add Exclude List' : 'Add List', diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 0579a1445..80eac14b4 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -504,6 +504,9 @@ "experiments.details.conditions.weight-adaptive-tooltip.text": "Adaptive experiments do not have static weights. Initial weights can be configured via the adaptive algorithm parameters. See GitBook documentation.", "experiments.details.conditions.description.text": "Description", "experiments.details.conditions.actions.text": "Actions", + "experiments.details.conditions.prior-successes.text": "Prior Successes", + "experiments.details.conditions.prior-failures.text": "Prior Failures", + "experiments.details.enrollment.estimated-weight.text": "Est. Weight", "experiments.details.conditions.card.no-data-row.text": "No conditions defined. Simple experiments require conditions.", "experiments.upsert-condition-modal.condition-hint.text": "The name of the condition that will be used to identify the variant in your code.", "experiments.upsert-condition-modal.condition-warning.text": "Condition name must be unique within the experiment.", @@ -572,9 +575,11 @@ "experiments.details.export-metrics-data.menu-item.text": "Export Metrics Data", "experiments.details.posteriors.condition.text": "Condition", "experiments.details.posteriors.successes.text": "Successes", + "experiments.details.posteriors.prior.text": "Prior", + "experiments.details.posteriors.posterior.text": "Posterior", "experiments.details.posteriors.failures.text": "Failures", - "experiments.details.posteriors.successRate.text": "Success Rate", - "experiments.details.posteriors.total.text": "Total Rewards", + "experiments.details.posteriors.estimated-weight.text": "Est. Weight", + "experiments.details.posteriors.estimated-weight-header-tooltip.text": "Estimated probability that this condition wins the next Thompson sampling draw. A random sample is drawn from each condition's posterior Beta distribution — Beta(successes, failures) — and the condition with the highest sample wins. Estimate is percentage of wins after 10,000 draws.", "experiments.details.posteriors.percentOfTotalRewards.text": "% of All Rewards Received", "experiments.details.posteriors.no-data-row.text": "Experiment has not begun to collect feedback.", "experiments.upsert-include-list-modal.name-hint.text": "The name for this include list.", @@ -594,6 +599,14 @@ "experiments.edit-condition-weights-modal.equal-assignment-weights.description.text": "Equally distribute weight percentages across all conditions.", "experiments.edit-condition-weights-modal.custom-percentages.label.text": "Custom Percentages", "experiments.edit-condition-weights-modal.custom-percentages.description.text": "Define a custom weight percentage for each condition.", + "experiments.edit-condition-prior-modal.title.text": "Edit Condition prior", + "experiments.edit-condition-prior-modal.condition-header.text": "Condition", + "experiments.edit-condition-prior-modal.successes-header.text": "Successes", + "experiments.edit-condition-prior-modal.failures-header.text": "Failures", + "experiments.edit-condition-prior-modal.min-error.text": "Value must be at least 1.", + "experiments.edit-condition-prior-modal.max-error.text": "Value exceeds the maximum allowed.", + "experiments.edit-condition-prior-modal.integer-error.text": "Value must be a whole number.", + "experiments.edit-condition-prior-modal.no-data.text": "No conditions available.", "feature-flags.global-name.text": "Name", "feature-flags.global-status.text": "Status", "feature-flags.global-updated-at.text": "Updated at", diff --git a/packages/types/src/Mooclet/MoocletTSConfigurablePolicyParametersDTO.ts b/packages/types/src/Mooclet/MoocletTSConfigurablePolicyParametersDTO.ts index 7a8915e48..ef6f229bc 100644 --- a/packages/types/src/Mooclet/MoocletTSConfigurablePolicyParametersDTO.ts +++ b/packages/types/src/Mooclet/MoocletTSConfigurablePolicyParametersDTO.ts @@ -25,10 +25,11 @@ export class CurrentPosteriors { } export class MoocletTSConfigurablePolicyParametersDTO extends MoocletPolicyParametersDTO { - @IsDefined() - @ValidateNested() + @IsOptional() + @IsObject() + @ValidateNested({ each: true }) @Type(() => Prior) - prior: Prior = new Prior(); + prior?: Record; @IsOptional() @IsObject() diff --git a/packages/types/src/Mooclet/index.ts b/packages/types/src/Mooclet/index.ts index 982ef063d..ea41ff776 100644 --- a/packages/types/src/Mooclet/index.ts +++ b/packages/types/src/Mooclet/index.ts @@ -27,9 +27,13 @@ interface ExperimentRewardsByCondition { conditionCode: string; successes: number; failures: number; - total: number; successRate: string; order: number; + estimatedWeight?: number; + priorSuccess?: number; + priorFailure?: number; + posteriorSuccesses?: number; + posteriorFailures?: number; } type ExperimentRewardsSummary = Array; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d603299ee..c81fb464f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -91,6 +91,8 @@ export { ExperimentQueryComparator, } from './Experiment/interfaces'; export { + Prior, + CurrentPosteriors, MoocletPolicyParametersDTO, MoocletTSConfigurablePolicyParametersDTO, MOOCLET_POLICY_SCHEMA_MAP,