Skip to content

Commit

Permalink
Fix part of #4044: Add protos & testing library for polynomials (#4050)
Browse files Browse the repository at this point in the history
## Explanation
Fix part of #4044
Originally copied from #2173 when it was in proof-of-concept form

This PR introduces the protos and test subject to represent polynomials.

For the purposes of upcoming classifier work, a polynomial is defined as a sum of terms where each term has a coefficient and zero or more variables with positive integer powers. This representation provides support for all real polynomials, and keeps a nicely structured representation for coefficients that actually allows for retaining integers and rational values (this will become more evident when math expression -> polynomial conversion is added).

Furthermore, various extensions files are added to help support some of the fundamental operations (mainly needed by test subjects). These operations are being thoroughly tested, and will be augmented with a lot more functionality in upcoming PRs.

The new polynomial test subject doesn't have new tests to keep this PR focused on production code, and since it's relatively easy to verify as correct via review. #4100 is tracking adding tests.

This structure will be utilized in a later PR in IsEquivalentTo classifier implementations for each numeric expression, algebraic expression, and math equation interaction. For specific details on this classifier, see [the PRD](https://docs.google.com/document/d/1x2vcSjocJUXkwwlce5Gjjq_Z83ykVIn2Fp0BcnrOrTg/edit#heading=h.1q3av9yssyi5). Polynomials are the ideal structure for verifying equivalence since they fully collapse expressions regardless of associativity, commutativity, and distributivity (to some extent--caveats will be noted in the future PR that converts expressions to this new polynomial structure).

Slightly separate from polynomials, ``FloatExtensions`` was updated to include better epsilon values for comparing both floats and doubles (and a separate one is used for doubles). These are loosely based on the computed machine epsilon value listed here: https://en.wikipedia.org/wiki/Machine_epsilon, but it uses a higher order of magnitude and rounding for a bit more "wiggle room" for equality (so that it's not essentially replicating '==' for values close to 1).

## Essential Checklist
- [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".)
- [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation.
- [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
N/A -- proto & testing library only change, and the proto changes are only additions. No UI functionality is yet affected by these changes.

Commit history:

* Copy proto-based changes from #2173.

* Introduce math.proto & refactor math extensions.

Much of this is copied from #2173.

* Migrate tests & remove unneeded prefix.

* Add needed newline.

* Some needed Fraction changes.

* Introduce math expression + equation protos.

Also adds testing libraries for both + fractions & reals (new
structure).

Most of this is copied from #2173.

* Add protos + testing lib for commutative exprs.

* Add protos & test libs for polynomials.

* Lint fix.

* Lint fixes.

* Fix broken test post-refactor.

* Post-merge fix.

* Add regex check, docs, and resolve TODOs.

This also changes regex handling in the check to be more generic for
better flexibility when matching files.

* Lint fix.

* Fix failing static checks.

* Fix broken CI checks.

Adds missing KDocs, test file exemptions, and fixes the Gradle build.

* Lint fixes.

* Add docs & exempted tests.

* Remove blank line.

* Add docs + tests.

* Address reviewer comments + other stuff.

This also fixes a typo and incorrectly ordered exemptions list I noticed
during development of downstream PRs.

* Move StringExtensions & fraction parsing.

This splits fraction parsing between UI & utility components.

* Address reviewer comments.

* Alphabetize test exemptions.

* Add missing KDocs.

* Remove the ComparableOperationList wrapper.

* Use more intentional epsilons for float comparing.

* Remove failing test.

* Fix broken build.

* Fix broken build post-merge.

* Post-merge fix.

* More post-merge fixes.
  • Loading branch information
BenHenning authored Mar 26, 2022
1 parent 4805c65 commit 7e75830
Show file tree
Hide file tree
Showing 18 changed files with 2,027 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.oppia.android.domain.util

import org.oppia.android.app.model.ClickOnImage
import org.oppia.android.app.model.Fraction
import org.oppia.android.app.model.ImageWithRegions
import org.oppia.android.app.model.InteractionObject
import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.BOOL_VALUE
Expand Down Expand Up @@ -107,15 +106,6 @@ private fun ImageWithRegions.toAnswerString(): String =
private fun ClickOnImage.toAnswerString(): String =
"[(${clickedRegionsList.joinToString()}), (${clickPosition.x}, ${clickPosition.y})]"

// https://github.com/oppia/oppia/blob/37285a/core/templates/dev/head/domain/objects/FractionObjectFactory.ts#L47
private fun Fraction.toAnswerString(): String {
val fractionString = if (numerator != 0) "$numerator/$denominator" else ""
val mixedString = if (wholeNumber != 0) "$wholeNumber $fractionString" else ""
val positiveFractionString = if (mixedString.isNotEmpty()) mixedString else fractionString
val negativeString = if (isNegative) "-" else ""
return if (positiveFractionString.isNotEmpty()) "$negativeString$positiveFractionString" else "0"
}

private fun TranslatableHtmlContentId.toAnswerString(): String {
return "content_id=$contentId"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.junit.runner.RunWith
import org.oppia.android.app.model.WrittenTranslationContext
import org.oppia.android.domain.classify.InteractionObjectTestBuilder
import org.oppia.android.testing.assertThrows
import org.oppia.android.util.math.FLOAT_EQUALITY_INTERVAL
import org.oppia.android.util.math.DOUBLE_EQUALITY_EPSILON
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import javax.inject.Inject
Expand All @@ -33,13 +33,11 @@ class NumericInputEqualsRuleClassifierProviderTest {
private val NEGATIVE_REAL_VALUE_3_5 =
InteractionObjectTestBuilder.createReal(value = -3.5)
private val FIVE_TIMES_FLOAT_EQUALITY_INTERVAL =
InteractionObjectTestBuilder.createReal(value = 5 * FLOAT_EQUALITY_INTERVAL)
private val SIX_TIMES_FLOAT_EQUALITY_INTERVAL =
InteractionObjectTestBuilder.createReal(value = 6 * FLOAT_EQUALITY_INTERVAL)
InteractionObjectTestBuilder.createReal(value = 5 * DOUBLE_EQUALITY_EPSILON)
private val FIVE_POINT_ONE_TIMES_FLOAT_EQUALITY_INTERVAL =
InteractionObjectTestBuilder.createReal(
value = 5 * FLOAT_EQUALITY_INTERVAL +
FLOAT_EQUALITY_INTERVAL / 10
value = 5 * DOUBLE_EQUALITY_EPSILON +
DOUBLE_EQUALITY_EPSILON / 10
)
private val STRING_VALUE =
InteractionObjectTestBuilder.createString(value = "test")
Expand Down Expand Up @@ -142,21 +140,6 @@ class NumericInputEqualsRuleClassifierProviderTest {
assertThat(matches).isFalse()
}

@Test
fun testPositiveRealAnswer_positiveRealInput_valueAtRange_valuesDoNotMatch() {
val inputs = mapOf(
"x" to FIVE_TIMES_FLOAT_EQUALITY_INTERVAL
)

val matches = inputEqualsRuleClassifier.matches(
answer = SIX_TIMES_FLOAT_EQUALITY_INTERVAL,
inputs = inputs,
writtenTranslationContext = WrittenTranslationContext.getDefaultInstance()
)

assertThat(matches).isFalse()
}

@Test
fun testRealAnswer_missingInput_throwsException() {
val inputs = mapOf("y" to POSITIVE_REAL_VALUE_1_5)
Expand Down
26 changes: 26 additions & 0 deletions model/src/main/proto/math.proto
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,29 @@ message ComparableOperation {
}
}
}

// Represents a polynomial, e.g.: 2x^3+3x-y+7.
message Polynomial {
// The list of terms in this polynomial, e.g. the '2x^3', '3x', '-y', and '-7' in 2x^3+3x-y+7.
repeated Term term = 1;

// Represents a polynomial term, i.e. a real coefficient multiplied by one or more variables, each
// of which may have a power >= 1.
message Term {
// The coefficient of this term (which may be zero or negative), e.g. '2' in '2x^3'.
Real coefficient = 1;

// The variables of this term. This list can be zero or more variables long (where zero
// variables indicate a constant polynomial term).
repeated Variable variable = 2;

// A variable within the term, that is, a variable with a positive power.
message Variable {
// The name of the variable, e.g. 'x' in 'x^3'.
string name = 1;

// The power of the variable, e.g. '3' in 'x^3'.
uint32 power = 2;
}
}
}
3 changes: 2 additions & 1 deletion scripts/assets/test_file_exemptions.textproto
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,9 @@ exempted_file_path: "testing/src/main/java/org/oppia/android/testing/espresso/Ko
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/junit/DefineAppLanguageLocaleContext.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/ComparableOperationSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/FractionSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathExpressionSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/MathEquationSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/PolynomialSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/math/RealSubject.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/mockito/MockitoKotlinHelper.kt"
exempted_file_path: "testing/src/main/java/org/oppia/android/testing/network/ApiMockLoader.kt"
Expand Down
18 changes: 18 additions & 0 deletions testing/src/main/java/org/oppia/android/testing/math/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ kt_android_library(
],
)

kt_android_library(
name = "polynomial_subject",
testonly = True,
srcs = [
"PolynomialSubject.kt",
],
visibility = [
"//:oppia_testing_visibility",
],
deps = [
":real_subject",
"//model/src/main/proto:math_java_proto_lite",
"//third_party:com_google_truth_extensions_truth-liteproto-extension",
"//third_party:com_google_truth_truth",
"//utility/src/main/java/org/oppia/android/util/math:extensions",
],
)

kt_android_library(
name = "real_subject",
testonly = True,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package org.oppia.android.testing.math

import com.google.common.truth.FailureMetadata
import com.google.common.truth.IntegerSubject
import com.google.common.truth.StringSubject
import com.google.common.truth.Truth.assertAbout
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import com.google.common.truth.extensions.proto.LiteProtoSubject
import org.oppia.android.app.model.Polynomial
import org.oppia.android.testing.math.PolynomialSubject.Companion.assertThat
import org.oppia.android.testing.math.RealSubject.Companion.assertThat
import org.oppia.android.util.math.getConstant
import org.oppia.android.util.math.isConstant
import org.oppia.android.util.math.toPlainText

// TODO(#4100): Add tests for this class.

/**
* Truth subject for verifying properties of [Polynomial]s.
*
* Note that this class is also a [LiteProtoSubject] so other aspects of the underlying [Polynomial]
* proto can be verified through inherited methods.
*
* Call [assertThat] to create the subject.
*/
class PolynomialSubject(
metadata: FailureMetadata,
private val actual: Polynomial?
) : LiteProtoSubject(metadata, actual) {
private val nonNullActual by lazy {
checkNotNull(actual) {
"Expected polynomial to be defined, not null (is the expression/equation not a valid" +
" polynomial?)"
}
}

/** Verifies that the represented [Polynomial] is null (i.e. not a valid polynomial). */
fun isNotValidPolynomial() {
assertWithMessage(
"Expected polynomial to be undefined, but was: ${actual?.toPlainText()}"
).that(actual).isNull()
}

/**
* Verifies that the represented [Polynomial] is a constant (i.e. [Polynomial.isConstant] and
* returns a [RealSubject] to verify the value of the constant polynomial.
*/
fun isConstantThat(): RealSubject {
assertWithMessage("Expected polynomial to be constant, but was: ${nonNullActual.toPlainText()}")
.that(nonNullActual.isConstant())
.isTrue()
return assertThat(nonNullActual.getConstant())
}

/**
* Returns an [IntegerSubject] to test [Polynomial.getTermCount].
*
* This method never fails since the underlying property defaults to 0 if there are no terms
* defined in the polynomial (unless the polynomial is null).
*/
fun hasTermCountThat(): IntegerSubject = assertThat(nonNullActual.termCount)

/**
* Returns a [PolynomialTermSubject] to test [Polynomial.getTerm] for the specified index.
*
* This method throws if the index doesn't correspond to a valid term. Callers should first verify
* the term count using [hasTermCountThat].
*/
fun term(index: Int): PolynomialTermSubject = assertThat(nonNullActual.termList[index])

/**
* Returns a [StringSubject] to test the plain-text representation of the [Polynomial] (i.e. via
* [Polynomial.toPlainText]).
*/
fun evaluatesToPlainTextThat(): StringSubject = assertThat(nonNullActual.toPlainText())

companion object {
/** Returns a new [PolynomialSubject] to verify aspects of the specified [Polynomial] value. */
fun assertThat(actual: Polynomial?): PolynomialSubject =
assertAbout(::PolynomialSubject).that(actual)

private fun assertThat(actual: Polynomial.Term): PolynomialTermSubject =
assertAbout(::PolynomialTermSubject).that(actual)

private fun assertThat(actual: Polynomial.Term.Variable): PolynomialTermVariableSubject =
assertAbout(::PolynomialTermVariableSubject).that(actual)
}

/**
* Truth subject for verifying properties of [Polynomial.Term]s.
*
* Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
* [Polynomial.Term] proto can be verified through inherited methods.
*/
class PolynomialTermSubject(
metadata: FailureMetadata,
private val actual: Polynomial.Term
) : LiteProtoSubject(metadata, actual) {
/**
* Returns a [RealSubject] to test [Polynomial.Term.getCoefficient] for the represented term.
*
* This method never fails since the underlying property defaults to a default instance if it's
* not defined in the term.
*/
fun hasCoefficientThat(): RealSubject = assertThat(actual.coefficient)

/**
* Returns an [IntegerSubject] to test [Polynomial.Term.getVariableCount] for the represented
* term.
*
* This method never fails since the underlying property defaults to 0 if there are no variables
* in the represented term.
*/
fun hasVariableCountThat(): IntegerSubject = assertThat(actual.variableCount)

/**
* Returns a [PolynomialTermVariableSubject] to test [Polynomial.Term.getVariable] for the
* specified index.
*
* This method throws if the index doesn't correspond to a valid variable. Callers should first
* verify the variable count using [hasVariableCountThat].
*/
fun variable(index: Int): PolynomialTermVariableSubject =
assertThat(actual.variableList[index])
}

/**
* Truth subject for verifying properties of [Polynomial.Term.Variable]s.
*
* Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
* [Polynomial.Term.Variable] proto can be verified through inherited methods.
*/
class PolynomialTermVariableSubject(
metadata: FailureMetadata,
private val actual: Polynomial.Term.Variable
) : LiteProtoSubject(metadata, actual) {
/**
* Returns a [StringSubject] to test [Polynomial.Term.Variable.getName] for the represented
* variable.
*
* This method never fails since the underlying property defaults to empty string if it's not
* defined in the variable.
*/
fun hasNameThat(): StringSubject = assertThat(actual.name)

/**
* Returns an [IntegerSubject] to test [Polynomial.Term.Variable.getPower] for the represented
* variable.
*
* This method never fails since the underlying property defaults to 0 if it's not defined in
* the variable.
*/
fun hasPowerThat(): IntegerSubject = assertThat(actual.power)
}
}
2 changes: 2 additions & 0 deletions utility/src/main/java/org/oppia/android/util/math/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ kt_android_library(
srcs = [
"FloatExtensions.kt",
"FractionExtensions.kt",
"PolynomialExtensions.kt",
"RatioExtensions.kt",
"RealExtensions.kt",
],
visibility = [
"//:oppia_api_visibility",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,40 @@ package org.oppia.android.util.math

import kotlin.math.abs

/** The error margin used for float equality by [Float.approximatelyEquals]. */
const val FLOAT_EQUALITY_INTERVAL = 1e-5
/**
* The error margin used for approximately [Float] equality checking.
*
* Note that the machine epsilon value from https://en.wikipedia.org/wiki/Machine_epsilon is defined
* defined as the smallest value that, when added to, or subtract from, 1, will result in a value
* that is exactly equal to 1. A slightly larger value is picked here for some allowance in
* variance.
*/
const val FLOAT_EQUALITY_EPSILON: Float = 1e-6f

/** Returns whether this float approximately equals another based on a consistent epsilon value. */
/**
* The error margin used for approximately [Double] equality checking.
*
* See [FLOAT_EQUALITY_EPSILON] for an explanation of this value.
*/
const val DOUBLE_EQUALITY_EPSILON: Double = 1e-15

/**
* Returns whether this float approximately equals another based on a consistent epsilon value
* ([FLOAT_EQUALITY_EPSILON]).
*/
fun Float.approximatelyEquals(other: Float): Boolean {
return abs(this - other) < FLOAT_EQUALITY_INTERVAL
return abs(this - other) < FLOAT_EQUALITY_EPSILON
}

/** Returns whether this double approximately equals another based on a consistent epsilon value. */
/** Returns whether this double approximately equals another based on a consistent epsilon value
* ([DOUBLE_EQUALITY_EPSILON]).
*/
fun Double.approximatelyEquals(other: Double): Boolean {
return abs(this - other) < FLOAT_EQUALITY_INTERVAL
return abs(this - other) < DOUBLE_EQUALITY_EPSILON
}

/**
* Returns a string representation of this [Double] that keeps the double in pure decimal and never
* relies on scientific notation (unlike [Double.toString]).
*/
fun Double.toPlainString(): String = toBigDecimal().toPlainString()
Loading

0 comments on commit 7e75830

Please sign in to comment.