diff --git a/.github/workflows/lint-c.yml b/.github/workflows/lint-c.yml new file mode 100644 index 0000000..0141b62 --- /dev/null +++ b/.github/workflows/lint-c.yml @@ -0,0 +1,34 @@ +name: C/C++ CI + +on: + pull_request: + branches: [ "dev", "main" ] + paths: + - 'algorithm/**' + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install clang-tools and cmake + run: sudo apt-get update && sudo apt-get install -y clang-format clang-tidy cmake + + - name: Configure CMake and generate compile_commands.json + run: cmake -B ./algorithm/build -S ./algorithm + + - name: Check C/C++ formatting (clang-format) + run: | + find ./algorithm/src ./algorithm/include ./algorithm/tests -type f \( -name "*.c" -o -name "*.cpp" -o -name "*.h" \) | xargs clang-format --Werror --dry-run + + - name: Run C/C++ linter (clang-tidy) + run: | + find ./algorithm/src ./algorithm/tests -type f \( -name "*.c" -o -name "*.cpp" \) | xargs clang-tidy -p ./algorithm/build + + - name: Build C/C++ project + run: cmake --build ./algorithm/build + + - name: Run tests (CTest) + working-directory: ./algorithm/build + run: ctest --output-on-failure \ No newline at end of file diff --git a/.github/workflows/lint-go.yml b/.github/workflows/lint-go.yml new file mode 100644 index 0000000..f73415d --- /dev/null +++ b/.github/workflows/lint-go.yml @@ -0,0 +1,23 @@ +name: Go Lint + +on: + pull_request: + branches: [ "dev", "main" ] + paths: + - 'backend/**' + +jobs: + lint-go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version: '1.24' + check-latest: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + working-directory: ./backend \ No newline at end of file diff --git a/.github/workflows/lint-python.yml b/.github/workflows/lint-python.yml new file mode 100644 index 0000000..aa32438 --- /dev/null +++ b/.github/workflows/lint-python.yml @@ -0,0 +1,21 @@ +name: Python Lint + +on: + pull_request: + branches: [ "dev", "main" ] + paths: + - 'parser/**' + +jobs: + lint-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff check and format + run: | + ruff check ./parser + ruff format --check ./parser \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e56e500..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Code Quality Check - -on: - pull_request: - branches: [ "dev", "main" ] - -jobs: - # Golang check - lint-go: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - working-directory: ./backend - - # C (Google Style) - lint-c: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install clang-tools - run: sudo apt-get install -y clang-format clang-tidy - - name: Check C formatting (clang-format) - run: find ./algorithm -name '*.c' -o -name '*.h' | xargs clang-format --Werror --dry-run - - name: Run C linter (clang-tidy) - run: find ./algorithm -name '*.c' | xargs clang-tidy --warnings-as-errors="*" -- - - # Python Ruff - lint-python: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Ruff - run: pip install ruff - - name: Run Ruff check and format - run: | - ruff check ./parser - ruff format --check ./parser \ No newline at end of file diff --git a/algorithm/.clang-format b/algorithm/.clang-format index 034dc90..43558d1 100644 --- a/algorithm/.clang-format +++ b/algorithm/.clang-format @@ -1,4 +1,3 @@ ---- Language: Cpp BasedOnStyle: Google @@ -9,5 +8,4 @@ UseTab: Never PointerAlignment: Left -SortIncludes: true ---- \ No newline at end of file +SortIncludes: true \ No newline at end of file diff --git a/algorithm/.clang-tidy b/algorithm/.clang-tidy index 71a7da5..63aab96 100644 --- a/algorithm/.clang-tidy +++ b/algorithm/.clang-tidy @@ -2,19 +2,17 @@ Checks: > -*, bugprone-*, + cert-*, clang-analyzer-*, + misc-*, performance-*, portability-*, readability-*, -readability-identifier-length, - -readability-magic-numbers, -bugprone-easily-swappable-parameters -WarningsAsErrors: '*' - HeaderFilterRegex: '.*' CheckOptions: - - key: readability-braces-around-statements.ShortStatementLines - value: '1' ---- \ No newline at end of file + readability-braces-around-statements.ShortStatementLines: 1 +... \ No newline at end of file diff --git a/algorithm/CMakeLists.txt b/algorithm/CMakeLists.txt index 7d44ff9..06b5225 100644 --- a/algorithm/CMakeLists.txt +++ b/algorithm/CMakeLists.txt @@ -1,12 +1,43 @@ -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.14) -project(SpacedRepetition C) +project(SpacedRepetition C CXX) set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 14) set(CMAKE_C_STANDARD_REQUIRED True) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -pedantic -Werror") +endif() include_directories(include) -add_library(spaced_rep STATIC - src/algorithm.c -) \ No newline at end of file +add_library(spaced_rep STATIC src/algorithm.c) + +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip + DOWNLOAD_EXTRACT_TIMESTAMP true +) + +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_algorithm.cpp") + add_executable(test_algorithm tests/test_algorithm.cpp) + + target_link_libraries(test_algorithm + PRIVATE + spaced_rep + GTest::gtest_main + m + ) + + include(GoogleTest) + include(CTest) + gtest_discover_tests(test_algorithm) +endif() \ No newline at end of file diff --git a/algorithm/include/algorithm.h b/algorithm/include/algorithm.h index e69de29..41028e1 100644 --- a/algorithm/include/algorithm.h +++ b/algorithm/include/algorithm.h @@ -0,0 +1,42 @@ +#ifndef ALGORITHM_H +#define ALGORITHM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Current state of the flashcard (data from DB) + */ +typedef struct { + int64_t interval; /**< Current interval until the next review (in days) */ + float easiness; /**< Easiness Factor (usually >= 1.3) */ + int64_t repetitions; /**< Number of consecutive correct answers */ +} CardState; + +/** + * @brief Result of the spaced repetition calculation to be saved in the DB + */ +typedef struct { + int64_t next_interval; /**< Newly calculated interval (in days) */ + float new_easiness; /**< Updated Easiness Factor */ + int64_t repetitions; /**< Updated repetitions counter */ + int32_t quality; /**< Answer quality grade (from 0 to 5) */ +} ReviewResult; + +/** + * @brief Calculates the next review parameters for a card based on similarity percentage. + * @param similarity_percent Cosine similarity percentage of the user's answer vs the reference (0.0 + * - 100.0). + * @param current_state Struct containing the current interval, easiness, and repetitions. + * @return ReviewResult Updated data ready to be saved in the database. + */ +ReviewResult calculate_next_review(float similarity_percent, CardState current_state); + +#ifdef __cplusplus +} +#endif + +#endif \ No newline at end of file diff --git a/algorithm/src/algorithm.c b/algorithm/src/algorithm.c index e69de29..1ea9baf 100644 --- a/algorithm/src/algorithm.c +++ b/algorithm/src/algorithm.c @@ -0,0 +1,75 @@ +#include "algorithm.h" + +#include + +// --- SM-2 Algorithm Constants --- + +// Thresholds for converting cosine similarity (0.0 - 100.0) to a quality grade (0 - 5) +static const float THRESHOLD_GRADE_1 = 20.0F; +static const float THRESHOLD_GRADE_2 = 40.0F; +static const float THRESHOLD_GRADE_3 = 60.0F; +static const float THRESHOLD_GRADE_4 = 80.0F; +static const float THRESHOLD_GRADE_5 = 95.0F; + +// Base constraints and intervals (in days) +static const float MIN_EASINESS_FACTOR = 1.3F; +static const int64_t INTERVAL_FIRST_STEP = 1; +static const int64_t INTERVAL_SECOND_STEP = 6; + +/** + * @brief Helper function: converts similarity percentage into a discrete grade from 0 to 5. + * Hidden from other translation units via the static modifier. + */ +static int32_t determine_quality_grade(const float similarity_percent) { + if (similarity_percent < THRESHOLD_GRADE_1) + return 0; // Complete blackout / completely incorrect + if (similarity_percent < THRESHOLD_GRADE_2) + return 1; // Incorrect, but remembered the correct answer + if (similarity_percent < THRESHOLD_GRADE_3) + return 2; // Incorrect, but the answer seemed familiar + if (similarity_percent < THRESHOLD_GRADE_4) + return 3; // Correct, but with significant difficulty + if (similarity_percent < THRESHOLD_GRADE_5) return 4; // Correct, after a slight hesitation + return 5; // Perfect and quick response +} + +ReviewResult calculate_next_review(const float similarity_percent, const CardState current_state) { + ReviewResult result; + + // Determine response quality + result.quality = determine_quality_grade(similarity_percent); + + // Calculate new Easiness Factor (EF) + // Original SM-2 formula: EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) + const float q_diff = 5.0F - (float)result.quality; + const float ef_modifier = 0.1F - (q_diff * (0.08F + (q_diff * 0.02F))); + + result.new_easiness = current_state.easiness + ef_modifier; + + // EF must not drop below the minimum threshold + if (result.new_easiness < MIN_EASINESS_FACTOR) { + result.new_easiness = MIN_EASINESS_FACTOR; + } + + // Calculate repetitions counter and next interval + if (result.quality < 3) { + // Grades 0, 1, or 2 indicate forgetting. Reset progress. + result.repetitions = 0; + result.next_interval = INTERVAL_FIRST_STEP; + } else { + // Successful review (grades 3, 4, 5) + result.repetitions = current_state.repetitions + 1; + + if (result.repetitions == 1) { + result.next_interval = INTERVAL_FIRST_STEP; + } else if (result.repetitions == 2) { + result.next_interval = INTERVAL_SECOND_STEP; + } else { + // Mathematically correct rounding instead of the (int)(... + 0.5F) hack + const float next_interval_calc = (float)current_state.interval * result.new_easiness; + result.next_interval = (int64_t)roundf(next_interval_calc); + } + } + + return result; +} \ No newline at end of file diff --git a/algorithm/tests/.clang-tidy b/algorithm/tests/.clang-tidy new file mode 100644 index 0000000..923ef37 --- /dev/null +++ b/algorithm/tests/.clang-tidy @@ -0,0 +1,9 @@ +--- +InheritParentConfig: true + +Checks: > + -misra-*, + -cert-err58-cpp, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-pro-type-vararg +...ы \ No newline at end of file diff --git a/algorithm/tests/test_algorithm.cpp b/algorithm/tests/test_algorithm.cpp new file mode 100644 index 0000000..5e74487 --- /dev/null +++ b/algorithm/tests/test_algorithm.cpp @@ -0,0 +1,65 @@ +#include + +#include "algorithm.h" + +// Auxiliary function to apply ReviewResult to CardState +CardState ApplyResultToCard(const ReviewResult& res) { + CardState new_card; + new_card.interval = res.next_interval; + new_card.easiness = res.new_easiness; + new_card.repetitions = res.repetitions; + return new_card; +} + +// Test fixture (base class for tests) to avoid code duplication +class AlgorithmTest : public ::testing::Test { + protected: + CardState default_card = {0, 2.5F, 0}; +}; + +TEST_F(AlgorithmTest, BadAnswer) { + const ReviewResult result = calculate_next_review(15.0F, default_card); + + EXPECT_EQ(result.quality, 0); + EXPECT_EQ(result.next_interval, 1); + EXPECT_EQ(result.repetitions, 0); + EXPECT_LT(result.new_easiness, 2.5F); +} + +TEST_F(AlgorithmTest, PerfectAnswerFirstTime) { + const ReviewResult result = calculate_next_review(95.0F, default_card); + + EXPECT_EQ(result.quality, 5); + EXPECT_EQ(result.repetitions, 1); + EXPECT_EQ(result.next_interval, 1); + EXPECT_GT(result.new_easiness, default_card.easiness); +} + +TEST_F(AlgorithmTest, PerfectAnswerSecondTime) { + const CardState card = ApplyResultToCard(calculate_next_review(95.0F, default_card)); + const ReviewResult result = calculate_next_review(95.0F, card); + + EXPECT_EQ(result.quality, 5); + EXPECT_EQ(result.repetitions, 2); + EXPECT_EQ(result.next_interval, 6); +} + +TEST_F(AlgorithmTest, PerfectAnswerThirdTime) { + const CardState card1 = ApplyResultToCard(calculate_next_review(95.0F, default_card)); + const CardState card2 = ApplyResultToCard(calculate_next_review(85.0F, card1)); + const ReviewResult result = calculate_next_review(100.0F, card2); + + EXPECT_EQ(result.quality, 5); + EXPECT_EQ(result.repetitions, 3); + EXPECT_GT(result.next_interval, 6); +} + +TEST_F(AlgorithmTest, BadAnswerAfterStreak) { + const CardState card1 = ApplyResultToCard(calculate_next_review(95.0F, default_card)); + const CardState card2 = ApplyResultToCard(calculate_next_review(85.0F, card1)); + const ReviewResult result = calculate_next_review(10.0F, card2); + + EXPECT_EQ(result.quality, 0); + EXPECT_EQ(result.repetitions, 0); + EXPECT_EQ(result.next_interval, 1); +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index e20ddd8..50cf777 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,3 @@ module github.com/ilindan-dev/RAGDM/backend -go 1.25.7 +go 1.24