diff --git a/.gitignore b/.gitignore
index b6e5eda..c77d7ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@ data/session
build/
dist/
old/
+AGENT.md
+AGENTS.md
*.log.*
# Byte-compiled / optimized / DLL files
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fe14469..8d18648 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,62 +1,467 @@
-# 项目架构
-
-## 架构图(待更新 0.5.0)
-
-以下 Mermaid 图展示了 HeurAMS 的主要组件及其关系:
+## 整体架构概览
```mermaid
graph TB
subgraph "用户界面层 (TUI)"
- TUI[Textual TUI]
- Widgets[界面组件]
+ TUI[Textual App]
Screens[应用屏幕]
+ Widgets[谜题组件]
+ end
+
+ subgraph "内核层 Kernel"
+ Reactor[调度反应器]
+ Algorithms[算法模块]
+ Particles[数据模型]
+ Puzzles[谜题引擎]
+ RepoLib[仓库系统]
+ Auxiliary[辅助工具]
end
subgraph "服务层"
- Config[配置管理]
+ Config[配置管理 ConfigDict]
Logger[日志系统]
Timer[时间服务]
- AudioService[音频服务]
- TTSService[TTS服务]
- SyncService[同步服务]
- OtherServices[其他服务]
- end
-
- subgraph "内核层"
- Algorithms[算法模块]
- Particles[数据模型]
- Puzzles[谜题模块]
- Reactor[调度反应器]
+ Audio[音频服务]
+ TTS[TTS 服务]
+ Favorites[收藏管理]
+ Attic[持久化]
+ Hasher[哈希服务]
end
subgraph "提供者层"
- AudioProvider[音频提供者]
- TTSProvider[TTS提供者]
- OtherProviders[其他提供者]
+ AudioProv[音频提供者]
+ TTSProv[TTS 提供者]
+ LLMProv[LLM 提供者]
end
subgraph "数据层"
- Files[本地文件数据]
+ RepoDir[TOML/JSON 仓库目录]
+ ConfigDir[TOML 配置目录]
+ Logs[日志文件]
end
- subgraph "上下文管理"
- Context[ConfigContext]
- CtxVar[config_var]
- end
-
- TUI --> Config
- TUI --> Logger
- TUI --> AudioService
- TUI --> TTSService
- TUI --> OtherServices
- Config --> Files
- Config --> Context
- AudioService --> AudioProvider
- TTSService --> TTSProvider
- OtherServices --> OtherProviders
+ TUI --> Screens
+ Screens --> Reactor
+ Screens --> RepoLib
+ Screens --> Widgets
+ Widgets --> Puzzles
+ Widgets --> Reactor
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
- Particles --> Files
- Algorithms --> Files
+ Particles --> RepoLib
+ RepoLib --> Config
+ RepoLib --> Auxiliary
+ Auxiliary --> Lict
+ Auxiliary --> Evalizer
+ TUI --> Config
+ TUI --> Logger
+ TUI --> Audio
+ TUI --> TTS
+ Config --> ConfigDir
+ Audio --> AudioProv
+ TTS --> TTSProv
+ Attic --> RepoDir
```
+
+## 数据模型
+
+项目以物理粒子隐喻为核心, 将记忆单元拆解为三个模型:
+
+### Nucleon (核子) — 内容层
+
+```
+Nucleon(ident, payload, common)
+```
+
+- **只读**内容容器. 通过 `Evalizer` (基于 `eval()` 的模板系统)对 payload 和 common 进行编译展开.
+- 包含 `puzzles` 字段, 定义该记忆单元支持哪些谜题类型.
+- 从 `repo.payload` 和 `repo.typedef["common"]` 配对创建.
+- 一旦创建, 内容不可修改 (`__setitem__` 抛出 `AttributeError`).
+
+### Electron (电子) — 状态层
+
+```
+Electron(ident, algodata, algo_name)
+```
+
+- 算法状态数据的包装器. 每个 Electron 绑定一个算法 (`algorithms[algo_name]`).
+- `algodata` 是到仓库 `algodata.lict` 中对应字典的**引用**, 修改即持久化.
+- 核心方法:`activate()` (标记激活)、`revisor()` (评分迭代)、`is_due()` (到期判断).
+
+### Orbital (轨道) — 策略层
+
+```
+orbital = {
+ "schedule": ["quick_review", "recognition"],
+ "routes": {
+ "quick_review": [["MCQ", "1.0"], ["Cloze", "0.5"]],
+ "recognition": [["Recognition", "1.0"]],
+ }
+}
+```
+
+- 定义复习阶段流程和各阶段内谜题选择策略的纯字典.
+- 每个阶段对应一组 `(谜题类型, 概率系数)` 元组列表, 概率系数 >1 的部分表示强制重复次数.
+
+### Atom (原子) — 运行时组装
+
+```
+Atom(nucleon, electron, orbital)
+```
+
+- 三者的运行时组合, 附带 `runtime` 运行时标志 (`locked`, `min_rate`, `new_activation`).
+- 是 UI 和调度层操作的基本单位.
+- `revise()` 方法在 `locked` 为真时调用 `electron.revisor(min_rate)`, 执行最终评分迭代.
+
+**关系图**:
+
+```mermaid
+graph LR
+ subgraph "持久化存储"
+ Payload[(payload.toml)]
+ Common[(typedef.toml)]
+ Algodata[(algodata.json)]
+ Schedule[(schedule.toml)]
+ end
+
+ subgraph "运行时组装"
+ Nucleon -->|内容| Atom
+ Electron -->|状态| Atom
+ Orbital -->|策略| Atom
+ end
+
+ Payload --> Nucleon
+ Common --> Nucleon
+ Algodata --> Electron
+ Schedule --> Orbital
+```
+
+---
+
+## 调度反应器 (Reactor)
+
+调度反应器是核心业务流程引擎, 采用三层嵌套的有限状态机设计 (基于 `transitions` 库).
+
+### 状态枚举定义
+
+| 状态机 | 状态 | 说明 |
+|--------|------|------|
+| **RouterState** | `unsure` | 初始状态, 自动推进 |
+| | `quick_review` | 快速复习阶段 |
+| | `recognition` | 新记忆识别阶段 |
+| | `final_review` | 最终总复习阶段 |
+| | `finished` | 完成, 执行评分 |
+| **ProcessionState** | `active` | 进行中 |
+| | `finished` | 已完成 |
+| **ExpanderState** | `exammode` | 考试模式 (正面答题) |
+| | `retronly` | 回溯模式 (仅识别) |
+
+### 三层嵌套结构
+
+```mermaid
+graph TB
+ subgraph "Router (全局路由器)"
+ R[Router
状态: unsure→quick_review→recognition→final_review→finished]
+ P1[Procession 队列1: 快速复习]
+ P2[Procession 队列2: 新记忆]
+ P3[Procession 队列3: 总复习]
+ R --> P1
+ R --> P2
+ R --> P3
+ end
+
+ subgraph "Procession (单阶段队列)"
+ P1 --> E1[Expander 原子A]
+ P1 --> E2[Expander 原子B]
+ P1 --> E3[Expander 原子C]
+ M{forward 推进} --> |完成| Finish((FINISHED))
+ end
+
+ subgraph "Expander (单原子展开器)"
+ E1 --> S[(轨道策略)]
+ S -->|概率展开| PZ1[谜题1: MCQ]
+ S -->|概率展开| PZ2[谜题2: Cloze]
+ PZ1 -->|评分| RPT[report]
+ PZ2 -->|评分| RPT
+ RPT -->|finish| RETRO[retronly 回溯模式]
+ end
+```
+
+### 数据流详解
+
+```
+Router.__init__(atoms)
+ │
+ ├─ 新旧原子分流
+ │ ├─ old_atoms → Procession(quick_review) "初始复习"
+ │ └─ new_atoms → Procession(recognition) "新记忆"
+ │
+ └─ 所有原子 → Procession(final_review) "总体复习"
+ │
+ └─ Procession.forward()
+ │
+ ├─ cursor >= len(atoms) → finish()
+ └─ cursor < len(atoms) → next_atom
+ │
+ └─ Procession.get_expander()
+ │
+ └─ Expander(atom, route)
+ │
+ ├─ 读取 orbital.routes[route_value]
+ ├─ 概率展开为谜题列表 self.puzzles_inf
+ ├─ exammode → 依次展示谜题
+ ├─ report(rating) → 记录最低评分
+ ├─ forward() → 下一个谜题或 finish → retronly
+ └─ retronly → 展示 Recognition
+ │
+ └─ Atom.revise()
+ │
+ └─ Electron.revisor(min_rate)
+ │
+ └─ Algorithm.revisor(algodata, feedback)
+```
+
+---
+
+## 算法系统
+
+所有算法继承自 `BaseAlgorithm`, 以类方法的风格实现, 通过 `algorithms` 字典注册.
+
+| 算法 | 文件 | 状态 | 说明 |
+|------|------|------|------|
+| **SM-2** | `sm2.py` | ✅ 完成 | 经典 SuperMemo 1987 算法 |
+| **NSP-0** | `nsp0.py` | ✅ 完成 | 非间隔过滤调度器 |
+| **SM-15M** | `sm15m.py` + `sm15m_calc.py` | ✅ 完成 | 从 CoffeeScript 移植的 SM-15 |
+| **FSRS** | `fsrs.py` | ❌ 未实现 | 进度: `logger.info("尚未实现")` |
+| **Base** | `base.py` | ✅ 基类 | 定义 `AlgodataDict` 结构和默认值 |
+
+每个算法提供以下类方法:
+
+| 方法 | 功能 |
+|------|------|
+| `revisor(algodata, feedback, is_new_activation)` | 根据评分迭代记忆数据 |
+| `is_due(algodata)` | 判断是否到期复习 |
+| `get_rating(algodata)` | 获取评分信息 |
+| `nextdate(algodata)` | 获取下一次复习时间戳 |
+| `check_integrity(algodata)` | 校验 algodata 数据结构完整性 |
+
+### 算法数据结构 (AlgodataDict)
+
+```python
+{
+ "real_rept": int, # 实际复习次数
+ "rept": int, # 当前重复计数
+ "interval": int, # 间隔天数
+ "last_date": int, # 上次复习日期
+ "next_date": int, # 下次到期日期
+ "is_activated": int, # 是否已激活 (0/1)
+ "last_modify": float, # 最后修改时间戳
+}
+```
+
+---
+
+## 仓库系统 (Repo)
+
+仓库是 TOML/JSON 文件目录, 无数据库依赖.
+
+### 目录结构
+
+```
+data/repo//
+├── manifest.toml # 元信息: title, author, package, desc
+├── typedef.toml # 通用元数据、谜题定义、注解
+├── payload.toml # 记忆条目 (key=ident)
+├── algodata.json # 算法状态 (key=ident)
+└── schedule.toml # 轨道/复习策略
+```
+
+### Repo 类设计
+
+```mermaid
+classDiagram
+ class Repo {
+ +dict schedule
+ +Lict payload
+ +dict manifest
+ +dict typedef
+ +Lict algodata
+ +Path source
+ +Lict nucleonic_data_lict
+ +dict orbitic_data
+ +Lict electronic_data_lict
+ +from_repodir(source) ~Repo
+ +from_dict(dictdata) ~Repo
+ +create_new_repo() ~Repo
+ +persist_to_repodir(save_list, source)
+ +export_to_dict() dict
+ }
+```
+
+- `payload` 和 `algodata` 使用 `Lict` (列表+字典混合容器), 支持双模式访问.
+- `_generate_particles_data()` 在初始化时自动将 payload 数据转换为 `Nucleon` 所需的格式.
+- 默认保存列表 `default_save_list = ["algodata"]`, 仅持久化算法状态.
+
+---
+
+## Lict 集合
+
+`Lict` 继承 `MutableSequence`, 同时维护列表和字典访问:
+
+```python
+lict = Lict()
+lict.append(("key1", value1)) # 列表追加
+lict["key1"] # 字典访问
+lict[0] # 索引访问
+lict.keys() # 所有键
+lict.dicted_data # 纯字典导出
+```
+
+脏同步机制:修改列表时自动同步字典, 修改字典时自动同步列表. 用于 `payload` 和 `algodata` 的双模式访问需求.
+
+---
+
+## 配置系统 (ConfigDict)
+
+`ConfigDict` 继承 `UserDict`, 是**单例模式**的 TOML 懒加载配置管理器.
+
+### 配置目录约定
+
+```
+data/config/
+├── _.toml # 顶层默认值 (递归合并)
+├── interface/
+│ ├── _.toml # interface 层默认值
+│ ├── global.toml
+│ └── puzzles.toml
+├── services/
+│ ├── _.toml # services 层默认值
+│ ├── audio.toml
+│ └── tts.toml
+└── repo/
+ └── _.toml
+```
+
+- `_.toml` 文件 = 该目录层级的默认值, 合并到父级
+- 带后缀文件 = 按需懒加载
+- 子目录 = 递归子配置
+
+### 上下文管理
+
+```python
+from heurams.context import config_var, ConfigContext
+
+# 全局访问
+config = config_var.get()
+algo = config["interface"]["global"]["algorithm"]
+
+# 作用域覆盖
+with ConfigContext(test_config):
+ ... # 临时使用测试配置
+```
+
+---
+
+## 提供者系统 (Providers)
+
+可插拔的后端实现, 通过 `providers/__init__.py` 中的字典注册.
+
+| 类别 | 提供者 | 说明 |
+|------|--------|------|
+| **TTS** | `edge_tts` | Microsoft Edge TTS (在线) |
+| | `basetts` | 桩基类 (未实现) |
+| **Audio** | `playsound` | 跨平台音频播放 |
+| | `termux` | Android Termux 环境 |
+| **LLM** | `openai` | OpenAI 兼容 API (未完整实现) |
+
+选择方式:`services/*.toml` 中的 `provider` 字段.
+
+---
+
+## 谜题系统 (Puzzles)
+
+谜题引擎用于在复习阶段生成评估视图:
+
+| 谜题 | 文件 | 说明 |
+|------|------|------|
+| **MCQ** | `mcq.py` | 选择题 (Multiple Choice) |
+| **Cloze** | `cloze.py` | 完形填空 (Cloze Deletion) |
+| **Recognition** | `recognition.py` | 认读识别 |
+| **Guess** | `guess.py` | 猜测词义 |
+| **Base** | `base.py` | 抽象基类 |
+
+谜题通过轨道策略 (Orbital)在 `Expander` 中按概率展开, 每个原子可产生多个谜题, 每个谜题独立评分.
+
+---
+
+## 服务层
+
+| 服务 | 文件 | 说明 |
+|------|------|------|
+| **Config** | `config.py` | `ConfigDict(UserDict)` TOML 懒加载单例 |
+| **Logger** | `logger.py` | `get_logger(name)` → 层级日志 (`heurams.*`) |
+| **Timer** | `timer.py` | `get_daystamp()` / `get_timestamp()`, 支持可配置覆盖 |
+| **Audio** | `audio_service.py` | 音频播放, 路由到配置的音频提供者 |
+| **TTS** | `tts_service.py` | 文本转语音, 路由到配置的 TTS 提供者 |
+| **Favorites** | `favorite_service.py` | JSON5 持久化的收藏管理器 (单例) |
+| **Attic** | `attic.py` | 结构化 pickle 持久化, 支持 ``/`` 占位符 |
+| **Hasher** | `hasher.py` | MD5 哈希 |
+| **Epath** | `epath.py` | 点符号嵌套字典访问 (`epath(dct, "a.b.c")`) |
+| **TextProc** | `textproc.py` | `truncate()`, `domize()`, `undomize()` |
+
+日志系统:每个模块通过 `get_logger(__name__)` 创建自己的日志器, 日志文件 10MB 轮转, 最多 5 个备份, 追加到 `heurams.log`.
+
+---
+
+## 复习全流程
+
+```mermaid
+sequenceDiagram
+ participant User as 用户
+ participant UI as TUI
+ participant Router as Router
+ participant Procession as Procession
+ participant Expander as Expander
+ participant Atom as Atom
+ participant Electron as Electron
+ participant Algo as Algorithm
+
+ User->>UI: 开始复习
+ UI->>Router: Router(atoms)
+ Router->>Procession: 创建复习队列
+ Router->>Procession: 创建新记忆队列
+ Router->>Procession: 创建总复习队列
+ Procession->>Expander: 展开当前原子
+ Expander->>Expander: 解析轨道策略
+ Expander-->>UI: 展示谜题
+ User->>UI: 评分 (1-5)
+ UI->>Expander: report(rating)
+ Expander->>Expander: forward() 下一个谜题
+ Expander-->>UI: 下一个谜题或回溯
+ Expander->>Expander: finish() → retronly
+ Expander-->>UI: Recognition 回溯
+ User->>UI: 最终评分
+ UI->>Atom: revise()
+ Atom->>Electron: revisor(min_rate)
+ Electron->>Algo: revisor(algodata, feedback)
+ Algo-->>Electron: 更新 algodata
+ Algo-->>Atom: 更新 interval, next_date
+ Procession->>Procession: forward() 下一原子
+ Procession-->>Router: 队列完成
+ Router->>Router: 切换阶段
+ Router-->>UI: 完成 (finished)
+ UI->>User: 显示总结
+```
+
+---
+
+## 关键设计决策
+
+1. **无数据库** — 所有持久化基于 TOML/JSON 文件目录, 方便版本管理和手动编辑.
+2. **Lict 双模式访问** — payload 和 algodata 同时支持列表迭代和字典查找, 兼顾批处理和随机访问.
+3. **物理隐喻分离** — 内容 (Nucleon)、状态 (Electron)、策略 (Orbital) 三者正交, 可独立替换, 便于组合不同算法和内容类型.
+4. **transitions 状态机** — 使用 `transitions` 库实现 Router → Procession → Expander 三层嵌套状态机, 每个层次职责明确.
+5. **Evalizer eval 模板** — 使用 `eval()` 实现动态模板替换, 功能灵活但存在安全风险 (标记为待替换).
+6. **配置单例** — `ConfigDict` 以规范化路径为键实现单例, 避免多实例导致的配置不一致问题.
+7. **评分累积** — 原子在多谜题阶段的最终评分取所有谜题的最低评分 (`min_rate`), 确保严格评估.
diff --git a/pyproject.toml b/pyproject.toml
index 68abe9b..28961fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
dependencies = [
+ "fsrs>=6.3.1",
#"edge-tts>=7.2.8",
#"jieba>=0.42.1",
#"openai>=2.32.0",
@@ -34,6 +35,21 @@ Issues = "https://github.com/heurams/heurams/issues"
heurams = "heurams.__main__:main"
tui = "heurams.interface.__main__:main"
+[dependency-groups]
+dev = [
+ "pytest>=8.0.0",
+ "pytest-cov>=6.0.0",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py", "*_test.py"]
+pythonpath = ["src"]
+markers = [
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+ "integration: marks tests as integration tests",
+]
+
[build-system]
requires = ["uv_build>=0.7.19"]
build-backend = "uv_build"
diff --git a/src/heurams/vendor/pyfsrs/LICENSE b/src/heurams/vendor/pyfsrs/LICENSE
deleted file mode 100644
index 29d7488..0000000
--- a/src/heurams/vendor/pyfsrs/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2022 Open Spaced Repetition
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/src/heurams/vendor/pyfsrs/__init__.py b/src/heurams/vendor/pyfsrs/__init__.py
deleted file mode 100644
index ae59e0d..0000000
--- a/src/heurams/vendor/pyfsrs/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""
-py-fsrs
--------
-
-Py-FSRS is the official Python implementation of the FSRS scheduler algorithm, which can be used to develop spaced repetition systems.
-"""
-
-from fsrs.scheduler import Scheduler
-from fsrs.state import State
-from fsrs.card import Card
-from fsrs.rating import Rating
-from fsrs.review_log import ReviewLog
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from fsrs.optimizer import Optimizer
-
-
-# lazy load the Optimizer module due to heavy dependencies
-def __getattr__(name: str) -> type:
- if name == "Optimizer":
- global Optimizer
- from fsrs.optimizer import Optimizer
-
- return Optimizer
- raise AttributeError
-
-
-__all__ = ["Scheduler", "Card", "Rating", "ReviewLog", "State", "Optimizer"]
diff --git a/src/heurams/vendor/pyfsrs/card.py b/src/heurams/vendor/pyfsrs/card.py
deleted file mode 100644
index 2531ea1..0000000
--- a/src/heurams/vendor/pyfsrs/card.py
+++ /dev/null
@@ -1,167 +0,0 @@
-"""
-fsrs.card
----------
-
-This module defines the Card and State classes.
-
-Classes:
- Card: Represents a flashcard in the FSRS system.
- State: Enum representing the learning state of a Card object.
-"""
-
-from __future__ import annotations
-from dataclasses import dataclass
-from datetime import datetime, timezone
-import time
-import json
-from typing import TypedDict
-from typing_extensions import Self
-from fsrs.state import State
-
-
-class CardDict(TypedDict):
- """
- JSON-serializable dictionary representation of a Card object.
- """
-
- card_id: int
- state: int
- step: int | None
- stability: float | None
- difficulty: float | None
- due: str
- last_review: str | None
-
-
-@dataclass(init=False)
-class Card:
- """
- Represents a flashcard in the FSRS system.
-
- Attributes:
- card_id: The id of the card. Defaults to the epoch milliseconds of when the card was created.
- state: The card's current learning state.
- step: The card's current learning or relearning step or None if the card is in the Review state.
- stability: Core mathematical parameter used for future scheduling.
- difficulty: Core mathematical parameter used for future scheduling.
- due: The date and time when the card is due next.
- last_review: The date and time of the card's last review.
- """
-
- card_id: int
- state: State
- step: int | None
- stability: float | None
- difficulty: float | None
- due: datetime
- last_review: datetime | None
-
- def __init__(
- self,
- card_id: int | None = None,
- state: State = State.Learning,
- step: int | None = None,
- stability: float | None = None,
- difficulty: float | None = None,
- due: datetime | None = None,
- last_review: datetime | None = None,
- ) -> None:
- if card_id is None:
- # epoch milliseconds of when the card was created
- card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
- # wait 1ms to prevent potential card_id collision on next Card creation
- time.sleep(0.001)
- self.card_id = card_id
-
- self.state = state
-
- if self.state == State.Learning and step is None:
- step = 0
- self.step = step
-
- self.stability = stability
- self.difficulty = difficulty
-
- if due is None:
- due = datetime.now(timezone.utc)
- self.due = due
-
- self.last_review = last_review
-
- def to_dict(self) -> CardDict:
- """
- Returns a dictionary representation of the Card object.
-
- Returns:
- CardDict: A dictionary representation of the Card object.
- """
-
- return {
- "card_id": self.card_id,
- "state": self.state.value,
- "step": self.step,
- "stability": self.stability,
- "difficulty": self.difficulty,
- "due": self.due.isoformat(),
- "last_review": self.last_review.isoformat() if self.last_review else None,
- }
-
- @classmethod
- def from_dict(cls, source_dict: CardDict) -> Self:
- """
- Creates a Card object from an existing dictionary.
-
- Args:
- source_dict: A dictionary representing an existing Card object.
-
- Returns:
- Self: A Card object created from the provided dictionary.
- """
-
- return cls(
- card_id=int(source_dict["card_id"]),
- state=State(int(source_dict["state"])),
- step=source_dict["step"],
- stability=(
- float(source_dict["stability"]) if source_dict["stability"] else None
- ),
- difficulty=(
- float(source_dict["difficulty"]) if source_dict["difficulty"] else None
- ),
- due=datetime.fromisoformat(source_dict["due"]),
- last_review=(
- datetime.fromisoformat(source_dict["last_review"])
- if source_dict["last_review"]
- else None
- ),
- )
-
- def to_json(self, indent: int | str | None = None) -> str:
- """
- Returns a JSON-serialized string of the Card object.
-
- Args:
- indent: Equivalent argument to the indent in json.dumps()
-
- Returns:
- str: A JSON-serialized string of the Card object.
- """
- return json.dumps(self.to_dict(), indent=indent)
-
- @classmethod
- def from_json(cls, source_json: str) -> Self:
- """
- Creates a Card object from a JSON-serialized string.
-
- Args:
- source_json: A JSON-serialized string of an existing Card object.
-
- Returns:
- Self: A Card object created from the JSON string.
- """
-
- source_dict: CardDict = json.loads(source_json)
- return cls.from_dict(source_dict=source_dict)
-
-
-__all__ = ["Card"]
diff --git a/src/heurams/vendor/pyfsrs/optimizer.py b/src/heurams/vendor/pyfsrs/optimizer.py
deleted file mode 100644
index d19c9bb..0000000
--- a/src/heurams/vendor/pyfsrs/optimizer.py
+++ /dev/null
@@ -1,674 +0,0 @@
-"""
-fsrs.optimizer
----------
-
-This module defines the optional Optimizer class.
-"""
-
-from fsrs.card import Card
-from fsrs.review_log import ReviewLog, Rating
-from fsrs.scheduler import (
- Scheduler,
- DEFAULT_PARAMETERS,
- LOWER_BOUNDS_PARAMETERS,
- UPPER_BOUNDS_PARAMETERS,
-)
-
-import math
-from datetime import datetime, timezone
-from copy import deepcopy
-from random import Random
-from statistics import mean
-
-try:
- import torch
- from torch.nn import BCELoss
- from torch import optim
- import pandas as pd
- from tqdm import tqdm
-
- # weight clipping
- LOWER_BOUNDS_PARAMETERS_TENSORS = torch.tensor(
- LOWER_BOUNDS_PARAMETERS,
- dtype=torch.float64,
- )
-
- UPPER_BOUNDS_PARAMETERS_TENSORS = torch.tensor(
- UPPER_BOUNDS_PARAMETERS,
- dtype=torch.float64,
- )
-
- # hyper parameters
- num_epochs = 5
- mini_batch_size = 512
- learning_rate = 4e-2
- max_seq_len = (
- 64 # up to the first 64 reviews of each card are used for optimization
- )
-
- class Optimizer:
- """
- The FSRS optimizer.
-
- Enables the optimization of FSRS scheduler parameters from existing review logs for more accurate interval calculations.
-
- Attributes:
- review_logs: A collection of previous ReviewLog objects from a user.
- _revlogs_train: The collection of review logs, sorted and formatted for optimization.
- """
-
- review_logs: tuple[ReviewLog, ...]
- _revlogs_train: dict
-
- def __init__(
- self, review_logs: tuple[ReviewLog, ...] | list[ReviewLog]
- ) -> None:
- """
- Initializes the Optimizer with a set of ReviewLogs. Also formats a copy of the review logs for optimization.
-
- Note that the ReviewLogs provided by the user don't need to be in order.
- """
-
- def _format_revlogs() -> dict:
- """
- Sorts and converts the tuple of ReviewLog objects to a dictionary format for optimizing
- """
-
- revlogs_train = {}
- for review_log in self.review_logs:
- # pull data out of current ReviewLog object
- card_id = review_log.card_id
- rating = review_log.rating
- review_datetime = review_log.review_datetime
- review_duration = review_log.review_duration
-
- # if the card was rated Again, it was not recalled
- recall = 0 if rating == Rating.Again else 1
-
- # as a ML problem, [x, y] = [ [review_datetime, rating, review_duration], recall ]
- datum = [[review_datetime, rating, review_duration], recall]
-
- if card_id not in revlogs_train:
- revlogs_train[card_id] = []
-
- revlogs_train[card_id].append((datum))
- revlogs_train[card_id] = sorted(
- revlogs_train[card_id], key=lambda x: x[0][0]
- ) # keep reviews sorted
-
- # sort the dictionary in order of when each card history starts
- revlogs_train = dict(sorted(revlogs_train.items()))
-
- return revlogs_train
-
- self.review_logs = deepcopy(tuple(review_logs))
-
- # format the ReviewLog data for optimization
- self._revlogs_train = _format_revlogs()
-
- def _compute_batch_loss(self, *, parameters: list[float]) -> float:
- """
- Computes the current total loss for the entire batch of review logs.
- """
-
- card_ids = list(self._revlogs_train.keys())
- params = torch.tensor(parameters, dtype=torch.float64)
- loss_fn = BCELoss()
- scheduler = Scheduler(parameters=params)
- step_losses = []
-
- for card_id in card_ids:
- card_review_history = self._revlogs_train[card_id][:max_seq_len]
-
- for i in range(len(card_review_history)):
- review = card_review_history[i]
-
- x_date = review[0][0]
- y_retrievability = review[1]
- u_rating = review[0][1]
-
- if i == 0:
- card = Card(card_id=card_id, due=x_date)
-
- y_pred_retrievability = scheduler.get_card_retrievability(
- card=card, current_datetime=x_date
- )
- y_retrievability = torch.tensor(
- y_retrievability, dtype=torch.float64
- )
-
- if card.last_review and (x_date - card.last_review).days > 0:
- step_loss = loss_fn(y_pred_retrievability, y_retrievability)
- step_losses.append(step_loss)
-
- card, _ = scheduler.review_card(
- card=card,
- rating=u_rating,
- review_datetime=x_date,
- review_duration=None,
- )
-
- batch_loss = torch.sum(torch.stack(step_losses))
- batch_loss = batch_loss.item() / len(step_losses)
-
- return batch_loss
-
- def compute_optimal_parameters(self, verbose: bool = False) -> list[float]:
- """
- Computes a set of optimized parameters for the FSRS scheduler and returns it as a list of floats.
-
- High level explanation of optimization:
- ---------------------------------------
- FSRS is a many-to-many sequence model where the "State" at each step is a Card object at a given point in time,
- the input is the time of the review and the output is the predicted retrievability of the card at the time of review.
-
- Each card's review history can be thought of as a sequence, each review as a step and each collection of card review histories
- as a batch.
-
- The loss is computed by comparing the predicted retrievability of the Card at each step with whether the Card was actually
- sucessfully recalled or not (0/1).
-
- Finally, the card objects at each step in their sequences are updated using the current parameters of the Scheduler
- as well as the rating given to that card by the user. The parameters of the Scheduler is what is being optimized.
- """
-
- def _num_reviews() -> int:
- """
- Computes how many Review-state reviews there are in the dataset.
- Only the loss from Review-state reviews count for optimization and their number must
- be computed in advance to properly initialize the Cosine Annealing learning rate scheduler.
- """
-
- scheduler = Scheduler()
- num_reviews = 0
- # iterate through the card review histories
- card_ids = list(self._revlogs_train.keys())
- for card_id in card_ids:
- card_review_history = self._revlogs_train[card_id][:max_seq_len]
-
- # iterate through the current Card's review history
- for i in range(len(card_review_history)):
- review = card_review_history[i]
-
- review_datetime = review[0][0]
- rating = review[0][1]
-
- # if this is the first review, create the Card object
- if i == 0:
- card = Card(card_id=card_id, due=review_datetime)
-
- # only non-same-day reviews count
- if (
- card.last_review
- and (review_datetime - card.last_review).days > 0
- ):
- num_reviews += 1
-
- card, _ = scheduler.review_card(
- card=card,
- rating=rating,
- review_datetime=review_datetime,
- review_duration=None,
- )
-
- return num_reviews
-
- def _update_parameters(
- *,
- step_losses: list,
- adam_optimizer: torch.optim.Adam,
- params: torch.Tensor,
- lr_scheduler: torch.optim.lr_scheduler.CosineAnnealingLR,
- ) -> None:
- """
- Computes and updates the current FSRS parameters based on the step losses. Also updates the learning rate scheduler.
- """
-
- # Backpropagate through the loss
- mini_batch_loss = torch.sum(torch.stack(step_losses))
- adam_optimizer.zero_grad() # clear previous gradients
- mini_batch_loss.backward() # compute gradients
- adam_optimizer.step() # Update parameters
-
- # clamp the weights in place without modifying the computational graph
- with torch.no_grad():
- params.clamp_(
- min=LOWER_BOUNDS_PARAMETERS_TENSORS,
- max=UPPER_BOUNDS_PARAMETERS_TENSORS,
- )
-
- # update the learning rate
- lr_scheduler.step()
-
- # set local random seed for reproducibility
- rng = Random(42)
-
- card_ids = list(self._revlogs_train.keys())
-
- num_reviews = _num_reviews()
-
- if num_reviews < mini_batch_size:
- return list(DEFAULT_PARAMETERS)
-
- # Define FSRS Scheduler parameters as torch tensors with gradients
- params = torch.tensor(
- DEFAULT_PARAMETERS, requires_grad=True, dtype=torch.float64
- )
-
- loss_fn = BCELoss()
- adam_optimizer = optim.Adam([params], lr=learning_rate)
- lr_scheduler = optim.lr_scheduler.CosineAnnealingLR(
- optimizer=adam_optimizer,
- T_max=math.ceil(num_reviews / mini_batch_size) * num_epochs,
- )
-
- best_params = None
- best_loss = math.inf
- # iterate through the epochs
- for _ in tqdm(
- range(num_epochs),
- desc="Optimizing",
- unit="epoch",
- disable=(not verbose),
- ):
- # randomly shuffle the order of which Card's review histories get computed first
- # at the beginning of each new epoch
- rng.shuffle(card_ids)
-
- # initialize new scheduler with updated parameters each epoch
- scheduler = Scheduler(parameters=params)
-
- # stores the computed loss of each individual review
- step_losses = []
-
- # iterate through the card review histories (sequences)
- for card_id in card_ids:
- card_review_history = self._revlogs_train[card_id][:max_seq_len]
-
- # iterate through the current Card's review history (steps)
- for i in range(len(card_review_history)):
- review = card_review_history[i]
-
- # input
- x_date = review[0][0]
- # target
- y_retrievability = review[1]
- # update
- u_rating = review[0][1]
-
- # if this is the first review, create the Card object
- if i == 0:
- card = Card(card_id=card_id, due=x_date)
-
- # predicted target
- y_pred_retrievability = scheduler.get_card_retrievability(
- card=card, current_datetime=x_date
- )
- y_retrievability = torch.tensor(
- y_retrievability, dtype=torch.float64
- )
-
- # only compute step-loss on non-same-day reviews
- if card.last_review and (x_date - card.last_review).days > 0:
- step_loss = loss_fn(y_pred_retrievability, y_retrievability)
- step_losses.append(step_loss)
-
- # update the card's state
- card, _ = scheduler.review_card(
- card=card,
- rating=u_rating,
- review_datetime=x_date,
- review_duration=None,
- )
-
- # take a gradient step after each mini-batch
- if len(step_losses) == mini_batch_size:
- _update_parameters(
- step_losses=step_losses,
- adam_optimizer=adam_optimizer,
- params=params,
- lr_scheduler=lr_scheduler,
- )
-
- # update the scheduler's with the new parameters
- scheduler = Scheduler(parameters=params)
- # clear the step losses for next batch
- step_losses = []
-
- # remove gradient history from tensor card parameters for next batch
- card.stability = card.stability.detach()
- card.difficulty = card.difficulty.detach()
-
- # update params on remaining review logs
- if len(step_losses) > 0:
- _update_parameters(
- step_losses=step_losses,
- adam_optimizer=adam_optimizer,
- params=params,
- lr_scheduler=lr_scheduler,
- )
-
- # compute the current batch loss after each epoch
- detached_params = [
- x.detach().item() for x in list(params.detach())
- ] # convert to floats
- with torch.no_grad():
- epoch_batch_loss = self._compute_batch_loss(
- parameters=detached_params
- )
-
- # if the batch loss is better with the current parameters, update the current best parameters
- if epoch_batch_loss < best_loss:
- best_loss = epoch_batch_loss
- best_params = detached_params
-
- return best_params
-
- def _compute_probs_and_costs(self) -> dict[str, float]:
- review_log_df = pd.DataFrame(
- vars(review_log) for review_log in self.review_logs
- )
-
- review_log_df = review_log_df.sort_values(
- by=["card_id", "review_datetime"], ascending=[True, True]
- ).reset_index(drop=True)
-
- # dictionary to return
- probs_and_costs_dict = {}
-
- # compute the probabilities and costs of the first rating
- first_reviews_df = review_log_df.loc[
- ~review_log_df["card_id"].duplicated(keep="first")
- ].reset_index(drop=True)
-
- first_again_reviews_df = first_reviews_df.loc[
- first_reviews_df["rating"] == Rating.Again
- ]
- first_hard_reviews_df = first_reviews_df.loc[
- first_reviews_df["rating"] == Rating.Hard
- ]
- first_good_reviews_df = first_reviews_df.loc[
- first_reviews_df["rating"] == Rating.Good
- ]
- first_easy_reviews_df = first_reviews_df.loc[
- first_reviews_df["rating"] == Rating.Easy
- ]
-
- # compute the probability of the user clicking again/hard/good/easy given it's their first review
- num_first_again = len(first_again_reviews_df)
- num_first_hard = len(first_hard_reviews_df)
- num_first_good = len(first_good_reviews_df)
- num_first_easy = len(first_easy_reviews_df)
-
- num_first_review = (
- num_first_again + num_first_hard + num_first_good + num_first_easy
- )
-
- prob_first_again = num_first_again / num_first_review
- prob_first_hard = num_first_hard / num_first_review
- prob_first_good = num_first_good / num_first_review
- prob_first_easy = num_first_easy / num_first_review
-
- probs_and_costs_dict["prob_first_again"] = prob_first_again
- probs_and_costs_dict["prob_first_hard"] = prob_first_hard
- probs_and_costs_dict["prob_first_good"] = prob_first_good
- probs_and_costs_dict["prob_first_easy"] = prob_first_easy
-
- # compute the cost of the user clicking again/hard/good/easy on their first review
- first_again_review_durations = list(
- first_again_reviews_df["review_duration"]
- )
- first_hard_review_durations = list(first_hard_reviews_df["review_duration"])
- first_good_review_durations = list(first_good_reviews_df["review_duration"])
- first_easy_review_durations = list(first_easy_reviews_df["review_duration"])
-
- avg_first_again_review_duration = (
- mean(first_again_review_durations)
- if first_again_review_durations
- else 0
- )
- avg_first_hard_review_duration = (
- mean(first_hard_review_durations) if first_hard_review_durations else 0
- )
- avg_first_good_review_duration = (
- mean(first_good_review_durations) if first_good_review_durations else 0
- )
- avg_first_easy_review_duration = (
- mean(first_easy_review_durations) if first_easy_review_durations else 0
- )
-
- probs_and_costs_dict["avg_first_again_review_duration"] = (
- avg_first_again_review_duration
- )
- probs_and_costs_dict["avg_first_hard_review_duration"] = (
- avg_first_hard_review_duration
- )
- probs_and_costs_dict["avg_first_good_review_duration"] = (
- avg_first_good_review_duration
- )
- probs_and_costs_dict["avg_first_easy_review_duration"] = (
- avg_first_easy_review_duration
- )
-
- # compute the probabilities and costs of non-first ratings
- non_first_reviews_df = review_log_df.loc[
- review_log_df["card_id"].duplicated(keep="first")
- ].reset_index(drop=True)
-
- again_reviews_df = non_first_reviews_df.loc[
- non_first_reviews_df["rating"] == Rating.Again
- ]
- hard_reviews_df = non_first_reviews_df.loc[
- non_first_reviews_df["rating"] == Rating.Hard
- ]
- good_reviews_df = non_first_reviews_df.loc[
- non_first_reviews_df["rating"] == Rating.Good
- ]
- easy_reviews_df = non_first_reviews_df.loc[
- non_first_reviews_df["rating"] == Rating.Easy
- ]
-
- # compute the probability of the user clicking hard/good/easy given they correctly recalled the card
- num_hard = len(hard_reviews_df)
- num_good = len(good_reviews_df)
- num_easy = len(easy_reviews_df)
-
- num_recall = num_hard + num_good + num_easy
-
- prob_hard = num_hard / num_recall
- prob_good = num_good / num_recall
- prob_easy = num_easy / num_recall
-
- probs_and_costs_dict["prob_hard"] = prob_hard
- probs_and_costs_dict["prob_good"] = prob_good
- probs_and_costs_dict["prob_easy"] = prob_easy
-
- again_review_durations = list(again_reviews_df["review_duration"])
- hard_review_durations = list(hard_reviews_df["review_duration"])
- good_review_durations = list(good_reviews_df["review_duration"])
- easy_review_durations = list(easy_reviews_df["review_duration"])
-
- avg_again_review_duration = (
- mean(again_review_durations) if again_review_durations else 0
- )
- avg_hard_review_duration = (
- mean(hard_review_durations) if hard_review_durations else 0
- )
- avg_good_review_duration = (
- mean(good_review_durations) if good_review_durations else 0
- )
- avg_easy_review_duration = (
- mean(easy_review_durations) if easy_review_durations else 0
- )
-
- probs_and_costs_dict["avg_again_review_duration"] = (
- avg_again_review_duration
- )
- probs_and_costs_dict["avg_hard_review_duration"] = avg_hard_review_duration
- probs_and_costs_dict["avg_good_review_duration"] = avg_good_review_duration
- probs_and_costs_dict["avg_easy_review_duration"] = avg_easy_review_duration
-
- return probs_and_costs_dict
-
- def _simulate_cost(
- self,
- *,
- desired_retention: float,
- parameters: tuple[float, ...] | list[float],
- num_cards_simulate: int,
- probs_and_costs_dict: dict[str, float],
- ) -> float:
- rng = Random(42)
-
- # simulate from the beginning of 2025 till before the beginning of 2026
- start_date = datetime(2025, 1, 1, 0, 0, 0, 0, timezone.utc)
- end_date = datetime(2026, 1, 1, 0, 0, 0, 0, timezone.utc)
-
- scheduler = Scheduler(
- parameters=parameters,
- desired_retention=desired_retention,
- enable_fuzzing=False,
- )
-
- # unpack probs_and_costs_dict
- prob_first_again = probs_and_costs_dict["prob_first_again"]
- prob_first_hard = probs_and_costs_dict["prob_first_hard"]
- prob_first_good = probs_and_costs_dict["prob_first_good"]
- prob_first_easy = probs_and_costs_dict["prob_first_easy"]
-
- avg_first_again_review_duration = probs_and_costs_dict[
- "avg_first_again_review_duration"
- ]
- avg_first_hard_review_duration = probs_and_costs_dict[
- "avg_first_hard_review_duration"
- ]
- avg_first_good_review_duration = probs_and_costs_dict[
- "avg_first_good_review_duration"
- ]
- avg_first_easy_review_duration = probs_and_costs_dict[
- "avg_first_easy_review_duration"
- ]
-
- prob_hard = probs_and_costs_dict["prob_hard"]
- prob_good = probs_and_costs_dict["prob_good"]
- prob_easy = probs_and_costs_dict["prob_easy"]
-
- avg_again_review_duration = probs_and_costs_dict[
- "avg_again_review_duration"
- ]
- avg_hard_review_duration = probs_and_costs_dict["avg_hard_review_duration"]
- avg_good_review_duration = probs_and_costs_dict["avg_good_review_duration"]
- avg_easy_review_duration = probs_and_costs_dict["avg_easy_review_duration"]
-
- simulation_cost = 0
- for i in range(num_cards_simulate):
- card = Card()
- curr_date = start_date
- while curr_date < end_date:
- # the card is new
- if curr_date == start_date:
- rating = rng.choices(
- [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy],
- weights=[
- prob_first_again,
- prob_first_hard,
- prob_first_good,
- prob_first_easy,
- ],
- )[0]
-
- if rating == Rating.Again:
- simulation_cost += avg_first_again_review_duration
-
- elif rating == Rating.Hard:
- simulation_cost += avg_first_hard_review_duration
-
- elif rating == Rating.Good:
- simulation_cost += avg_first_good_review_duration
-
- elif rating == Rating.Easy:
- simulation_cost += avg_first_easy_review_duration
-
- # the card is not new
- else:
- rating = rng.choices(
- ["recall", Rating.Again],
- weights=[desired_retention, 1.0 - desired_retention],
- )[0]
-
- if rating == "recall":
- # compute probability that the user chose hard/good/easy, GIVEN that they correctly recalled the card
- rating = rng.choices(
- [Rating.Hard, Rating.Good, Rating.Easy],
- weights=[prob_hard, prob_good, prob_easy],
- )[0]
-
- if rating == Rating.Again:
- simulation_cost += avg_again_review_duration
-
- elif rating == Rating.Hard:
- simulation_cost += avg_hard_review_duration
-
- elif rating == Rating.Good:
- simulation_cost += avg_good_review_duration
-
- elif rating == Rating.Easy:
- simulation_cost += avg_easy_review_duration
-
- card, _ = scheduler.review_card(
- card=card, rating=rating, review_datetime=curr_date
- )
- curr_date = card.due
-
- total_knowledge = desired_retention * num_cards_simulate
- simulation_cost = simulation_cost / total_knowledge
-
- return simulation_cost
-
- def compute_optimal_retention(
- self, parameters: tuple[float, ...] | list[float]
- ) -> list[float]:
- def _validate_review_logs() -> None:
- if len(self.review_logs) < 512:
- raise ValueError(
- "Not enough ReviewLog's: at least 512 ReviewLog objects are required to compute optimal retention"
- )
-
- for review_log in self.review_logs:
- if review_log.review_duration is None:
- raise ValueError(
- "ReviewLog.review_duration cannot be None when computing optimal retention"
- )
-
- _validate_review_logs()
-
- NUM_CARDS_SIMULATE = 1000
- DESIRED_RETENTIONS = [0.7, 0.75, 0.8, 0.85, 0.9, 0.95]
-
- probs_and_costs_dict = self._compute_probs_and_costs()
-
- simulation_costs = []
- for desired_retention in DESIRED_RETENTIONS:
- simulation_cost = self._simulate_cost(
- desired_retention=desired_retention,
- parameters=parameters,
- num_cards_simulate=NUM_CARDS_SIMULATE,
- probs_and_costs_dict=probs_and_costs_dict,
- )
- simulation_costs.append(simulation_cost)
-
- min_index = simulation_costs.index(min(simulation_costs))
- optimal_retention = DESIRED_RETENTIONS[min_index]
-
- return optimal_retention
-
-except ImportError:
-
- class Optimizer:
- def __init__(self, *args, **kwargs) -> None:
- raise ImportError(
- 'Optimizer is not installed.\nInstall it with: pip install "fsrs[optimizer]"'
- )
-
-
-__all__ = ["Optimizer"]
diff --git a/src/heurams/vendor/pyfsrs/py.typed b/src/heurams/vendor/pyfsrs/py.typed
deleted file mode 100644
index e69de29..0000000
diff --git a/src/heurams/vendor/pyfsrs/rating.py b/src/heurams/vendor/pyfsrs/rating.py
deleted file mode 100644
index 65c994d..0000000
--- a/src/heurams/vendor/pyfsrs/rating.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from enum import IntEnum
-
-
-class Rating(IntEnum):
- """
- Enum representing the four possible ratings when reviewing a card.
- """
-
- Again = 1
- Hard = 2
- Good = 3
- Easy = 4
-
-
-__all__ = ["Rating"]
diff --git a/src/heurams/vendor/pyfsrs/review_log.py b/src/heurams/vendor/pyfsrs/review_log.py
deleted file mode 100644
index a787d9b..0000000
--- a/src/heurams/vendor/pyfsrs/review_log.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""
-fsrs.review_log
----------
-
-This module defines the ReviewLog and Rating classes.
-
-Classes:
- ReviewLog: Represents the log entry of a Card that has been reviewed.
- Rating: Enum representing the four possible ratings when reviewing a card.
-"""
-
-from __future__ import annotations
-from dataclasses import dataclass
-from datetime import datetime
-from typing import TypedDict
-import json
-from typing_extensions import Self
-from fsrs.rating import Rating
-
-
-class ReviewLogDict(TypedDict):
- """
- JSON-serializable dictionary representation of a ReviewLog object.
- """
-
- card_id: int
- rating: int
- review_datetime: str
- review_duration: int | None
-
-
-@dataclass
-class ReviewLog:
- """
- Represents the log entry of a Card object that has been reviewed.
-
- Attributes:
- card_id: The id of the card being reviewed.
- rating: The rating given to the card during the review.
- review_datetime: The date and time of the review.
- review_duration: The number of milliseconds it took to review the card or None if unspecified.
- """
-
- card_id: int
- rating: Rating
- review_datetime: datetime
- review_duration: int | None
-
- def to_dict(
- self,
- ) -> ReviewLogDict:
- """
- Returns a dictionary representation of the ReviewLog object.
-
- Returns:
- ReviewLogDict: A dictionary representation of the ReviewLog object.
- """
-
- return {
- "card_id": self.card_id,
- "rating": int(self.rating),
- "review_datetime": self.review_datetime.isoformat(),
- "review_duration": self.review_duration,
- }
-
- @classmethod
- def from_dict(
- cls,
- source_dict: ReviewLogDict,
- ) -> Self:
- """
- Creates a ReviewLog object from an existing dictionary.
-
- Args:
- source_dict: A dictionary representing an existing ReviewLog object.
-
- Returns:
- Self: A ReviewLog object created from the provided dictionary.
- """
-
- return cls(
- card_id=source_dict["card_id"],
- rating=Rating(int(source_dict["rating"])),
- review_datetime=datetime.fromisoformat(source_dict["review_datetime"]),
- review_duration=source_dict["review_duration"],
- )
-
- def to_json(self, indent: int | str | None = None) -> str:
- """
- Returns a JSON-serialized string of the ReviewLog object.
-
- Args:
- indent: Equivalent argument to the indent in json.dumps()
-
- Returns:
- str: A JSON-serialized string of the ReviewLog object.
- """
-
- return json.dumps(self.to_dict(), indent=indent)
-
- @classmethod
- def from_json(cls, source_json: str) -> Self:
- """
- Creates a ReviewLog object from a JSON-serialized string.
-
- Args:
- source_json: A JSON-serialized string of an existing ReviewLog object.
-
- Returns:
- Self: A ReviewLog object created from the JSON string.
- """
-
- source_dict: ReviewLogDict = json.loads(source_json)
- return cls.from_dict(source_dict=source_dict)
-
-
-__all__ = ["ReviewLog"]
diff --git a/src/heurams/vendor/pyfsrs/scheduler.py b/src/heurams/vendor/pyfsrs/scheduler.py
deleted file mode 100644
index 6d42ecb..0000000
--- a/src/heurams/vendor/pyfsrs/scheduler.py
+++ /dev/null
@@ -1,856 +0,0 @@
-"""
-fsrs.scheduler
----------
-
-This module defines the Scheduler class as well as the various constants used in its calculations.
-
-Classes:
- Scheduler: The FSRS spaced-repetition scheduler.
-"""
-
-from __future__ import annotations
-from collections.abc import Sequence
-import math
-from datetime import datetime, timezone, timedelta
-from copy import copy
-import json
-from random import random
-from dataclasses import dataclass
-from fsrs.state import State
-from fsrs.card import Card
-from fsrs.rating import Rating
-from fsrs.review_log import ReviewLog
-from typing import TYPE_CHECKING, TypedDict, overload
-
-if TYPE_CHECKING:
- from torch import Tensor # torch is optional; import only for type checking
-from typing_extensions import Self
-
-FSRS_DEFAULT_DECAY = 0.1542
-DEFAULT_PARAMETERS = (
- 0.212,
- 1.2931,
- 2.3065,
- 8.2956,
- 6.4133,
- 0.8334,
- 3.0194,
- 0.001,
- 1.8722,
- 0.1666,
- 0.796,
- 1.4835,
- 0.0614,
- 0.2629,
- 1.6483,
- 0.6014,
- 1.8729,
- 0.5425,
- 0.0912,
- 0.0658,
- FSRS_DEFAULT_DECAY,
-)
-
-STABILITY_MIN = 0.001
-LOWER_BOUNDS_PARAMETERS = (
- STABILITY_MIN,
- STABILITY_MIN,
- STABILITY_MIN,
- STABILITY_MIN,
- 1.0,
- 0.001,
- 0.001,
- 0.001,
- 0.0,
- 0.0,
- 0.001,
- 0.001,
- 0.001,
- 0.001,
- 0.0,
- 0.0,
- 1.0,
- 0.0,
- 0.0,
- 0.0,
- 0.1,
-)
-
-INITIAL_STABILITY_MAX = 100.0
-UPPER_BOUNDS_PARAMETERS = (
- INITIAL_STABILITY_MAX,
- INITIAL_STABILITY_MAX,
- INITIAL_STABILITY_MAX,
- INITIAL_STABILITY_MAX,
- 10.0,
- 4.0,
- 4.0,
- 0.75,
- 4.5,
- 0.8,
- 3.5,
- 5.0,
- 0.25,
- 0.9,
- 4.0,
- 1.0,
- 6.0,
- 2.0,
- 2.0,
- 0.8,
- 0.8,
-)
-
-MIN_DIFFICULTY = 1.0
-MAX_DIFFICULTY = 10.0
-
-FUZZ_RANGES = [
- {
- "start": 2.5,
- "end": 7.0,
- "factor": 0.15,
- },
- {
- "start": 7.0,
- "end": 20.0,
- "factor": 0.1,
- },
- {
- "start": 20.0,
- "end": math.inf,
- "factor": 0.05,
- },
-]
-
-
-class SchedulerDict(TypedDict):
- """
- JSON-serializable dictionary representation of a Scheduler object.
- """
-
- parameters: list[float]
- desired_retention: float
- learning_steps: list[int]
- relearning_steps: list[int]
- maximum_interval: int
- enable_fuzzing: bool
-
-
-@dataclass(init=False)
-class Scheduler:
- """
- The FSRS scheduler.
-
- Enables the reviewing and future scheduling of cards according to the FSRS algorithm.
-
- Attributes:
- parameters: The model weights of the FSRS scheduler.
- desired_retention: The desired retention rate of cards scheduled with the scheduler.
- learning_steps: Small time intervals that schedule cards in the Learning state.
- relearning_steps: Small time intervals that schedule cards in the Relearning state.
- maximum_interval: The maximum number of days a Review-state card can be scheduled into the future.
- enable_fuzzing: Whether to apply a small amount of random 'fuzz' to calculated intervals.
- """
-
- parameters: tuple[float, ...]
- desired_retention: float
- learning_steps: tuple[timedelta, ...]
- relearning_steps: tuple[timedelta, ...]
- maximum_interval: int
- enable_fuzzing: bool
-
- def __init__(
- self,
- parameters: Sequence[float] = DEFAULT_PARAMETERS,
- desired_retention: float = 0.9,
- learning_steps: tuple[timedelta, ...] | list[timedelta] = (
- timedelta(minutes=1),
- timedelta(minutes=10),
- ),
- relearning_steps: tuple[timedelta, ...] | list[timedelta] = (
- timedelta(minutes=10),
- ),
- maximum_interval: int = 36500,
- enable_fuzzing: bool = True,
- ) -> None:
- self._validate_parameters(parameters=parameters)
-
- self.parameters = tuple(parameters)
- self.desired_retention = desired_retention
- self.learning_steps = tuple(learning_steps)
- self.relearning_steps = tuple(relearning_steps)
- self.maximum_interval = maximum_interval
- self.enable_fuzzing = enable_fuzzing
-
- self._DECAY = -self.parameters[20]
- self._FACTOR = 0.9 ** (1 / self._DECAY) - 1
-
- def _validate_parameters(self, *, parameters: Sequence[float]) -> None:
- if len(parameters) != len(LOWER_BOUNDS_PARAMETERS):
- raise ValueError(
- f"Expected {len(LOWER_BOUNDS_PARAMETERS)} parameters, got {len(parameters)}."
- )
-
- error_messages = []
- for index, (parameter, lower_bound, upper_bound) in enumerate(
- zip(parameters, LOWER_BOUNDS_PARAMETERS, UPPER_BOUNDS_PARAMETERS)
- ):
- if not lower_bound <= parameter <= upper_bound:
- error_message = f"parameters[{index}] = {parameter} is out of bounds: ({lower_bound}, {upper_bound})"
- error_messages.append(error_message)
-
- if len(error_messages) > 0:
- raise ValueError(
- "One or more parameters are out of bounds:\n"
- + "\n".join(error_messages)
- )
-
- def get_card_retrievability(
- self, card: Card, current_datetime: datetime | None = None
- ) -> float:
- """
- Calculates a Card object's current retrievability for a given date and time.
-
- The retrievability of a card is the predicted probability that the card is correctly recalled at the provided datetime.
-
- Args:
- card: The card whose retrievability is to be calculated
- current_datetime: The current date and time
-
- Returns:
- float: The retrievability of the Card object.
- """
-
- if card.last_review is None or card.stability is None:
- return 0
-
- if current_datetime is None:
- current_datetime = datetime.now(timezone.utc)
-
- elapsed_days = max(0, (current_datetime - card.last_review).days)
-
- return (1 + self._FACTOR * elapsed_days / card.stability) ** self._DECAY
-
- def review_card(
- self,
- card: Card,
- rating: Rating,
- review_datetime: datetime | None = None,
- review_duration: int | None = None,
- ) -> tuple[Card, ReviewLog]:
- """
- Reviews a card with a given rating at a given time for a specified duration.
-
- Args:
- card: The card being reviewed.
- rating: The chosen rating for the card being reviewed.
- review_datetime: The date and time of the review.
- review_duration: The number of miliseconds it took to review the card or None if unspecified.
-
- Returns:
- tuple[Card,ReviewLog]: A tuple containing the updated, reviewed card and its corresponding review log.
-
- Raises:
- ValueError: If the `review_datetime` argument is not timezone-aware and set to UTC.
- """
-
- if review_datetime is not None and (
- (review_datetime.tzinfo is None) or (review_datetime.tzinfo != timezone.utc)
- ):
- raise ValueError("datetime must be timezone-aware and set to UTC")
-
- card = copy(card)
-
- if review_datetime is None:
- review_datetime = datetime.now(timezone.utc)
-
- days_since_last_review = (
- (review_datetime - card.last_review).days if card.last_review else None
- )
-
- match card.state:
- case State.Learning:
- assert card.step is not None
-
- # update the card's stability and difficulty
- if card.stability is None or card.difficulty is None:
- card.stability = self._initial_stability(rating=rating)
- card.difficulty = self._initial_difficulty(
- rating=rating, clamp=True
- )
-
- elif days_since_last_review is not None and days_since_last_review < 1:
- card.stability = self._short_term_stability(
- stability=card.stability, rating=rating
- )
- card.difficulty = self._next_difficulty(
- difficulty=card.difficulty, rating=rating
- )
-
- else:
- card.stability = self._next_stability(
- difficulty=card.difficulty,
- stability=card.stability,
- retrievability=self.get_card_retrievability(
- card,
- current_datetime=review_datetime,
- ),
- rating=rating,
- )
- card.difficulty = self._next_difficulty(
- difficulty=card.difficulty, rating=rating
- )
-
- # calculate the card's next interval
- ## first if-clause handles edge case where the Card in the Learning state was previously
- ## scheduled with a Scheduler with more learning_steps than the current Scheduler
- if len(self.learning_steps) == 0 or (
- card.step >= len(self.learning_steps)
- and rating in (Rating.Hard, Rating.Good, Rating.Easy)
- ):
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(stability=card.stability)
- next_interval = timedelta(days=next_interval_days)
-
- else:
- match rating:
- case Rating.Again:
- card.step = 0
- next_interval = self.learning_steps[card.step]
-
- case Rating.Hard:
- # card step stays the same
-
- if card.step == 0 and len(self.learning_steps) == 1:
- next_interval = self.learning_steps[0] * 1.5
- elif card.step == 0 and len(self.learning_steps) >= 2:
- next_interval = (
- self.learning_steps[0] + self.learning_steps[1]
- ) / 2.0
- else:
- next_interval = self.learning_steps[card.step]
-
- case Rating.Good:
- if card.step + 1 == len(
- self.learning_steps
- ): # the last step
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- else:
- card.step += 1
- next_interval = self.learning_steps[card.step]
-
- case Rating.Easy:
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- case _:
- raise ValueError(f"Unknown rating: {rating}")
-
- case State.Review:
- assert card.stability is not None
- assert card.difficulty is not None
-
- # update the card's stability and difficulty
- if days_since_last_review is not None and days_since_last_review < 1:
- card.stability = self._short_term_stability(
- stability=card.stability, rating=rating
- )
- else:
- card.stability = self._next_stability(
- difficulty=card.difficulty,
- stability=card.stability,
- retrievability=self.get_card_retrievability(
- card,
- current_datetime=review_datetime,
- ),
- rating=rating,
- )
-
- card.difficulty = self._next_difficulty(
- difficulty=card.difficulty, rating=rating
- )
-
- # calculate the card's next interval
- match rating:
- case Rating.Again:
- # if there are no relearning steps (they were left blank)
- if len(self.relearning_steps) == 0:
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- else:
- card.state = State.Relearning
- card.step = 0
-
- next_interval = self.relearning_steps[card.step]
-
- case Rating.Hard | Rating.Good | Rating.Easy:
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- case _:
- raise ValueError(f"Unknown rating: {rating}")
-
- case State.Relearning:
- assert card.stability is not None
- assert card.difficulty is not None
- assert card.step is not None
-
- # update the card's stability and difficulty
- if days_since_last_review is not None and days_since_last_review < 1:
- card.stability = self._short_term_stability(
- stability=card.stability, rating=rating
- )
- card.difficulty = self._next_difficulty(
- difficulty=card.difficulty, rating=rating
- )
-
- else:
- card.stability = self._next_stability(
- difficulty=card.difficulty,
- stability=card.stability,
- retrievability=self.get_card_retrievability(
- card,
- current_datetime=review_datetime,
- ),
- rating=rating,
- )
- card.difficulty = self._next_difficulty(
- difficulty=card.difficulty, rating=rating
- )
-
- # calculate the card's next interval
- ## first if-clause handles edge case where the Card in the Relearning state was previously
- ## scheduled with a Scheduler with more relearning_steps than the current Scheduler
- if len(self.relearning_steps) == 0 or (
- card.step >= len(self.relearning_steps)
- and rating in (Rating.Hard, Rating.Good, Rating.Easy)
- ):
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(stability=card.stability)
- next_interval = timedelta(days=next_interval_days)
-
- else:
- match rating:
- case Rating.Again:
- card.step = 0
- next_interval = self.relearning_steps[card.step]
-
- case Rating.Hard:
- # card step stays the same
-
- if card.step == 0 and len(self.relearning_steps) == 1:
- next_interval = self.relearning_steps[0] * 1.5
- elif card.step == 0 and len(self.relearning_steps) >= 2:
- next_interval = (
- self.relearning_steps[0] + self.relearning_steps[1]
- ) / 2.0
- else:
- next_interval = self.relearning_steps[card.step]
-
- case Rating.Good:
- if card.step + 1 == len(
- self.relearning_steps
- ): # the last step
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- else:
- card.step += 1
- next_interval = self.relearning_steps[card.step]
-
- case Rating.Easy:
- card.state = State.Review
- card.step = None
-
- next_interval_days = self._next_interval(
- stability=card.stability
- )
- next_interval = timedelta(days=next_interval_days)
-
- case _:
- raise ValueError(f"Unknown rating: {rating}")
-
- case _:
- raise ValueError(f"Unknown card state: {card.state}")
-
- if self.enable_fuzzing and card.state == State.Review:
- next_interval = self._get_fuzzed_interval(interval=next_interval)
-
- card.due = review_datetime + next_interval
- card.last_review = review_datetime
-
- review_log = ReviewLog(
- card_id=card.card_id,
- rating=rating,
- review_datetime=review_datetime,
- review_duration=review_duration,
- )
-
- return card, review_log
-
- def reschedule_card(self, card: Card, review_logs: list[ReviewLog]) -> Card:
- """
- Reschedules/updates the given card with the current scheduler provided that card's review logs.
-
- If the current card was previously scheduled with a different scheduler, you may want to reschedule/update
- it as if it had always been scheduled with this current scheduler. For example, you may want to reschedule
- each of your cards with a new scheduler after computing the optimal parameters with the Optimizer.
-
- Args:
- card: The card to be rescheduled/updated.
- review_logs: A list of that card's review logs (order doesn't matter).
-
- Returns:
- Card: A new card that has been rescheduled/updated with this current scheduler.
-
- Raises:
- ValueError: If any of the review logs are for a card other than the one specified, this will raise an error.
-
- """
-
- for review_log in review_logs:
- if review_log.card_id != card.card_id:
- raise ValueError(
- f"ReviewLog card_id {review_log.card_id} does not match Card card_id {card.card_id}"
- )
-
- review_logs = sorted(review_logs, key=lambda log: log.review_datetime)
-
- rescheduled_card = Card(card_id=card.card_id, due=card.due)
-
- for review_log in review_logs:
- rescheduled_card, _ = self.review_card(
- card=rescheduled_card,
- rating=review_log.rating,
- review_datetime=review_log.review_datetime,
- )
-
- return rescheduled_card
-
- def to_dict(
- self,
- ) -> SchedulerDict:
- """
- Returns a dictionary representation of the Scheduler object.
-
- Returns:
- SchedulerDict: A dictionary representation of the Scheduler object.
- """
-
- return {
- "parameters": list(self.parameters),
- "desired_retention": self.desired_retention,
- "learning_steps": [
- int(learning_step.total_seconds())
- for learning_step in self.learning_steps
- ],
- "relearning_steps": [
- int(relearning_step.total_seconds())
- for relearning_step in self.relearning_steps
- ],
- "maximum_interval": self.maximum_interval,
- "enable_fuzzing": self.enable_fuzzing,
- }
-
- @classmethod
- def from_dict(cls, source_dict: SchedulerDict) -> Self:
- """
- Creates a Scheduler object from an existing dictionary.
-
- Args:
- source_dict: A dictionary representing an existing Scheduler object.
-
- Returns:
- Self: A Scheduler object created from the provided dictionary.
- """
-
- return cls(
- parameters=source_dict["parameters"],
- desired_retention=source_dict["desired_retention"],
- learning_steps=[
- timedelta(seconds=learning_step)
- for learning_step in source_dict["learning_steps"]
- ],
- relearning_steps=[
- timedelta(seconds=relearning_step)
- for relearning_step in source_dict["relearning_steps"]
- ],
- maximum_interval=source_dict["maximum_interval"],
- enable_fuzzing=source_dict["enable_fuzzing"],
- )
-
- def to_json(self, indent: int | str | None = None) -> str:
- """
- Returns a JSON-serialized string of the Scheduler object.
-
- Args:
- indent: Equivalent argument to the indent in json.dumps()
-
- Returns:
- str: A JSON-serialized string of the Scheduler object.
- """
-
- return json.dumps(self.to_dict(), indent=indent)
-
- @classmethod
- def from_json(cls, source_json: str) -> Self:
- """
- Creates a Scheduler object from a JSON-serialized string.
-
- Args:
- source_json: A JSON-serialized string of an existing Scheduler object.
-
- Returns:
- Self: A Scheduler object created from the JSON string.
- """
-
- source_dict: SchedulerDict = json.loads(source_json)
- return cls.from_dict(source_dict=source_dict)
-
- @overload
- def _clamp_difficulty(self, *, difficulty: float) -> float: ...
- @overload
- def _clamp_difficulty(self, *, difficulty: Tensor) -> Tensor: ...
- def _clamp_difficulty(self, *, difficulty: float | Tensor) -> float | Tensor:
- if isinstance(difficulty, (int, float)):
- difficulty = min(max(difficulty, MIN_DIFFICULTY), MAX_DIFFICULTY)
- else:
- difficulty = difficulty.clamp(min=MIN_DIFFICULTY, max=MAX_DIFFICULTY)
-
- return difficulty
-
- @overload
- def _clamp_stability(self, *, stability: float) -> float: ...
- @overload
- def _clamp_stability(self, *, stability: Tensor) -> Tensor: ...
- def _clamp_stability(self, *, stability: float | Tensor) -> float | Tensor:
- if isinstance(stability, (int, float)):
- stability = max(stability, STABILITY_MIN)
- else:
- stability = stability.clamp(min=STABILITY_MIN)
-
- return stability
-
- def _initial_stability(self, *, rating: Rating) -> float:
- initial_stability = self.parameters[rating - 1]
-
- initial_stability = self._clamp_stability(stability=initial_stability)
-
- return initial_stability
-
- def _initial_difficulty(self, *, rating: Rating, clamp: bool) -> float:
- initial_difficulty = (
- self.parameters[4] - (math.e ** (self.parameters[5] * (rating - 1))) + 1
- )
-
- if clamp:
- initial_difficulty = self._clamp_difficulty(difficulty=initial_difficulty)
-
- return initial_difficulty
-
- def _next_interval(self, *, stability: float) -> int:
- next_interval = (stability / self._FACTOR) * (
- (self.desired_retention ** (1 / self._DECAY)) - 1
- )
-
- if not isinstance(next_interval, (int, float)):
- next_interval = next_interval.detach().item()
-
- next_interval = round(next_interval) # intervals are full days
-
- # must be at least 1 day long
- next_interval = max(next_interval, 1)
-
- # can not be longer than the maximum interval
- next_interval = min(next_interval, self.maximum_interval)
-
- return next_interval
-
- def _short_term_stability(self, *, stability: float, rating: Rating) -> float:
- short_term_stability_increase = (
- math.e ** (self.parameters[17] * (rating - 3 + self.parameters[18]))
- ) * (stability ** -self.parameters[19])
-
- if rating in (Rating.Good, Rating.Easy):
- if isinstance(short_term_stability_increase, (int, float)):
- short_term_stability_increase = max(short_term_stability_increase, 1.0)
- else:
- short_term_stability_increase = short_term_stability_increase.clamp(
- min=1.0
- )
-
- short_term_stability = stability * short_term_stability_increase
-
- short_term_stability = self._clamp_stability(stability=short_term_stability)
-
- return short_term_stability
-
- def _next_difficulty(self, *, difficulty: float, rating: Rating) -> float:
- def _linear_damping(*, delta_difficulty: float, difficulty: float) -> float:
- return (10.0 - difficulty) * delta_difficulty / 9.0
-
- def _mean_reversion(*, arg_1: float, arg_2: float) -> float:
- return self.parameters[7] * arg_1 + (1 - self.parameters[7]) * arg_2
-
- arg_1 = self._initial_difficulty(rating=Rating.Easy, clamp=False)
-
- delta_difficulty = -(self.parameters[6] * (rating - 3))
- arg_2 = difficulty + _linear_damping(
- delta_difficulty=delta_difficulty, difficulty=difficulty
- )
-
- next_difficulty = _mean_reversion(arg_1=arg_1, arg_2=arg_2)
-
- next_difficulty = self._clamp_difficulty(difficulty=next_difficulty)
-
- return next_difficulty
-
- def _next_stability(
- self,
- *,
- difficulty: float,
- stability: float,
- retrievability: float,
- rating: Rating,
- ) -> float:
- if rating == Rating.Again:
- next_stability = self._next_forget_stability(
- difficulty=difficulty,
- stability=stability,
- retrievability=retrievability,
- )
-
- elif rating in (Rating.Hard, Rating.Good, Rating.Easy):
- next_stability = self._next_recall_stability(
- difficulty=difficulty,
- stability=stability,
- retrievability=retrievability,
- rating=rating,
- )
-
- else:
- raise ValueError(f"Unknown rating: {rating}")
-
- next_stability = self._clamp_stability(stability=next_stability)
-
- return next_stability
-
- def _next_forget_stability(
- self, *, difficulty: float, stability: float, retrievability: float
- ) -> float:
- next_forget_stability_long_term_params = (
- self.parameters[11]
- * (difficulty ** -self.parameters[12])
- * (((stability + 1) ** (self.parameters[13])) - 1)
- * (math.e ** ((1 - retrievability) * self.parameters[14]))
- )
-
- next_forget_stability_short_term_params = stability / (
- math.e ** (self.parameters[17] * self.parameters[18])
- )
-
- return min(
- next_forget_stability_long_term_params,
- next_forget_stability_short_term_params,
- )
-
- def _next_recall_stability(
- self,
- *,
- difficulty: float,
- stability: float,
- retrievability: float,
- rating: Rating,
- ) -> float:
- hard_penalty = self.parameters[15] if rating == Rating.Hard else 1
- easy_bonus = self.parameters[16] if rating == Rating.Easy else 1
-
- return stability * (
- 1
- + (math.e ** (self.parameters[8]))
- * (11 - difficulty)
- * (stability ** -self.parameters[9])
- * ((math.e ** ((1 - retrievability) * self.parameters[10])) - 1)
- * hard_penalty
- * easy_bonus
- )
-
- def _get_fuzzed_interval(self, *, interval: timedelta) -> timedelta:
- """
- Takes the current calculated interval and adds a small amount of random fuzz to it.
- For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days.
-
- Args:
- interval: The calculated next interval, before fuzzing.
-
- Returns:
- timedelta: The new interval, after fuzzing.
- """
-
- interval_days = interval.days
-
- if interval_days < 2.5: # fuzz is not applied to intervals less than 2.5
- return interval
-
- def _get_fuzz_range(*, interval_days: int) -> tuple[int, int]:
- """
- Helper function that computes the possible upper and lower bounds of the interval after fuzzing.
- """
-
- delta = 1.0
- for fuzz_range in FUZZ_RANGES:
- delta += fuzz_range["factor"] * max(
- min(float(interval_days), fuzz_range["end"]) - fuzz_range["start"],
- 0.0,
- )
-
- min_ivl = int(round(interval_days - delta))
- max_ivl = int(round(interval_days + delta))
-
- # make sure the min_ivl and max_ivl fall into a valid range
- min_ivl = max(2, min_ivl)
- max_ivl = min(max_ivl, self.maximum_interval)
- min_ivl = min(min_ivl, max_ivl)
-
- return min_ivl, max_ivl
-
- min_ivl, max_ivl = _get_fuzz_range(interval_days=interval_days)
-
- fuzzed_interval_days = (
- random() * (max_ivl - min_ivl + 1)
- ) + min_ivl # the next interval is a random value between min_ivl and max_ivl
-
- fuzzed_interval_days = min(round(fuzzed_interval_days), self.maximum_interval)
-
- fuzzed_interval = timedelta(days=fuzzed_interval_days)
-
- return fuzzed_interval
-
-
-__all__ = ["Scheduler"]
diff --git a/src/heurams/vendor/pyfsrs/state.py b/src/heurams/vendor/pyfsrs/state.py
deleted file mode 100644
index e4157c0..0000000
--- a/src/heurams/vendor/pyfsrs/state.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from enum import IntEnum
-
-
-class State(IntEnum):
- """
- Enum representing the learning state of a Card object.
- """
-
- Learning = 1
- Review = 2
- Relearning = 3
-
-
-__all__ = ["State"]
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..89674ff
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,54 @@
+"""
+Pytest shared fixtures for HeurAMS test suite.
+
+Provides:
+- timer_config: A ConfigDict with deterministic timer overrides for reproducible tests.
+- timer_context: A ConfigContext that applies timer_config for the duration of a test.
+- sample_algodata_sm2: A fresh SM-2 algodata dict.
+- sample_algodata_nsp0: A fresh NSP-0 algodata dict.
+"""
+
+import pathlib
+from copy import deepcopy
+
+import pytest
+
+from heurams.context import ConfigContext, config_var, workdir
+from heurams.kernel.algorithms import algorithms, nsp0
+from heurams.services.config import ConfigDict
+
+
+@pytest.fixture
+def timer_config():
+ """A ConfigDict with deterministic timer overrides (daystamp=20000, timestamp=1e9).
+
+ This allows reproducible algorithm tests without relying on wall-clock time.
+ """
+ # Use the real config path as base, then overlay timer overrides
+ config = ConfigDict(workdir / "data" / "config")
+ # Override timer values in-place via the nested ConfigDict
+ timer_cfg = config["services"]["timer"]
+ timer_cfg["daystamp_override"] = 20000
+ timer_cfg["timestamp_override"] = 1000000000.0
+ return config
+
+
+@pytest.fixture
+def timer_context(timer_config):
+ """Context manager fixture that applies the timer overrides."""
+ with ConfigContext(timer_config):
+ yield
+
+
+@pytest.fixture
+def sample_algodata_sm2():
+ """A fresh SM-2 algodata dict (pre-activation)."""
+ algo = algorithms["SM-2"]
+ return {algo.algo_name: deepcopy(algo.defaults)}
+
+
+@pytest.fixture
+def sample_algodata_nsp0():
+ """A fresh NSP-0 algodata dict (pre-activation)."""
+ algo = algorithms["NSP-0"]
+ return {algo.algo_name: deepcopy(algo.defaults)}
diff --git a/tests/test_base_algorithm.py b/tests/test_base_algorithm.py
new file mode 100644
index 0000000..ddc85da
--- /dev/null
+++ b/tests/test_base_algorithm.py
@@ -0,0 +1,58 @@
+"""Tests for heurams.kernel.algorithms.base.BaseAlgorithm"""
+
+from copy import deepcopy
+
+import pytest
+
+from heurams.kernel.algorithms import BaseAlgorithm
+from heurams.services import timer
+
+
+class TestBaseAlgorithmDefaults:
+ def test_defaults_have_required_keys(self):
+ required = {
+ "real_rept",
+ "rept",
+ "interval",
+ "last_date",
+ "next_date",
+ "is_activated",
+ "last_modify",
+ }
+ assert required.issubset(BaseAlgorithm.defaults.keys())
+
+ def test_defaults_last_modify_is_reasonable(self):
+ # defaults is evaluated at module import, not test time
+ ts = BaseAlgorithm.defaults["last_modify"]
+ assert isinstance(ts, float)
+ assert ts > 1e9 # reasonable UNIX timestamp
+
+
+class TestBaseAlgorithmMethods:
+ def test_revisor_does_nothing(self):
+ d = {"SM-2": {"rept": 0}}
+ BaseAlgorithm.revisor(d, feedback=5)
+ # Base.revisor is a no-op — dict unchanged
+ assert d["SM-2"]["rept"] == 0
+
+ def test_is_due_returns_one(self):
+ assert BaseAlgorithm.is_due({}) == 1
+
+ def test_get_rating_returns_empty(self):
+ assert BaseAlgorithm.get_rating({}) == ""
+
+ def test_nextdate_returns_negative_one(self):
+ assert BaseAlgorithm.nextdate({}) == -1
+
+
+class TestBaseAlgorithmIntegrity:
+ def test_check_integrity_valid(self, sample_algodata_sm2):
+ # BaseAlgorithm.algo_name is "BaseAlgorithm", not "SM-2"
+ data = {"BaseAlgorithm": sample_algodata_sm2["SM-2"]}
+ assert BaseAlgorithm.check_integrity(data) == 1
+
+ def test_check_integrity_invalid(self):
+ assert BaseAlgorithm.check_integrity({"SM-2": {}}) == 0
+
+ def test_check_integrity_missing_key(self):
+ assert BaseAlgorithm.check_integrity({}) == 0
diff --git a/tests/test_electron.py b/tests/test_electron.py
new file mode 100644
index 0000000..80326fb
--- /dev/null
+++ b/tests/test_electron.py
@@ -0,0 +1,150 @@
+"""Tests for heurams.kernel.particles.electron.Electron"""
+
+from copy import deepcopy
+
+import pytest
+
+from heurams.kernel.algorithms import algorithms
+from heurams.kernel.particles.electron import Electron
+from heurams.services import timer
+
+
+class TestElectronInit:
+ def test_default_algo_is_sm2(self, timer_context):
+ e = Electron("test-id", {})
+ assert e.algoname == "SM-2"
+ assert e.ident == "test-id"
+
+ def test_specific_algo(self, timer_context):
+ e = Electron("test-id", {}, algo_name="NSP-0")
+ assert e.algoname == "NSP-0"
+
+ def test_integrity_check_fills_defaults(self, timer_context):
+ e = Electron("test-id", {})
+ assert "SM-2" in e.algodata
+ assert e.algodata["SM-2"]["efactor"] == 2.5
+
+ def test_existing_data_preserved(self, timer_context):
+ data = {"SM-2": {"efactor": 1.5, "rept": 3, "real_rept": 5, "interval": 10,
+ "last_date": 100, "next_date": 200, "is_activated": 1,
+ "last_modify": 1e9}}
+ e = Electron("test-id", data)
+ assert e.algodata["SM-2"]["efactor"] == 1.5
+ assert e.algodata["SM-2"]["rept"] == 3
+
+
+class TestElectronActivation:
+ def test_activate_sets_flag(self, timer_context):
+ e = Electron("test-id", {})
+ assert e.is_activated() == 0
+ e.activate()
+ assert e.is_activated() == 1
+
+ def test_is_due_requires_activation(self, timer_context):
+ e = Electron("test-id", {})
+ e.algodata["SM-2"]["next_date"] = 0 # past
+ assert e.is_due() == 0 # not activated
+
+ e.activate()
+ assert e.is_due() == 1
+
+ def test_is_due_returns_false_when_not_due(self, timer_context):
+ e = Electron("test-id", {})
+ e.activate()
+ e.algodata["SM-2"]["next_date"] = 999999
+ assert e.is_due() is False
+
+
+class TestElectronModify:
+ def test_modify_valid_key(self, timer_context):
+ e = Electron("test-id", {})
+ e.modify("efactor", 3.0)
+ assert e.algodata["SM-2"]["efactor"] == 3.0
+
+ def test_modify_invalid_key_raises(self, timer_context):
+ e = Electron("test-id", {})
+ with pytest.raises(AttributeError):
+ e.modify("nonexistent", 42)
+
+
+class TestElectronRevisor:
+ def test_revisor_delegates_to_algo(self, timer_context):
+ e = Electron("test-id", {})
+ e.activate()
+ e.algodata["SM-2"]["next_date"] = 0
+ assert e.is_due() == 1
+
+ e.revisor(quality=5)
+ # After good review, interval > 0
+ assert e.algodata["SM-2"]["interval"] >= 1
+
+ def test_revisor_nsp0(self, timer_context):
+ e = Electron("test-id", {}, algo_name="NSP-0")
+ e.activate()
+ e.algodata["NSP-0"]["next_date"] = 0
+ assert e.is_due() == 1
+
+ e.revisor(quality=3) # bad feedback
+ assert e.algodata["NSP-0"]["interval"] == 1
+
+
+class TestElectronProperties:
+ def test_rept(self, timer_context):
+ e = Electron("test-id", {})
+ assert e.rept() == 0
+
+ def test_rept_real(self, timer_context):
+ e = Electron("test-id", {})
+ assert e.rept(real_rept=True) == 0
+
+ def test_get_rating(self, timer_context):
+ e = Electron("test-id", {})
+ rating = e.get_rating()
+ assert isinstance(rating, str)
+
+ def test_nextdate_returns_int(self, timer_context):
+ e = Electron("test-id", {})
+ nd = e.nextdate()
+ assert isinstance(nd, int)
+
+ def test_hash(self, timer_context):
+ e = Electron("test-id", {})
+ assert hash(e) == hash("test-id")
+
+ def test_len(self, timer_context):
+ e = Electron("test-id", {})
+ assert len(e) == len(algorithms["SM-2"].defaults)
+
+
+class TestElectronGetSetItem:
+ def test_getitem_ident(self, timer_context):
+ e = Electron("test-id", {})
+ assert e["ident"] == "test-id"
+
+ def test_getitem_algo_key(self, timer_context):
+ e = Electron("test-id", {})
+ assert e["efactor"] == 2.5
+
+ def test_getitem_missing_key_raises(self, timer_context):
+ e = Electron("test-id", {})
+ with pytest.raises(KeyError):
+ _ = e["nonexistent"]
+
+ def test_setitem_valid_key(self, timer_context):
+ e = Electron("test-id", {})
+ e["efactor"] = 3.5
+ assert e["efactor"] == 3.5
+
+ def test_setitem_ident_raises(self, timer_context):
+ e = Electron("test-id", {})
+ with pytest.raises(AttributeError):
+ e["ident"] = "new-id"
+
+
+class TestElectronFromData:
+ def test_from_data_creates_electron(self, timer_context):
+ data = {"SM-2": {}}
+ e = Electron.from_data(("my-ident", data), algo_name="SM-2")
+ assert e.ident == "my-ident"
+ assert e.algoname == "SM-2"
+ assert "SM-2" in e.algodata
diff --git a/tests/test_epath.py b/tests/test_epath.py
new file mode 100644
index 0000000..590b6db
--- /dev/null
+++ b/tests/test_epath.py
@@ -0,0 +1,72 @@
+"""Tests for heurams.services.epath"""
+
+from heurams.services.epath import epath
+
+
+class TestEpathRead:
+ def test_empty_path_returns_self(self):
+ d = {"a": 1}
+ assert epath(d, "") is d
+
+ def test_simple_key(self):
+ d = {"a": 1}
+ assert epath(d, "a") == 1
+
+ def test_nested_key(self):
+ d = {"a": {"b": {"c": 42}}}
+ assert epath(d, "a.b.c") == 42
+
+ def test_missing_key_returns_default(self):
+ d = {"a": 1}
+ assert epath(d, "b", default=None) is None
+
+ def test_missing_key_no_default(self):
+ d = {"a": 1}
+ assert epath(d, "b") is None
+
+ def test_list_index_access(self):
+ d = {"items": [10, 20, 30]}
+ assert epath(d, "items.[1]") == 20
+
+ def test_leading_dot_stripped(self):
+ d = {"a": 1}
+ assert epath(d, ".a") == 1
+
+ def test_trailing_dot_stripped(self):
+ d = {"a": 1}
+ assert epath(d, "a.") == 1
+
+
+class TestEpathParents:
+ def test_parents_creates_missing_dict_keys(self):
+ d = {}
+ result = epath(d, "a.b.c", parents=True, default=None)
+ # parents=True creates all intermediate keys including the leaf
+ assert result == {}
+ assert d == {"a": {"b": {"c": {}}}}
+
+
+class TestEpathModify:
+ def test_modify_dict_key(self):
+ d = {"a": 1}
+ result = epath(d, "a", enable_modify=True, new_value=99)
+ assert result == 99
+ assert d["a"] == 99
+
+ def test_modify_nested_key(self):
+ d = {"a": {"b": 2}}
+ epath(d, "a.b", enable_modify=True, new_value=42)
+ assert d["a"]["b"] == 42
+
+ def test_modify_list_index(self):
+ d = {"items": [10, 20]}
+ epath(d, "items.[0]", enable_modify=True, new_value=99)
+ assert d["items"][0] == 99
+
+ def test_modify_list_index_with_parents(self):
+ d = {"items": []}
+ result = epath(
+ d, "items.[3]", enable_modify=True, new_value=42, parents=True
+ )
+ assert result == 42
+ assert d["items"] == [None, None, None, 42]
diff --git a/tests/test_evalizor.py b/tests/test_evalizor.py
new file mode 100644
index 0000000..9187cd8
--- /dev/null
+++ b/tests/test_evalizor.py
@@ -0,0 +1,47 @@
+"""Tests for heurams.kernel.auxiliary.evalizor.Evalizer"""
+
+from heurams.kernel.auxiliary.evalizor import Evalizer
+
+
+class TestEvalizer:
+ def test_noop_on_plain_string(self):
+ e = Evalizer({"x": 42})
+ assert e("hello") == "hello"
+
+ def test_eval_expression(self):
+ e = Evalizer({"x": 42})
+ assert e("eval: x") == 42
+
+ def test_eval_arithmetic(self):
+ e = Evalizer({"a": 10, "b": 20})
+ assert e("eval: a + b") == 30
+
+ def test_traverses_dict(self):
+ e = Evalizer({"val": 99})
+ data = {"key_a": "plain", "key_b": "eval: val + 1"}
+ result = e(data)
+ assert result == {"key_a": "plain", "key_b": 100}
+
+ def test_traverses_list(self):
+ e = Evalizer({"val": 5})
+ data = ["eval: val", "plain", "eval: val * 2"]
+ result = e(data)
+ assert result == [5, "plain", 10]
+
+ def test_traverses_nested(self):
+ e = Evalizer({"val": 3})
+ data = {"outer": {"inner": "eval: val ** 2"}}
+ result = e(data)
+ assert result == {"outer": {"inner": 9}}
+
+ def test_traverses_tuple(self):
+ e = Evalizer({"val": 7})
+ data = ("eval: val", "other")
+ result = e(data)
+ assert result == (7, "other")
+
+ def test_non_string_passthrough(self):
+ e = Evalizer({})
+ assert e(42) == 42
+ assert e(None) is None
+ assert e([1, 2, 3]) == [1, 2, 3]
diff --git a/tests/test_hasher.py b/tests/test_hasher.py
new file mode 100644
index 0000000..7bda877
--- /dev/null
+++ b/tests/test_hasher.py
@@ -0,0 +1,25 @@
+"""Tests for heurams.services.hasher"""
+
+from heurams.services.hasher import get_md5, hash
+
+
+class TestGetMD5:
+ def test_known_value(self):
+ # MD5 of "hello" is known
+ assert get_md5("hello") == "5d41402abc4b2a76b9719d911017c592"
+
+ def test_empty_string(self):
+ assert get_md5("") == "d41d8cd98f00b204e9800998ecf8427e"
+
+ def test_unicode(self):
+ result = get_md5("中文测试")
+ assert isinstance(result, str)
+ assert len(result) == 32
+
+ def test_different_inputs_differ(self):
+ assert get_md5("abc") != get_md5("abcd")
+
+
+class TestHash:
+ def test_hash_delegates_to_md5(self):
+ assert hash("hello") == get_md5("hello")
diff --git a/tests/test_lict.py b/tests/test_lict.py
new file mode 100644
index 0000000..75faa42
--- /dev/null
+++ b/tests/test_lict.py
@@ -0,0 +1,198 @@
+"""Tests for heurams.kernel.auxiliary.lict.Lict"""
+
+import pytest
+
+from heurams.kernel.auxiliary.lict import Lict
+
+
+class TestLictInit:
+ def test_empty(self):
+ l = Lict()
+ assert len(l) == 0
+ assert list(l) == []
+
+ def test_from_list(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ assert l["a"] == 1
+ assert l["b"] == 2
+ assert len(l) == 2
+
+ def test_from_dict(self):
+ l = Lict(initdict={"x": 10, "y": 20})
+ assert l["x"] == 10
+ assert l["y"] == 20
+ assert len(l) == 2
+
+
+class TestLictListInterface:
+ def test_list_getitem(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ assert l[0] == ("a", 1)
+ assert l[1] == ("b", 2)
+
+ def test_list_setitem(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ l[0] = ("c", 3)
+ assert l["c"] == 3
+ assert l[0] == ("c", 3)
+
+ def test_list_delitem(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ del l[0]
+ assert "a" not in l
+ assert len(l) == 1
+
+ def test_append(self):
+ l = Lict()
+ l.append(("k", "v"))
+ assert l["k"] == "v"
+ assert l[0] == ("k", "v")
+
+ def test_insert(self):
+ l = Lict(initlist=[("a", 1), ("c", 3)])
+ l.insert(1, ("b", 2))
+ assert l[1] == ("b", 2)
+ assert l["b"] == 2
+ assert len(l) == 3
+
+ def test_pop(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ item = l.pop()
+ assert item == ("b", 2)
+ assert "b" not in l
+
+ def test_remove_by_key(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ l.remove("a")
+ assert "a" not in l
+ assert len(l) == 1
+
+ def test_remove_by_tuple(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ l.remove(("a", 1))
+ assert "a" not in l
+
+ def test_clear(self):
+ l = Lict(initlist=[("a", 1)])
+ l.clear()
+ assert len(l) == 0
+ assert list(l) == []
+
+
+class TestLictDictInterface:
+ def test_dict_getitem(self):
+ l = Lict(initlist=[("a", 1)])
+ assert l["a"] == 1
+
+ def test_dict_setitem(self):
+ l = Lict()
+ l["k"] = "v"
+ assert l["k"] == "v"
+ # dict set marks list dirty — sync on access
+ assert l[0] == ("k", "v")
+
+ def test_dict_delitem(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ del l["a"]
+ assert "a" not in l
+ assert len(l) == 1
+
+ def test_keys(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ assert set(l.keys()) == {"a", "b"}
+
+ def test_values(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ assert set(l.values()) == {1, 2}
+
+ def test_items(self):
+ l = Lict(initlist=[("a", 1), ("b", 2)])
+ assert set(l.items()) == {("a", 1), ("b", 2)}
+
+ def test_get_itemic_unit(self):
+ l = Lict(initlist=[("a", 1)])
+ assert l.get_itemic_unit("a") == ("a", 1)
+
+
+class TestLictSync:
+ def test_dict_to_list_sync(self):
+ """After dict modification, list access triggers sync."""
+ l = Lict(initdict={"a": 1})
+ assert l[0] == ("a", 1)
+
+ def test_list_to_dict_sync(self):
+ """After list modification, dict access triggers sync."""
+ l = Lict(initlist=[("a", 1)])
+ assert l["a"] == 1
+
+ def test_append_list_maintained(self):
+ l = Lict()
+ l.append(("x", 100))
+ l.append(("y", 200))
+ # List order preserved
+ assert list(l) == [("x", 100), ("y", 200)]
+
+
+class TestLictEdgeCases:
+ def test_append_non_tuple_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.append("not_a_tuple") # type: ignore
+
+ def test_append_bad_tuple_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.append((1, 2, 3)) # type: ignore
+
+ def test_contains_by_key(self):
+ l = Lict(initlist=[("a", 1)])
+ assert "a" in l
+
+ def test_contains_by_value(self):
+ l = Lict(initlist=[("a", 1)])
+ assert 1 in l
+
+ def test_contains_by_tuple(self):
+ l = Lict(initlist=[("a", 1)])
+ assert ("a", 1) in l
+
+ def test_not_contains(self):
+ l = Lict(initlist=[("a", 1)])
+ assert "z" not in l
+
+ def test_forced_order(self):
+ l = Lict(initlist=[("b", 2), ("a", 1)], forced_order=True)
+ assert l[0] == ("a", 1)
+ assert l[1] == ("b", 2)
+
+ def test_append_if_not_exists(self):
+ l = Lict()
+ l.append_if_it_doesnt_exist_before(("k", "v"))
+ assert l["k"] == "v"
+ l.append_if_it_doesnt_exist_before(("k", "v"))
+ assert len(l) == 1
+
+ def test_keys_equal_with(self):
+ a = Lict(initlist=[("x", 1), ("y", 2)])
+ b = Lict(initlist=[("y", 3), ("x", 4)])
+ assert a.keys_equal_with(b)
+
+ def test_index_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.index()
+
+ def test_extend_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.extend()
+
+ def test_sort_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.sort()
+
+ def test_reverse_raises(self):
+ l = Lict()
+ with pytest.raises(NotImplementedError):
+ l.reverse()
diff --git a/tests/test_nsp0.py b/tests/test_nsp0.py
new file mode 100644
index 0000000..33c0bb4
--- /dev/null
+++ b/tests/test_nsp0.py
@@ -0,0 +1,70 @@
+"""Tests for heurams.kernel.algorithms.nsp0.NSP0Algorithm"""
+
+from copy import deepcopy
+
+import pytest
+
+from heurams.kernel.algorithms import algorithms
+from heurams.services import timer
+
+
+@pytest.fixture
+def algo():
+ return algorithms["NSP-0"]
+
+
+@pytest.fixture
+def algodata(sample_algodata_nsp0):
+ return sample_algodata_nsp0
+
+
+class TestNSP0Defaults:
+ def test_defaults_have_important(self, algo):
+ assert algo.defaults["important"] == 0
+
+ def test_algo_name(self, algo):
+ assert algo.algo_name == "NSP-0"
+
+
+class TestNSP0Revisor:
+ def test_negative_one_skip(self, algo, algodata):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=-1)
+ assert d == algodata
+
+ def test_feedback_three_or_less_sets_interval_one(self, algo, algodata):
+ for fb in (0, 1, 2, 3):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=fb)
+ assert d["NSP-0"]["interval"] == 1
+ assert d["NSP-0"]["important"] == 1
+
+ def test_feedback_greater_than_three_sets_infinite_interval(self, algo, algodata):
+ for fb in (4, 5):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=fb)
+ assert d["NSP-0"]["interval"] == float("inf")
+ assert d["NSP-0"]["important"] == 0
+
+ def test_revisor_updates_dates(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=3)
+ assert d["NSP-0"]["last_date"] == timer.get_daystamp()
+ assert d["NSP-0"]["next_date"] == timer.get_daystamp() + 1
+
+
+class TestNSP0IsDue:
+ def test_due_when_past(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ d["NSP-0"]["next_date"] = 100
+ assert algo.is_due(d) is True
+
+ def test_not_due_when_future(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ d["NSP-0"]["next_date"] = 999999
+ assert algo.is_due(d) is False
+
+ def test_nextdate_returns_stored(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["NSP-0"]["next_date"] = 42
+ assert algo.nextdate(d) == 42
diff --git a/tests/test_sm2.py b/tests/test_sm2.py
new file mode 100644
index 0000000..ac20df4
--- /dev/null
+++ b/tests/test_sm2.py
@@ -0,0 +1,123 @@
+"""Tests for heurams.kernel.algorithms.sm2.SM2Algorithm"""
+
+from copy import deepcopy
+
+import pytest
+
+from heurams.kernel.algorithms import algorithms
+from heurams.services import timer
+
+
+@pytest.fixture
+def algo():
+ return algorithms["SM-2"]
+
+
+@pytest.fixture
+def algodata(sample_algodata_sm2):
+ return sample_algodata_sm2
+
+
+class TestSM2Defaults:
+ def test_defaults_have_efactor(self, algo):
+ assert algo.defaults["efactor"] == 2.5
+
+ def test_algo_name(self, algo):
+ assert algo.algo_name == "SM-2"
+
+
+class TestSM2Revisor:
+ def test_feedback_negative_one_skips(self, algo, algodata):
+ """feedback == -1 should be a no-op."""
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=-1)
+ assert d == algodata # unchanged
+
+ def test_good_feedback_increases_efactor(self, algo, algodata):
+ d = deepcopy(algodata)
+ ef_before = d["SM-2"]["efactor"]
+ algo.revisor(d, feedback=5)
+ assert d["SM-2"]["efactor"] > ef_before
+
+ def test_bad_feedback_resets_rept(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["rept"] = 5
+ algo.revisor(d, feedback=2)
+ assert d["SM-2"]["rept"] == 0
+ assert d["SM-2"]["interval"] == 1
+
+ def test_efactor_minimum_floor(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["efactor"] = 0.5
+ algo.revisor(d, feedback=2)
+ assert d["SM-2"]["efactor"] >= 1.3
+
+ def test_rept_increments_on_good_feedback(self, algo, algodata):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=4)
+ assert d["SM-2"]["rept"] == 1
+
+ def test_new_activation_resets_state(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["rept"] = 10
+ d["SM-2"]["efactor"] = 3.0
+ algo.revisor(d, feedback=5, is_new_activation=True)
+ assert d["SM-2"]["rept"] == 0
+ assert d["SM-2"]["efactor"] == 2.5
+
+ def test_interval_at_rept_zero(self, algo, algodata):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=2)
+ assert d["SM-2"]["interval"] == 1
+
+ def test_interval_at_rept_one(self, algo, algodata):
+ d = deepcopy(algodata)
+ # rept=0 + feedback>=3 -> rept becomes 1 -> interval=6
+ algo.revisor(d, feedback=5)
+ assert d["SM-2"]["interval"] == 6
+
+ def test_interval_for_rept_gt_one(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["rept"] = 2
+ d["SM-2"]["interval"] = 6
+ d["SM-2"]["efactor"] = 2.0
+ algo.revisor(d, feedback=5)
+ # efactor 2.0 + 0.1(feedback=5) = 2.1; interval = round(6 * 2.1) = 13
+ assert d["SM-2"]["interval"] == 13
+
+ def test_real_rept_always_increments(self, algo, algodata):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=5)
+ assert d["SM-2"]["real_rept"] == 1
+ algo.revisor(d, feedback=0)
+ assert d["SM-2"]["real_rept"] == 2
+
+
+class TestSM2DueDate:
+ def test_is_due_when_past(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ d["SM-2"]["next_date"] = 100 # far in the past
+ assert algo.is_due(d) is True
+
+ def test_not_due_when_future(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ d["SM-2"]["next_date"] = 999999 # far in the future
+ assert algo.is_due(d) is False
+
+ def test_nextdate_returns_stored(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["next_date"] = 12345
+ assert algo.nextdate(d) == 12345
+
+ def test_revisor_updates_dates(self, algo, algodata, timer_context):
+ d = deepcopy(algodata)
+ algo.revisor(d, feedback=5)
+ assert d["SM-2"]["last_date"] == timer.get_daystamp()
+ assert d["SM-2"]["next_date"] > timer.get_daystamp()
+
+
+class TestSM2Rating:
+ def test_get_rating_returns_efactor(self, algo, algodata):
+ d = deepcopy(algodata)
+ d["SM-2"]["efactor"] = 2.5
+ assert algo.get_rating(d) == "2.5"
diff --git a/tests/test_textproc.py b/tests/test_textproc.py
new file mode 100644
index 0000000..a78fd6f
--- /dev/null
+++ b/tests/test_textproc.py
@@ -0,0 +1,35 @@
+"""Tests for heurams.services.textproc"""
+
+from heurams.services.textproc import domize, truncate, undomize
+
+
+class TestTruncate:
+ def test_short_string_unchanged(self):
+ assert truncate("ab") == "ab"
+
+ def test_three_char_unchanged(self):
+ assert truncate("abc") == "abc"
+
+ def test_longer_string_truncated(self):
+ assert truncate("abcd") == "abc>"
+
+ def test_empty_string(self):
+ assert truncate("") == ""
+
+
+class TestDomizeUndomize:
+ def test_domize_replaces_dot(self):
+ assert domize("a.b.c") == "a--DOT--b--DOT--c"
+
+ def test_domize_no_dot(self):
+ assert domize("abc") == "abc"
+
+ def test_undomize_restores_dot(self):
+ assert undomize("a--DOT--b") == "a.b"
+
+ def test_undomize_no_marker(self):
+ assert undomize("abc") == "abc"
+
+ def test_roundtrip(self):
+ original = "config.key.subkey"
+ assert undomize(domize(original)) == original
diff --git a/uv.lock b/uv.lock
index c219516..81fe0e3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -84,11 +84,163 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
+ { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
+ { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
+ { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
+ { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
+ { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
+ { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
+ { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
+ { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
+ { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
+ { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "fsrs"
+version = "6.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d7/00/337de60fd5497ea4fed046192d17fa79809ca2aad7326da2e464d9d8950b/fsrs-6.3.1.tar.gz", hash = "sha256:43c5c6056b97266baf6ebfef9e4cadeb9ac5a4e1b29ffdfb300f445b6e6b15ca", size = 32645, upload-time = "2026-03-10T14:01:03.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/3c/e7e140f8cdb95b042cb125ee142e7630187e8e78d21847ca81e9d1e99bb8/fsrs-6.3.1-py3-none-any.whl", hash = "sha256:ac1bf9939573592d8c9bc1e11a00bd17e04146dc9f2c913127e2bcc431b9040b", size = 22840, upload-time = "2026-03-10T14:01:01.084Z" },
+]
+
[[package]]
name = "heurams"
version = "0.5.0"
source = { editable = "." }
dependencies = [
+ { name = "fsrs" },
{ name = "psutil" },
{ name = "tabulate" },
{ name = "textual" },
@@ -97,8 +249,15 @@ dependencies = [
{ name = "zmq" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+]
+
[package.metadata]
requires-dist = [
+ { name = "fsrs", specifier = ">=6.3.1" },
{ name = "psutil", specifier = ">=7.2.2" },
{ name = "tabulate", specifier = ">=0.10.0" },
{ name = "textual", specifier = ">=8.2.3" },
@@ -107,6 +266,21 @@ requires-dist = [
{ name = "zmq", specifier = ">=0.0.0" },
]
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.0.0" },
+ { name = "pytest-cov", specifier = ">=6.0.0" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "linkify-it-py"
version = "2.1.0"
@@ -157,6 +331,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "packaging"
+version = "26.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -166,6 +349,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "psutil"
version = "7.2.2"
@@ -212,6 +404,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
[[package]]
name = "pyzmq"
version = "27.1.0"
@@ -342,6 +566,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
[[package]]
name = "transitions"
version = "0.9.3"