Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c460aab
removed mooclet infrastructure, implemented native thompson-sampling …
danoswaltCL Jul 8, 2026
9e0be52
ensure priors won't be silently dropped on update
danoswaltCL Sep 1, 2026
7ac9488
removed unused import
danoswaltCL Sep 1, 2026
5c50be0
use utility that already exists
danoswaltCL Sep 1, 2026
8de23e8
refine how reward counts are used, actually wire up batch-size
danoswaltCL Sep 2, 2026
e64d675
use experiments cache in reward path, send immediate receipt response…
danoswaltCL Sep 3, 2026
f0cd8a9
tidying up inconsistencies and DRY-able opportunities
danoswaltCL Sep 4, 2026
e86c667
Change reward endpoint to acceptReward in skill docs
danoswaltCL Sep 4, 2026
15efa69
a small abstraction to anticipate adding new algorithm type configura…
danoswaltCL Sep 4, 2026
f1bc4d2
fix 8 live issues from Copilot code review: algorithm-transition conf…
danoswaltCL Sep 4, 2026
2ca97d7
handle bad prior id mapping, fix sync edge case
danoswaltCL Sep 9, 2026
2453c2b
fix lint
danoswaltCL Sep 9, 2026
4378f55
fix: key condition priors by conditionId, not conditionCode
danoswaltCL Sep 10, 2026
cd0542e
fix: non-nullable TS config defaults, drop stale uniform_random enum …
danoswaltCL Sep 10, 2026
0a7d0a9
fix: Thompson Sampling /mark trusts client-reported condition; exclud…
danoswaltCL Sep 11, 2026
ee764f1
feat: reward feedback card shows batch/warmup progress, header alignm…
danoswaltCL Sep 12, 2026
1bc2262
include priors on imports, tighten edge-cases
danoswaltCL Sep 14, 2026
722f21d
Throw error if posterior state no longer exists
danoswaltCL Sep 14, 2026
058f648
add adaptive-quicktest and a fix for warmup of zero semantics
danoswaltCL Sep 14, 2026
70bb630
chore: trigger CI
danoswaltCL Sep 14, 2026
fe7e703
fix priors data missing after update, guard against priors changes in…
danoswaltCL Sep 16, 2026
f05df20
Change LOCAL port from 3032 to 3030
danoswaltCL Sep 16, 2026
cb4ee38
Optimize reward flushing with Promise.all
danoswaltCL Sep 16, 2026
2c54cae
Fix condition to check updated experiment's algorithm
danoswaltCL Sep 16, 2026
6c78f34
fix test fail
danoswaltCL Sep 16, 2026
5cf555a
do not allow switching to or from adaptive experiments in edit mode, …
danoswaltCL Sep 17, 2026
6c6a779
Merge remote-tracking branch 'origin/dev' into poc/native-thompson-sa…
danoswaltCL Sep 21, 2026
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 .claude/skills/setup-perftrace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ file they live in. Verify against the branch rather than trusting this table if
| `PATCH /v6/groupmembership` | `experimentUserService.updateGroupMembership` | controller |
| `PATCH /v6/workinggroup` | `experimentUserService.updateWorkingGroup` | controller |
| `PATCH /v6/useraliases` | `experimentUserService.setAliasesForUser(aliases=N)` | controller |
| `POST /v6/reward` | `moocletRewardsService.sendReward` | controller |
| `POST /v6/reward` | `thompsonSamplingRewardService.acceptReward` | controller |
| `POST /v6/mark` | `experimentAssignmentService.markExperimentPoint` | controller |
| " | 8 spans inside `markExperimentPoint` — `previewUserService.findOneFromCache`, `getCachedExperiments`, `checkUserOrGroupIsGloballyExcluded`, `experimentLevelExclusionInclusion`, `monitoredDecisionPointRepository.findOne`, `saveGroupExclusionDoc`, `updateEnrollmentExclusionDocumentsAndCheckEndingCriteria`, `monitoredDecisionPointRepository.saveRawJson` | `services/ExperimentAssignmentService.ts` |
| `POST /v6/assign` | `formatAssignments` (sync) | controller |
Expand Down
127 changes: 127 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion clientlibs/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"docs:markdown": "typedoc --options typedoc.json",
"test": "jest",
"test:coverage": "jest --coverage",
"quicktest": "([ -d dist ] || yarn build) && ts-node quickTest.ts"
"quicktest": "([ -d dist ] || yarn build) && ts-node quickTest.ts",
"quicktest:adaptive": "([ -d dist ] || yarn build) && ts-node quickTestAdaptive.ts"
},
"keywords": [],
"author": "",
Expand Down
218 changes: 218 additions & 0 deletions clientlibs/js/quickTestAdaptive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// to run: npx ts-node clientlibs/js/quickTestAdaptive.ts
//
// Manual smoke test for Thompson Sampling (adaptive) experiments end-to-end:
// 1. Creates a real experiment via the admin API (POST /experiments) and starts enrollment.
// 2. Simulates a batch of synthetic users each calling /v6/init -> /v6/assign -> /v6/mark ->
// /v6/reward, the same sequence a real client would.
// 3. Prints the reward summary (GET /experiments/rewards/:id) -- the same data the frontend's
// Reward Feedback card reads -- so you can watch "Pending rewards" cycle with batchSize and
// "Algorithm in Effect" flip from Random Assignment to Thompson Sampling as warmupThreshold
// is crossed, without opening a browser.
// 4. Deletes the experiment when done (see CLEANUP_AFTER_RUN below).
//
// Local dev only. See ADMIN_TOKEN below for why.

