From a501b2322fd9d0390d894504ed751dabe768a76c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 8 Oct 2019 18:38:01 -0700 Subject: [PATCH] Fix #114: Implement answer classification controller (#211) * Introduce first pass interface for ExplorationProgressController. * Fill in the stubbed logic for ExplorationProgressController. Still no tests to verify correctness. Also, added a method to facilitate notifying of DataProvider changes on the UI thread. * Fix lateinit issue in ExplorationProgressController due to wrongly ordered initialization. * Fix a variaty of issues in the exp progress controller, properly hook it up to the data controller, and start adding tests. * Created a separate ExplorationRetriever, hooked up AnswerClassificationController, and attempted to make ExplorationProgressController thread-safe. The thread-safety led to significant interface changes in the progress controller, and led to discovering some issues with the mediator live data approach to interop coroutines and LiveData. This locking mechanism will need to change since the optimal solution requires resolving #90. * Change the locking mechanism for ExplorationProgressController to work with the current MediatorLiveData implementation (see #90 for more context). Fix existing progress controller tests and add a few more. All current progress controller tests are passing. * Finish tests for ExplorationProgressController and add test classification support for the second test exploration (about_oppia). * First iteration at implementing real answer classification for the Oppia prototype. This uses a Dagger-powered solution to make it straightforward to add new rule types and interactions in a way that automatically hooks into the classifier. This only adds TextInput support, so existing tests do not pass. Tests and the app do build. * Bind all text input rules, add numeric input rules, and add support for two bound input values. * Add numbers with units classification support. * Add multiple choice input classification support. Fix some of the exploration progress controller tests for numeric input. * Add item selection classification support. Clean up all rule classifier comments to point to their corresponding Oppia web versions. * Add fractions input classification support. * Add Continue module. Resolve some TODOs, add TODOs to test classifiers, and fix ExplorationProgressController tests (which also included fixing one bug in the TextInput FuzzyEquals and the progress controller's fallback routing logic). * Add thorough tests for AnswerClassificationController. * Consolidate generic rule classifiers into a single class. --- .../app/application/ApplicationComponent.kt | 14 +- .../AnswerClassificationController.kt | 87 ++-- .../classify/GenericInteractionClassifier.kt | 14 + .../domain/classify/InteractionClassifier.kt | 13 + .../domain/classify/InteractionsModule.kt | 80 ++++ .../oppia/domain/classify/RuleClassifier.kt | 11 + .../classify/rules/GenericRuleClassifier.kt | 175 +++++++ ...nteractionObjectTypeExtractorRepository.kt | 65 +++ .../classify/rules/RuleClassifierProvider.kt | 9 + .../domain/classify/rules/RuleQualifiers.kt | 24 + .../continueinteraction/ContinueModule.kt | 13 + ...enominatorEqualToRuleClassifierProvider.kt | 29 ++ ...artExactlyEqualToRuleClassifierProvider.kt | 28 ++ ...ntegerPartEqualToRuleClassifierProvider.kt | 29 ++ ...sNoFractionalPartRuleClassifierProvider.kt | 28 ++ ...sNumeratorEqualToRuleClassifierProvider.kt | 29 ++ ...AndInSimplestFormRuleClassifierProvider.kt | 31 ++ ...putIsEquivalentToRuleClassifierProvider.kt | 30 ++ ...tIsExactlyEqualToRuleClassifierProvider.kt | 28 ++ ...nputIsGreaterThanRuleClassifierProvider.kt | 29 ++ ...onInputIsLessThanRuleClassifierProvider.kt | 29 ++ .../fractioninput/FractionInputModule.kt | 92 ++++ ...tainsAtLeastOneOfRuleClassifierProvider.kt | 28 ++ ...ntainAtLeastOneOfRuleClassifierProvider.kt | 28 ++ ...ectionInputEqualsRuleClassifierProvider.kt | 28 ++ ...tIsProperSubsetOfRuleClassifierProvider.kt | 30 ++ .../ItemSelectionInputModule.kt | 44 ++ ...ChoiceInputEqualsRuleClassifierProvider.kt | 27 ++ .../MultipleChoiceInputModule.kt | 20 + ...ithUnitsIsEqualToRuleClassifierProvider.kt | 52 ++ ...itsIsEquivalentToRuleClassifierProvider.kt | 45 ++ .../NumberWithUnitsRuleModule.kt | 28 ++ ...umericInputEqualsRuleClassifierProvider.kt | 27 ++ ...aterThanOrEqualToRuleClassifierProvider.kt | 26 + ...nputIsGreaterThanRuleClassifierProvider.kt | 26 + ...nclusivelyBetweenRuleClassifierProvider.kt | 27 ++ ...LessThanOrEqualToRuleClassifierProvider.kt | 26 + ...icInputIsLessThanRuleClassifierProvider.kt | 26 + ...IsWithinToleranceRuleClassifierProvider.kt | 27 ++ .../numericinput/NumericInputRuleModule.kt | 68 +++ ...seSensitiveEqualsRuleClassifierProvider.kt | 28 ++ ...TextInputContainsRuleClassifierProvider.kt | 28 ++ .../TextInputEqualsRuleClassifierProvider.kt | 27 ++ ...tInputFuzzyEqualsRuleClassifierProvider.kt | 60 +++ .../rules/textinput/TextInputRuleModule.kt | 52 ++ ...xtInputStartsWithRuleClassifierProvider.kt | 28 ++ .../ExplorationProgressController.kt | 23 +- .../exploration/ExplorationRetriever.kt | 24 +- .../org/oppia/domain/util/FloatExtensions.kt | 15 + .../oppia/domain/util/FractionExtensions.kt | 29 ++ .../org/oppia/domain/util/StringExtensions.kt | 9 + .../AnswerClassificationControllerTest.kt | 450 ++++++++++++++++-- .../ExplorationDataControllerTest.kt | 14 +- .../ExplorationProgressControllerTest.kt | 42 +- model/src/main/proto/exploration.proto | 2 +- model/src/main/proto/interaction_object.proto | 9 +- 56 files changed, 2169 insertions(+), 141 deletions(-) create mode 100644 domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt create mode 100644 domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt create mode 100644 domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt create mode 100644 domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt create mode 100644 domain/src/main/java/org/oppia/domain/util/StringExtensions.kt diff --git a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt index ead83aa985a..2d6bcafaa82 100644 --- a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt +++ b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt @@ -5,6 +5,14 @@ import dagger.BindsInstance import dagger.Component import org.oppia.app.activity.ActivityComponent import org.oppia.data.backends.gae.NetworkModule +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.logging.LoggerModule import org.oppia.util.threading.DispatcherModule import javax.inject.Provider @@ -12,7 +20,11 @@ import javax.inject.Singleton /** Root Dagger component for the application. All application-scoped modules should be included in this component. */ @Singleton -@Component(modules = [ApplicationModule::class, DispatcherModule::class, NetworkModule::class, LoggerModule::class]) +@Component(modules = [ + ApplicationModule::class, DispatcherModule::class, NetworkModule::class, LoggerModule::class, + ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, InteractionsModule::class +]) interface ApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt index d6a8b65b8f5..f2f734da486 100644 --- a/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt +++ b/domain/src/main/java/org/oppia/domain/classify/AnswerClassificationController.kt @@ -1,9 +1,10 @@ package org.oppia.domain.classify +import org.oppia.app.model.AnswerGroup import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.Outcome -import org.oppia.app.model.State +import java.lang.IllegalStateException import javax.inject.Inject // TODO(#59): Restrict the visibility of this class to only other controllers. @@ -14,66 +15,46 @@ import javax.inject.Inject * * This controller should only be interacted with via background threads. */ -class AnswerClassificationController @Inject constructor() { - // TODO(#114): Add support for classifying answers based on an actual exploration. Also, classify() should take an - // Interaction, not a State. - +class AnswerClassificationController @Inject constructor( + private val interactionClassifiers: Map +) { /** * Classifies the specified answer in the context of the specified [Interaction] and returns the [Outcome] that best * matches the learner's answer. */ - internal fun classify(currentState: State, answer: InteractionObject): Outcome { - return when (currentState.name) { - // Exp 5 - "Welcome!" -> simulateMultipleChoiceForWelcomeStateExp5(currentState, answer) - "What language" -> simulateTextInputForWhatLanguageStateExp5(currentState, answer) - "Numeric input" -> simulateNumericInputForNumericInputStateExp5(currentState, answer) - "Things you can do" -> currentState.interaction.defaultOutcome - // Exp 6 - "First State" -> currentState.interaction.defaultOutcome - "So what can I tell you" -> simulateMultipleChoiceForWelcomeStateExp6(currentState, answer) - "Example1" -> currentState.interaction.defaultOutcome // TextInput with ignored answer. - "Example3" -> currentState.interaction.defaultOutcome - "End Card" -> currentState.interaction.defaultOutcome - else -> throw Exception("Cannot submit answer to unexpected state: ${currentState.name}.") + internal fun classify(interaction: Interaction, answer: InteractionObject): Outcome { + val interactionClassifier = checkNotNull(interactionClassifiers[interaction.id]) { + "Encountered unknown interaction type: ${interaction.id}, expected one of: ${interactionClassifiers.keys}" } + // TODO(#207): Add support for additional classification types. + return classifyAnswer( + answer, interaction.answerGroupsList, interaction.defaultOutcome, interactionClassifier, interaction.id) } - private fun simulateMultipleChoiceForWelcomeStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> - throw Exception("Expected non-negative int answer, not $answer.") - answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[0].outcome - answer.nonNegativeInt == 2 -> currentState.interaction.answerGroupsList[1].outcome - else -> currentState.interaction.defaultOutcome + // Based on the Oppia web version: + // https://github.com/oppia/oppia/blob/edb62f/core/templates/dev/head/pages/exploration-player-page/services/answer-classification.service.ts#L57. + private fun classifyAnswer( + answer: InteractionObject, answerGroups: List, defaultOutcome: Outcome, + interactionClassifier: InteractionClassifier, interactionId: String + ): Outcome { + for (answerGroup in answerGroups) { + for (ruleSpec in answerGroup.ruleSpecsList) { + val ruleClassifier = checkNotNull(interactionClassifier.getRuleClassifier(ruleSpec.ruleType)) { + "Expected interaction $interactionId to have classifier for rule type: ${ruleSpec.ruleType}, but only" + + " has: ${interactionClassifier.getRuleTypes()}" + } + try { + if (ruleClassifier.matches(answer, ruleSpec.inputMap)) { + // Explicit classification matched. + return answerGroup.outcome + } + } catch (e: Exception) { + throw IllegalStateException("Failed when classifying answer $answer for interaction $interactionId", e) + } + } } - } - - private fun simulateTextInputForWhatLanguageStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NORMALIZED_STRING -> - throw Exception("Expected string answer, not $answer.") - answer.normalizedString.toLowerCase() == "finnish" -> currentState.interaction.getAnswerGroups(6).outcome - else -> currentState.interaction.defaultOutcome - } - } - private fun simulateNumericInputForNumericInputStateExp5(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.SIGNED_INT -> - throw Exception("Expected signed int answer, not $answer.") - answer.signedInt == 121 -> currentState.interaction.answerGroupsList.first().outcome - else -> currentState.interaction.defaultOutcome - } - } - - private fun simulateMultipleChoiceForWelcomeStateExp6(currentState: State, answer: InteractionObject): Outcome { - return when { - answer.objectTypeCase != InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> - throw Exception("Expected non-negative int answer, not $answer.") - answer.nonNegativeInt == 3 -> currentState.interaction.answerGroupsList[1].outcome - answer.nonNegativeInt == 0 -> currentState.interaction.answerGroupsList[2].outcome - else -> currentState.interaction.defaultOutcome - } + // Default outcome classification. + return defaultOutcome } } diff --git a/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt new file mode 100644 index 00000000000..21fd8aaa3b2 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/GenericInteractionClassifier.kt @@ -0,0 +1,14 @@ +package org.oppia.domain.classify + +/** A general-purpose [InteractionClassifier] that utilizes a Dagger-bound [RuleClassifier] map. */ +internal class GenericInteractionClassifier( + private val ruleClassifiers: Map +): InteractionClassifier { + override fun getRuleTypes(): Set { + return ruleClassifiers.keys + } + + override fun getRuleClassifier(ruleType: String): RuleClassifier? { + return ruleClassifiers[ruleType] + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt new file mode 100644 index 00000000000..5fc1dd8097b --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/InteractionClassifier.kt @@ -0,0 +1,13 @@ +package org.oppia.domain.classify + +/** + * An answer classifier for a specific interaction type. Instances of this classifier should be bound to a map of + * interaction IDs to classifier instances so that they can be used by the [AnswerClassificationController]. + */ +interface InteractionClassifier { + /** Returns a set of rule types that this interaction supports classifying. */ + fun getRuleTypes(): Set + + /** Returns the [RuleClassifier] corresponding to the specified rule type. */ + fun getRuleClassifier(ruleType: String): RuleClassifier? +} diff --git a/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt b/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt new file mode 100644 index 00000000000..e1779354ace --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/InteractionsModule.kt @@ -0,0 +1,80 @@ +package org.oppia.domain.classify + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.rules.ContinueRules +import org.oppia.domain.classify.rules.FractionInputRules +import org.oppia.domain.classify.rules.ItemSelectionInputRules +import org.oppia.domain.classify.rules.MultipleChoiceInputRules +import org.oppia.domain.classify.rules.NumberWithUnitsRules +import org.oppia.domain.classify.rules.NumericInputRules +import org.oppia.domain.classify.rules.TextInputRules + +/** Module that provides a map of [InteractionClassifier]s. */ +@Module +class InteractionsModule { + @Provides + @IntoMap + @StringKey("Continue") + fun provideContinueInteractionClassifier( + @ContinueRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("FractionInput") + fun provideFractionInputInteractionClassifier( + @FractionInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("ItemSelectionInput") + fun provideItemSelectionInputInteractionClassifier( + @ItemSelectionInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("MultipleChoiceInput") + fun provideMultipleChoiceInputInteractionClassifier( + @MultipleChoiceInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("NumberWithUnits") + fun provideNumberWithUnitsInteractionClassifier( + @NumberWithUnitsRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("NumericInput") + fun provideNumericInputInteractionClassifier( + @NumericInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } + + @Provides + @IntoMap + @StringKey("TextInput") + fun provideTextInputInteractionClassifier( + @TextInputRules ruleClassifiers: Map + ): InteractionClassifier { + return GenericInteractionClassifier(ruleClassifiers) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt new file mode 100644 index 00000000000..729545db581 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/RuleClassifier.kt @@ -0,0 +1,11 @@ +package org.oppia.domain.classify + +import org.oppia.app.model.InteractionObject + +/** An answer classifier for a specific interaction rule. */ +interface RuleClassifier { + /** + * Returns whether the specified answer matches the rule's parameter inputs per this rule's classification strategy. + */ + fun matches(answer: InteractionObject, inputs: Map): Boolean +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt b/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt new file mode 100644 index 00000000000..a8e3a945d33 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/GenericRuleClassifier.kt @@ -0,0 +1,175 @@ +package org.oppia.domain.classify.rules + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import javax.inject.Inject + +/** + * A convenience [RuleClassifier] which performs parameter extraction and sanitation to simplify classifiers, with the + * possible configurations for zero, one, or two parameters with the same or differing types compared to the answer + * being matched. + * + * Child classes must ensure all specified types properly correspond to the type to which the parameter's specified + * [InteractionObject.ObjectTypeCase] also corresponds. + */ +internal class GenericRuleClassifier private constructor( + private val expectedAnswerObjectType: InteractionObject.ObjectTypeCase, + private val orderedExpectedParameterTypes: LinkedHashMap, + private val matcherDelegate: MatcherDelegate +): RuleClassifier { + override fun matches(answer: InteractionObject, inputs: Map): Boolean { + check(answer.objectTypeCase == expectedAnswerObjectType) { + "Expected answer to be of type ${expectedAnswerObjectType.name} not ${answer.objectTypeCase.name}" + } + val parameterInputs = orderedExpectedParameterTypes.toList().map { (parameterName, expectedObjectType) -> + retrieveInputObject(parameterName, expectedObjectType, inputs) + } + return matcherDelegate.matches(answer, parameterInputs) + } + + private fun retrieveInputObject( + parameterName: String, expectedObjectType: InteractionObject.ObjectTypeCase, inputs: Map + ): InteractionObject { + val input = checkNotNull(inputs[parameterName]) { + "Expected classifier inputs to contain parameter with name '$parameterName' but had: ${inputs.keys}" + } + check(input.objectTypeCase == expectedObjectType) { + "Expected input value to be of type ${expectedObjectType.name} not ${input.objectTypeCase.name}" + } + return input + } + + internal interface NoInputInputMatcher { + /** + * Returns whether the validated and extracted answer matches the expectations per the specification of this + * classifier. + */ + fun matches(answer: T): Boolean + } + + internal interface SingleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the single validated and extracted input parameter per + * the specification of this classifier. + */ + fun matches(answer: T, input: T): Boolean + } + + internal interface MultiTypeSingleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the single validated and extracted input parameter per + * the specification of this classifier. + */ + fun matches(answer: AT, input: IT): Boolean + } + + internal interface DoubleInputMatcher { + /** + * Returns whether the validated and extracted answer matches the two validated and extracted input parameters per + * the specification of this classifier. + */ + fun matches(answer: T, firstInput: T, secondInput: T): Boolean + } + + internal sealed class MatcherDelegate { + + abstract fun matches(answer: InteractionObject, inputs: List): Boolean + + internal class NoInputMatcherDelegate( + private val matcher: NoInputInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.isEmpty()) + return matcher.matches(extractObject(answer)) + } + } + + internal class SingleInputMatcherDelegate( + private val matcher: SingleInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 1) + return matcher.matches(extractObject(answer), extractObject(inputs.first())) + } + } + + internal class MultiTypeSingleInputMatcherDelegate( + private val matcher: MultiTypeSingleInputMatcher, + private val extractAnswerObject: (InteractionObject) -> AT, + private val extractInputObject: (InteractionObject) -> IT + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 1) + return matcher.matches(extractAnswerObject(answer), extractInputObject(inputs.first())) + } + } + + internal class DoubleInputMatcherDelegate( + private val matcher: DoubleInputMatcher, + private val extractObject: (InteractionObject) -> T + ): MatcherDelegate() { + override fun matches(answer: InteractionObject, inputs: List): Boolean { + check(inputs.size == 2) + return matcher.matches(extractObject(answer), extractObject(inputs[0]), extractObject(inputs[1])) + } + } + } + + /** Factory to create new [GenericRuleClassifier]s. */ + internal class Factory @Inject constructor( + private val interactionObjectTypeExtractorRepository: InteractionObjectTypeExtractorRepository + ) { + /** Returns a new [GenericRuleClassifier] for an answer that is not matched to any input values. */ + inline fun createNoInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, matcher: NoInputInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + return GenericRuleClassifier( + expectedObjectType, LinkedHashMap(), MatcherDelegate.NoInputMatcherDelegate(matcher, objectExtractor)) + } + + /** + * Returns a new [GenericRuleClassifier] for a single input value with the same type as the answer being classified. + */ + inline fun createSingleInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, inputParameterName: String, matcher: SingleInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + return GenericRuleClassifier( + expectedObjectType, linkedMapOf(inputParameterName to expectedObjectType), + MatcherDelegate.SingleInputMatcherDelegate(matcher, objectExtractor)) + } + + /** + * Returns a new [GenericRuleClassifier] for a single input value that has a different type than the answer being + * classified. + */ + inline fun createMultiTypeSingleInputClassifier( + expectedAnswerObjectType: InteractionObject.ObjectTypeCase, + expectedInputObjectType: InteractionObject.ObjectTypeCase, inputParameterName: String, + matcher: MultiTypeSingleInputMatcher + ): GenericRuleClassifier { + val answerObjectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedAnswerObjectType) + val inputObjectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedInputObjectType) + return GenericRuleClassifier( + expectedAnswerObjectType, linkedMapOf(inputParameterName to expectedInputObjectType), + MatcherDelegate.MultiTypeSingleInputMatcherDelegate(matcher, answerObjectExtractor, inputObjectExtractor)) + } + + /** Returns a new [GenericRuleClassifier] for two input values of the same type as the answer it classifies. */ + inline fun createDoubleInputClassifier( + expectedObjectType: InteractionObject.ObjectTypeCase, firstInputParameterName: String, + secondInputParameterName: String, matcher: DoubleInputMatcher + ): GenericRuleClassifier { + val objectExtractor = interactionObjectTypeExtractorRepository.getExtractor(expectedObjectType) + val parameters = linkedMapOf( + firstInputParameterName to expectedObjectType, + secondInputParameterName to expectedObjectType + ) + return GenericRuleClassifier( + expectedObjectType, parameters, MatcherDelegate.DoubleInputMatcherDelegate(matcher, objectExtractor)) + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt b/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt new file mode 100644 index 00000000000..c1901a448f0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/InteractionObjectTypeExtractorRepository.kt @@ -0,0 +1,65 @@ +package org.oppia.domain.classify.rules + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.InteractionObject.ObjectTypeCase +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.reflect.KClass + +/** + * A convenience utility for mapping [ObjectTypeCase] constants to methods that can extract the corresponding value + * from instances of [InteractionObject]. This utility is preferred over manually maintaining the mapping since it's a + * single source of truth that allows code requiring an extraction method to rely only on the type enum rather than + * managing the enum->method relationship directly. + */ +@Singleton // Avoid recomputing the mapping multiple times. +internal class InteractionObjectTypeExtractorRepository @Inject constructor() { + private val extractors: Map> by lazy { + computeExtractorMap() + } + + /** + * Returns a function that can be used to extract an element of type [T] from an [InteractionObject] where [T] + * corresponds to the specified [ObjectTypeCase]. Note that referencing the wrong type will result in a runtime type + * check failure. + */ + inline fun getExtractor(objectTypeCase: ObjectTypeCase): (InteractionObject) -> T { + val (extractionType, genericExtractor) = checkNotNull(extractors[objectTypeCase]) { + "No mapping found for interaction object type: ${objectTypeCase.name}. Was it not yet registered?" + } + check(extractionType.java.isAssignableFrom(T::class.java)) { + "Trying to retrieve incompatible extractor type: ${T::class.java} expected: ${extractionType.java}" + } + // Note that a new conversion method is returned since it's not clear whether it's safe to simply cast the + // extractor. + return { interactionObject -> + genericExtractor(interactionObject) as T // The runtime check above makes this cast safe. + } + } + + internal data class ExtractorMapping( + val extractionType: KClass, + val genericExtractor: (InteractionObject) -> T + ) + + private companion object { + private fun computeExtractorMap(): Map> { + return mapOf( + ObjectTypeCase.NORMALIZED_STRING to createMapping(InteractionObject::getNormalizedString), + ObjectTypeCase.SIGNED_INT to createMapping(InteractionObject::getSignedInt), + ObjectTypeCase.NON_NEGATIVE_INT to createMapping(InteractionObject::getNonNegativeInt), + ObjectTypeCase.REAL to createMapping(InteractionObject::getReal), + ObjectTypeCase.BOOL_VALUE to createMapping(InteractionObject::getBoolValue), + ObjectTypeCase.NUMBER_WITH_UNITS to createMapping(InteractionObject::getNumberWithUnits), + ObjectTypeCase.SET_OF_HTML_STRING to createMapping(InteractionObject::getSetOfHtmlString), + ObjectTypeCase.FRACTION to createMapping(InteractionObject::getFraction) + ) + } + + private inline fun createMapping( + noinline extractor: (InteractionObject) -> T + ): ExtractorMapping { + return ExtractorMapping(T::class, extractor) + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt new file mode 100644 index 00000000000..e73ac1d2920 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/RuleClassifierProvider.kt @@ -0,0 +1,9 @@ +package org.oppia.domain.classify.rules + +import org.oppia.domain.classify.RuleClassifier + +/** Provider for [RuleClassifier]s. */ +interface RuleClassifierProvider { + /** Returns a new [RuleClassifier]. */ + fun createRuleClassifier(): RuleClassifier +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt b/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt new file mode 100644 index 00000000000..73d6f475a2a --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/RuleQualifiers.kt @@ -0,0 +1,24 @@ +package org.oppia.domain.classify.rules + +import javax.inject.Qualifier + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the continue interaction. */ +@Qualifier annotation class ContinueRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the fraction input interaction. */ +@Qualifier annotation class FractionInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the item selection interaction. */ +@Qualifier annotation class ItemSelectionInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the multiple choice interaction. */ +@Qualifier annotation class MultipleChoiceInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the number with units interaction. */ +@Qualifier annotation class NumberWithUnitsRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the text input interaction. */ +@Qualifier annotation class TextInputRules + +/** Corresponds to [org.oppia.domain.classify.RuleClassifier]s that can be used by the numeric input interaction. */ +@Qualifier annotation class NumericInputRules diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt new file mode 100644 index 00000000000..1f1e5f84cb0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/continueinteraction/ContinueModule.kt @@ -0,0 +1,13 @@ +package org.oppia.domain.classify.rules.continueinteraction + +import dagger.Module +import dagger.multibindings.Multibinds +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.ContinueRules + +/** Module that binds rule classifiers corresponding to the continue interaction. */ +@Module +abstract class ContinueModule { + // No rules are bound since tapping the continue button for this interaction should always succeed. + @Multibinds @ContinueRules abstract fun provideContinueInteractionRules(): Map +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..7e91899ac34 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasDenominatorEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a denominator equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L55 + */ +internal class FractionInputHasDenominatorEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.denominator == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..e7f798f69d7 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a fractional part exactly equal to the fractional + * part of an input fraction per the fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L61 + */ +internal class FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.numerator == input.numerator && answer.denominator == input.denominator + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..d4a2aab4f13 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasIntegerPartEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has an integer part equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L48 + */ +internal class FractionInputHasIntegerPartEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.wholeNumber == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt new file mode 100644 index 00000000000..f34d57d1edd --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNoFractionalPartRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has no fractional part per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L58 + */ +internal class FractionInputHasNoFractionalPartRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.NoInputInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createNoInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction): Boolean { + return answer.numerator == 0 + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..09ef5abd53d --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputHasNumeratorEqualToRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction has a numerator equal to the specified value per the + * fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L52 + */ +internal class FractionInputHasNumeratorEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createMultiTypeSingleInputClassifier( + InteractionObject.ObjectTypeCase.FRACTION, InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Int): Boolean { + return answer.numerator == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt new file mode 100644 index 00000000000..2374f752504 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider.kt @@ -0,0 +1,31 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import org.oppia.domain.util.toSimplestForm +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is both effectively equal to another fraction and equal + * in its simplest form per the fraction input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L32 + */ +internal class FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat().approximatelyEquals(input.toFloat()) && answer == input.toSimplestForm() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt new file mode 100644 index 00000000000..bbd16b8b619 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsEquivalentToRuleClassifierProvider.kt @@ -0,0 +1,30 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction effectively equal to another fraction per the fraction + * input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L29 + */ +internal class FractionInputIsEquivalentToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat().approximatelyEquals(input.toFloat()) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..ff0e73e6992 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsExactlyEqualToRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is exactly equal to another fraction per the fraction + * input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L38 + */ +internal class FractionInputIsExactlyEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..48168244325 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsGreaterThanRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is greater than another fraction per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L45 + */ +internal class FractionInputIsGreaterThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat() > input.toFloat() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..4833dd53279 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputIsLessThanRuleClassifierProvider.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.classify.rules.fractioninput + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a fraction is less than another fraction per the fraction input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/FractionInput/directives/fraction-input-rules.service.ts#L42 + */ +internal class FractionInputIsLessThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.FRACTION, "f", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Fraction, input: Fraction): Boolean { + return answer.toFloat() < input.toFloat() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt new file mode 100644 index 00000000000..3ef57b4be5c --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/fractioninput/FractionInputModule.kt @@ -0,0 +1,92 @@ +package org.oppia.domain.classify.rules.fractioninput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.FractionInputRules + +/** Module that binds rule classifiers corresponding to the fraction input interaction. */ +@Module +class FractionInputModule { + @Provides + @IntoMap + @StringKey("HasDenominatorEqualTo") + @FractionInputRules + internal fun provideFractionInputHasDenominatorEqualToRuleClassifier( + classifierProvider: FractionInputHasDenominatorEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasFractionalPartExactlyEqualTo") + @FractionInputRules + internal fun provideFractionInputHasFractionalPartExactlyEqualToRuleClassifier( + classifierProvider: FractionInputHasFractionalPartExactlyEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasIntegerPartEqualTo") + @FractionInputRules + internal fun provideFractionInputHasIntegerPartEqualToRuleClassifier( + classifierProvider: FractionInputHasIntegerPartEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasNoFractionalPart") + @FractionInputRules + internal fun provideFractionInputHasNoFractionalPartRuleClassifier( + classifierProvider: FractionInputHasNoFractionalPartRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("HasNumeratorEqualTo") + @FractionInputRules + internal fun provideFractionInputHasNumeratorEqualToRuleClassifier( + classifierProvider: FractionInputHasNumeratorEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentToAndInSimplestForm") + @FractionInputRules + internal fun provideFractionInputIsEquivalentToAndInSimplestFormRuleClassifier( + classifierProvider: FractionInputIsEquivalentToAndInSimplestFormRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentTo") + @FractionInputRules + internal fun provideFractionInputIsEquivalentToRuleClassifier( + classifierProvider: FractionInputIsEquivalentToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsExactlyEqualTo") + @FractionInputRules + internal fun provideFractionInputIsExactlyEqualToRuleClassifier( + classifierProvider: FractionInputIsExactlyEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThan") + @FractionInputRules + internal fun provideFractionInputIsGreaterThanRuleClassifier( + classifierProvider: FractionInputIsGreaterThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThan") + @FractionInputRules + internal fun provideFractionInputIsLessThanRuleClassifier( + classifierProvider: FractionInputIsLessThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..b85a2de1672 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer contains at least one of a set of options + * per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L32 + */ +internal class ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet().intersect(input.htmlList).isNotEmpty() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..de2e7a100ac --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer does not contain any of a set of options + * per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L41 + */ +internal class ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet().intersect(input.htmlList).isEmpty() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..c789313c626 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer has exactly the same elements as an input + * set per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L24 + */ +internal class ItemSelectionInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + return answer.htmlList.toSet() == input.htmlList.toSet() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt new file mode 100644 index 00000000000..74f6db2c4a1 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputIsProperSubsetOfRuleClassifierProvider.kt @@ -0,0 +1,30 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an item selection answer is a proper subset of an input set of + * values per the item selection input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/ItemSelectionInput/directives/item-selection-input-rules.service.ts#L50 + */ +internal class ItemSelectionInputIsProperSubsetOfRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: StringList, input: StringList): Boolean { + val answerSet = answer.htmlList.toSet() + val inputSet = input.htmlList.toSet() + return answerSet.size < inputSet.size && inputSet.containsAll(answerSet) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt new file mode 100644 index 00000000000..9b39da06b19 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/itemselectioninput/ItemSelectionInputModule.kt @@ -0,0 +1,44 @@ +package org.oppia.domain.classify.rules.itemselectioninput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.ItemSelectionInputRules + +/** Module that binds rule classifiers corresponding to the item selection choice input interaction. */ +@Module +class ItemSelectionInputModule { + @Provides + @IntoMap + @StringKey("ContainsAtLeastOneOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputContainsAtLeastOneOfRuleClassifier( + classifierProvider: ItemSelectionInputContainsAtLeastOneOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("DoesNotContainAtLeastOneOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifier( + classifierProvider: ItemSelectionInputDoesNotContainAtLeastOneOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Equals") + @ItemSelectionInputRules + internal fun provideItemSelectionInputEqualsRuleClassifier( + classifierProvider: ItemSelectionInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsProperSubsetOf") + @ItemSelectionInputRules + internal fun provideItemSelectionInputIsProperSubsetOfRuleClassifier( + classifierProvider: ItemSelectionInputIsProperSubsetOfRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..3396b00cd30 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.multiplechoiceinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether a multiple choice answer matches a specific option per the multiple + * choice input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/MultipleChoiceInput/directives/multiple-choice-input-rules.service.ts#L21 + */ +internal class MultipleChoiceInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Int, input: Int): Boolean { + return answer == input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt new file mode 100644 index 00000000000..ee6c0e22839 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/multiplechoiceinput/MultipleChoiceInputModule.kt @@ -0,0 +1,20 @@ +package org.oppia.domain.classify.rules.multiplechoiceinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.MultipleChoiceInputRules + +/** Module that binds rule classifiers corresponding to the multiple choice input interaction. */ +@Module +class MultipleChoiceInputModule { + @Provides + @IntoMap + @StringKey("Equals") + @MultipleChoiceInputRules + internal fun provideMultipleChoiceInputEqualsRuleClassifier( + classifierProvider: MultipleChoiceInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..b427217bf7e --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEqualToRuleClassifierProvider.kt @@ -0,0 +1,52 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import org.oppia.app.model.Fraction +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberWithUnits +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two numbers with units are equal per the numbers with units + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumberWithUnits/directives/number-with-units-rules.service.ts#L34 + */ +internal class NumberWithUnitsIsEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS, "f", this) + } + + // TODO(#209): Determine whether additional sanitation of the input is necessary here. + // TODO(#210): Add tests for this classifier. + override fun matches(answer: NumberWithUnits, input: NumberWithUnits): Boolean { + // The number types must match. + if (answer.numberTypeCase != input.numberTypeCase) { + return false + } + // Units must match, but in different orders is fine. + if (answer.unitList.toSet() != input.unitList.toSet()) { + return false + } + // Otherwise, verify the value itself matches. + return when (answer.numberTypeCase) { + NumberWithUnits.NumberTypeCase.REAL -> realMatches(answer.real, input.real) + NumberWithUnits.NumberTypeCase.FRACTION -> fractionMatches(answer.fraction, input.fraction) + else -> false // Unknown type never matches. + } + } + + private fun realMatches(answer: Float, input: Float): Boolean { + return input.approximatelyEquals(answer) + } + + private fun fractionMatches(answer: Fraction, input: Fraction): Boolean { + return input == answer + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt new file mode 100644 index 00000000000..5bc71da1957 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsIsEquivalentToRuleClassifierProvider.kt @@ -0,0 +1,45 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberWithUnits +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import org.oppia.domain.util.toFloat +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two numbers with units are effectively equal per the number with + * units interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumberWithUnits/directives/number-with-units-rules.service.ts#L48 + */ +internal class NumberWithUnitsIsEquivalentToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS, "f", this) + } + + // TODO(#209): Determine whether additional normalization of the input is necessary here. + // TODO(#210): Add tests for this classifier. + override fun matches(answer: NumberWithUnits, input: NumberWithUnits): Boolean { + // Units must match, but in different orders is fine. + if (answer.unitList.toSet() != input.unitList.toSet()) { + return false + } + + // Verify the float version of the value for approximate comparison. + return extractRealValue(input).approximatelyEquals(extractRealValue(answer)) + } + + private fun extractRealValue(number: NumberWithUnits): Float { + return when (number.numberTypeCase) { + NumberWithUnits.NumberTypeCase.REAL -> number.real + NumberWithUnits.NumberTypeCase.FRACTION -> number.fraction.toFloat() + else -> throw IllegalArgumentException("Invalid number type: ${number.numberTypeCase.name}") + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt new file mode 100644 index 00000000000..076a57cf3f3 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numberwithunits/NumberWithUnitsRuleModule.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.numberwithunits + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.NumberWithUnitsRules + +/** Module that binds rule classifiers corresponding to the number with units interaction. */ +@Module +class NumberWithUnitsRuleModule { + @Provides + @IntoMap + @StringKey("IsEqualTo") + @NumberWithUnitsRules + internal fun provideNumberWithUnitsIsEqualToRuleClassifier( + classifierProvider: NumberWithUnitsIsEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsEquivalentTo") + @NumberWithUnitsRules + internal fun provideNumberWithUnitsIsEquivalentToRuleClassifier( + classifierProvider: NumberWithUnitsIsEquivalentToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..b732043ddf7 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.approximatelyEquals +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two integers are equal per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L21 + */ +internal class NumericInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return input.approximatelyEquals(answer) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..a984e2f2698 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanOrEqualToRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is >= an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L33 + */ +internal class NumericInputIsGreaterThanOrEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer >= input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..d030bbc15c4 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsGreaterThanRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is > an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L27 + */ +internal class NumericInputIsGreaterThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer > input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt new file mode 100644 index 00000000000..b23944bad2b --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsInclusivelyBetweenRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is >= one input and <= another input value per the + * numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L36 + */ +internal class NumericInputIsInclusivelyBetweenRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.DoubleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createDoubleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "a", "b", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, firstInput: Double, secondInput: Double): Boolean { + return answer in firstInput..secondInput + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt new file mode 100644 index 00000000000..edbf7cfdacc --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanOrEqualToRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is <= an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L30 + */ +internal class NumericInputIsLessThanOrEqualToRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer <= input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt new file mode 100644 index 00000000000..33b982c7798 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsLessThanRuleClassifierProvider.kt @@ -0,0 +1,26 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is < an input per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L24 + */ +internal class NumericInputIsLessThanRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, input: Double): Boolean { + return answer < input + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt new file mode 100644 index 00000000000..4352992a94a --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputIsWithinToleranceRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.numericinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer is within the input-specified tolerance of another input + * value per the numeric input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/NumericInput/directives/numeric-input-rules.service.ts#L41 + */ +internal class NumericInputIsWithinToleranceRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.DoubleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createDoubleInputClassifier(InteractionObject.ObjectTypeCase.REAL, "x", "tol", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: Double, firstInput: Double, secondInput: Double): Boolean { + return answer in (firstInput - secondInput)..(firstInput + secondInput) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt new file mode 100644 index 00000000000..eb47261a5c0 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/numericinput/NumericInputRuleModule.kt @@ -0,0 +1,68 @@ +package org.oppia.domain.classify.rules.numericinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.NumericInputRules + +/** Module that binds rule classifiers corresponding to the numeric input interaction. */ +@Module +class NumericInputRuleModule { + @Provides + @IntoMap + @StringKey("Equals") + @NumericInputRules + internal fun provideNumericInputEqualsRuleClassifier( + classifierProvider: NumericInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThanOrEqualTo") + @NumericInputRules + internal fun provideNumericInputIsGreaterThanOrEqualToRuleClassifier( + classifierProvider: NumericInputIsGreaterThanOrEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsGreaterThan") + @NumericInputRules + internal fun provideNumericInputIsGreaterThanRuleClassifier( + classifierProvider: NumericInputIsGreaterThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsInclusivelyBetween") + @NumericInputRules + internal fun provideNumericInputIsInclusivelyBetweenRuleClassifier( + classifierProvider: NumericInputIsInclusivelyBetweenRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThanOrEqualTo") + @NumericInputRules + internal fun provideNumericInputIsLessThanOrEqualToRuleClassifier( + classifierProvider: NumericInputIsLessThanOrEqualToRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsLessThan") + @NumericInputRules + internal fun provideNumericInputIsLessThanRuleClassifier( + classifierProvider: NumericInputIsLessThanRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("IsWithinTolerance") + @NumericInputRules + internal fun provideNumericInputIsWithinToleranceRuleClassifier( + classifierProvider: NumericInputIsWithinToleranceRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..3c66af19b42 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputCaseSensitiveEqualsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are case sensitively equal per the text input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L59 + */ +internal class TextInputCaseSensitiveEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace() == input.normalizeWhitespace() + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt new file mode 100644 index 00000000000..429e120522d --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer contains the rule's input per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L70 + */ +internal class TextInputContainsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().contains(input.normalizeWhitespace()) + } +} + diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..cb85790cd17 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt @@ -0,0 +1,27 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are equal per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L24 + */ +internal class TextInputEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().equals(input.normalizeWhitespace(), /* ignoreCase= */ true) + } +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt new file mode 100644 index 00000000000..26753c7cd85 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt @@ -0,0 +1,60 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether two strings are fuzzily equal per the text input interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L29. + */ +internal class TextInputFuzzyEqualsRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + val lowerInput = input.normalizeWhitespace().toLowerCase() + val lowerAnswer = answer.normalizeWhitespace().toLowerCase() + if (lowerInput == lowerAnswer) { + return true + } + + val editDistance = mutableListOf>() + for (i in 0..lowerInput.length) { + editDistance.add(mutableListOf(i)) + } + for (j in 1..lowerAnswer.length) { + editDistance[0].add(j) + } + + for (i in 1..lowerInput.length) { + for (j in 1..lowerAnswer.length) { + check(j == editDistance[i].size) { + "Something went wrong. Expected index $j to not yet be in array: ${editDistance[i]}" + } + if (lowerInput[i - 1] == lowerAnswer[j - 1]) { + editDistance[i].add(editDistance[i - 1][j - 1]) + } else { + editDistance[i].add( + minOf( + editDistance[i - 1][j - 1], + editDistance[i][j - 1], + editDistance[i - 1][j] + ) + 1 + ) + } + } + } + return editDistance[lowerInput.length][lowerAnswer.length] == 1 + } +} + diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt new file mode 100644 index 00000000000..cfb9cd46cfa --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputRuleModule.kt @@ -0,0 +1,52 @@ +package org.oppia.domain.classify.rules.textinput + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.TextInputRules + +/** Module that binds rule classifiers corresponding to the text input interaction. */ +@Module +class TextInputRuleModule { + @Provides + @IntoMap + @StringKey("CaseSensitiveEquals") + @TextInputRules + internal fun provideTextInputCaseSensitiveEqualsRuleClassifier( + classifierProvider: TextInputCaseSensitiveEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Contains") + @TextInputRules + internal fun provideTextInputContainsRuleClassifier( + classifierProvider: TextInputContainsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("Equals") + @TextInputRules + internal fun provideTextInputEqualsRuleClassifier( + classifierProvider: TextInputEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("FuzzyEquals") + @TextInputRules + internal fun provideTextInputFuzzyEqualsRuleClassifier( + classifierProvider: TextInputFuzzyEqualsRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() + + @Provides + @IntoMap + @StringKey("StartsWith") + @TextInputRules + internal fun provideTextInputStartsWithRuleClassifier( + classifierProvider: TextInputStartsWithRuleClassifierProvider + ): RuleClassifier = classifierProvider.createRuleClassifier() +} diff --git a/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt new file mode 100644 index 00000000000..7a8d73fbefe --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt @@ -0,0 +1,28 @@ +package org.oppia.domain.classify.rules.textinput + +import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.RuleClassifier +import org.oppia.domain.classify.rules.RuleClassifierProvider +import org.oppia.domain.classify.rules.GenericRuleClassifier +import org.oppia.domain.util.normalizeWhitespace +import javax.inject.Inject + +/** + * Provider for a classifier that determines whether an answer starts with the rule's input per the text input + * interaction. + * + * https://github.com/oppia/oppia/blob/37285a/extensions/interactions/TextInput/directives/text-input-rules.service.ts#L64 + */ +internal class TextInputStartsWithRuleClassifierProvider @Inject constructor( + private val classifierFactory: GenericRuleClassifier.Factory +): RuleClassifierProvider, GenericRuleClassifier.SingleInputMatcher { + + override fun createRuleClassifier(): RuleClassifier { + return classifierFactory.createSingleInputClassifier(InteractionObject.ObjectTypeCase.NORMALIZED_STRING, "x", this) + } + + // TODO(#210): Add tests for this classifier. + override fun matches(answer: String, input: String): Boolean { + return answer.normalizeWhitespace().startsWith(input.normalizeWhitespace()) + } +} diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt index f439d5463cd..8269b6b073f 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationProgressController.kt @@ -124,17 +124,22 @@ class ExplorationProgressController @Inject constructor( explorationProgress.advancePlayStageTo(PlayStage.SUBMITTING_ANSWER) asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) - val topPendingState = explorationProgress.stateDeck.getPendingTopState() - val outcome = answerClassificationController.classify(topPendingState, answer) - val answerOutcome = explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) - explorationProgress.stateDeck.submitAnswer(answer, answerOutcome.feedback) - // Follow the answer's outcome to another part of the graph if it's different. - if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) { - explorationProgress.stateDeck.pushState(explorationProgress.stateGraph.getState(answerOutcome.stateName)) + lateinit var answerOutcome: AnswerOutcome + try { + val topPendingState = explorationProgress.stateDeck.getPendingTopState() + val outcome = answerClassificationController.classify(topPendingState.interaction, answer) + answerOutcome = explorationProgress.stateGraph.computeAnswerOutcomeForResult(topPendingState, outcome) + explorationProgress.stateDeck.submitAnswer(answer, answerOutcome.feedback) + // Follow the answer's outcome to another part of the graph if it's different. + if (answerOutcome.destinationCase == AnswerOutcome.DestinationCase.STATE_NAME) { + explorationProgress.stateDeck.pushState(explorationProgress.stateGraph.getState(answerOutcome.stateName)) + } + } finally { + // Ensure that the user always returns to the VIEWING_STATE stage to avoid getting stuck in an 'always + // submitting answer' situation. This can specifically happen if answer classification throws an exception. + explorationProgress.advancePlayStageTo(PlayStage.VIEWING_STATE) } - // Return to viewing the state. - explorationProgress.advancePlayStageTo(PlayStage.VIEWING_STATE) asyncDataSubscriptionManager.notifyChangeAsync(CURRENT_STATE_DATA_PROVIDER_ID) return MutableLiveData(AsyncResult.success(answerOutcome)) diff --git a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt index 65f41980fed..2962eaa8276 100644 --- a/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt +++ b/domain/src/main/java/org/oppia/domain/exploration/ExplorationRetriever.kt @@ -177,19 +177,15 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { return ruleSpecList } for (i in 0 until ruleSpecJson.length()) { - ruleSpecList.add( - RuleSpec.newBuilder() - .setRuleType( - ruleSpecJson.getJSONObject(i).getString("rule_type") - ) - .setInput( - createInputFromJson( - ruleSpecJson.getJSONObject(i).getJSONObject("inputs"), - /* keyName= */"x", interactionId - ) - ) - .build() - ) + val ruleSpecBuilder = RuleSpec.newBuilder() + ruleSpecBuilder.ruleType = ruleSpecJson.getJSONObject(i).getString("rule_type") + val inputsJson = ruleSpecJson.getJSONObject(i).getJSONObject("inputs") + val inputKeysIterator = inputsJson.keys() + while (inputKeysIterator.hasNext()) { + val inputName = inputKeysIterator.next() + ruleSpecBuilder.putInput(inputName, createInputFromJson(inputsJson, inputName, interactionId)) + } + ruleSpecList.add(ruleSpecBuilder.build()) } return ruleSpecList } @@ -258,7 +254,7 @@ class ExplorationRetriever @Inject constructor(private val context: Context) { val stringList = mutableListOf() if (value[0] is String) { stringList.addAll(value as List) - return StringList.newBuilder().addAllStringList(stringList).build() + return StringList.newBuilder().addAllHtml(stringList).build() } return StringList.getDefaultInstance() } diff --git a/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt b/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt new file mode 100644 index 00000000000..886dfa1eeda --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/FloatExtensions.kt @@ -0,0 +1,15 @@ +package org.oppia.domain.util + +import kotlin.math.abs + +private const val EPSILON = 1e-5 + +/** Returns whether this float approximately equals another based on a consistent epsilon value. */ +fun Float.approximatelyEquals(other: Float): Boolean { + return abs(this - other) < EPSILON +} + +/** Returns whether this double approximately equals another based on a consistent epsilon value. */ +fun Double.approximatelyEquals(other: Double): Boolean { + return abs(this - other) < EPSILON +} diff --git a/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt b/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt new file mode 100644 index 00000000000..ec336625886 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/FractionExtensions.kt @@ -0,0 +1,29 @@ +package org.oppia.domain.util + +import org.oppia.app.model.Fraction + +/** + * Returns a float version of this fraction. + * + * See: https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L73. + */ +fun Fraction.toFloat(): Float { + val totalParts = ((wholeNumber * denominator) + numerator).toFloat() + val floatVal = totalParts / denominator.toFloat() + return if (isNegative) -floatVal else floatVal +} + +/** + * Returns this fraction in its most simplified form. + * + * See: https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L83. + */ +fun Fraction.toSimplestForm(): Fraction { + val commonDenominator = gcd(numerator, denominator) + return toBuilder().setNumerator(numerator / commonDenominator).setDenominator(denominator / commonDenominator).build() +} + +/** Returns the greatest common divisor between two integers. */ +private fun gcd(x: Int, y: Int): Int { + return if (y == 0) x else gcd(y, x % y) +} diff --git a/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt b/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt new file mode 100644 index 00000000000..cc3f660fb84 --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/util/StringExtensions.kt @@ -0,0 +1,9 @@ +package org.oppia.domain.util + +/** + * Normalizes whitespace in the specified string in a way consistent with Oppia web: + * https://github.com/oppia/oppia/blob/392323/core/templates/dev/head/filters/string-utility-filters/normalize-whitespace.filter.ts. + */ +fun String.normalizeWhitespace(): String { + return trim().replace(Regex.fromLiteral("\\s{2,}"), " ") +} diff --git a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt index 690388c65a7..81796e86877 100644 --- a/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/classify/AnswerClassificationControllerTest.kt @@ -9,39 +9,100 @@ import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.oppia.app.model.AnswerGroup +import org.oppia.app.model.Fraction import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject +import org.oppia.app.model.NumberUnit +import org.oppia.app.model.NumberWithUnits import org.oppia.app.model.Outcome -import org.oppia.app.model.State +import org.oppia.app.model.RuleSpec +import org.oppia.app.model.StringList import org.oppia.app.model.SubtitledHtml +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.robolectric.annotation.Config import javax.inject.Inject import javax.inject.Singleton +import kotlin.reflect.KClass +import kotlin.reflect.full.cast + +// For context: +// https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts. +private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." /** Tests for [AnswerClassificationController]. */ @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) class AnswerClassificationControllerTest { - private val TEST_STRING_ANSWER = InteractionObject.newBuilder().setNormalizedString("Some value").build() - private val TEST_INT_2_ANSWER = InteractionObject.newBuilder().setNonNegativeInt(1).build() - private val TEST_SIGNED_INT_121_ANSWER = InteractionObject.newBuilder().setSignedInt(121).build() - - private val OUTCOME_0 = Outcome.newBuilder() - .setDestStateName("First state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_0").setHtml("Feedback 1")) - .build() - private val OUTCOME_1 = Outcome.newBuilder() - .setDestStateName("Second state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_1").setHtml("Feedback 2")) - .build() - private val OUTCOME_2 = Outcome.newBuilder() - .setDestStateName("Third state") - .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_2").setHtml("Feedback 3")) - .build() + companion object { + private val TEST_STRING_0 = InteractionObject.newBuilder().setNormalizedString("Test string 0").build() + private val TEST_STRING_1 = InteractionObject.newBuilder().setNormalizedString("Test string 1").build() + + private val TEST_FRACTION_0 = InteractionObject.newBuilder() + .setFraction( + Fraction.newBuilder() + .setNumerator(1) + .setDenominator(2) + ) + .build() + private val TEST_FRACTION_1 = InteractionObject.newBuilder() + .setFraction( + Fraction.newBuilder() + .setIsNegative(true) + .setWholeNumber(5) + .setNumerator(1) + .setDenominator(2) + ) + .build() + + private val TEST_ITEM_SELECTION_SET_0 = InteractionObject.newBuilder() + .setSetOfHtmlString(StringList.newBuilder().addHtml("Elem 1").addHtml("Elem 2")) + .build() + private val TEST_ITEM_SELECTION_SET_1 = InteractionObject.newBuilder() + .setSetOfHtmlString(StringList.newBuilder().addHtml("Elem 0").addHtml("Elem 2").addHtml("Elem 3")) + .build() + + private val TEST_MULTIPLE_CHOICE_OPTION_0 = InteractionObject.newBuilder().setNonNegativeInt(0).build() + private val TEST_MULTIPLE_CHOICE_OPTION_1 = InteractionObject.newBuilder().setNonNegativeInt(1).build() + + private val TEST_NUMBER_WITH_UNITS_0 = InteractionObject.newBuilder() + .setNumberWithUnits(NumberWithUnits.newBuilder().setReal(1.0f).addUnit(NumberUnit.newBuilder().setUnit("cm"))) + .build() + private val TEST_NUMBER_WITH_UNITS_1 = InteractionObject.newBuilder() + .setNumberWithUnits(NumberWithUnits.newBuilder().setReal(1.0f).addUnit(NumberUnit.newBuilder().setUnit("m"))) + .build() + + private val TEST_NUMBER_0 = InteractionObject.newBuilder().setReal(1.0).build() + private val TEST_NUMBER_1 = InteractionObject.newBuilder().setReal(-3.5).build() + + private val CONTINUE_ANSWER = InteractionObject.newBuilder() + .setNormalizedString(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) + .build() + + private val DEFAULT_OUTCOME = Outcome.newBuilder() + .setDestStateName("Default state dest") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_def").setHtml("Default feedback.")) + .build() + + private val OUTCOME_0 = Outcome.newBuilder() + .setDestStateName("First state") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_0").setHtml("Feedback 1")) + .build() + private val OUTCOME_1 = Outcome.newBuilder() + .setDestStateName("Second state") + .setFeedback(SubtitledHtml.newBuilder().setContentId("content_id_1").setHtml("Feedback 2")) + .build() + } @Inject lateinit var answerClassificationController: AnswerClassificationController @@ -52,53 +113,338 @@ class AnswerClassificationControllerTest { } @Test - fun testClassify_testInteraction_withOnlyDefaultOutcome_returnsDefaultOutcome() { + fun testClassify_forUnknownInteraction_throwsException() { + val interaction = Interaction.getDefaultInstance() + + val exception = assertThrows(IllegalStateException::class) { + answerClassificationController.classify(interaction, TEST_STRING_0) + } + + assertThat(exception).hasMessageThat().contains("Encountered unknown interaction type") + } + + @Test + fun testClassify_forUnknownRuleType_throwsException() { val interaction = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_0) + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder().addRuleSpecs(RuleSpec.getDefaultInstance())) .build() - val state = createTestState("Things you can do", interaction) - val outcome = answerClassificationController.classify(state, TEST_STRING_ANSWER) + val exception = assertThrows(IllegalStateException::class) { + answerClassificationController.classify(interaction, TEST_STRING_0) + } + assertThat(exception).hasMessageThat().contains("Expected interaction TextInput to have classifier for rule type") + } + + @Test + fun testClassify_forNoAnswerGroups_returnsFeedbackAndDestOfDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forOneAnswerGroup_oneRuleSpec_doesNotMatch_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .setDefaultOutcome(DEFAULT_OUTCOME) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().putInput("x", TEST_STRING_1).setRuleType("Equals")) + .setOutcome(OUTCOME_0)) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The test string does not match the rule spec. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forContinueInteraction_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("Continue") + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, CONTINUE_ANSWER) + + // The continue interaction always returns the default outcome because it has no rule classifiers. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forFractionInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("FractionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEquivalentTo").putInput("f", TEST_FRACTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_FRACTION_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forFractionInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("FractionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEquivalentTo").putInput("f", TEST_FRACTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_FRACTION_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forItemSelectionInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("ItemSelectionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_ITEM_SELECTION_SET_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_ITEM_SELECTION_SET_0) + + // The first group should match. assertThat(outcome).isEqualTo(OUTCOME_0) } @Test - fun testClassify_testInteraction_withMultipleOutcomes_wrongAnswer_returnsDefaultOutcome() { + fun testClassify_forItemSelectionInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("ItemSelectionInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_ITEM_SELECTION_SET_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_ITEM_SELECTION_SET_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forMultipleChoiceInput_matches_returnAnswerGroup() { val interaction = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_1) - .addAnswerGroups(AnswerGroup.newBuilder().setOutcome(OUTCOME_2)) + .setId("MultipleChoiceInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_MULTIPLE_CHOICE_OPTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val state = createTestState("Welcome!", interaction) - val outcome = answerClassificationController.classify(state, TEST_INT_2_ANSWER) + val outcome = answerClassificationController.classify(interaction, TEST_MULTIPLE_CHOICE_OPTION_0) + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forMultipleChoiceInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("MultipleChoiceInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_MULTIPLE_CHOICE_OPTION_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_MULTIPLE_CHOICE_OPTION_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forNumberWithUnits_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("NumberWithUnits") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEqualTo").putInput("f", TEST_NUMBER_WITH_UNITS_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_WITH_UNITS_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forNumberWithUnits_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("NumberWithUnits") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("IsEqualTo").putInput("f", TEST_NUMBER_WITH_UNITS_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_WITH_UNITS_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forNumericInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("NumericInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_NUMBER_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forNumericInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("NumericInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_NUMBER_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_NUMBER_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_forTextInput_matches_returnAnswerGroup() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The first group should match. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_forTextInput_doesNotMatch_returnDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // The default outcome should be returned since the answer didn't match. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) + } + + @Test + fun testClassify_multipleAnswerGroups_matchesOneRuleSpec_returnsAnswerGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_1)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) + .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // The outcome of the singly matched answer group should be returned. assertThat(outcome).isEqualTo(OUTCOME_1) } @Test - fun testClassify_afterPreviousInteraction_returnsDefaultOutcomeOfSecondInteraction() { - val interaction1 = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_1) - .addAnswerGroups(AnswerGroup.newBuilder().setOutcome(OUTCOME_0)) + fun testClassify_multipleAnswerGroups_matchesMultipleRuleSpecs_returnsAnswerGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_1)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val interaction2 = Interaction.newBuilder() - .setDefaultOutcome(OUTCOME_2) + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) + + // The outcome of the singly matched answer group should be returned. Matching multiple rule specs doesn't matter. + assertThat(outcome).isEqualTo(OUTCOME_0) + } + + @Test + fun testClassify_multipleAnswerGroups_matchesMultipleGroups_returnsFirstMatchedGroupOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() - val state1 = createTestState("Numeric input", interaction1) - answerClassificationController.classify(state1, TEST_SIGNED_INT_121_ANSWER) - val state2 = createTestState("Things you can do", interaction2) - val outcome = answerClassificationController.classify(state2, TEST_STRING_ANSWER) + val outcome = answerClassificationController.classify(interaction, TEST_STRING_0) - assertThat(outcome).isEqualTo(OUTCOME_2) + // The first matched group should be returned even though multiple groups are matching. + assertThat(outcome).isEqualTo(OUTCOME_0) } - private fun createTestState(stateName: String, interaction: Interaction): State { - return State.newBuilder() - .setName(stateName) - .setInteraction(interaction) + @Test + fun testClassify_multipleAnswerGroups_matchesNone_returnsDefaultOutcome() { + val interaction = Interaction.newBuilder() + .setId("TextInput") + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("Equals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_0)) + .addAnswerGroups(AnswerGroup.newBuilder() + .addRuleSpecs(RuleSpec.newBuilder().setRuleType("CaseSensitiveEquals").putInput("x", TEST_STRING_0)) + .setOutcome(OUTCOME_1)) + .setDefaultOutcome(DEFAULT_OUTCOME) .build() + + val outcome = answerClassificationController.classify(interaction, TEST_STRING_1) + + // No matching groups should always yield the default outcome. + assertThat(outcome).isEqualTo(DEFAULT_OUTCOME) } private fun setUpTestApplicationComponent() { @@ -108,6 +454,22 @@ class AnswerClassificationControllerTest { .inject(this) } + // TODO(#89): Move to a common test library. + /** A replacement to JUnit5's assertThrows(). */ + private fun assertThrows(type: KClass, operation: () -> Unit): T { + try { + operation() + fail("Expected to encounter exception of $type") + } catch (t: Throwable) { + if (type.isInstance(t)) { + return type.cast(t) + } + // Unexpected exception; throw it. + throw t + } + throw AssertionError("Reached an impossible state when verifying that an exception was thrown.") + } + // TODO(#89): Move this to a common test application component. @Module class TestModule { @@ -120,7 +482,11 @@ class AnswerClassificationControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt index da49561b8cd..927bb1ce96e 100644 --- a/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationDataControllerTest.kt @@ -33,6 +33,14 @@ import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.app.model.Exploration +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.data.AsyncResult import org.oppia.util.logging.EnableConsoleLog import org.oppia.util.logging.EnableFileLog @@ -205,7 +213,11 @@ class ExplorationDataControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt index 0738bfdbbd0..48214795510 100644 --- a/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/domain/exploration/ExplorationProgressControllerTest.kt @@ -33,6 +33,14 @@ import org.oppia.app.model.EphemeralState.StateTypeCase.PENDING_STATE import org.oppia.app.model.EphemeralState.StateTypeCase.TERMINAL_STATE import org.oppia.app.model.Exploration import org.oppia.app.model.InteractionObject +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.util.data.AsyncResult import org.oppia.util.threading.BackgroundDispatcher import org.oppia.util.threading.BlockingDispatcher @@ -828,7 +836,7 @@ class ExplorationProgressControllerTest { submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121)) + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(121.0)) result.observeForever(mockAsyncAnswerOutcomeObserver) advanceUntilIdle() @@ -841,23 +849,21 @@ class ExplorationProgressControllerTest { @Test @ExperimentalCoroutinesApi - fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsDefaultOutcome() = runBlockingTest(coroutineContext) { + fun testSubmitAnswer_forNumericInput_wrongAnswer_returnsOutcomeWithTransition() = runBlockingTest(coroutineContext) { subscribeToCurrentStateToAllowExplorationToLoad() playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - val result = explorationProgressController.submitAnswer(createNumericInputAnswer(0)) + val result = explorationProgressController.submitAnswer(createNumericInputAnswer(122.0)) result.observeForever(mockAsyncAnswerOutcomeObserver) advanceUntilIdle() - // Verify that the answer was wrong, and that there's no handler for it so the default outcome is returned. - // TODO(#114): Update this test to target a non-default outcome since the default outcome *should* be impossible to - // encounter. + // Verify that the answer submission failed as expected. verify(mockAsyncAnswerOutcomeObserver, atLeastOnce()).onChanged(asyncAnswerOutcomeCaptor.capture()) val answerOutcome = asyncAnswerOutcomeCaptor.value.getOrThrow() assertThat(answerOutcome.destinationCase).isEqualTo(AnswerOutcome.DestinationCase.SAME_STATE) - assertThat(answerOutcome.feedback.html).contains("If you got here, something's gone a bit wrong") + assertThat(answerOutcome.feedback.html).contains("You are actually very close.") } @Test @@ -867,7 +873,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) val result = explorationProgressController.submitAnswer(createContinueButtonAnswer()) result.observeForever(mockAsyncAnswerOutcomeObserver) @@ -888,7 +894,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() @@ -929,7 +935,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() val moveToStateResult = explorationProgressController.moveToNextState() @@ -1049,7 +1055,7 @@ class ExplorationProgressControllerTest { } @ExperimentalCoroutinesApi - private fun submitNumericInputAnswer(numericAnswer: Int) { + private fun submitNumericInputAnswer(numericAnswer: Double) { explorationProgressController.submitAnswer(createNumericInputAnswer(numericAnswer)) testDispatcher.advanceUntilIdle() } @@ -1073,7 +1079,7 @@ class ExplorationProgressControllerTest { } @ExperimentalCoroutinesApi - private fun submitNumericInputAnswerAndMoveToNextState(numericAnswer: Int) { + private fun submitNumericInputAnswerAndMoveToNextState(numericAnswer: Double) { submitNumericInputAnswer(numericAnswer) moveToNextState() } @@ -1107,7 +1113,7 @@ class ExplorationProgressControllerTest { playExploration(TEST_EXPLORATION_ID_5) submitMultipleChoiceAnswerAndMoveToNextState(0) submitTextInputAnswerAndMoveToNextState("Finnish") - submitNumericInputAnswerAndMoveToNextState(121) + submitNumericInputAnswerAndMoveToNextState(121.0) submitContinueButtonAnswerAndMoveToNextState() endExploration() } @@ -1120,8 +1126,8 @@ class ExplorationProgressControllerTest { return InteractionObject.newBuilder().setNormalizedString(textAnswer).build() } - private fun createNumericInputAnswer(numericAnswer: Int): InteractionObject { - return InteractionObject.newBuilder().setSignedInt(numericAnswer).build() + private fun createNumericInputAnswer(numericAnswer: Double): InteractionObject { + return InteractionObject.newBuilder().setReal(numericAnswer).build() } private fun createContinueButtonAnswer() = createTextInputAnswer(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) @@ -1164,7 +1170,11 @@ class ExplorationProgressControllerTest { // TODO(#89): Move this to a common test application component. @Singleton - @Component(modules = [TestModule::class]) + @Component(modules = [ + TestModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, + MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, + TextInputRuleModule::class, InteractionsModule::class + ]) interface TestApplicationComponent { @Component.Builder interface Builder { diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index 338994bbd94..1bb435382a2 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -134,7 +134,7 @@ message RuleSpec { // Mapping from the name of the rule template to the value of the rule template. // The keys and values for this map can be deduced from // https://github.com/oppia/oppia/blob/develop/extensions/interactions/rule_templates.json - InteractionObject input = 1; + map input = 1; string rule_type = 2; } diff --git a/model/src/main/proto/interaction_object.proto b/model/src/main/proto/interaction_object.proto index 0fb753b3671..662337f37b6 100644 --- a/model/src/main/proto/interaction_object.proto +++ b/model/src/main/proto/interaction_object.proto @@ -17,12 +17,13 @@ message InteractionObject { // repeated fields cannot be within a oneof so lists are // wrapped into a new message. StringList set_of_html_string = 7; + Fraction fraction = 8; } } // Structure containing a string list. message StringList { - repeated string string_list = 1; + repeated string html = 1; } // Structure for a number with units object. @@ -31,11 +32,11 @@ message NumberWithUnits { float real = 1; Fraction fraction = 2; } - repeated Unit units = 3; + repeated NumberUnit unit = 3; } // Structure for a unit -message Unit { +message NumberUnit { string unit = 1; int32 exponent = 2; } @@ -43,7 +44,7 @@ message Unit { // Structure for a fraction object. message Fraction { bool is_negative = 1; - bool whole_number = 2; + int32 whole_number = 2; int32 numerator = 3; int32 denominator = 4; }