├── .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 | }
--------------------------------------------------------------------------------