core/templates/pages/diagnostic-test-player-page/diagnostic-test-topic-tracker.model.ts

Summary

Maintainability
C
1 day
Test Coverage
// Copyright 2022 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview The model maintains a list of the eligible topic IDs from
 * which the next topic should be selected and tested in the diagnostic test.
 * This model also keeps track of all the topic IDs that were failed by
 * the learner.
 */

import cloneDeep from 'lodash/cloneDeep';

export interface TopicIdToRelatedTopicIds {
  [topicId: string]: string[];
}

export class DiagnosticTestTopicTrackerModel {
  // The list of pending topic IDs from which the next topic can be selected
  // and tested in the diagnostic test.
  private _pendingTopicIdsToTest: string[];

  // The field keeps track of the topic ID that are failed by the learner.
  // Failing on a topic means the learner has attempted two questions
  // incorrectly that are associated with the given topic.
  private _failedTopicIds: string[];

  // The dependency among topics is represented in a form of a dict with
  // topic ID as key and a list of immediate parent topic IDs as value.
  // Example graph: A --> B --> C, here, the prerequisite of C is only B.
  private _topicIdToPrerequisiteTopicIds: TopicIdToRelatedTopicIds;

  // A dict with topic ID as key and a list of ancestor topic IDs as value.
  // Example graph: A --> B --> C, therefore the ancestors of C are both
  // A and B.
  private _topicIdToAncestorTopicIds: TopicIdToRelatedTopicIds;

  // A dict with topic ID as key and a list of successor topic IDs as value.
  // Example graph: A --> B --> C, here the successors of A are B, C, and the
  // successor of B is only C.
  private _topicIdToSuccessorTopicIds: TopicIdToRelatedTopicIds;

  constructor(topicIdToPrerequisiteTopicIds: TopicIdToRelatedTopicIds) {
    this._failedTopicIds = [];
    this._topicIdToAncestorTopicIds = {};
    this._topicIdToSuccessorTopicIds = {};

    this._topicIdToPrerequisiteTopicIds = topicIdToPrerequisiteTopicIds;
    this._pendingTopicIdsToTest = Object.keys(
      this._topicIdToPrerequisiteTopicIds
    ).sort();

    this.generateTopicIdToAncestorTopicIds();
    this.generateTopicIdToSuccessorTopicIds();
  }

  getPendingTopicIdsToTest(): string[] {
    return this._pendingTopicIdsToTest;
  }

  getFailedTopicIds(): string[] {
    return this._failedTopicIds;
  }

  getAncestorTopicIds(topicId: string): string[] {
    return this._topicIdToAncestorTopicIds[topicId];
  }

  getTopicIdToAncestorTopicIds(): TopicIdToRelatedTopicIds {
    return this._topicIdToAncestorTopicIds;
  }

  getSuccessorTopicIds(topicId: string): string[] {
    return this._topicIdToSuccessorTopicIds[topicId];
  }

  getTopicIdToSuccessorTopicIds(): TopicIdToRelatedTopicIds {
    return this._topicIdToSuccessorTopicIds;
  }

  getTopicIdToPrerequisiteTopicIds(): TopicIdToRelatedTopicIds {
    return this._topicIdToPrerequisiteTopicIds;
  }

  generateTopicIdToAncestorTopicIds(): void {
    // The method generates a dict with topic ID as the key and all of its
    // ancestor topic IDs as value. The ancestor's list is generated using the
    // prerequisite dependency graph.
    // Example: A -> B -> C, here the topic ID to ancestor topic IDs dict will
    // look like: {A: [], B: [A], C: [A, B]}.
    for (let topicId in this._topicIdToPrerequisiteTopicIds) {
      let ancestorTopicIds: string[] = [];
      let unprocessedAncestorTopicIds: string[] = cloneDeep(
        this._topicIdToPrerequisiteTopicIds[topicId]
      );

      let visitedTopicIdsForCurrentTopic: string[] = [];
      let lastTopicId: string;

      while (unprocessedAncestorTopicIds.length > 0) {
        // '.pop()' here can return an undefined value, but we're already
        // checking for length > 0, so this is safe.
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        lastTopicId = unprocessedAncestorTopicIds.pop()!;

        if (ancestorTopicIds.indexOf(lastTopicId) === -1) {
          ancestorTopicIds.push(lastTopicId);
        }

        unprocessedAncestorTopicIds = unprocessedAncestorTopicIds.concat(
          this._topicIdToPrerequisiteTopicIds[lastTopicId].filter(
            topic => visitedTopicIdsForCurrentTopic.indexOf(topic) === -1
          )
        );
        visitedTopicIdsForCurrentTopic.push(lastTopicId);
      }
      this._topicIdToAncestorTopicIds[topicId] = ancestorTopicIds.sort();
    }
  }