import axios, { AxiosError } from 'axios';
import UpgradeClient from './dist/node';

const URL = {
// 3030 is the standard docker-compose port (see root CLAUDE.md); a git worktree set up via
// /new-worktree auto-assigns its own port instead (check packages/backend/.env's APP_PORT) --
// update this if you're running in a worktree.
LOCAL: 'http://localhost:3030',
ECS_QA: 'https://apps.qa-cli.net/upgrade-service',
ECS_STAGING: 'https://apps.qa-cli.com/upgrade-service',
};

// -------------------------------------------------------------------------------------------
// Admin auth
// -------------------------------------------------------------------------------------------
// authorizationChecker.ts (packages/backend/src/auth/) bypasses real Google token validation
// for this exact string, attaching a dev admin user instead -- but only when the target
// server's GOOGLE_AUTH_TOKEN_REQUIRED env var is false (check packages/backend/.env; this is
// this worktree's current local setting, not a given for every environment). This will NOT
// work against a real deployed server. Value must match FAKE_DEV_CREDENTIAL in
// packages/types/src/User/index.ts -- hardcoded rather than imported from upgrade_types
// because this file runs directly under ts-node (see quickTest.ts), which doesn't resolve the
// upgrade_types path alias at runtime the way a webpack-built consumer does.
const ADMIN_TOKEN = 'fake-dev-user-google-credential';

// -------------------------------------------------------------------------------------------
// Config -- edit these to change what gets created/simulated
// -------------------------------------------------------------------------------------------
const hostUrl = URL.LOCAL;
const adminApiUrl = hostUrl + '/api';
const context = 'upgrade-internal';
const site = 'quicktest-adaptive-site';
const target = 'quicktest-adaptive-target';

// Adaptive algorithm parameters -- see packages/frontend .../thompson-sampling-helper.service.ts
// and the Reward Feedback card for how these show up in the UI.
const BATCH_SIZE = 3; // rewards buffered before posteriors update; watch "Pending rewards" cycle 0..batchSize-1
const WARMUP_THRESHOLD = 4; // reward-count gate before real TS sampling kicks in; watch "Algorithm in Effect" flip
const MINIMUM_DRAW_DIFFERENCE = 0;

