Repository: learningequality/kolibri

Issue:
Rounding (or lack of rounding) error in calculating completion on summary logs
## Observed behavior

From the teacher's dashboard, after drilling in to a specific exercise, it shows this one student as "in progress":
<img width="184" alt="image" src="https://user-images.githubusercontent.com/618416/216136573-cbffff9d-5007-4a39-86c9-b921ed60a663.png">
However, when clicking into that specific student in the report, it shows them as having gotten every question correct, and achieved (more than) the 5/7 m_of_n completion criterion:
<img width="438" alt="image" src="https://user-images.githubusercontent.com/618416/216137598-6a08d753-a8d2-4fe3-866b-783c035ea4f5.png">

Digging into the database, it looks as if the progress on the `ContentSummaryLog` is `0.9990000000000001`. It's also not the only case on this server:
```
>>> ContentSummaryLog.objects.filter(progress__lt=1, progress__gte=0.99).count()
32
```
(this is 1.7% of all content summary logs that "should" have been marked complete, i.e. >= 0.99)

And on KDP we have many records like this as well:
```
>>> ContentSummaryLog.objects.filter(progress__lt=1, progress__gte=0.99).count()
3546
```
(this is 0.5% of all logs >= 0.99)


## Errors and logs
ContentSummaryLog:
```
{
    "user_id": "xx",
    "content_id": "fdeebadba90156108eb16af91caff015",
    "channel_id": "c1f2b7e6ac9f56a2bb44fa7a48b66dce",
    "start_timestamp": "2023-01-31 21:13:15.604212(America/Guatemala)",
    "end_timestamp": "2023-02-01 13:56:51.545380(America/Guatemala)",
    "completion_timestamp": null,
    "time_spent": 2305.732000000001,
    "progress": 0.9990000000000001,
    "kind": "exercise",
    "extra_fields": "{}"
}
```

MasteryLog:
```
{
    "summarylog_id": "xx",
    "mastery_criterion": "{\n  \"type\":\"m_of_n\",\n  \"n\":7,\n  \"m\":5\n}",
    "start_timestamp": "2023-01-31 21:13:15.604212(America/Guatemala)",
    "end_timestamp": "2023-01-31 21:13:15.604212(America/Guatemala)",
    "completion_timestamp": null,
    "mastery_level": 1,
    "complete": false
}
```

ContentSessionLogs (note: truncated, as they had a total of 54 sessions, but progress stopped being tracked after the first 3, presumably since the completion criteria had been met):
```
[
    {
        "start_timestamp": "2023-01-31 15:13:15.604212-06:00",
        "end_timestamp": "2023-01-31 15:13:55.075311-06:00",
        "progress": 0.2
    },
    {
        "start_timestamp": "2023-01-31 15:14:11.532967-06:00",
        "end_timestamp": "2023-01-31 15:19:25.849518-06:00",
        "progress": 0.601
    },
    {
        "start_timestamp": "2023-01-31 15:19:34.530544-06:00",
        "end_timestamp": "2023-01-31 15:20:26.833711-06:00",
        "progress": 0.198
    },
    {
        "start_timestamp": "2023-01-31 15:20:37.638792-06:00",
        "end_timestamp": "2023-01-31 15:20:51.470351-06:00",
        "progress": 0.0
    },
...
```