  _generateTopicIdToChildTopicId(
    topicIdToPrerequisiteTopicIds: TopicIdToRelatedTopicIds
  ): TopicIdToRelatedTopicIds {
    // The method generates a dict with topic ID as the key and its immediate
    // successor topic IDs as value. The successor's list is generated using
    // the prerequisite dependency graph.
    // Example: A -> B -> C, here the topic ID to child topic IDs dict will
    // look like: {A: [B], B: [C], C: []}.
    let topicIdToChildTopicId: TopicIdToRelatedTopicIds = {};

    for (let topicId in topicIdToPrerequisiteTopicIds) {
      topicIdToChildTopicId[topicId] = [];
    }

    for (let topicId in topicIdToPrerequisiteTopicIds) {
      let prereqTopicIds = topicIdToPrerequisiteTopicIds[topicId];
      for (let prereqTopicId of prereqTopicIds) {
        topicIdToChildTopicId[prereqTopicId].push(topicId);
      }
    }
    return topicIdToChildTopicId;
  }

  generateTopicIdToSuccessorTopicIds(): void {
    // The method generates a dict with topic ID as the key and all of its
    // successor topic IDs as value. The successor's list is generated using
    // the prerequisite dependency graph.
    // Example: A -> B -> C, here the topic ID to successor topic IDs dict will
    // look like: {A: [B, C], B: [C], C: []}.
    let topicIdToChildTopicId: TopicIdToRelatedTopicIds =
      this._generateTopicIdToChildTopicId(this._topicIdToPrerequisiteTopicIds);

    for (let topicId in topicIdToChildTopicId) {
      let successorTopicIds: string[] = [];
      let unprocessedSuccessorTopicIds: string[] = cloneDeep(
        topicIdToChildTopicId[topicId]
      );

      let visitedTopicIdsForCurrentTopic: string[] = [];
      let lastTopicId: string;

      while (unprocessedSuccessorTopicIds.length > 0) {
        // '.pop()' here can return an undefined value, but we're already
        // checking for length > 0, so this is safe.
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        lastTopicId = unprocessedSuccessorTopicIds.pop()!;

        if (successorTopicIds.indexOf(lastTopicId) === -1) {
          successorTopicIds.push(lastTopicId);
        }

        unprocessedSuccessorTopicIds = unprocessedSuccessorTopicIds.concat(
          topicIdToChildTopicId[lastTopicId].filter(
            topicId => visitedTopicIdsForCurrentTopic.indexOf(topicId) === -1
          )
        );
        visitedTopicIdsForCurrentTopic.push(lastTopicId);
      }
      this._topicIdToSuccessorTopicIds[topicId] = successorTopicIds.sort();
    }
  }

  selectNextTopicIdToTest(): string {
    // A dict with topic ID as key and
    // min(length of ancestors, length of successors) topic IDs as value.
    let topicIdToLengthOfRelatedTopicIds: {[topicId: string]: number} = {};

    for (let topicId of this._pendingTopicIdsToTest) {
      let ancestorTopicIds = this._topicIdToAncestorTopicIds[topicId];
      let successorTopicIds = this._topicIdToSuccessorTopicIds[topicId];

      // Considering a given topic, the assured number of topics that will be
      // removed from the eligible topic IDs after testing, is the minimum of
      // the counts of its ancestors and successors.
      topicIdToLengthOfRelatedTopicIds[topicId] = Math.min(
        ancestorTopicIds.length,
        successorTopicIds.length
      );
    }

    // Among all the eligible topics, the topic with the maximum value for
    // min(ancestors, successors) should be selected for testing. The
    // maximum value helps us to reach the result faster which helps in quicker
    // recommendations.
    return Object.keys(topicIdToLengthOfRelatedTopicIds).reduce(
      (item1, item2) => {
        return topicIdToLengthOfRelatedTopicIds[item1] >=
          topicIdToLengthOfRelatedTopicIds[item2]
          ? item1
          : item2;
      }
    );
  }

