├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── lib ├── ebisu.dart └── src │ ├── math.dart │ └── mingolden.dart ├── pubspec.lock ├── pubspec.yaml └── test ├── ebisu_test.dart ├── test.json └── ulp.dart /.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool/ 2 | .packages 3 | doc/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.0.0 2 | ----- 3 | 4 | First release, ported from version 2.0.0 of the Java implementation. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ebisu 2 | ===== 3 | 4 | This is a Dart implementation of the [Ebisu](https://fasiha.github.io/ebisu/) 5 | quiz scheduling algorithm, originally developed in Python by 6 | [Ahmed Fasih](https://github.com/fasiha). 7 | 8 | In a nutshell, Ebisu works by modelling the probability that a fact will be 9 | remembered correctly at any arbitrary moment since the last time the fact was 10 | last quizzed. For more information, refer to the excellent 11 | [literate document](https://fasiha.github.io/ebisu/) describing the original 12 | implementation. 13 | 14 | This `ebisu_dart` package is unrelated to the similarly named 15 | [`ebisu`](https://pub.dev/packages/ebisu) package. 16 | 17 | Example 18 | ------- 19 | 20 | import 'package:ebisu_dart/ebisu.dart'; 21 | 22 | // Assume an inital halflife of 10 units (interpreted as minutes here). 23 | const initialHalflife = 10.0; 24 | var model = EbisuModel(initialHalflife); 25 | 26 | // Predict recall after 30 minutes have elapsed. 27 | final predictedRecall = model.predictRecall(30.0); 28 | 29 | // Update model after a correct answer. 30 | model = model.updateRecall(1, 1, 30.0); 31 | 32 | // Calculate new halflife. 33 | print(model.modelToPercentileDecay()); 34 | 35 | Porting notes 36 | ------------- 37 | 38 | This Dart implementation is a fairly literal port of 39 | [the Java implementation](https://github.com/fasiha/ebisu-java), but converted 40 | into idiomatic Dart: object oriented, no separation of interface/class, named 41 | and optional method arguments, and so on. To keep the excellent documentation of 42 | the original version relevant, method names have not been changed, even though 43 | this results in slightly worse naming. 44 | 45 | Documentation comments have been ported and updated from the Java version, but 46 | for an in-depth explanation of the algorithm, refer to the 47 | [original](https://fasiha.github.io/ebisu/). 48 | 49 | Versioning 50 | ---------- 51 | 52 | The major version number follows that of the Python implementation while also 53 | obeying semantic versioning; thus, API-breaking changes can only happen if a 54 | new major version of the Python implementation is released. 55 | 56 | Development 57 | ----------- 58 | 59 | All unit tests of the original Python implementation have been ported. To run 60 | them: 61 | 62 | pub test 63 | 64 | To run the linter (configured with the rules from the `pedantic` package): 65 | 66 | dartanalyzer . 67 | 68 | To publish a new version: 69 | 70 | - Update the version number in `pubspec.yaml`. 71 | - Update `CHANGELOG.md`. 72 | - Commit the changes with a message of the form `vX.Y.Z: Brief summary`. 73 | - Add a tag of the form `vX.Y.Z`. 74 | - Run `git push && git push --tags` to push the code and tag to GitHub. 75 | - Run `pub publish --dry-run` to check if everything is okay. 76 | - Run `pub publish` to publish. 77 | 78 | License 79 | ------- 80 | 81 | Public domain (see the `LICENSE` file). 82 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:pedantic/analysis_options.yaml 2 | -------------------------------------------------------------------------------- /lib/ebisu.dart: -------------------------------------------------------------------------------- 1 | /// Implementation of the Ebisu quiz scheduling algorithm: 2 | /// https://fasiha.github.io/ebisu/ 3 | /// 4 | /// This Dart port is largely based on the Java implementation of Ebisu: 5 | /// https://github.com/fasiha/ebisu-java 6 | 7 | import 'dart:math'; 8 | 9 | import 'package:meta/meta.dart'; 10 | import 'src/math.dart'; 11 | import 'src/mingolden.dart'; 12 | 13 | /// Wrapper class to store three numbers representing an Ebisu model. 14 | /// 15 | /// The model is encoded by a Beta distribution, parameterized by `alpha` and 16 | /// `beta`, which defines the probability of recall after a certain amount of 17 | /// elapsed `time` (units are left to the user). 18 | /// 19 | /// Instances of this class are immutable. The `updateRecall` method returns a 20 | /// new instance with the updated parameters. 21 | /// 22 | /// N.B. In the Python and JavaScript implementations of Ebisu, this class 23 | /// doesn't exist: those versions just store the three numeric parameters in a 24 | /// 3-tuple or array. This Dart implementation is more object oriented. 25 | @immutable 26 | class EbisuModel { 27 | /// The time since last review (in unspecified units) for which the Beta 28 | /// distribution models the recall probability. 29 | final double time; 30 | 31 | /// The `alpha` parameter of the Beta distribution. 32 | final double alpha; 33 | 34 | /// The `beta` parameter of the Beta distribution. 35 | final double beta; 36 | 37 | EbisuModel({required this.time, this.alpha = 4.0, double? beta}) 38 | : beta = beta ?? alpha, 39 | assert(time > 0.0), 40 | assert(alpha > 0.0) { 41 | assert(this.beta > 0.0); 42 | } 43 | 44 | /// Expected recall probability now, given a prior distribution on it. 45 | /// 46 | /// `tNow` is the *actual* time elapsed since this fact's most recent review. 47 | /// 48 | /// Optional keyword parameter `exact` makes the return value a probability, 49 | /// specifically, the expected recall probability `tNow` after the last review: a 50 | /// number between 0 and 1. If `exact` is false (the default), some calculations 51 | /// are skipped and the return value won't be a probability, but can still be 52 | /// compared against other values returned by this function. That is, if for an 53 | /// `EbishuModel m` 54 | /// 55 | /// ``` 56 | /// m.predictRecall(tNow1, exact = true) < m.predictRecall(tNow2, exact = true) 57 | /// ``` 58 | /// 59 | /// then it is guaranteed that 60 | /// 61 | /// ``` 62 | /// m.predictRecall(tNow1, exact = false) < m.predictRecall(tNow2, exact = false) 63 | /// ``` 64 | /// 65 | /// The default is set to `false` for computational efficiency. 66 | double predictRecall(double tNow, {bool exact = false}) { 67 | assert(tNow >= 0.0); 68 | if (tNow == 0.0) { 69 | return exact ? 1.0 : 0.0; 70 | } 71 | final dt = tNow / time; 72 | final ret = logBetaRatio(alpha + dt, alpha, beta); 73 | return exact ? exp(ret) : ret; 74 | } 75 | 76 | /// Update a prior on recall probability with a quiz result and time. 77 | /// 78 | /// `successes` is the number of times the user *successfully* exercised this 79 | /// memory during this review session, out of `n` attempts. Therefore, `0 <= 80 | /// successes <= total` and `1 <= total`. 81 | /// 82 | /// If the user was shown this flashcard only once during this review session, 83 | /// then `total=1`. If the quiz was a success, then `successes=1`, else 84 | /// `successes=0`. 85 | /// 86 | /// If the user was shown this flashcard *multiple* times during the review 87 | /// session (e.g., Duolingo-style), then `total` can be greater than 1. 88 | /// 89 | /// `tNow` is the time elapsed between this fact's last review and the review 90 | /// being used to update. 91 | /// 92 | /// Returns a new object describing the posterior distribution of recall 93 | /// probability at `tNow`. 94 | /// 95 | /// N.B. This function is tested for numerical stability for small `total < 5`. It 96 | /// may be unstable for much larger `total`. 97 | /// 98 | /// N.B.2. This function may throw `RangeError` upon numerical instability. 99 | /// This can happen if the algorithm is *extremely* surprised by a result; for 100 | /// example, if `successes=0` and `total=5` (complete failure) when `tNow` is very 101 | /// small compared to the halflife encoded in `prior`. Calling functions are asked 102 | /// to call this inside a try-except block and to handle any possible 103 | /// `RangeError`s in a manner consistent with user expectations, for example, 104 | /// by faking a more reasonable `tNow`. 105 | EbisuModel updateRecall(int successes, int total, double tNow) { 106 | assert(0 <= successes); 107 | assert(successes <= total); 108 | assert (1 <= total); 109 | assert(tNow > 0.0); 110 | final proposed = _updateRecall(successes, total, tNow, time); 111 | return _rebalance(successes, total, tNow, proposed); 112 | } 113 | 114 | /// Implementation of `updateRecall` that leaves it to the caller to rebalance 115 | /// the result if needed. 116 | EbisuModel _updateRecall(int successes, int total, double tNow, double tBack) { 117 | final dt = tNow / time; 118 | final et = tBack / tNow; 119 | 120 | final binomlns = [ 121 | for (var i = 0; i <= total - successes; i++) logBinom(total - successes, i), 122 | ]; 123 | final logs = [ 124 | for (final m in [0, 1, 2]) logSumExp( 125 | [for (var i = 0; i <= total - successes; i++) binomlns[i] + logBeta(beta, alpha + dt * (successes + i) + m * dt * et)], 126 | [for (var i = 0; i <= total - successes; i++) pow(-1.0, i).toDouble()], 127 | ), 128 | ]; 129 | 130 | final logDenominator = logs[0]; 131 | final logMeanNum = logs[1]; 132 | final logM2Num = logs[2]; 133 | 134 | final mean = exp(logMeanNum - logDenominator); 135 | final m2 = exp(logM2Num - logDenominator); 136 | final meanSq = exp(2 * (logMeanNum - logDenominator)); 137 | final sig2 = m2 - meanSq; 138 | 139 | if (mean <= 0) { 140 | throw RangeError('Invalid mean $mean found'); 141 | } 142 | if (m2 <= 0) { 143 | throw RangeError('Invalid second moment $m2 found'); 144 | } 145 | if (sig2 <= 0) { 146 | throw RangeError('Invalid variance $sig2 found: ' 147 | 'a=$alpha, b=$beta, t=$time, k=$successes, n=$total, tnow=$tNow, mean=$mean, m2=$m2, sig2=$sig2'); 148 | } 149 | 150 | // Convert the mean and variance of a Beta distribution to its parameters. See: 151 | // https://en.wikipedia.org/w/index.php?title=Beta_distribution&oldid=774237683#Two_unknown_parameters 152 | final tmp = mean * (1.0 - mean) / sig2 - 1.0; 153 | final newAlpha = mean * tmp; 154 | final newBeta = (1.0 - mean) * tmp; 155 | 156 | return EbisuModel(time: tBack, alpha: newAlpha, beta: newBeta); 157 | } 158 | 159 | /// Computes this model's half-life (or other `percentile` in the range [0, 1]). 160 | /// Returns the time at which `predictRecall` would return `percentile`. 161 | /// 162 | /// If `coarse` is `true` (the default is `false`), returns an approximate solution 163 | /// (within an order of magnitude). 164 | /// 165 | /// `tolerance` indicates the accuracy of the search; ignored if `coarse`. 166 | double modelToPercentileDecay({double percentile = 0.5, bool coarse = false, double tolerance = 1e-4}) { 167 | assert(0.0 <= percentile); 168 | assert(percentile <= 1.0); 169 | 170 | final logBab = logBeta(alpha, beta); 171 | final logPercentile = log(percentile); 172 | final f = (double lndelta) => (logBeta(alpha + exp(lndelta), beta) - logBab) - logPercentile; 173 | 174 | final bracketWidth = coarse ? 1.0 : 6.0; 175 | var blow = -bracketWidth / 2.0; 176 | var bhigh = bracketWidth / 2.0; 177 | var flow = f(blow); 178 | var fhigh = f(bhigh); 179 | while (flow > 0 && fhigh > 0) { 180 | // Move the bracket up. 181 | blow = bhigh; 182 | flow = fhigh; 183 | bhigh += bracketWidth; 184 | fhigh = f(bhigh); 185 | } 186 | while (flow < 0 && fhigh < 0) { 187 | // Move the bracket down. 188 | bhigh = blow; 189 | fhigh = flow; 190 | blow -= bracketWidth; 191 | flow = f(blow); 192 | } 193 | 194 | if (!(flow > 0 && fhigh < 0)) { 195 | throw RangeError('Failed to bracket: flow=$flow, fhigh=$fhigh'); 196 | } 197 | if (coarse) { 198 | return (exp(blow) + exp(bhigh)) / 2 * time; 199 | } 200 | final status = minimize((y) => f(y).abs(), blow, bhigh, tolerance, 10000); 201 | assert(status.converged); 202 | final sol = status.argmin; 203 | return exp(sol) * time; 204 | } 205 | 206 | EbisuModel _rebalance(int successes, int total, double tNow, EbisuModel proposed) { 207 | if (proposed.alpha > 2 * proposed.beta || proposed.beta > 2 * proposed.alpha) { 208 | final roughHalflife = proposed.modelToPercentileDecay(percentile: 0.5, coarse: true); 209 | return _updateRecall(successes, total, tNow, roughHalflife); 210 | } else { 211 | return proposed; 212 | } 213 | } 214 | 215 | static bool _approxEqual(double a, double b) { 216 | return a == b || a.isNaN == b.isNaN || max(a, b) - min(a, b) < 1e-6 * max(a, b); 217 | } 218 | 219 | @override 220 | bool operator ==(dynamic other) => other is EbisuModel && 221 | _approxEqual(time, other.time) && 222 | _approxEqual(alpha, other.alpha) && 223 | _approxEqual(beta, other.beta); 224 | 225 | // XXX This is not fully consistent with operator == because it's not approximate! 226 | @override 227 | int get hashCode => time.hashCode ^ alpha.hashCode ^ beta.hashCode; 228 | 229 | @override 230 | String toString() => 'EbisuModel(time: $time, alpha: $alpha, beta: $beta)'; 231 | } 232 | -------------------------------------------------------------------------------- /lib/src/math.dart: -------------------------------------------------------------------------------- 1 | /// Implementation of some mathematical functions needed by the Ebisu 2 | /// algorithm but missing from Dart's standard library, most notably an 3 | /// approximation of the Gamma function. 4 | /// 5 | /// Port of https://github.com/fasiha/gamma-java/blob/5288b5890968f047aed9c9c9b96f8d5016e4ac1b/src/main/java/me/aldebrn/gamma/Gamma.java 6 | 7 | import 'dart:math'; 8 | 9 | const _gLn = 607.0 / 128.0; 10 | const _pLn = [ 11 | 0.99999999999999709182, 57.156235665862923517, -59.597960355475491248, 12 | 14.136097974741747174, -0.49191381609762019978, 0.33994649984811888699e-4, 13 | 0.46523628927048575665e-4, -0.98374475304879564677e-4, 0.15808870322491248884e-3, 14 | -0.21026444172410488319e-3, 0.21743961811521264320e-3, -0.16431810653676389022e-3, 15 | 0.84418223983852743293e-4, -0.26190838401581408670e-4, 0.36899182659531622704e-5, 16 | ]; 17 | 18 | final _log2pi = log(2 * pi); 19 | 20 | /// Spouge approximation of `log(Gamma(z))`. 21 | double logGamma(double z) { 22 | if (z < 0) { 23 | return double.nan; 24 | } 25 | var x = _pLn[0]; 26 | for (var i = _pLn.length - 1; i > 0; --i) { 27 | x += _pLn[i] / (z + i); 28 | } 29 | final t = z + _gLn + 0.5; 30 | return .5 * _log2pi + (z + .5) * log(t) - t + log(x) - log(z); 31 | } 32 | 33 | /// Returns the sign of `x`. 34 | double signum(double x) { 35 | return x == 0.0 ? 0.0 : x > 0.0 ? 1.0 : -1.0; 36 | } 37 | 38 | /// Evaluates `log(Beta(a, b))`. 39 | double logBeta(double a, double b) { 40 | return logGamma(a) + logGamma(b) - logGamma(a + b); 41 | } 42 | 43 | /// Evaluates `log(Beta(a1, b) / Beta(a, b))`. 44 | double logBetaRatio(double a1, double a, double b) { 45 | return logGamma(a1) - logGamma(a1 + b) + logGamma(a + b) - logGamma(a); 46 | } 47 | 48 | /// Evaluates `log(binom(n, k))` entirely in the log domain. 49 | double logBinom(int n, int k) { 50 | return -logBeta(1.0 + n - k, 1.0 + k) - log(n + 1.0); 51 | } 52 | 53 | /// Stably evaluates the log of the sum of the exponentials of inputs: 54 | /// `log(sum(b .* exp(a)))`. Returns the absolute value of the result. 55 | /// 56 | /// If `b` is shorter than `a`, it is implicitly padded with ones. 57 | /// 58 | /// In the Java implementation of Ebisu, this also returned the sign of the 59 | /// result, but it was unused. 60 | double logSumExp(List a, List b) { 61 | if (a.isEmpty) { 62 | return double.negativeInfinity; 63 | } 64 | final amax = a.reduce(max); 65 | var sum = 0.0; 66 | for (var i = 0; i < a.length; i++) { 67 | sum += exp(a[i] - amax) * (i < b.length ? b[i] : 1.0); 68 | } 69 | return log(sum.abs()) + amax; 70 | // final sign = signum(sum); 71 | // sum *= sign; 72 | // final abs = log(sum) + amax; 73 | // return [abs, sign]; 74 | } -------------------------------------------------------------------------------- /lib/src/mingolden.dart: -------------------------------------------------------------------------------- 1 | /// Golden section minimization algorithm. 2 | /// 3 | /// Port of https://github.com/fasiha/minimize-golden-section-java 4 | 5 | import 'dart:math'; 6 | 7 | import 'package:meta/meta.dart'; 8 | 9 | @immutable 10 | class MinimizationStatus { 11 | final int iterations; 12 | final double argmin; 13 | final double minimum; 14 | final bool converged; 15 | 16 | MinimizationStatus(this.iterations, this.argmin, this.minimum, this.converged); 17 | } 18 | 19 | final _phiRatio = 2 / (1 + sqrt(5)); 20 | 21 | MinimizationStatus minimize(double Function(double) f, double xL, double xU, double tol, int maxIterations) { 22 | double xF; 23 | double fF; 24 | var iteration = 0; 25 | var x1 = xU - _phiRatio * (xU - xL); 26 | var x2 = xL + _phiRatio * (xU - xL); 27 | // Initial bounds: 28 | var f1 = f(x1); 29 | var f2 = f(x2); 30 | 31 | // Store these values so that we can return these if they're better. 32 | // This happens when the minimization falls *approaches* but never 33 | // actually reaches one of the bounds 34 | final f10 = f(xL); 35 | final f20 = f(xU); 36 | final xL0 = xL; 37 | final xU0 = xU; 38 | 39 | // Simple, robust golden section minimization: 40 | while (++iteration < maxIterations && (xU - xL).abs() > tol) { 41 | if (f2 > f1) { 42 | xU = x2; 43 | x2 = x1; 44 | f2 = f1; 45 | x1 = xU - _phiRatio * (xU - xL); 46 | f1 = f(x1); 47 | } else { 48 | xL = x1; 49 | x1 = x2; 50 | f1 = f2; 51 | x2 = xL + _phiRatio * (xU - xL); 52 | f2 = f(x2); 53 | } 54 | } 55 | 56 | xF = 0.5 * (xU + xL); 57 | fF = 0.5 * (f1 + f2); 58 | 59 | final converged = !f2.isNaN && !f1.isNaN && iteration < maxIterations; 60 | final argmin = 61 | f10 < fF ? xL0 : 62 | f20 < fF ? xU0 : 63 | xF; 64 | return MinimizationStatus(iteration, argmin, fF, converged); 65 | } -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | _fe_analyzer_shared: 5 | dependency: transitive 6 | description: 7 | name: _fe_analyzer_shared 8 | url: "https://pub.dartlang.org" 9 | source: hosted 10 | version: "21.0.0" 11 | analyzer: 12 | dependency: transitive 13 | description: 14 | name: analyzer 15 | url: "https://pub.dartlang.org" 16 | source: hosted 17 | version: "1.5.0" 18 | args: 19 | dependency: transitive 20 | description: 21 | name: args 22 | url: "https://pub.dartlang.org" 23 | source: hosted 24 | version: "2.1.0" 25 | async: 26 | dependency: transitive 27 | description: 28 | name: async 29 | url: "https://pub.dartlang.org" 30 | source: hosted 31 | version: "2.6.1" 32 | boolean_selector: 33 | dependency: transitive 34 | description: 35 | name: boolean_selector 36 | url: "https://pub.dartlang.org" 37 | source: hosted 38 | version: "2.1.0" 39 | charcode: 40 | dependency: transitive 41 | description: 42 | name: charcode 43 | url: "https://pub.dartlang.org" 44 | source: hosted 45 | version: "1.2.0" 46 | cli_util: 47 | dependency: transitive 48 | description: 49 | name: cli_util 50 | url: "https://pub.dartlang.org" 51 | source: hosted 52 | version: "0.3.0" 53 | collection: 54 | dependency: transitive 55 | description: 56 | name: collection 57 | url: "https://pub.dartlang.org" 58 | source: hosted 59 | version: "1.15.0" 60 | convert: 61 | dependency: transitive 62 | description: 63 | name: convert 64 | url: "https://pub.dartlang.org" 65 | source: hosted 66 | version: "3.0.0" 67 | coverage: 68 | dependency: transitive 69 | description: 70 | name: coverage 71 | url: "https://pub.dartlang.org" 72 | source: hosted 73 | version: "1.0.2" 74 | crypto: 75 | dependency: transitive 76 | description: 77 | name: crypto 78 | url: "https://pub.dartlang.org" 79 | source: hosted 80 | version: "3.0.1" 81 | file: 82 | dependency: transitive 83 | description: 84 | name: file 85 | url: "https://pub.dartlang.org" 86 | source: hosted 87 | version: "6.1.0" 88 | frontend_server_client: 89 | dependency: transitive 90 | description: 91 | name: frontend_server_client 92 | url: "https://pub.dartlang.org" 93 | source: hosted 94 | version: "2.1.0" 95 | glob: 96 | dependency: transitive 97 | description: 98 | name: glob 99 | url: "https://pub.dartlang.org" 100 | source: hosted 101 | version: "2.0.1" 102 | http_multi_server: 103 | dependency: transitive 104 | description: 105 | name: http_multi_server 106 | url: "https://pub.dartlang.org" 107 | source: hosted 108 | version: "3.0.1" 109 | http_parser: 110 | dependency: transitive 111 | description: 112 | name: http_parser 113 | url: "https://pub.dartlang.org" 114 | source: hosted 115 | version: "4.0.0" 116 | io: 117 | dependency: transitive 118 | description: 119 | name: io 120 | url: "https://pub.dartlang.org" 121 | source: hosted 122 | version: "1.0.0" 123 | js: 124 | dependency: transitive 125 | description: 126 | name: js 127 | url: "https://pub.dartlang.org" 128 | source: hosted 129 | version: "0.6.3" 130 | logging: 131 | dependency: transitive 132 | description: 133 | name: logging 134 | url: "https://pub.dartlang.org" 135 | source: hosted 136 | version: "1.0.1" 137 | matcher: 138 | dependency: transitive 139 | description: 140 | name: matcher 141 | url: "https://pub.dartlang.org" 142 | source: hosted 143 | version: "0.12.10" 144 | meta: 145 | dependency: "direct main" 146 | description: 147 | name: meta 148 | url: "https://pub.dartlang.org" 149 | source: hosted 150 | version: "1.3.0" 151 | mime: 152 | dependency: transitive 153 | description: 154 | name: mime 155 | url: "https://pub.dartlang.org" 156 | source: hosted 157 | version: "1.0.0" 158 | node_preamble: 159 | dependency: transitive 160 | description: 161 | name: node_preamble 162 | url: "https://pub.dartlang.org" 163 | source: hosted 164 | version: "2.0.0" 165 | package_config: 166 | dependency: transitive 167 | description: 168 | name: package_config 169 | url: "https://pub.dartlang.org" 170 | source: hosted 171 | version: "2.0.0" 172 | path: 173 | dependency: transitive 174 | description: 175 | name: path 176 | url: "https://pub.dartlang.org" 177 | source: hosted 178 | version: "1.8.0" 179 | pedantic: 180 | dependency: "direct dev" 181 | description: 182 | name: pedantic 183 | url: "https://pub.dartlang.org" 184 | source: hosted 185 | version: "1.11.0" 186 | pool: 187 | dependency: transitive 188 | description: 189 | name: pool 190 | url: "https://pub.dartlang.org" 191 | source: hosted 192 | version: "1.5.0" 193 | pub_semver: 194 | dependency: transitive 195 | description: 196 | name: pub_semver 197 | url: "https://pub.dartlang.org" 198 | source: hosted 199 | version: "2.0.0" 200 | shelf: 201 | dependency: transitive 202 | description: 203 | name: shelf 204 | url: "https://pub.dartlang.org" 205 | source: hosted 206 | version: "1.1.1" 207 | shelf_packages_handler: 208 | dependency: transitive 209 | description: 210 | name: shelf_packages_handler 211 | url: "https://pub.dartlang.org" 212 | source: hosted 213 | version: "3.0.0" 214 | shelf_static: 215 | dependency: transitive 216 | description: 217 | name: shelf_static 218 | url: "https://pub.dartlang.org" 219 | source: hosted 220 | version: "1.0.0" 221 | shelf_web_socket: 222 | dependency: transitive 223 | description: 224 | name: shelf_web_socket 225 | url: "https://pub.dartlang.org" 226 | source: hosted 227 | version: "1.0.1" 228 | source_map_stack_trace: 229 | dependency: transitive 230 | description: 231 | name: source_map_stack_trace 232 | url: "https://pub.dartlang.org" 233 | source: hosted 234 | version: "2.1.0" 235 | source_maps: 236 | dependency: transitive 237 | description: 238 | name: source_maps 239 | url: "https://pub.dartlang.org" 240 | source: hosted 241 | version: "0.10.10" 242 | source_span: 243 | dependency: transitive 244 | description: 245 | name: source_span 246 | url: "https://pub.dartlang.org" 247 | source: hosted 248 | version: "1.8.1" 249 | stack_trace: 250 | dependency: transitive 251 | description: 252 | name: stack_trace 253 | url: "https://pub.dartlang.org" 254 | source: hosted 255 | version: "1.10.0" 256 | stream_channel: 257 | dependency: transitive 258 | description: 259 | name: stream_channel 260 | url: "https://pub.dartlang.org" 261 | source: hosted 262 | version: "2.1.0" 263 | string_scanner: 264 | dependency: transitive 265 | description: 266 | name: string_scanner 267 | url: "https://pub.dartlang.org" 268 | source: hosted 269 | version: "1.1.0" 270 | term_glyph: 271 | dependency: transitive 272 | description: 273 | name: term_glyph 274 | url: "https://pub.dartlang.org" 275 | source: hosted 276 | version: "1.2.0" 277 | test: 278 | dependency: "direct dev" 279 | description: 280 | name: test 281 | url: "https://pub.dartlang.org" 282 | source: hosted 283 | version: "1.17.3" 284 | test_api: 285 | dependency: transitive 286 | description: 287 | name: test_api 288 | url: "https://pub.dartlang.org" 289 | source: hosted 290 | version: "0.4.0" 291 | test_core: 292 | dependency: transitive 293 | description: 294 | name: test_core 295 | url: "https://pub.dartlang.org" 296 | source: hosted 297 | version: "0.3.23" 298 | typed_data: 299 | dependency: transitive 300 | description: 301 | name: typed_data 302 | url: "https://pub.dartlang.org" 303 | source: hosted 304 | version: "1.3.0" 305 | vm_service: 306 | dependency: transitive 307 | description: 308 | name: vm_service 309 | url: "https://pub.dartlang.org" 310 | source: hosted 311 | version: "6.2.0" 312 | watcher: 313 | dependency: transitive 314 | description: 315 | name: watcher 316 | url: "https://pub.dartlang.org" 317 | source: hosted 318 | version: "1.0.0" 319 | web_socket_channel: 320 | dependency: transitive 321 | description: 322 | name: web_socket_channel 323 | url: "https://pub.dartlang.org" 324 | source: hosted 325 | version: "2.1.0" 326 | webkit_inspection_protocol: 327 | dependency: transitive 328 | description: 329 | name: webkit_inspection_protocol 330 | url: "https://pub.dartlang.org" 331 | source: hosted 332 | version: "1.0.0" 333 | yaml: 334 | dependency: transitive 335 | description: 336 | name: yaml 337 | url: "https://pub.dartlang.org" 338 | source: hosted 339 | version: "3.1.0" 340 | sdks: 341 | dart: ">=2.12.0 <3.0.0" 342 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: ebisu_dart 2 | version: 2.0.0 3 | description: >- 4 | Ebisu intelligent quiz scheduling algorithm based on Bayesian statistics and an exponential forgetting curve 5 | homepage: https://github.com/ttencate/ebisu_dart 6 | repository: https://github.com/ttencate/ebisu_dart 7 | issue_tracker: https://github.com/ttencate/ebisu_dart/issues 8 | documentation: https://pub.dev/documentation/ebisu_dart/latest 9 | environment: 10 | sdk: '>=2.12.0 <3.0.0' 11 | dependencies: 12 | meta: ^1.3.0 13 | dev_dependencies: 14 | pedantic: ^1.11.0 15 | test: ^1.17.3 16 | -------------------------------------------------------------------------------- /test/ebisu_test.dart: -------------------------------------------------------------------------------- 1 | /// Unit tests for `ebisu.dart`. 2 | /// 3 | /// Ported from the Java implementation: 4 | /// https://github.com/fasiha/ebisu-java/blob/master/src/test/java/me/aldebrn/ebisu/EbisuTest.java 5 | 6 | import 'dart:convert'; 7 | import 'dart:io'; 8 | import 'dart:math'; 9 | 10 | import 'package:test/test.dart'; 11 | import 'package:ebisu_dart/ebisu.dart'; 12 | import 'package:ebisu_dart/src/math.dart'; 13 | 14 | import 'ulp.dart'; 15 | 16 | void main() { 17 | final eps = 2.0 * ulp(1.0); 18 | 19 | // The ulp function is only used in tests, but we better make sure that it 20 | // works. 21 | test('ulp', () { 22 | final ulp1 = ulp(1.0); 23 | expect(ulp1, greaterThan(0.0)); 24 | expect(1.0 + ulp1, greaterThan(1.0)); 25 | expect(1.0 + ulp1 / 2.0, 1.0); 26 | }); 27 | 28 | group('compare against test.json from reference implementation', () { 29 | const maxTol = 5e-3; 30 | // test.json was taken verbatim from the Python implementation: 31 | // https://github.com/fasiha/ebisu/blob/gh-pages/test.json 32 | final testJson = File('test/test.json').readAsStringSync(); 33 | final json = jsonDecode(testJson) as List; 34 | for (final i in json) { 35 | final testCase = i as List; 36 | final description = jsonEncode(testCase.sublist(0, 3)); 37 | final model = parseModel(testCase[1]); 38 | switch (testCase[0] as String) { 39 | case 'update': 40 | test(description, () { 41 | final successes = (testCase[2] as List)[0] as int; 42 | final total = (testCase[2] as List)[1] as int; 43 | final tNow = (testCase[2] as List)[2] as double; 44 | final expected = parseModel((testCase[3] as Map)['post']); 45 | 46 | final actual = model.updateRecall(successes, total, tNow); 47 | 48 | expect(actual.alpha, closeTo(expected.alpha, maxTol)); 49 | expect(actual.beta, closeTo(expected.beta, maxTol)); 50 | expect(actual.time, closeTo(expected.time, maxTol)); 51 | }); 52 | break; 53 | 54 | case 'predict': 55 | test(description, () { 56 | final tNow = (testCase[2] as List)[0] as double; 57 | final expected = (testCase[3] as Map)['mean'] as double; 58 | 59 | final actual = model.predictRecall(tNow, exact: true); 60 | 61 | expect(actual, closeTo(expected, maxTol)); 62 | }); 63 | break; 64 | 65 | default: 66 | assert(false); 67 | } 68 | } 69 | }); 70 | 71 | test('verify halflife', () { 72 | final hl = 20.0; 73 | final m = EbisuModel(time: hl, alpha: 2.0, beta: 2.0); 74 | expect((m.modelToPercentileDecay(percentile: 0.5, coarse: true) - hl).abs(), greaterThan(1e-2)); 75 | expect(relerr(m.modelToPercentileDecay(percentile: 0.5, tolerance: 1e-6), hl), lessThan(1e-3)); 76 | expect(() => m.modelToPercentileDecay(percentile: 0.5, tolerance: 1e-150), throwsA(isA())); 77 | }); 78 | 79 | test('Ebisu predict at exactly half-life', () { 80 | final m = EbisuModel(time: 2.0, alpha: 2.0, beta: 2.0); 81 | final p = m.predictRecall(2, exact: true); 82 | expect(p, closeTo(0.5, eps)); 83 | }); 84 | 85 | test('Ebisu update at exactly half-life', () { 86 | final m = EbisuModel(time: 2.0, alpha: 2.0, beta: 2.0); 87 | final success = m.updateRecall(1, 1, 2.0); 88 | final failure = m.updateRecall(0, 1, 2.0); 89 | 90 | expect(success.alpha, closeTo(3.0, 500 * eps)); 91 | expect(success.beta, closeTo(2.0, 500 * eps)); 92 | 93 | expect(failure.alpha, closeTo(2.0, 500 * eps)); 94 | expect(failure.beta, closeTo(3.0, 500 * eps)); 95 | }); 96 | 97 | test('Check logSumExp', () { 98 | final expected = exp(3.3) + exp(4.4) - exp(5.5); 99 | final actual = logSumExp([3.3, 4.4, 5.5], [1, 1, -1]); 100 | 101 | final epsilon = ulp(actual); 102 | expect(actual, closeTo(log(expected.abs()), epsilon)); 103 | // expect(actual[1], signum(expected)); 104 | }); 105 | } 106 | 107 | EbisuModel parseModel(dynamic params) { 108 | final doubles = params as List; 109 | assert(doubles.length == 3); 110 | return EbisuModel(alpha: params[0] as double, beta: params[1] as double, time: params[2] as double); 111 | } 112 | 113 | double relerr(double dirt, double gold) { 114 | return (dirt == gold) ? 0 : (dirt - gold).abs() / gold.abs(); 115 | } -------------------------------------------------------------------------------- /test/test.json: -------------------------------------------------------------------------------- 1 | [["update", [3.3, 4.4, 1.0], [0, 5, 0.1], {"post": [7.333641958415551, 8.949256654818793, 0.4148304099305316]}], ["update", [3.3, 4.4, 1.0], [1, 5, 0.1], {"post": [7.921333234538209, 7.986078907729781, 0.4148304099305316]}], ["update", [3.3, 4.4, 1.0], [2, 5, 0.1], {"post": [8.54115608235795, 7.039810875112419, 0.4148304099305316]}], ["update", [3.3, 4.4, 1.0], [3, 5, 0.1], {"post": [9.19263541257189, 6.102814088801724, 0.4148304099305316]}], ["update", [3.3, 4.4, 1.0], [4, 5, 0.1], {"post": [3.392668219154276, 5.479702779318754, 1.0]}], ["update", [3.3, 4.4, 1.0], [5, 5, 0.1], {"post": [3.7999999999999674, 4.399999999999965, 1.0]}], ["update", [3.3, 4.4, 1.0], [0, 5, 1.0], {"post": [10.42314781200118, 8.313283751698094, 0.4148304099305316]}], ["update", [3.3, 4.4, 1.0], [1, 5, 1.0], {"post": [4.300000000004559, 8.400000000008617, 1.0]}], ["update", [3.3, 4.4, 1.0], [2, 5, 1.0], {"post": [5.299999999994806, 7.399999999993122, 1.0]}], ["update", [3.3, 4.4, 1.0], [3, 5, 1.0], {"post": [6.300000000003196, 6.400000000002965, 1.0]}], ["update", [3.3, 4.4, 1.0], [4, 5, 1.0], {"post": [7.29999999999964, 5.3999999999998245, 1.0]}], ["update", [3.3, 4.4, 1.0], [5, 5, 1.0], {"post": [8.299999999999644, 4.3999999999998165, 1.0]}], ["update", [3.3, 4.4, 1.0], [0, 5, 9.5], {"post": [3.5601980922377012, 4.953347976808389, 1.0]}], ["update", [3.3, 4.4, 1.0], [1, 5, 9.5], {"post": [4.059118901399234, 6.956645983861514, 3.0652051705190964]}], ["update", [3.3, 4.4, 1.0], [2, 5, 9.5], {"post": [7.352444943202578, 6.778702354841638, 3.0652051705190964]}], ["update", [3.3, 4.4, 1.0], [3, 5, 9.5], {"post": [2.92509341822776, 6.744103533623485, 8.33209151552077]}], ["update", [3.3, 4.4, 1.0], [4, 5, 9.5], {"post": [3.942742342008821, 5.609521448770878, 8.33209151552077]}], ["update", [3.3, 4.4, 1.0], [5, 5, 9.5], {"post": [4.960598139318119, 4.5269207284344954, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [0, 5, 0.1], {"post": [8.760308130181903, 8.706432410647471, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [1, 5, 0.1], {"post": [9.10166480676482, 7.599773940410919, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [2, 5, 0.1], {"post": [9.470767961549766, 6.526756194178172, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [3, 5, 0.1], {"post": [2.8193081340183848, 5.8660071644208225, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [4, 5, 0.1], {"post": [3.099708783386964, 4.6435435843774, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [5, 5, 0.1], {"post": [3.414956576256669, 3.5065282010435452, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [0, 5, 1.0], {"post": [9.39628482629719, 8.646019197616297, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [1, 5, 1.0], {"post": [9.911175851781708, 7.562783988233255, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [2, 5, 1.0], {"post": [10.44361446625134, 6.501799830801661, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [3, 5, 1.0], {"post": [3.2044869706691466, 5.7934965103790335, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [4, 5, 1.0], {"post": [3.55036687026256, 4.602501903233139, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [5, 5, 1.0], {"post": [3.9320618448872042, 3.487419813549091, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [0, 5, 5.5], {"post": [11.673894526689034, 8.19258414278996, 3.0652051705190964]}], ["update", [34.4, 3.4, 1.0], [1, 5, 5.5], {"post": [3.769629060906505, 7.893196309207965, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [2, 5, 5.5], {"post": [4.431262591926502, 6.693721383041708, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [3, 5, 5.5], {"post": [5.117097117261679, 5.5661761667158896, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [4, 5, 5.5], {"post": [5.8261655907057435, 4.48706762922024, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [5, 5, 5.5], {"post": [1.9697726778066604, 3.6196824722158882, 22.648972959697893]}], ["update", [34.4, 3.4, 1.0], [0, 5, 50.0], {"post": [4.078371066588873, 5.099824929325114, 8.33209151552077]}], ["update", [34.4, 3.4, 1.0], [1, 5, 50.0], {"post": [3.4468346321124614, 6.580022257121179, 22.648972959697893]}], ["update", [34.4, 3.4, 1.0], [2, 5, 50.0], {"post": [5.698262778324584, 6.066639134278961, 22.648972959697893]}], ["update", [34.4, 3.4, 1.0], [3, 5, 50.0], {"post": [7.78211003232517, 5.2894146486836915, 22.648972959697893]}], ["update", [34.4, 3.4, 1.0], [4, 5, 50.0], {"post": [2.927115520019575, 4.610159065548562, 61.566291629607065]}], ["update", [34.4, 3.4, 1.0], [5, 5, 50.0], {"post": [3.711551159097705, 3.496333137758124, 61.566291629607065]}], ["predict", [3.3, 4.4, 1.0], [0.1], {"mean": 0.9112400768028355}], ["predict", [3.3, 4.4, 1.0], [0.99], {"mean": 0.43187379980345186}], ["predict", [3.3, 4.4, 1.0], [1.0], {"mean": 0.4285714285714294}], ["predict", [3.3, 4.4, 1.0], [1.01], {"mean": 0.4253002580752596}], ["predict", [3.3, 4.4, 1.0], [5.5], {"mean": 0.034193559924496846}], ["predict", [34.4, 34.4, 1.0], [0.1], {"mean": 0.9324193906545447}], ["predict", [34.4, 34.4, 1.0], [0.99], {"mean": 0.5034418103093425}], ["predict", [34.4, 34.4, 1.0], [1.0], {"mean": 0.5000000000000027}], ["predict", [34.4, 34.4, 1.0], [1.01], {"mean": 0.4965824260522384}], ["predict", [34.4, 34.4, 1.0], [5.5], {"mean": 0.026134289032202798}]] -------------------------------------------------------------------------------- /test/ulp.dart: -------------------------------------------------------------------------------- 1 | /// Computation of ulp (unit in last position). 2 | /// 3 | /// Ported from https://stackoverflow.com/a/24129944/14637 4 | 5 | import 'dart:typed_data'; 6 | 7 | const _MAX_ULP = 1.9958403095347198E292; 8 | 9 | /// Returns the size of an ulp of the argument. An ulp of a double value is the 10 | /// positive distance between this / floating-point value and the double value 11 | /// next larger in magnitude. Note that for non-NaN x, `ulp(-x) == ulp(x)`. 12 | /// 13 | /// Special Cases: 14 | /// 15 | /// - If the argument is `double.nan`, then the result is `double.nan`. 16 | /// - If the argument is positive or negative infinity, then the result is positive infinity. 17 | /// - If the argument is positive or negative zero, then the result is `double.minPositive`. 18 | /// - If the argument is ±double.maxFinite, then the result is equal to 2^971. 19 | double ulp(double d) { 20 | if (d.isNaN) { 21 | // If the argument is NaN, then the result is NaN. 22 | return double.nan; 23 | } 24 | 25 | if (d.isInfinite) { 26 | // If the argument is positive or negative infinity, then the 27 | // result is positive infinity. 28 | return double.infinity; 29 | } 30 | 31 | if (d == 0.0) { 32 | // If the argument is positive or negative zero, then the result is Double.MIN_VALUE. 33 | return double.minPositive; 34 | } 35 | 36 | d = d.abs(); 37 | if (d == double.maxFinite) { 38 | // If the argument is Double.MAX_VALUE, then the result is equal to 2^971. 39 | return _MAX_ULP; 40 | } 41 | 42 | return nextAfter(d, double.maxFinite) - d; 43 | } 44 | 45 | double copySign(double x, double y) { 46 | return bitsToDouble((doubleToBits(x) & 0x7fffffffffffffff) | (doubleToBits(y) & 0x8000000000000000)); 47 | } 48 | 49 | bool isSameSign(double x, double y) { 50 | return copySign(x, y) == x; 51 | } 52 | 53 | double nextAfter(double start, double direction) { 54 | if (start.isNaN || direction.isNaN) { 55 | // If either argument is a NaN, then NaN is returned. 56 | return double.nan; 57 | } 58 | 59 | if (start == direction) { 60 | // If both arguments compare as equal the second argument is returned. 61 | return direction; 62 | } 63 | 64 | final absStart = start.abs(); 65 | final absDir = direction.abs(); 66 | final toZero = !isSameSign(start, direction) || absDir < absStart; 67 | 68 | if (toZero) { 69 | // we are reducing the magnitude, going toward zero. 70 | if (absStart == double.minPositive) { 71 | return copySign(0.0, start); 72 | } 73 | if (absStart.isInfinite) { 74 | return copySign(double.maxFinite, start); 75 | } 76 | return copySign(bitsToDouble(doubleToBits(absStart) - 1), start); 77 | } else { 78 | // we are increasing the magnitude, toward +-Infinity 79 | if (start == 0.0) { 80 | return copySign(double.minPositive, direction); 81 | } 82 | if (absStart == double.maxFinite) { 83 | return copySign(double.infinity, start); 84 | } 85 | return copySign(bitsToDouble(doubleToBits(absStart) + 1), start); 86 | } 87 | } 88 | 89 | /// Equivalent of Java's `Double.doubleToRawLongBits(x)`. 90 | int doubleToBits(double x) { 91 | return Float64List.fromList([x]).buffer.asInt64List()[0]; 92 | } 93 | 94 | /// Equivalent of Java's `Double.longBitsToDouble(bits)`. 95 | double bitsToDouble(int bits) { 96 | return Int64List.fromList([bits]).buffer.asFloat64List()[0]; 97 | } --------------------------------------------------------------------------------