Skip to content

Commit bf5b10b

Browse files
authored
Merge pull request #419 from topcoder-platform/develop
Updates for relative scoring score hydration
2 parents 0d98c0b + 8c2df56 commit bf5b10b

6 files changed

Lines changed: 642 additions & 51 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ Config identity semantics:
300300
- `POST /challenge/:challengeId` validates that the challenge exists in challenge-api and that `reviewScorecardId` resolves in review-api before persisting. If a config already exists for the challenge, the API returns `409 Conflict`.
301301
- `phaseConfig.phaseId` stores the canonical challenge phase definition id from challenge-api `phases[].phaseId`. Create/update requests also accept the challenge-phase row `id` and normalize it before persistence for backwards compatibility.
302302
- `reviewScorecardId` can be either the current review-api scorecard id or a legacy id; scoring callback processing resolves it to the canonical scorecard id before posting review summations.
303-
- When `relativeScoringEnabled = true`, review scores are recomputed from per-test raw scores against the best score currently held by the latest submission from each member, so final review summation scores stay within `0..100` and can change as new submissions arrive. The raw callback result is stored only as a pending placeholder until the recompute worker writes the normalized score; queued recompute passes also finalize visible pending placeholders that are not selected as the latest member submission, and fetch the queued callback submission's review summation directly from Review API.
303+
- When `relativeScoringEnabled = true`, review scores are recomputed from per-test raw scores against the best score currently held by the latest submission from each member, so final review summation scores stay within `0..100` and can change as new submissions arrive. The raw callback result is stored only as a pending placeholder until the recompute worker writes the normalized score. Recompute uses submission-api to choose the latest submission per member, hydrates phase review summation metadata directly from Review API, finalizes pending placeholders it can see, and retries instead of writing a normalized score if no latest-submission baseline with usable raw `testScores` is available.
304304

305305
Important runtime behavior:
306306

docs/Marathon Match Setup and Management.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Relative scoring applies when:
187187
- `Relative Scoring` is enabled on the scorer config.
188188
- The ECS runner callback metadata includes `testScores`.
189189

190-
For each testcase, the API finds the best raw score among the latest scored submission for each member. `scoreDirection` controls whether the best raw score is the maximum or minimum value. The stored relative testcase score is normalized to `0..100`, with zero raw or zero best scores counted as `0` for `MAXIMIZE` challenges. For `MINIMIZE` challenges, a raw score of `0` tied with the best score of `0` receives `100`. The aggregate review score is the average of those relative testcase scores.
190+
For each testcase, the API finds the best raw score among the latest submissions for members whose latest submission has usable raw score metadata for the scoring phase. `scoreDirection` controls whether the best raw score is the maximum or minimum value. The stored relative testcase score is normalized to `0..100`, with zero raw or zero best scores counted as `0` for `MAXIMIZE` challenges. For `MINIMIZE` challenges, a raw score of `0` tied with the best score of `0` receives `100`. The aggregate review score is the average of those relative testcase scores.
191191

192192
Implications:
193193

docs/review-phase-scoring.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The queue is keyed by challenge ID, review ID, and submission ID so repeated pha
8484

8585
## Relative scoring at completion
8686

87-
If `relativeScoringEnabled = true`, `ScoringResultService` persists the raw callback result as a pending, non-passing placeholder and queues a pg-boss `relative-scoring-recompute` job. The worker recalculates normalized aggregate scores under a challenge/phase PostgreSQL advisory lock, updates Review API, and then completes matching SYSTEM reviews with the recomputed scores. Each worker pass also finalizes any persisted `relativeScoringPending` placeholders it can see, even when a placeholder is not the selected latest submission for that member; the queued callback submission's review summation is fetched directly from Review API so reruns omitted from the challenge submission list still complete. `ChallengeCompletionService.finalizeChallenge(...)` consumes those persisted review summaries; it does not recompute relative scoring itself.
87+
If `relativeScoringEnabled = true`, `ScoringResultService` persists the raw callback result as a pending, non-passing placeholder and queues a pg-boss `relative-scoring-recompute` job. The worker recalculates normalized aggregate scores under a challenge/phase PostgreSQL advisory lock, updates Review API, and then completes matching SYSTEM reviews with the recomputed scores. It uses submission-api to select the latest submission for each member, then hydrates phase review summation metadata directly from Review API so incomplete embedded `reviewSummation` expansions do not shrink the baseline set. Each worker pass also finalizes any persisted `relativeScoringPending` placeholders it can see, even when a placeholder is not the selected latest submission for that member; the queued callback submission's review summation is fetched directly from Review API so reruns omitted from the challenge submission list still complete. If no latest-submission baseline has usable raw `testScores`, the job retries instead of writing a self-normalized score. `ChallengeCompletionService.finalizeChallenge(...)` consumes those persisted review summaries; it does not recompute relative scoring itself.
8888

