Skip to content

Commit 01c6b1b

Browse files
authored
Merge pull request #125 from topcoder-platform/PM-5221
PM-5221: rate Data Science Challenge history
2 parents 1c5c5ac + 0afc407 commit 01c6b1b

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

@@ -4299,8 +4323,9 @@ rerateChallengeSubmitterRatings.schema = {
42994323
}
43004324

43014325
/**
4302-
* Trigger a DEVELOPMENT / Challenge, DATA_SCIENCE / MARATHON_MATCH, or configured
4303-
* tag- or skill-based rating path re-rating pass beginning with the supplied challenge.
4326+
* Trigger a DEVELOPMENT / Challenge, DATA_SCIENCE / Challenge,
4327+
* DATA_SCIENCE / MARATHON_MATCH, or configured tag- or skill-based rating path
4328+
* re-rating pass beginning with the supplied challenge.
43044329
* The relevant review-api results are reprocessed in chronological order and
43054330
* persisted into the existing unified rating tables for the member.
43064331
* @param {Object} currentUser the user who performs operation
@@ -4343,6 +4368,20 @@ async function rerateMemberStats (currentUser, handle, data) {
43434368
member.userId,
43444369
payload.challengeId
43454370
)
4371+
} else if (trackId === TRACK_NAMES.DATA_SCIENCE && typeId === TYPE_NAMES.CHALLENGE) {
4372+
result = await rerateDevTrack(
4373+
prisma,
4374+
challengeClient,
4375+
reviewDbClient,
4376+
member.userId,
4377+
payload.challengeId,
4378+
{
4379+
targetTrackName: TRACK_NAMES.DATA_SCIENCE,
4380+
targetTypeName: TYPE_NAMES.CHALLENGE,
4381+
challengeTrackNames: [TRACK_NAMES.DATA_SCIENCE],
4382+
challengeTypeNames: [TYPE_NAMES.CHALLENGE]
4383+
}
4384+
)
43464385
} else if (trackId === TRACK_NAMES.DATA_SCIENCE && typeId === TYPE_NAMES.MARATHON_MATCH) {
43474386
result = await rerateMmTrack(
43484387
prisma,
@@ -4353,7 +4392,7 @@ async function rerateMemberStats (currentUser, handle, data) {
43534392
payload.challengeId
43544393
)
43554394
} else {
4356-
throw new errors.BadRequestError('Only DEVELOP / Challenge and DATA_SCIENCE / MARATHON_MATCH rerates are currently supported.')
4395+
throw new errors.BadRequestError('Only DEVELOP / Challenge, DATA_SCIENCE / Challenge, and DATA_SCIENCE / MARATHON_MATCH rerates are currently supported.')
43574396
}
43584397

43594398
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)