const CONDITIONS = [
{ tempId: 'quicktest-cond-control', conditionCode: 'control', priorSuccess: 1, priorFailure: 1 },
{ tempId: 'quicktest-cond-variant', conditionCode: 'variant', priorSuccess: 1, priorFailure: 1 },
];

const NUM_SIMULATED_USERS = 10;
// Each simulated user's reward outcome. A fixed pattern by default so runs are reproducible --
// swap in `Math.random() < 0.7 ? 'SUCCESS' : 'FAILURE'` if you want noisy data instead.
function rewardForUser(index: number): 'SUCCESS' | 'FAILURE' {
return index % 3 === 0 ? 'FAILURE' : 'SUCCESS';
}

const CLEANUP_AFTER_RUN = false; // delete the created experiment when the script finishes

// -------------------------------------------------------------------------------------------

const adminClient = axios.create({
baseURL: adminApiUrl,
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
});

quickTestAdaptive();

/** main test *******************************************************************************/
async function quickTestAdaptive() {
const experiment = await createAdaptiveExperiment();
if (!experiment) return;

console.log(`\n[Created experiment]: ${experiment.id} (${experiment.name})`);
console.log(
'[Conditions]:',
experiment.conditions.map((c: { conditionCode: string; id: string }) => `${c.conditionCode}=${c.id}`).join(', ')
);

await setExperimentState(experiment.id, 'enrolling');
console.log('[Experiment state]: enrolling');

for (let i = 0; i < NUM_SIMULATED_USERS; i++) {
await simulateUser(i, experiment.id);
}

// /v6/reward is fire-and-forget (POST /v6/reward acknowledges before the DB write happens --
// see ThompsonSamplingRewardService.acceptReward()), so give the background processing a beat
// to finish before reading the summary back, or the last few rewards may not show up yet.
await sleep(1000);

await printRewardsSummary(experiment.id);

if (CLEANUP_AFTER_RUN) {
await deleteExperiment(experiment.id);
console.log(`\n[Cleaned up]: deleted experiment ${experiment.id}`);
} else {
console.log(`\n[Left in place]: experiment ${experiment.id} -- delete manually when done.`);
}
}

/** admin API calls (experiment CRUD) *******************************************************/

async function createAdaptiveExperiment(): Promise<{
id: string;
name: string;
conditions: { id: string; conditionCode: string }[];
} | null> {
const payload = {
name: `quicktest-adaptive-${Date.now()}`,
description: 'Created by clientlibs/js/quickTestAdaptive.ts -- safe to delete.',
context: [context],
state: 'inactive',
consistencyRule: 'individual',
assignmentUnit: 'individual',
postExperimentRule: 'continue',
tags: ['quicktest'],
filterMode: 'includeAll', // excludeAll would exclude every user unless individually/group included via a segment
type: 'Simple',
assignmentAlgorithm: 'thompson_sampling',
// Conditions/partitions need a client-supplied id even though the server regenerates its
// own -- ExperimentService.create() remaps thompsonSamplingConfig.priors from these ids onto
// the server-generated ones automatically (see ThompsonSamplingExperimentCrudService).
conditions: CONDITIONS.map((c, index) => ({
id: c.tempId,
name: c.conditionCode,
description: '',
conditionCode: c.conditionCode,
assignmentWeight: 100 / CONDITIONS.length, // ignored for Thompson Sampling, but required by the DTO
order: index + 1,
})),
partitions: [{ id: 'quicktest-adaptive-dp-1', site, target, description: '', order: 1, excludeIfReached: false }],
thompsonSamplingConfig: {
warmupThreshold: WARMUP_THRESHOLD,
batchSize: BATCH_SIZE,
minimumDrawDifference: MINIMUM_DRAW_DIFFERENCE,
priors: Object.fromEntries(
CONDITIONS.map((c) => [c.tempId, { success: c.priorSuccess, failure: c.priorFailure }])
),
},
};

try {
const response = await adminClient.post('/experiments', payload);
return response.data;
} catch (error) {
logAxiosError('Create experiment', error);
return null;
}
}