AttemptLogs (note: truncated, of a total of 86 attempts -- every single one correct!):
```
[
    {
        "start_timestamp": "2023-01-31 15:13:15.702311-06:00",
        "completion_timestamp": "2023-01-31 15:13:55.075311-06:00",
        "end_timestamp": "2023-01-31 15:13:55.075311-06:00",
        "time_spent": 39.373,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "c4e7873764215b3b96d179ca680db121"
    },
    {
        "start_timestamp": "2023-01-31 15:14:11.808177-06:00",
        "completion_timestamp": "2023-01-31 15:14:24.505177-06:00",
        "end_timestamp": "2023-01-31 15:14:24.505177-06:00",
        "time_spent": 12.697,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "6782821ca3a05f6893da894ff6531189"
    },
    {
        "start_timestamp": "2023-01-31 15:14:26.168133-06:00",
        "completion_timestamp": "2023-01-31 15:18:34.012133-06:00",
        "end_timestamp": "2023-01-31 15:18:34.012133-06:00",
        "time_spent": 247.844,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "6ac1af44dc115766aaa15291a29b31f0"
    },
    {
        "start_timestamp": "2023-01-31 15:18:34.852518-06:00",
        "completion_timestamp": "2023-01-31 15:19:25.849518-06:00",
        "end_timestamp": "2023-01-31 15:19:25.849518-06:00",
        "time_spent": 50.997,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "e669920579fd55a3908cc6af0445d400"
    },
    {
        "start_timestamp": "2023-01-31 15:19:34.658734-06:00",
        "completion_timestamp": "2023-01-31 15:20:22.043734-06:00",
        "end_timestamp": "2023-01-31 15:20:22.043734-06:00",
        "time_spent": 47.385,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "04787d6809295252a69c23e34a53981b"
    },
    {
        "start_timestamp": "2023-01-31 15:20:37.759194-06:00",
        "completion_timestamp": "2023-01-31 15:20:49.538194-06:00",
        "end_timestamp": "2023-01-31 15:20:49.538194-06:00",
        "time_spent": 11.779,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "d406eea464425c36960f7fbbf0926ebf"
    },
    {
        "start_timestamp": "2023-01-31 15:20:56.960323-06:00",
        "completion_timestamp": "2023-01-31 15:21:13.160323-06:00",
        "end_timestamp": "2023-01-31 15:21:13.160323-06:00",
        "time_spent": 16.2,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "7a98405c90115241a12e7574b03ffdbb"
    },
    {
        "start_timestamp": "2023-01-31 15:21:40.267715-06:00",
        "completion_timestamp": "2023-01-31 15:21:58.310715-06:00",
        "end_timestamp": "2023-01-31 15:21:58.310715-06:00",
        "time_spent": 18.043,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "b31fc7f74b385cf1b4220f6b10712640"
    },
    {
        "start_timestamp": "2023-01-31 15:22:06.720226-06:00",
        "completion_timestamp": "2023-01-31 15:22:55.827226-06:00",
        "end_timestamp": "2023-01-31 15:22:55.827226-06:00",
        "time_spent": 49.107,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "14ad53314a8253fb8ca63b2b0194fdc7"
    },
    {
        "start_timestamp": "2023-01-31 15:25:38.636994-06:00",
        "completion_timestamp": "2023-01-31 15:25:44.628994-06:00",
        "end_timestamp": "2023-01-31 15:25:44.628994-06:00",
        "time_spent": 5.992,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "4e06eed24634556db3cddecd238829f1"
    },
    {
        "start_timestamp": "2023-01-31 15:25:45.149459-06:00",
        "completion_timestamp": "2023-01-31 15:26:12.236459-06:00",
        "end_timestamp": "2023-01-31 15:26:12.236459-06:00",
        "time_spent": 27.087,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "a88500f741435c17bb99db4000f8b2aa"
    },
    {
        "start_timestamp": "2023-01-31 15:26:12.733908-06:00",
        "completion_timestamp": "2023-01-31 15:26:20.205908-06:00",
        "end_timestamp": "2023-01-31 15:26:20.205908-06:00",
        "time_spent": 7.472,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "b6bd24d206805328bcab09c5a81c298b"
    },
    {
        "start_timestamp": "2023-01-31 15:26:20.671379-06:00",
        "completion_timestamp": "2023-01-31 15:26:29.212379-06:00",
        "end_timestamp": "2023-01-31 15:26:29.212379-06:00",
        "time_spent": 8.541,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "f5e5ecd9120f55159629510f9ca430e1"
    },
    {
        "start_timestamp": "2023-01-31 15:26:29.717841-06:00",
        "completion_timestamp": "2023-01-31 15:26:35.309841-06:00",
        "end_timestamp": "2023-01-31 15:26:35.309841-06:00",
        "time_spent": 5.592,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "53ce5956a379527db2ae90e55957bfdd"
    },
    {
        "start_timestamp": "2023-01-31 15:26:41.031528-06:00",
        "completion_timestamp": "2023-01-31 15:26:55.463528-06:00",
        "end_timestamp": "2023-01-31 15:26:55.463528-06:00",
        "time_spent": 14.432,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "2d04b246dd8957fcbc5559affc190ac4"
    },
    {
        "start_timestamp": "2023-01-31 15:26:55.981965-06:00",
        "completion_timestamp": "2023-01-31 15:27:00.365965-06:00",
        "end_timestamp": "2023-01-31 15:27:00.365965-06:00",
        "time_spent": 4.384,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "2df9816d7d9056ef8e71af94cad0c9ff"
    },
    {
        "start_timestamp": "2023-01-31 15:27:05.581168-06:00",
        "completion_timestamp": "2023-01-31 15:27:19.139168-06:00",
        "end_timestamp": "2023-01-31 15:27:19.139168-06:00",
        "time_spent": 13.558,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "5dfd7ddee1225ace888451e7177b3bbd"
    },
    {
        "start_timestamp": "2023-01-31 15:27:29.736783-06:00",
        "completion_timestamp": "2023-01-31 15:27:39.500783-06:00",
        "end_timestamp": "2023-01-31 15:27:39.500783-06:00",
        "time_spent": 9.764,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "46694e49927b54faa676395d73ea058b"
    },
    {
        "start_timestamp": "2023-01-31 15:27:40.127069-06:00",
        "completion_timestamp": "2023-01-31 15:27:45.180069-06:00",
        "end_timestamp": "2023-01-31 15:27:45.180069-06:00",
        "time_spent": 5.053,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "09f70b9ab70858ef8f28415b9539fc77"
    },
    {
        "start_timestamp": "2023-01-31 15:27:51.996223-06:00",
        "completion_timestamp": "2023-01-31 15:27:58.418223-06:00",
        "end_timestamp": "2023-01-31 15:27:58.418223-06:00",
        "time_spent": 6.422,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "c75e40b60bef5cf8bb5c4109d6b32132"
    },
    {
        "start_timestamp": "2023-01-31 15:27:58.766381-06:00",
        "completion_timestamp": "2023-01-31 15:28:02.221381-06:00",
        "end_timestamp": "2023-01-31 15:28:02.221381-06:00",
        "time_spent": 3.455,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "6dd48acb477b5db4a66f9f2f33c3025d"
    },
    {
        "start_timestamp": "2023-01-31 15:28:08.212720-06:00",
        "completion_timestamp": "2023-01-31 15:28:29.152720-06:00",
        "end_timestamp": "2023-01-31 15:28:29.152720-06:00",
        "time_spent": 20.94,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "c4e7873764215b3b96d179ca680db121"
    },
    {
        "start_timestamp": "2023-01-31 15:28:34.962709-06:00",
        "completion_timestamp": "2023-01-31 15:28:42.478709-06:00",
        "end_timestamp": "2023-01-31 15:28:42.478709-06:00",
        "time_spent": 7.516,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "6782821ca3a05f6893da894ff6531189"
    },
    {
        "start_timestamp": "2023-01-31 15:28:48.286914-06:00",
        "completion_timestamp": "2023-01-31 15:28:57.478914-06:00",
        "end_timestamp": "2023-01-31 15:28:57.478914-06:00",
        "time_spent": 9.192,
        "complete": true,
        "correct": 1.0,
        "hinted": false,
        "error": false,
        "item": "6ac1af44dc115766aaa15291a29b31f0"
    },
...
```

