Skip to content

Commit 0afc407

Browse files
committed
PM-5221: rate Data Science Challenge history
What was broken Completed Data Science / Challenge events were skipped by the member stats rerate endpoint, so submitters could receive profile history cards without rating data and the challenge completion hook reported no supported ratings. Root cause (if identifiable) The rerate source resolver only recognized Development / Challenge and Data Science / Marathon Match native ratings. The challenge-result rating engine also hardcoded its source and storage dimensions to Development / Challenge. What was changed Added Data Science / Challenge as a native rating source and parameterized the challenge-result rating replay so it can store ratings and history under DATA_SCIENCE / Challenge while preserving the existing Development behavior. Any added/updated tests Added unit coverage for rerating Data Science Challenge submitters through the completion endpoint and for persisting Data Science Challenge ratings/history in the rating engine.
1 parent b3a0193 commit 0afc407

4 files changed

Lines changed: 288 additions & 22 deletions

File tree

src/ratings/developRatingEngine.js

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,52 @@ function normalizeChallengeDimension (value) {
6868
.replace(/[\s-]+/g, '_')
6969
}
7070

71+
/**
72+
* Normalize one or more rating-context values into an array.
73+
* @param {*} value scalar or array option value
74+
* @returns {Array<*>} option values as an array
75+
*/
76+
function toRatingContextArray (value) {
77+
if (Array.isArray(value)) {
78+
return value
79+
}
80+
81+
return value ? [value] : []
82+
}
83+
84+
/**
85+
* Build source challenge filters and target unified dimensions for this rating run.
86+
* Defaults preserve the historical DEVELOPMENT / Challenge stream.
87+
* @param {Object} [options] rerate options
88+
* @param {string} [options.targetTrackName] unified stats track to update
89+
* @param {string} [options.targetTypeName] unified stats type to update
90+
* @param {Array<string>|string} [options.challengeTrackNames] source challenge tracks to replay
91+
* @param {Array<string>|string} [options.challengeTypeNames] source challenge types to replay
92+
* @returns {Object} normalized rating context
93+
*/
94+
function buildRatingContext (options = {}) {
95+
const challengeTrackNames = toRatingContextArray(options.challengeTrackNames || options.challengeTrackName)
96+
const challengeTypeNames = toRatingContextArray(options.challengeTypeNames || options.challengeTypeName)
97+
98+
return {
99+
targetTrackName: String(options.targetTrackName || TRACK_NAME).trim() || TRACK_NAME,
100+
targetTypeName: String(options.targetTypeName || TYPE_NAME).trim() || TYPE_NAME,
101+
challengeTrackNames: (challengeTrackNames.length > 0 ? challengeTrackNames : [CHALLENGE_TRACK_NAME])
102+
.map(normalizeChallengeDimension),
103+
challengeTypeNames: (challengeTypeNames.length > 0 ? challengeTypeNames : CHALLENGE_TYPE_NAMES)
104+
.map(normalizeChallengeDimension)
105+
}
106+
}
107+
108+
/**
109+
* Build the human-readable label for rerate errors.
110+
* @param {Object} ratingContext normalized rating context
111+
* @returns {string} target track/type label
112+
*/
113+
function getRatingContextLabel (ratingContext) {
114+
return `${ratingContext.targetTrackName}/${ratingContext.targetTypeName}`
115+
}
116+
71117
/**
72118
* Add one non-empty challenge id candidate to the supplied set.
73119
* @param {Set<string>} candidates mutable challenge id candidate set
@@ -150,22 +196,23 @@ function historyEntryMatchesChallengeId (historyEntry, challengeId) {
150196
}
151197

152198
/**
153-
* Resolve whether challenge metadata belongs to the Development Challenge rating stream.
154-
* Development CODE challenge rows are rated into the same DEVELOP / Challenge
155-
* stream as standard Development Challenge rows.
199+
* Resolve whether challenge metadata belongs to the configured Challenge rating stream.
200+
* By default, Development CODE challenge rows are rated into the same
201+
* DEVELOP / Challenge stream as standard Development Challenge rows.
156202
* @param {Object} challenge challenge metadata record
203+
* @param {Object} [ratingContext] normalized rating context
157204
* @returns {boolean} true when the challenge should be replayed by this engine
158205
*/
159-
function isDevelopmentRatingChallenge (challenge) {
206+
function isDevelopmentRatingChallenge (challenge, ratingContext = buildRatingContext()) {
160207
if (!challenge || !challenge.track || !challenge.type) {
161208
return false
162209
}
163210

164211
const normalizedTrackName = normalizeChallengeDimension(challenge.track.name)
165212
const normalizedTypeName = normalizeChallengeDimension(challenge.type.name)
166-
const supportedTypeNames = CHALLENGE_TYPE_NAMES.map(normalizeChallengeDimension)
167213

168-
return normalizedTrackName === CHALLENGE_TRACK_NAME && supportedTypeNames.includes(normalizedTypeName)
214+
return ratingContext.challengeTrackNames.includes(normalizedTrackName) &&
215+
ratingContext.challengeTypeNames.includes(normalizedTypeName)
169216
}
170217

171218
function isCompletedChallenge (challenge) {
@@ -246,24 +293,25 @@ function buildUserStateKey (userId) {
246293
}
247294

248295
/**
249-
* Resolve the unified track/type UUIDs used for DEVELOPMENT / Challenge rows.
296+
* Resolve the unified track/type UUIDs used for this challenge-result rating stream.
250297
* @param {Object} challengeClient prisma challenge client
298+
* @param {Object} [ratingContext] normalized rating context
251299
* @returns {Promise<{trackId: string, typeId: string, trackName: string, typeName: string, dimensionLookup: Object}>} resolved unified ids
252300
*/
253-
async function resolveUnifiedDimensionIds (challengeClient) {
301+
async function resolveUnifiedDimensionIds (challengeClient, ratingContext = buildRatingContext()) {
254302
const dimensionLookup = await loadChallengeDimensionLookup(challengeClient)
255-
const trackId = resolveTrackIdFromLookup(dimensionLookup, TRACK_NAME)
256-
const typeId = resolveTypeIdFromLookup(dimensionLookup, TYPE_NAME)
303+
const trackId = resolveTrackIdFromLookup(dimensionLookup, ratingContext.targetTrackName)
304+
const typeId = resolveTypeIdFromLookup(dimensionLookup, ratingContext.targetTypeName)
257305

258306
if (!trackId || !typeId) {
259-
throw new Error(`Unable to resolve unified dimension ids for ${TRACK_NAME}/${TYPE_NAME}`)
307+
throw new Error(`Unable to resolve unified dimension ids for ${getRatingContextLabel(ratingContext)}`)
260308
}
261309

262310
return {
263311
trackId,
264312
typeId,
265-
trackName: TRACK_NAME,
266-
typeName: TYPE_NAME,
313+
trackName: ratingContext.targetTrackName,
314+
typeName: ratingContext.targetTypeName,
267315
dimensionLookup
268316
}
269317
}
@@ -548,10 +596,12 @@ function isCanonicalReviewChallengeRow (row, challenge) {
548596
* @param {Object} [options] history filtering options
549597
* @param {boolean} [options.skipLegacyReviewIds=false] ignore legacy numeric review ids that are already represented by legacy subtrack history
550598
* @param {boolean} [options.useLegacySourceRatings=false] preserve challengeResult oldRating/newRating for legacy-backed rows
599+
* @param {Object} [options.ratingContext] source and target dimension config
551600
* @returns {Array<Object>} ordered challenge history entries for rerating
552601
*/
553602
function buildTargetHistory (reviewRows, challengeMetadataById, options = {}) {
554603
const historyByChallengeId = new Map()
604+
const ratingContext = options.ratingContext || buildRatingContext()
555605

556606
reviewRows.forEach((row) => {
557607
if (!isParticipantEligibleForRating(row)) {
@@ -575,7 +625,7 @@ function buildTargetHistory (reviewRows, challengeMetadataById, options = {}) {
575625
return
576626
}
577627

578-
if (!isDevelopmentRatingChallenge(challenge)) {
628+
if (!isDevelopmentRatingChallenge(challenge, ratingContext)) {
579629
return
580630
}
581631

@@ -942,6 +992,10 @@ async function refreshMostRecentHistoryFlag (tx, userId, dimensionIds) {
942992
* @param {boolean} [options.recalculateRanks=true] recompute Develop Challenge ranks after this member rerate
943993
* @param {boolean} [options.skipLegacyReviewIds=false] skip legacy numeric challengeResult aliases during full migration rerates
944994
* @param {boolean} [options.useLegacySourceRatings=false] preserve challengeResult oldRating/newRating for legacy-backed rows
995+
* @param {string} [options.targetTrackName=DEVELOP] unified stats track to update
996+
* @param {string} [options.targetTypeName=Challenge] unified stats type to update
997+
* @param {Array<string>|string} [options.challengeTrackNames=DEVELOPMENT] source challenge tracks to replay
998+
* @param {Array<string>|string} [options.challengeTypeNames=[Challenge,CODE]] source challenge types to replay
945999
* @returns {Promise<{challengesProcessed: number, ratingsUpdated: number}>} rerate counters
9461000
* @throws {Error} when required review DB or dimension data is unavailable
9471001
*/
@@ -951,6 +1005,7 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
9511005
}
9521006

9531007
const normalizedUserId = toBigIntUserId(userId)
1008+
const ratingContext = buildRatingContext(options)
9541009
const reviewRows = await fetchReviewResultsForUser(reviewDbClient, normalizedUserId)
9551010
if (reviewRows.length === 0) {
9561011
return {
@@ -966,7 +1021,8 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
9661021

9671022
const targetHistory = buildTargetHistory(reviewRows, challengeMetadataById, {
9681023
skipLegacyReviewIds: options.skipLegacyReviewIds === true,
969-
useLegacySourceRatings: options.useLegacySourceRatings === true
1024+
useLegacySourceRatings: options.useLegacySourceRatings === true,
1025+
ratingContext
9701026
})
9711027
if (targetHistory.length === 0) {
9721028
return {
@@ -975,13 +1031,14 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
9751031
}
9761032
}
9771033

978-
const dimensionIds = await resolveUnifiedDimensionIds(challengeClient)
1034+
const dimensionIds = await resolveUnifiedDimensionIds(challengeClient, ratingContext)
1035+
const ratingLabel = getRatingContextLabel(ratingContext)
9791036

9801037
let startIndex = 0
9811038
if (fromChallengeId) {
9821039
startIndex = targetHistory.findIndex((entry) => historyEntryMatchesChallengeId(entry, fromChallengeId))
9831040
if (startIndex < 0) {
984-
throw new errors.BadRequestError(`Challenge ${fromChallengeId} is not a rated ${TRACK_NAME}/${TYPE_NAME} event for this member`)
1041+
throw new errors.BadRequestError(`Challenge ${fromChallengeId} is not a rated ${ratingLabel} event for this member`)
9851042
}
9861043
}
9871044

src/services/StatisticsService.js

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ if (!_.includes(SUPPORTED_STATS_READ_SOURCES, configuredStatsReadSource)) {
6666
}
6767
const USE_LEGACY_STATS_READS = configuredStatsReadSource === LEGACY_STATS_READ_SOURCE
6868
const RATING_SOURCE_DEVELOPMENT = 'DEVELOPMENT_CHALLENGE'
69+
const RATING_SOURCE_DATA_SCIENCE_CHALLENGE = 'DATA_SCIENCE_CHALLENGE'
6970
const RATING_SOURCE_MARATHON_MATCH = 'MARATHON_MATCH'
7071
const RERATE_MARATHON_ACTOR = 'rerate-mm-stats'
7172
const CHALLENGE_WINNER_PLACEMENT_TYPE = 'PLACEMENT'
@@ -435,6 +436,10 @@ function resolveChallengeRatingSource (challenge) {
435436
return RATING_SOURCE_DEVELOPMENT
436437
}
437438

439+
if (trackName === TRACK_NAMES.DATA_SCIENCE && typeName === TYPE_NAMES.CHALLENGE) {
440+
return RATING_SOURCE_DATA_SCIENCE_CHALLENGE
441+
}
442+
438443
if (trackName === TRACK_NAMES.DATA_SCIENCE && typeName === TYPE_NAMES.MARATHON_MATCH) {
439444
return RATING_SOURCE_MARATHON_MATCH
440445
}
@@ -461,6 +466,14 @@ function buildBaseRatingJob (challenge, source) {
461466
}
462467
}
463468

469+
if (source === RATING_SOURCE_DATA_SCIENCE_CHALLENGE) {
470+
return {
471+
source,
472+
trackId: TRACK_NAMES.DATA_SCIENCE,
473+
typeId: TYPE_NAMES.CHALLENGE
474+
}
475+
}
476+
464477
if (source === RATING_SOURCE_MARATHON_MATCH) {
465478
return {
466479
source,
@@ -635,13 +648,24 @@ async function rerateChallengeRatingJobForMember (challengeClient, reviewDbClien
635648
)
636649
}
637650

638-
if (job.source === RATING_SOURCE_DEVELOPMENT) {
651+
if (job.source === RATING_SOURCE_DEVELOPMENT ||
652+
job.source === RATING_SOURCE_DATA_SCIENCE_CHALLENGE) {
653+
const rerateOptions = job.source === RATING_SOURCE_DATA_SCIENCE_CHALLENGE
654+
? {
655+
targetTrackName: TRACK_NAMES.DATA_SCIENCE,
656+
targetTypeName: TYPE_NAMES.CHALLENGE,
657+
challengeTrackNames: [TRACK_NAMES.DATA_SCIENCE],
658+
challengeTypeNames: [TYPE_NAMES.CHALLENGE]
659+
}
660+
: undefined
661+
639662
return rerateDevTrack(
640663
prisma,
641664
challengeClient,
642665
reviewDbClient,
643666
userId,
644-
challengeId
667+
challengeId,
668+
rerateOptions
645669
)
646670
}
647671

@@ -4289,8 +4313,9 @@ rerateChallengeSubmitterRatings.schema = {
42894313
}
42904314

42914315
/**
4292-
* Trigger a DEVELOPMENT / Challenge, DATA_SCIENCE / MARATHON_MATCH, or configured
4293-
* tag- or skill-based rating path re-rating pass beginning with the supplied challenge.
4316+
* Trigger a DEVELOPMENT / Challenge, DATA_SCIENCE / Challenge,
4317+
* DATA_SCIENCE / MARATHON_MATCH, or configured tag- or skill-based rating path
4318+
* re-rating pass beginning with the supplied challenge.
42944319
* The relevant review-api results are reprocessed in chronological order and
42954320
* persisted into the existing unified rating tables for the member.
42964321
* @param {Object} currentUser the user who performs operation
@@ -4333,6 +4358,20 @@ async function rerateMemberStats (currentUser, handle, data) {
43334358
member.userId,
43344359
payload.challengeId
43354360
)
4361+
} else if (trackId === TRACK_NAMES.DATA_SCIENCE && typeId === TYPE_NAMES.CHALLENGE) {
4362+
result = await rerateDevTrack(
4363+
prisma,
4364+
challengeClient,
4365+
reviewDbClient,
4366+
member.userId,
4367+
payload.challengeId,
4368+
{
4369+
targetTrackName: TRACK_NAMES.DATA_SCIENCE,
4370+
targetTypeName: TYPE_NAMES.CHALLENGE,
4371+
challengeTrackNames: [TRACK_NAMES.DATA_SCIENCE],
4372+
challengeTypeNames: [TYPE_NAMES.CHALLENGE]
4373+
}
4374+
)
43364375
} else if (trackId === TRACK_NAMES.DATA_SCIENCE && typeId === TYPE_NAMES.MARATHON_MATCH) {
43374376
result = await rerateMmTrack(
43384377
prisma,
@@ -4343,7 +4382,7 @@ async function rerateMemberStats (currentUser, handle, data) {
43434382
payload.challengeId
43444383
)
43454384
} else {
4346-
throw new errors.BadRequestError('Only DEVELOP / Challenge and DATA_SCIENCE / MARATHON_MATCH rerates are currently supported.')
4385+
throw new errors.BadRequestError('Only DEVELOP / Challenge, DATA_SCIENCE / Challenge, and DATA_SCIENCE / MARATHON_MATCH rerates are currently supported.')
43474386
}
43484387

43494388
return {

test/unit/DevelopRatingEngine.test.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,96 @@ function findHistoryRow (historyRows, userId, challengeId) {
422422
}
423423

424424
describe('develop rating engine unit tests', () => {
425+
it('rerateDevTrack should support Data Science Challenge rating dimensions', async () => {
426+
const targetUserId = toBigInt(1001)
427+
const opponentUserId = toBigInt(2002)
428+
const challengeId = 'ds-challenge-1'
429+
const eventDate = new Date('2026-06-02T05:30:04.536Z')
430+
const members = createMembersClient({
431+
historyRows: [],
432+
statsRows: [],
433+
maxRatingRows: []
434+
})
435+
const reviewDbClient = createReviewDbClient([
436+
{
437+
challengeId,
438+
userId: targetUserId,
439+
finalScore: 100,
440+
placement: 1,
441+
rated: true,
442+
createdAt: new Date('2026-06-02T04:49:42.752Z')
443+
},
444+
{
445+
challengeId,
446+
userId: opponentUserId,
447+
finalScore: 88.89,
448+
placement: 2,
449+
rated: true,
450+
createdAt: new Date('2026-06-02T04:46:08.538Z')
451+
}
452+
])
453+
const challengeClient = createChallengeClient({
454+
[challengeId]: {
455+
id: challengeId,
456+
endDate: eventDate,
457+
track: { name: 'Data Science' },
458+
type: { name: 'Challenge' }
459+
}
460+
})
461+
const expectedParticipants = [
462+
createParticipant(targetUserId, 0, 0, 0, 100),
463+
createParticipant(opponentUserId, 0, 0, 0, 88.89)
464+
]
465+
runQubitsRating(expectedParticipants)
466+
const expectedTarget = expectedParticipants.find((participant) => participant.coderId === String(targetUserId))
467+
468+
const result = await rerateDevTrack(
469+
members.client,
470+
challengeClient,
471+
reviewDbClient,
472+
targetUserId,
473+
challengeId,
474+
{
475+
targetTrackName: 'DATA_SCIENCE',
476+
targetTypeName: 'Challenge',
477+
challengeTrackNames: ['DATA_SCIENCE'],
478+
challengeTypeNames: ['Challenge']
479+
}
480+
)
481+
482+
result.challengesProcessed.should.equal(1)
483+
result.ratingsUpdated.should.equal(1)
484+
485+
const statsRow = members.state.statsRows.find((row) =>
486+
String(row.userId) === String(targetUserId) &&
487+
row.trackId === DATA_SCIENCE_TRACK_ID &&
488+
row.typeId === CHALLENGE_TYPE_ID
489+
)
490+
should.exist(statsRow)
491+
statsRow.rating.should.equal(expectedTarget.rating)
492+
statsRow.volatility.should.equal(expectedTarget.volatility)
493+
statsRow.challenges.should.equal(1)
494+
statsRow.mostRecentEventDate.should.deep.equal(eventDate)
495+
496+
const historyRow = findHistoryRow(members.state.historyRows, targetUserId, challengeId)
497+
should.exist(historyRow)
498+
historyRow.trackId.should.equal(DATA_SCIENCE_TRACK_ID)
499+
historyRow.typeId.should.equal(CHALLENGE_TYPE_ID)
500+
historyRow.newRating.should.equal(expectedTarget.rating)
501+
historyRow.mostRecent.should.equal(true)
502+
503+
const maxRatingRow = members.state.maxRatingRows.find((row) => String(row.userId) === String(targetUserId))
504+
should.exist(maxRatingRow)
505+
maxRatingRow.rating.should.equal(expectedTarget.rating)
506+
maxRatingRow.track.should.equal('DATA_SCIENCE')
507+
maxRatingRow.subTrack.should.equal('Challenge')
508+
maxRatingRow.ratingColor.should.equal(getRatingColor(expectedTarget.rating))
509+
510+
members.state.rankRecalculationCalls.should.have.length(1)
511+
members.state.rankRecalculationCalls[0].trackId.should.equal(DATA_SCIENCE_TRACK_ID)
512+
members.state.rankRecalculationCalls[0].typeId.should.equal(CHALLENGE_TYPE_ID)
513+
})
514+
425515
it('rerateDevTrack should seed rerates from prior history instead of current snapshots', async () => {
426516
const targetUserId = toBigInt(1001)
427517
const opponentUserId = toBigInt(2002)

0 commit comments

Comments
 (0)