async function setExperimentState(experimentId: string, state: string): Promise<void> {
try {
await adminClient.post('/experiments/state', { experimentId, state });
} catch (error) {
logAxiosError('Set experiment state', error);
}
}

async function printRewardsSummary(experimentId: string): Promise<void> {
try {
const response = await adminClient.get(`/experiments/rewards/${experimentId}`);
console.log('\n[Rewards summary]:', JSON.stringify(response.data, null, 2));
} catch (error) {
logAxiosError('Rewards summary', error);
}
}

async function deleteExperiment(experimentId: string): Promise<void> {
try {
await adminClient.delete(`/experiments/${experimentId}`);
} catch (error) {
logAxiosError('Delete experiment', error);
}
}

/** simulated user flow (client SDK, same as a real client would call) **********************/

async function simulateUser(index: number, experimentId: string): Promise<void> {
const userId = `quicktest_adaptive_user_${Date.now()}_${index}`;
const client = new UpgradeClient(userId, hostUrl, context);

try {
await client.init();

const assignment = await client.getDecisionPointAssignment(site, target);
const condition = assignment.getCondition();
await assignment.markDecisionPoint(UpgradeClient.MARKED_DECISION_POINT_STATUS.CONDITION_APPLIED);

const rewardValue = rewardForUser(index);
await client.sendReward({ rewardValue, experimentId });

console.log(`[User ${index}]: condition=${condition} reward=${rewardValue}`);
} catch (error) {
logAxiosError(`User ${index}`, error);
}
}

/** utility functions *************************************************************************/

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function logAxiosError(functionContext: string, error: unknown): void {
const axiosError = error as AxiosError;
const data = axiosError?.response?.data;
console.error(`\n[${functionContext} error]:`, data ?? axiosError?.message ?? error);
}
2 changes: 1 addition & 1 deletion clientlibs/js/src/UpGradeClient/UpgradeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export default class UpgradeClient {
}