## Expected behavior

After accomplishing the completion criteria, an exercise should be marked as complete.

## User-facing consequences

The teacher was confused, the student spent 38 minutes answering 86 questions, each time correctly, trying to get marked as complete in the teacher's report.

## Steps to reproduce

Have not attempted to replicate. It may be somewhat stochastic. But it seems like we could safely assume that >= 0.99 completion means it's been fully completed, while being saved, and clean up any existing data as part of an upgrade migration. Something like:

## Context

I believe this is Kolibri 0.15.x, on Windows.


Discussion:
UPDATE: this one was in error.

~I note that this isn't limited to progress >= 0.99 -- here's a case where the ContentSessionLog says progress is 1.0, but the ContentSummaryLog is only at 0.973:~
I note also that this isn't restricted to exercises:
```
>>> ContentSummaryLog.objects.filter(progress__gte=0.99).values_list("kind", flat=True)
<BaseLogQuerySet ['html5', 'html5', 'html5', 'document', 'document', 'document', 'document', 'document', 'html5', 'html5', 'html5', 'html5', 'html5', 'html5', 'html5', 'html5', 'html5', 'exercise', 'exercise', 'exercise', '...(remaining elements truncated)...']>
```

```
>>> ContentSummaryLog.objects.filter(progress__gte=0.99).values_list("kind", flat=True).distinct()
<BaseLogQuerySet ['html5', 'document', 'exercise', 'video']>
```

Resolving patch:
diff --git a/kolibri/core/logger/api.py b/kolibri/core/logger/api.py
--- a/kolibri/core/logger/api.py
+++ b/kolibri/core/logger/api.py
@@ -1,6 +1,7 @@
 import logging
 from datetime import timedelta
 from itertools import groupby
+from math import ceil
 from random import randint
 
 from django.core.exceptions import PermissionDenied
@@ -699,6 +700,9 @@ def _get_session_log(self, session_id, user):
             )
 
     def _normalize_progress(self, progress):
+        # Round progress to three decimal places
+        # but always rounding up.
+        progress = ceil(progress * 1000) / float(1000)
         return max(0, min(1.0, progress))
 
     def _update_content_log(self, log, end_timestamp, validated_data):