8989
The recompute queue debounces SYSTEM bursts by challenge and phase so simultaneous scorer callbacks do not all run the expensive fan-out. Retry behavior is controlled by `RELATIVE_SCORING_RECOMPUTE_RETRY_DELAY_SECONDS` (default `60`), `RELATIVE_SCORING_RECOMPUTE_RETRY_LIMIT` (default `1000`), `RELATIVE_SCORING_RECOMPUTE_DEBOUNCE_SECONDS` (default `15`), `RELATIVE_SCORING_RECOMPUTE_START_DELAY_SECONDS` (default `10`), and `RELATIVE_SCORING_RECOMPUTE_WORKER_CONCURRENCY` (default `1`).
9090

docs/submission-phase-scoring.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Relative scoring applies when:
112112
- `relativeScoringEnabled = true` on the Marathon Match config
113113
- `testScores` are present in the scorer metadata
114114

115-
In that case, `ScoringResultService` writes the raw callback result as a pending, non-passing placeholder, then queues a pg-boss relative scoring recomputation. The worker recalculates latest-submission review scores relative to the current best result, updates the persisted aggregate scores, and keeps existing `reviewedDate` values so relative-score updates do not move historical review timestamps. A worker pass also finalizes visible `relativeScoringPending` placeholders that are not selected as the latest member submission, and fetches the queued callback submission's review summation directly from Review API so completed reruns do not remain in progress.
115+
In that case, `ScoringResultService` writes the raw callback result as a pending, non-passing placeholder, then queues a pg-boss relative scoring recomputation. The worker selects the latest submission for each member from submission-api, hydrates the phase review summation metadata directly from Review API, recalculates review scores relative to the current best raw testcase results, updates the persisted aggregate scores, and keeps existing `reviewedDate` values so relative-score updates do not move historical review timestamps. A worker pass also finalizes visible `relativeScoringPending` placeholders that are not selected as the latest member submission, and fetches the queued callback submission's review summation directly from Review API so completed reruns do not remain in progress. If no latest-submission baseline has usable raw `testScores`, the job retries instead of writing a self-normalized score.
116116

117117
## Tester-change rerun
118118