/**
* Sends a binary reward signal for an adaptive experiment (Mooclet).
* Sends a binary reward signal for an adaptive experiment.
*
* This method allows sending reward feedback (SUCCESS or FAILURE) for adaptive experiments.
* The reward is used by the adaptive algorithm to update its learning model and improve future assignments.
Expand Down
7 changes: 0 additions & 7 deletions clientlibs/js/src/types/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,6 @@ export namespace UpGradeClientInterfaces {
context?: string;
decisionPoint?: IDecisionPoint;
};
reward: {
variable: string;
value: number;
mooclet: number;
version: number;
learner: string;
};
}

export interface IHttpClientWrapperRequestConfig {
Expand Down
2 changes: 1 addition & 1 deletion clientlibs/python/BUILD_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class BinaryRewardValue(str, Enum):
- `MarkDecisionPointResponse` — userId, site, target, experimentId
- `LogEventResponse` — id, uniquifier, timeStamp, data
- `UserAliasResponse` — userId, aliases
- `SendRewardResponse` — message, request (rewardValue, experimentId, context, decisionPoint), reward (variable, value, mooclet, version, learner)
- `SendRewardResponse` — message, request (rewardValue, experimentId, context, decisionPoint)
- `ErrorResponse` — message, httpStatusCode, type

### Deliverable
Expand Down
2 changes: 1 addition & 1 deletion clientlibs/python/src/upgrade_client_lib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ async def send_reward(
context: str | None = None,
decision_point: dict[str, str] | None = None,
) -> SendRewardResponse:
"""Send a binary reward signal for an adaptive (Mooclet) experiment."""
"""Send a binary reward signal for an adaptive experiment."""
return await self._api_service.send_reward(
reward_value=reward_value,
experiment_id=experiment_id,
Expand Down
2 changes: 0 additions & 2 deletions clientlibs/python/src/upgrade_client_lib/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
LogEventResponse,
MarkDecisionPointResponse,
Payload,
RewardDetails,
RewardRequest,
SendRewardResponse,
UserAliasResponse,
Expand Down Expand Up @@ -71,7 +70,6 @@
"LogEventResponse",
"MarkDecisionPointResponse",
"Payload",
"RewardDetails",
"RewardRequest",
"SendRewardResponse",
"UserAliasResponse",
Expand Down
9 changes: 0 additions & 9 deletions clientlibs/python/src/upgrade_client_lib/types/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,9 @@ class RewardRequest(BaseModel):
decisionPoint: dict[str, str] | None = None


class RewardDetails(BaseModel):
variable: str
value: float
mooclet: int
version: int
learner: str


class SendRewardResponse(BaseModel):
message: str
request: RewardRequest
reward: RewardDetails


class ErrorResponse(BaseModel):
Expand Down
5 changes: 2 additions & 3 deletions clientlibs/python/tests/test_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,6 @@ async def test_sends_aliases(self) -> None:
REWARD_RESPONSE = {
"message": "Reward sent",
"request": {"rewardValue": "SUCCESS", "experimentId": "exp-1"},
"reward": {"variable": "score", "value": 1.0, "mooclet": 42, "version": 3, "learner": USER_ID},
}


Expand All @@ -416,13 +415,13 @@ async def test_async_minimal(self) -> None:
respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_RESPONSE))
result = await make_service().send_reward(BinaryRewardValue.SUCCESS)
assert result.message == "Reward sent"
assert result.reward.variable == "score"
assert result.request.experimentId == "exp-1"

@respx.mock
def test_sync(self) -> None:
respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_RESPONSE))
result = make_service().send_reward_sync(BinaryRewardValue.SUCCESS)
assert result.reward.mooclet == 42
assert result.message == "Reward sent"

@respx.mock
async def test_full_params(self) -> None:
Expand Down
5 changes: 2 additions & 3 deletions clientlibs/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
REWARD_PAYLOAD = {
"message": "ok",
"request": {"rewardValue": "SUCCESS"},
"reward": {"variable": "score", "value": 1.0, "mooclet": 1, "version": 1, "learner": USER},
}


Expand Down Expand Up @@ -596,7 +595,7 @@ async def test_async_success(self) -> None:
respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_PAYLOAD))
result = await make_client().send_reward(BinaryRewardValue.SUCCESS)
assert result.message == "ok"
assert result.reward.variable == "score"
assert result.request.rewardValue == BinaryRewardValue.SUCCESS

@respx.mock
async def test_passes_all_params(self) -> None:
Expand All @@ -617,4 +616,4 @@ async def test_passes_all_params(self) -> None:
def test_sync(self) -> None:
respx.post(f"{BASE}/reward").mock(return_value=Response(200, json=REWARD_PAYLOAD))
result = make_client().send_reward_sync(BinaryRewardValue.SUCCESS)
assert result.reward.mooclet == 1
assert result.message == "ok"
9 changes: 0 additions & 9 deletions packages/backend/.env.docker.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,6 @@ EMAIL_EXPIRE_AFTER_SECONDS=36000
EMAIL_BUCKET="s3_bucket"
EMAIL_TMP_WRITEABLE_FILE_PATH="src/api/assets/files/"

#
# Mooclets
#

MOOCLETS_ENABLED = false
MOOCLETS_HOST_URL = mooclet_host_url
MOOCLETS_API_ROUTE = /engine/api/v1
MOOCLETS_API_TOKEN = some_token

#
# Initialization
#
Expand Down
9 changes: 0 additions & 9 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,6 @@ EMAIL_EXPIRE_AFTER_SECONDS=36000
EMAIL_BUCKET="s3_bucket"
EMAIL_TMP_WRITEABLE_FILE_PATH="src/api/assets/files/"

#
# Mooclets
#

MOOCLETS_ENABLED=false
MOOCLETS_HOST_URL=mooclet_host_url
MOOCLETS_API_ROUTE=/engine/api/v1
MOOCLETS_API_TOKEN=some_token

#
# Initialization
#
Expand Down
Loading