Skip to content

Commit

Permalink
Added score calculations (#3040)
Browse files Browse the repository at this point in the history
* added score calculations

* linted

linted again

more linting :)

linted

* addressed comments

* use BigDecimal to avoid floating point error

linted

more linting

* fixed as per comments

* mock constants for other related tests

* linted

* addressed comments (minus factory pattern)

* refactored to use factory pattern

* lint

* more lint

* updated textproto with new hint

* fix json formatting

* fixed as per comments

* made calculated scores immutable

* linted

* fixed
  • Loading branch information
TheRealJessicaLi authored May 24, 2021
1 parent ed8e42b commit 7adefe2
Show file tree
Hide file tree
Showing 10 changed files with 673 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.oppialogger.loguploader.LogUploadWorkerModule
import org.oppia.android.domain.oppialogger.loguploader.WorkManagerConfigurationModule
import org.oppia.android.domain.question.InternalScoreMultiplyFactor
import org.oppia.android.domain.question.MaxScorePerQuestion
import org.oppia.android.domain.question.QuestionCountPerTrainingSession
import org.oppia.android.domain.question.QuestionTrainingSeed
import org.oppia.android.domain.question.ViewHintScorePenalty
import org.oppia.android.domain.question.WrongAnswerScorePenalty
import org.oppia.android.domain.topic.FRACTIONS_SKILL_ID_0
import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
import org.oppia.android.testing.OppiaTestRule
Expand Down Expand Up @@ -447,6 +451,22 @@ class QuestionPlayerActivityTest {
@Provides
@QuestionTrainingSeed
fun provideQuestionTrainingSeed(): Long = 3

@Provides
@ViewHintScorePenalty
fun provideViewHintScorePenalty(): Int = 1

@Provides
@WrongAnswerScorePenalty
fun provideWrongAnswerScorePenalty(): Int = 1

@Provides
@MaxScorePerQuestion
fun provideMaxScorePerQuestion(): Int = 10

@Provides
@InternalScoreMultiplyFactor
fun provideInternalScoreMultiplyFactor(): Int = 10
}

// TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
import org.oppia.android.domain.oppialogger.LogStorageModule
import org.oppia.android.domain.oppialogger.loguploader.LogUploadWorkerModule
import org.oppia.android.domain.oppialogger.loguploader.WorkManagerConfigurationModule
import org.oppia.android.domain.question.InternalScoreMultiplyFactor
import org.oppia.android.domain.question.MaxScorePerQuestion
import org.oppia.android.domain.question.QuestionCountPerTrainingSession
import org.oppia.android.domain.question.QuestionTrainingSeed
import org.oppia.android.domain.question.ViewHintScorePenalty
import org.oppia.android.domain.question.WrongAnswerScorePenalty
import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
import org.oppia.android.domain.topic.TEST_SKILL_ID_1
import org.oppia.android.testing.TestLogReportingModule
Expand Down Expand Up @@ -324,6 +328,22 @@ class QuestionPlayerActivityLocalTest {
@Provides
@QuestionTrainingSeed
fun provideQuestionTrainingSeed(): Long = 1

@Provides
@ViewHintScorePenalty
fun provideViewHintScorePenalty(): Int = 1

@Provides
@WrongAnswerScorePenalty
fun provideWrongAnswerScorePenalty(): Int = 1

@Provides
@MaxScorePerQuestion
fun provideMaxScorePerQuestion(): Int = 10

@Provides
@InternalScoreMultiplyFactor
fun provideInternalScoreMultiplyFactor(): Int = 10
}

// TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
Expand Down
6 changes: 6 additions & 0 deletions domain/src/main/assets/questions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,12 @@
"content_id": "hint_1",
"html": "<p>Hint text will appear here</p>"
}
},
{
"hint_content": {
"content_id": "hint_2",
"html": "<p>Second hint text will appear here</p>"
}
}],
"solution": null
},
Expand Down
14 changes: 14 additions & 0 deletions domain/src/main/assets/questions.textproto
Original file line number Diff line number Diff line change
Expand Up @@ -2374,6 +2374,11 @@ questions {
value {
}
}
recorded_voiceovers {
key: "hint_2"
value {
}
}
recorded_voiceovers {
key: "default_outcome"
value {
Expand Down Expand Up @@ -2418,6 +2423,11 @@ questions {
value {
}
}
written_translations {
key: "hint_2"
value {
}
}
written_translations {
key: "default_outcome"
value {
Expand Down Expand Up @@ -2448,6 +2458,10 @@ questions {
html: "<p>Hint text will appear here</p>"
content_id: "hint_1"
}
hint_content {
html: "<p>Second hint text will appear here</p>"
content_id: "hint_2"
}
}
default_outcome {
feedback {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package org.oppia.android.domain.question

import org.oppia.android.app.model.FractionGrade
import org.oppia.android.app.model.UserAssessmentPerformance
import javax.inject.Inject

/**
* Private class that computes the state of the user's performance at the end of a practice session.
* This class is not thread-safe, so it is the calling code's responsibility to synchronize access
* to this class.
*/
internal class QuestionAssessmentCalculation private constructor(
private val viewHintPenalty: Int,
private val wrongAnswerPenalty: Int,
private val maxScorePerQuestion: Int,
private val internalScoreMultiplyFactor: Int,
private val questionSessionMetrics: List<QuestionSessionMetrics>,
private val skillIdList: List<String>
) {
/** Compute the overall score as well as the score and mastery per skill. */
internal fun computeAll(): UserAssessmentPerformance {
return UserAssessmentPerformance.newBuilder().apply {
totalFractionScore = computeTotalScore()
putAllFractionScorePerSkillMapping(computeScoresPerSkill())
// TODO(#3067): Set up finalMasteryPerSkillMapping
}.build()
}

private fun computeTotalScore(): FractionGrade =
FractionGrade.newBuilder().apply {
val allQuestionScores: List<Int> = questionSessionMetrics.map { it.computeQuestionScore() }
pointsReceived = allQuestionScores.sum().toDouble() / internalScoreMultiplyFactor
totalPointsAvailable =
allQuestionScores.size.toDouble() * maxScorePerQuestion / internalScoreMultiplyFactor
}.build()

private fun computeScoresPerSkill(): Map<String, FractionGrade> =
questionSessionMetrics.flatMap { questionMetric ->
// Convert to List<Pair<String, QuestionSessionMetrics>>>.
questionMetric.question.linkedSkillIdsList.filter { skillId ->
skillIdList.contains(skillId)
}.map { skillId -> skillId to questionMetric }
}.groupBy(
// Covert to Map<String, List<QuestionSessionMetrics>> for each linked skill ID.
{ (skillId, _) -> skillId },
valueTransform = { (_, questionMetric) -> questionMetric }
).mapValues { (_, questionMetrics) ->
// Compute the question score for each question, converting type to Map<String, List<Int>>.
questionMetrics.map { it.computeQuestionScore() }
}.filter { (_, scores) ->
// Eliminate any skills with no questions this session.
!scores.isNullOrEmpty()
}.mapValues { (_, scores) ->
// Convert to Map<String, FractionGrade> for each linked skill ID.
FractionGrade.newBuilder().apply {
pointsReceived = scores.sum().toDouble() / internalScoreMultiplyFactor
totalPointsAvailable =
scores.size.toDouble() * maxScorePerQuestion / internalScoreMultiplyFactor
}.build()
}

private fun QuestionSessionMetrics.computeQuestionScore(): Int = if (!didViewSolution) {
val hintsPenalty = numberOfHintsUsed * viewHintPenalty
val wrongAnswerPenalty = getAdjustedWrongAnswerCount() * wrongAnswerPenalty
(maxScorePerQuestion - hintsPenalty - wrongAnswerPenalty).coerceAtLeast(0)
} else 0

private fun QuestionSessionMetrics.getAdjustedWrongAnswerCount(): Int =
(numberOfAnswersSubmitted - 1).coerceAtLeast(0)

/** Factory to create a new [QuestionAssessmentCalculation]. */
class Factory @Inject constructor(
@ViewHintScorePenalty private val viewHintPenalty: Int,
@WrongAnswerScorePenalty private val wrongAnswerPenalty: Int,
@MaxScorePerQuestion private val maxScorePerQuestion: Int,
@InternalScoreMultiplyFactor private val internalScoreMultiplyFactor: Int
) {
/**
* Creates a new [QuestionAssessmentCalculation] with its state set up.
*
* @param skillIdList the list of IDs for the skills that were tested in this practice session
* @param questionSessionMetrics metrics for the user's answers submitted, hints viewed, and
* solutions viewed per question during this practice session
*/
fun create(
skillIdList: List<String>,
questionSessionMetrics: List<QuestionSessionMetrics>
): QuestionAssessmentCalculation {
return QuestionAssessmentCalculation(
viewHintPenalty,
wrongAnswerPenalty,
maxScorePerQuestion,
internalScoreMultiplyFactor,
questionSessionMetrics,
skillIdList
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.oppia.android.app.model.Question
import org.oppia.android.app.model.Solution
import org.oppia.android.app.model.State
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.app.model.UserAssessmentPerformance
import org.oppia.android.domain.classify.AnswerClassificationController
import org.oppia.android.domain.classify.ClassificationResult.OutcomeWithMisconception
import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
Expand Down Expand Up @@ -56,6 +57,7 @@ class QuestionAssessmentProgressController @Inject constructor(

private val progress = QuestionAssessmentProgress()
private val progressLock = ReentrantLock()
@Inject internal lateinit var scoreCalculatorFactory: QuestionAssessmentCalculation.Factory
private val currentQuestionDataProvider: NestedTransformedDataProvider<EphemeralQuestion> =
createCurrentQuestionDataProvider(createEmptyQuestionsListDataProvider())

Expand Down Expand Up @@ -319,6 +321,31 @@ class QuestionAssessmentProgressController @Inject constructor(
currentQuestionDataProvider
}

/**
* Returns a [DataProvider] monitoring the [UserAssessmentPerformance] corresponding to the user's
* computed overall performance this practice session.
*
* This method should only be called at the end of a practice session, after all the questions
* have been completed.
*/
fun calculateScores(skillIdList: List<String>): DataProvider<UserAssessmentPerformance> =
progressLock.withLock {
return dataProviders.createInMemoryDataProviderAsync(
"user_assessment_performance"
) {
retrieveUserAssessmentPerformanceAsync(skillIdList)
}
}

private suspend fun retrieveUserAssessmentPerformanceAsync(skillIdList: List<String>):
AsyncResult<UserAssessmentPerformance> {
progressLock.withLock {
val scoreCalculator =
scoreCalculatorFactory.create(skillIdList, progress.questionSessionMetrics)
return AsyncResult.success(scoreCalculator.computeAll())
}
}

private fun createCurrentQuestionDataProvider(
questionsListDataProvider: DataProvider<List<Question>>
): NestedTransformedDataProvider<EphemeralQuestion> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ annotation class QuestionCountPerTrainingSession
@Qualifier
annotation class QuestionTrainingSeed

/**
* Qualifier corresponding to the penalty users receive for each hint viewed in a practice session.
*/
@Qualifier
annotation class ViewHintScorePenalty

/**
* Qualifier corresponding to the penalty users receive for each wrong answer submitted in a
* practice session.
*/
@Qualifier
annotation class WrongAnswerScorePenalty

/**
* Qualifier corresponding to the maximum score users can receive for each question in a practice
* session.
*/
@Qualifier
annotation class MaxScorePerQuestion

/**
* Qualifier corresponding to the factor by which all the score constants were internally multiplied
* (relative to Oppia web) with the purpose of maintaining integer representations of constants and
* scores for internal score calculations.
*/
@Qualifier
annotation class InternalScoreMultiplyFactor

/** Provider to return any constants required during the training session. */
@Module
class QuestionModule {
Expand All @@ -21,4 +49,20 @@ class QuestionModule {
@Provides
@QuestionTrainingSeed
fun provideQuestionTrainingSeed(oppiaClock: OppiaClock): Long = oppiaClock.getCurrentTimeMs()

@Provides
@ViewHintScorePenalty
fun provideViewHintScorePenalty(): Int = 1

@Provides
@WrongAnswerScorePenalty
fun provideWrongAnswerScorePenalty(): Int = 1

@Provides
@MaxScorePerQuestion
fun provideMaxScorePerQuestion(): Int = 10

@Provides
@InternalScoreMultiplyFactor
fun provideInternalScoreMultiplyFactor(): Int = 10
}
Loading

0 comments on commit 7adefe2

Please sign in to comment.