src/api/scoring-result/scoring-result.service.spec.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ describe('ScoringResultService', () => {
198198
challengeId: true,
199199
name: true,
200200
submissionApiUrl: true,
201+
reviewScorecardId: true,
201202
relativeScoringEnabled: true,
202203
scoreDirection: true,
203204
},
@@ -500,6 +501,22 @@ describe('ScoringResultService', () => {
500501
reviewSummation: [],
501502
},
502503
]);
504+
jest
505+
.spyOn(service as any, 'fetchChallengeReviewSummations')
506+
.mockResolvedValue([
507+
{
508+
id: 'summation-2',
509+
isProvisional: true,
510+
metadata: {
511+
testType: 'provisional',
512+
testScores: [
513+
{ testcase: '1', score: 0 },
514+
{ testcase: '2', score: 0 },
515+
],
516+
},
517+
submissionId: 'submission-2',
518+
},
519+
]);
503520
const upsertReviewSummationSpy = jest
504521
.spyOn(service as any, 'upsertReviewSummation')
505522
.mockResolvedValue(undefined);
@@ -756,6 +773,12 @@ describe('ScoringResultService', () => {
756773
reviewSummation: [reviewFor('submission-b', 45)],
757774
},
758775
]);
776+
jest
777+
.spyOn(service as any, 'fetchChallengeReviewSummations')
778+
.mockResolvedValue([
779+
{ ...reviewFor('submission-a', 90), submissionId: 'submission-a' },
780+
{ ...reviewFor('submission-b', 45), submissionId: 'submission-b' },
781+
]);
759782
const upsertReviewSummationSpy = jest
760783
.spyOn(service as any, 'upsertReviewSummation')
761784
.mockResolvedValue(undefined);
@@ -893,6 +916,22 @@ describe('ScoringResultService', () => {
893916
reviewSummation: [reviewFor('submission-other', 80)],
894917
},
895918
]);
919+
jest
920+
.spyOn(service as any, 'fetchChallengeReviewSummations')
921+
.mockResolvedValue([
922+
{
923+
...reviewFor('submission-newer', 100),
924+
submissionId: 'submission-newer',
925+
},
926+
{
927+
...reviewFor('submission-old-pending', 50, true),
928+
submissionId: 'submission-old-pending',
929+
},
930+
{
931+
...reviewFor('submission-other', 80),
932+
submissionId: 'submission-other',
933+
},
934+
]);
896935
const upsertReviewSummationSpy = jest
897936
.spyOn(service as any, 'upsertReviewSummation')
898937
.mockResolvedValue(undefined);
@@ -988,6 +1027,22 @@ describe('ScoringResultService', () => {
9881027
],
9891028
},
9901029
]);
1030+
jest
1031+
.spyOn(service as any, 'fetchChallengeReviewSummations')
1032+
.mockResolvedValue([
1033+
{
1034+
id: 'summation-visible-latest',
1035+
aggregateScore: 100,
1036+
isFinal: true,
1037+
metadata: {
1038+
testScores: [{ testcase: '753388858', score: 100 }],
1039+
testStatus: ScoringTestStatus.Success,
1040+
testType: 'system',
1041+
},
1042+
scorecardId: 'scorecard-1',
1043+
submissionId: 'submission-visible-latest',
1044+
},
1045+
]);
9911046
jest
9921047
.spyOn(service as any, 'findExistingReviewSummations')
9931048
.mockResolvedValue([
@@ -1059,6 +1114,129 @@ describe('ScoringResultService', () => {
10591114
);
10601115
});
10611116

1117+
it('normalizes queued system scores against Review API hydrated latest submissions', async () => {
1118+
const { service, m2mService, prisma } = createService();
1119+
1120+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
1121+
challengeId: basePayload.challengeId,
1122+
name: 'Blocks',
1123+
submissionApiUrl: 'https://api.topcoder-dev.com/v6',
1124+
relativeScoringEnabled: true,
1125+
scoreDirection: ScoreDirection.MAXIMIZE,
1126+
});
1127+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
1128+
1129+
jest
1130+
.spyOn(service as any, 'withRelativeScoringLock')
1131+
.mockImplementation(
1132+
async (
1133+
_challengeId: string,
1134+
_testPhase: string,
1135+
work: () => Promise<unknown>,
1136+
) => work(),
1137+
);
1138+
jest
1139+
.spyOn(service as any, 'resolveScorecardId')
1140+
.mockResolvedValue('scorecard-1');
1141+
jest.spyOn(service as any, 'fetchChallengeSubmissions').mockResolvedValue([
1142+
{
1143+
id: 'submission-current',
1144+
memberId: 'member-current',
1145+
isLatest: true,
1146+
submittedDate: '2026-05-01T00:00:02.000Z',
1147+
reviewSummation: [],
1148+
},
1149+
{
1150+
id: 'submission-other',
1151+
memberId: 'member-other',
1152+
isLatest: true,
1153+
submittedDate: '2026-05-01T00:00:03.000Z',
1154+
reviewSummation: [],
1155+
},
1156+
]);
1157+
jest
1158+
.spyOn(service as any, 'fetchChallengeReviewSummations')
1159+
.mockResolvedValue([
1160+
{
1161+
id: 'summation-current',
1162+
aggregateScore: 0,
1163+
isFinal: true,
1164+
metadata: {
1165+
relativeScoringPending: true,
1166+
testScores: [{ testcase: '753388858', score: 50 }],
1167+
testStatus: ScoringTestStatus.InProgress,
1168+
testType: 'system',
1169+
},
1170+
scorecardId: 'scorecard-1',
1171+
submissionId: 'submission-current',
1172+
},
1173+
{
1174+
id: 'summation-other',
1175+
aggregateScore: 100,
1176+
isFinal: true,
1177+
metadata: {
1178+
testScores: [{ testcase: '753388858', score: 100 }],
1179+
testStatus: ScoringTestStatus.Success,
1180+
testType: 'system',
1181+
},
1182+
scorecardId: 'scorecard-1',
1183+
submissionId: 'submission-other',
1184+
},
1185+
]);
1186+
const upsertReviewSummationSpy = jest
1187+
.spyOn(service as any, 'upsertReviewSummation')
1188+
.mockResolvedValue(undefined);
1189+
const completeSystemReviewIfNeededSpy = jest
1190+
.spyOn(service as any, 'completeSystemReviewIfNeeded')
1191+
.mockResolvedValue(undefined);
1192+
jest
1193+
.spyOn(service as any, 'notifyScoringCompletionEmailIfReady')
1194+
.mockResolvedValue(undefined);
1195+
1196+
await expect(
1197+
service.recomputeQueuedRelativeScoring({
1198+
challengeId: basePayload.challengeId,
1199+
submissionId: 'submission-current',
1200+
reviewId: 'review-current',
1201+
testPhase: 'system',
1202+
reviewTypeId: basePayload.reviewTypeId,
1203+
scorecardId: 'scorecard-1',
1204+
queuedAt: '2026-05-01T00:00:10.000Z',
1205+
}),
1206+
).resolves.toBe(undefined);
1207+
1208+
const currentPayload = upsertReviewSummationSpy.mock.calls.find(
1209+
([, , payload]) =>
1210+
(payload as { submissionId: string }).submissionId ===
1211+
'submission-current',
1212+
)?.[2] as
1213+
| {
1214+
aggregateScore: number;
1215+
metadata?: { relativeScores?: Array<{ score: number }> };
1216+
}
1217+
| undefined;
1218+
1219+
expect(currentPayload).toEqual(
1220+
expect.objectContaining({
1221+
aggregateScore: 50,
1222+
isPassing: true,
1223+
submissionId: 'submission-current',
1224+
}),
1225+
);
1226+
expect(currentPayload?.metadata?.relativeScores).toEqual([
1227+
{ testcase: '1', score: 50 },
1228+
]);
1229+
expect(completeSystemReviewIfNeededSpy).toHaveBeenCalledWith(
1230+
'm2m-token',
1231+
'review-current',
1232+
50,
1233+
'system',
1234+
expect.objectContaining({
1235+
submissionId: 'submission-current',
1236+
}),
1237+
);
1238+
});
1239+
10621240
it('serializes concurrent relative scoring recomputations for the same challenge phase', async () => {
10631241
const { service, m2mService, prisma } = createService();
10641242
const firstPayload: ScoringResultCallbackPayload = {
@@ -1149,6 +1327,20 @@ describe('ScoringResultService', () => {
11491327
reviewSummation: [],
11501328
},
11511329
]);
1330+
jest
1331+
.spyOn(service as any, 'fetchChallengeReviewSummations')
1332+
.mockResolvedValueOnce([])
1333+
.mockResolvedValueOnce([
1334+
{
1335+
id: 'review-a',
1336+
isProvisional: true,
1337+
metadata: {
1338+
testType: 'provisional',
1339+
testScores: [{ testcase: '1', score: 90 }],
1340+
},
1341+
submissionId: 'submission-a',
1342+
},
1343+
]);
11521344
const upsertReviewSummationSpy = jest
11531345
.spyOn(service as any, 'upsertReviewSummation')
11541346
.mockImplementationOnce(async () => {
@@ -2147,6 +2339,9 @@ describe('ScoringResultService', () => {
21472339
const upsertReviewSummationSpy = jest
21482340
.spyOn(service as any, 'upsertReviewSummation')
21492341
.mockResolvedValue(undefined);
2342+
jest
2343+
.spyOn(service as any, 'fetchChallengeReviewSummations')
2344+
.mockResolvedValue([]);
21502345

21512346
httpService.get
21522347
.mockReturnValueOnce(
@@ -2913,6 +3108,38 @@ describe('ScoringResultService', () => {
29133108
memberId: 'member-tsegaye',
29143109
},
29153110
]);
3111+
jest
3112+
.spyOn(service as any, 'fetchChallengeReviewSummations')
3113+
.mockResolvedValue([
3114+
{
3115+
...reviewFor('submission-ghost', 100),
3116+
submissionId: 'submission-ghost',
3117+
},
3118+
{
3119+
...reviewFor('submission-vdave', 88.33507138754184),
3120+
submissionId: 'submission-vdave',
3121+
},
3122+
{
3123+
...reviewFor('submission-bitrelica', 79.5106923255694),
3124+
submissionId: 'submission-bitrelica',
3125+
},
3126+
{
3127+
...reviewFor('submission-kazaward', 0.004946668727778636),
3128+
submissionId: 'submission-kazaward',
3129+
},
3130+
{
3131+
...reviewFor('submission-eulerschez', 0.004946668727778636),
3132+
submissionId: 'submission-eulerschez',
3133+
},
3134+
{
3135+
...reviewFor('submission-shxzhaosr', 0),
3136+
submissionId: 'submission-shxzhaosr',
3137+
},
3138+
{
3139+
...reviewFor('submission-failed', -1),
3140+
submissionId: 'submission-failed',
3141+
},
3142+
]);
29163143
const upsertReviewSummationSpy = jest
29173144
.spyOn(service as any, 'upsertReviewSummation')
29183145
.mockResolvedValue(undefined);
@@ -3236,6 +3463,55 @@ describe('ScoringResultService', () => {
32363463
);
32373464
});
32383465

3466+
it('does not use an older scored summation when the latest submission has no phase score', () => {
3467+
const { service } = createService();
3468+
const selectLatestRelativeReviewRecords = (
3469+
service as any
3470+
).selectLatestRelativeReviewRecords.bind(service) as (
3471+
submissions: Record<string, unknown>[],
3472+
testPhase: string,
3473+
reviewTypeId: string,
3474+
excludedSubmissionId: string,
3475+
excludedMemberId?: string,
3476+
) => Array<{
3477+
submissionId: string;
3478+
rawTestScores: Array<{ score: number }>;
3479+
}>;
3480+
3481+
const records = selectLatestRelativeReviewRecords(
3482+
[
3483+
{
3484+
id: 'latest-unscored-submission',
3485+
memberId: 'member-1',
3486+
isLatest: true,
3487+
submittedDate: '2026-05-28T15:23:32.877Z',
3488+
reviewSummation: [],
3489+
},
3490+
{
3491+
id: 'older-scored-submission',
3492+
memberId: 'member-1',
3493+
isLatest: false,
3494+
submittedDate: '2026-05-28T15:21:12.605Z',
3495+
reviewSummation: [
3496+
{
3497+
id: 'older-scored-review',
3498+
isFinal: true,
3499+
metadata: {
3500+
testType: 'system',
3501+
testScores: [{ testcase: '753388858', score: 100 }],
3502+
},
3503+
},
3504+
],
3505+
},
3506+
],
3507+
'system',
3508+
basePayload.reviewTypeId,
3509+
'current-submission',
3510+
);
3511+
3512+
expect(records).toEqual([]);
3513+
});
3514+
32393515
it('groups relative reviews by extracted submission ID and member identity', () => {
32403516
const { service } = createService();
32413517
const selectLatestRelativeReviewRecords = (

0 commit comments

Comments
 (0)