diff --git a/kolibri/core/logger/upgrade.py b/kolibri/core/logger/upgrade.py
--- a/kolibri/core/logger/upgrade.py
+++ b/kolibri/core/logger/upgrade.py
@@ -1,8 +1,11 @@
 """
 A file to contain specific logic to handle version upgrades in Kolibri.
 """
+from kolibri.core.logger.models import ContentSummaryLog
 from kolibri.core.logger.models import ExamLog
 from kolibri.core.logger.utils.exam_log_migration import migrate_from_exam_logs
+from kolibri.core.notifications.api import parse_summarylog
+from kolibri.core.notifications.api import quiz_completed_notification
 from kolibri.core.upgrade import version_upgrade
 
 
@@ -13,3 +16,31 @@ def migrate_examlog_and_examattemptlogs():
     data structures.
     """
     migrate_from_exam_logs(ExamLog.objects.all())
+
+
+@version_upgrade(old_version="<0.15.11")
+def fix_asymptotic_progress():
+    """
+    Migrate any ContentSummaryLogs that have erroneously had their progress set to >0.999 but not to 1
+    """
+    for summarylog in ContentSummaryLog.objects.filter(
+        progress__gt=0.999, progress__lt=1
+    ):
+        # Do this in a for loop rather than trying to minimize write queries, as it's a one time event
+        # and the queries for finding any correlated mastery log would be quite complex joins.
+        summarylog.progress = 1
+        summarylog.completion_timestamp = summarylog.end_timestamp
+        masterylog = summarylog.masterylogs.all().order_by("-end_timestamp").first()
+        quiz = False
+        if masterylog:
+            masterylog.complete = True
+            masterylog.completion_timestamp = masterylog.end_timestamp
+            masterylog.save()
+            if masterylog.mastery_level < 0:
+                # This is a quiz, so we need to generate a quiz notification
+                quiz_completed_notification(masterylog, summarylog.content_id)
+                quiz = True
+        summarylog.save()
+        if not quiz:
+            # If this isn't a quiz generate the appropriate completion notification for the resource.
+            parse_summarylog(summarylog)


Test patch:
diff --git a/kolibri/core/logger/test/test_integrated_api.py b/kolibri/core/logger/test/test_integrated_api.py
--- a/kolibri/core/logger/test/test_integrated_api.py
+++ b/kolibri/core/logger/test/test_integrated_api.py
@@ -882,6 +882,17 @@ def test_update_session_progress_delta_overflow_succeeds(self):
         self.assertEqual(response.status_code, 200)
         self._assert_logs_value("progress", 1.0)
 
+    def test_update_session_progress_delta_asymptotic_succeeds(self):
+        self._update_logs("progress", 0.666)
+        response = self._make_request(
+            {
+                "progress_delta": 0.3331,
+            }
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self._assert_logs_value("progress", 1.0)
+
     def test_update_session_absolute_progress_succeeds(self):
         self._update_logs("progress", 0.3)
         response = self._make_request(
diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
--- a/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
+++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
@@ -337,6 +337,14 @@ describe('useProgressTracking composable', () => {
       await updateContentSession({ contentState: { test: 'test' } });
       expect(store.state.core.totalProgress).toEqual(1);
     });
+    it('should update progress_state if the backend returns complete', async () => {
+      const { updateContentSession, progress } = await initStore();
+      client.__setPayload({
+        complete: true,
+      });
+      await updateContentSession({ contentState: { test: 'test' } });
+      expect(get(progress)).toEqual(1);
+    });
     it('should not update total progress if the backend returns complete and was already complete', async () => {
       const { updateContentSession, store } = await initStore({ complete: true });
       store.commit('CORE_SET_SESSION', { kind: ['learner'] });
@@ -392,6 +400,12 @@ describe('useProgressTracking composable', () => {
       expect(get(progress)).toEqual(1);
       expect(client.mock.calls[0][0].data.progress_delta).toBeGreaterThanOrEqual(0.5);
     });
+    it('should max progress and store progress_delta if progress starts at 0.9999999999999999 and is updated to 1', async () => {
+      const { updateContentSession, progress } = await initStore({ progress: 0.9999999999999999 });
+      await updateContentSession({ progress: 1 });
+      expect(get(progress)).toEqual(1);
+      expect(client.mock.calls[0][0].data.progress_delta).toBeGreaterThanOrEqual(0.001);
+    });
     it('should not update progress and store progress_delta if progress is updated under current value', async () => {
       const { updateContentSession, progress, progress_delta } = await initStore();
       await updateContentSession({ progress: 0.4 });