  recordTopicPassed(passedTopicId: string): void {
    // If a topic is passed, that means we don't need to test any of the
    // ancestor topics. Hence the ancestors of the current topic must be
    // removed from the eligible topic IDs, topic ID to ancestor topic IDs dict,
    // and topic ID to successor topic IDs dict.
    let topicIdsToRemove: string[] = [];

    let ancestorTopicIds = this._topicIdToAncestorTopicIds[passedTopicId];

    // Removing the ancestor topic IDs because the given topic ID is passed.
    topicIdsToRemove = topicIdsToRemove.concat(ancestorTopicIds);
    // Removing the given topic ID because it is already tested.
    topicIdsToRemove.push(passedTopicId);

    this._pendingTopicIdsToTest = this._pendingTopicIdsToTest.filter(
      topicId => topicIdsToRemove.indexOf(topicId) === -1
    );
    this.removeTopicIdsFromTopicIdToAncestorsDict(topicIdsToRemove);
    this.removeTopicIdsFromTopicIdToSuccessorsDict(topicIdsToRemove);
  }

  recordTopicFailed(failedTopicId: string): void {
    // If a topic is failed, that means we don't need to test any of the
    // successor topics. Hence the successors of the current topic must be
    // removed from the eligible topic IDs, topic ID to ancestor topic IDs dict,
    // and topic ID to successor topic IDs dict.
    this._failedTopicIds.push(failedTopicId);
    let topicIdsToRemove: string[] = [];

    let successorTopicIds = this._topicIdToSuccessorTopicIds[failedTopicId];

    // Removing the successor topic IDs because the given topic ID is failed.
    topicIdsToRemove = topicIdsToRemove.concat(successorTopicIds);
    // Removing the given topic ID because it is already tested.
    topicIdsToRemove.push(failedTopicId);

    this._pendingTopicIdsToTest = this._pendingTopicIdsToTest.filter(
      topicId => topicIdsToRemove.indexOf(topicId) === -1
    );
    this.removeTopicIdsFromTopicIdToAncestorsDict(topicIdsToRemove);
    this.removeTopicIdsFromTopicIdToSuccessorsDict(topicIdsToRemove);
  }

  removeTopicIdsFromTopicIdToAncestorsDict(topicIdsToRemove: string[]): void {
    // Removing the given topic IDs from the topic ID to the ancestor
    // topic IDs dict.
    for (let topicId of topicIdsToRemove) {
      delete this._topicIdToAncestorTopicIds[topicId];
    }
    for (let topicId in this._topicIdToAncestorTopicIds) {
      let ancestors = this._topicIdToAncestorTopicIds[topicId];
      this._topicIdToAncestorTopicIds[topicId] = ancestors.filter(topic => {
        return topicIdsToRemove.indexOf(topic) === -1;
      });
    }
  }

  removeTopicIdsFromTopicIdToSuccessorsDict(topicIdsToRemove: string[]): void {
    // Removing the given topic IDs from the topic ID to the successor
    // topic IDs dict.
    for (let topicId of topicIdsToRemove) {
      delete this._topicIdToSuccessorTopicIds[topicId];
    }
    for (let topicId in this._topicIdToSuccessorTopicIds) {
      let successors = this._topicIdToSuccessorTopicIds[topicId];
      this._topicIdToSuccessorTopicIds[topicId] = successors.filter(topic => {
        return topicIdsToRemove.indexOf(topic) === -1;
      });
    }
  }
}