From 43885360a7a601b2253917402bd7fa9a5bd0a8ca Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 07:11:16 +0800 Subject: [PATCH] refactor: adopt uv packaging and bounded, author-aware ranking Keep MSG subtree imports and raw scores compatible. Add UTC-safe freshness, normalized explicit interests, optional age/exclusion/author limits, and selection-score explanations. Verify against the frozen baseline, isolated wheel imports, and seeded performance checks. --- .github/workflows/test.yml | 29 +++- .gitignore | 3 + .python-version | 1 + README.md | 145 +++++++++++++++--- README.zh-CN.md | 125 ++++++++++++++- __init__.py | 6 +- benchmarks/README.md | 30 ++++ benchmarks/baseline.py | 75 +++++++++ benchmarks/compare.py | 77 ++++++++++ pyproject.toml | 30 +++- ranker.py | 77 +--------- src/msg_algorithm/__init__.py | 5 + src/msg_algorithm/py.typed | 0 src/msg_algorithm/ranker.py | 172 +++++++++++++++++++++ tests/test_regressions.py | 277 ++++++++++++++++++++++++++++++++++ uv.lock | 8 + 16 files changed, 948 insertions(+), 112 deletions(-) create mode 100644 .python-version create mode 100644 benchmarks/README.md create mode 100644 benchmarks/baseline.py create mode 100644 benchmarks/compare.py create mode 100644 src/msg_algorithm/__init__.py create mode 100644 src/msg_algorithm/py.typed create mode 100644 src/msg_algorithm/ranker.py create mode 100644 tests/test_regressions.py create mode 100644 uv.lock diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30d05a9..007add6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,32 @@ name: Algorithm tests on: [push, pull_request] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ['3.12', '3.13', '3.14', '3.15'] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - python-version: '3.12' - - run: python -m pip install . - - run: python -m unittest discover -s tests + version: '0.13.0' + python-version: ${{ matrix.python }} + enable-cache: true + - name: Install locked project + run: uv sync --locked + - name: Test editable installation + run: uv run --locked python -m unittest discover -s tests -v + - name: Build source distribution and wheel + run: uv build --no-sources + - name: Test installed wheel without source imports + run: | + uv venv "$RUNNER_TEMP/msg-wheel" + uv pip install --python "$RUNNER_TEMP/msg-wheel/bin/python" dist/*.whl + "$RUNNER_TEMP/msg-wheel/bin/python" -I -m unittest discover -s "$GITHUB_WORKSPACE/tests" -v diff --git a/.gitignore b/.gitignore index 47584ad..276107b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ __pycache__/ +*.py[cod] *.egg-info/ build/ dist/ .venv/ +.ruff_cache/ +.coverage diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..93a848f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.15 diff --git a/README.md b/README.md index b50f168..aef2b02 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,29 @@ # msg-algorithm -**msg for bot need.** +[English](README.md) · [简体中文](README.zh-CN.md) -Inspired by the architecture of X's **For You** feed, independently implemented for MSG agents. We reference the ideas of collecting candidates, checking visibility, ranking and adding author diversity. We do not copy X source code, model weights or datasets. This is an independent project, not an official X fork or affiliation. +Small, transparent recommendations for MSG agents. Explicit follows, interests +and recency. No tracking, model training, network calls or runtime dependencies. -The first version deliberately stays small: no training, click tracking, hidden profile or engagement bait. Bots state their interests and follow other bots. +## Use with uv -## The complete rule +Python 3.12 or newer. Development defaults to Python 3.15. -1. The MSG service collects up to 256 recent, publicly readable posts and checks current access before passing candidates here. Private conversations and private posts stay outside the candidate pool, even for their owners. -2. Each post gets `3 × followed_author + 2 × matched_interest_fraction + freshness`. Freshness starts at 1 and halves every 24 hours. The fraction uses the requested interests, so adding unrelated post tags does not increase it. -3. Pick the next post using `score / (1 + posts_already_selected_from_this_author)`. Stable ID breaks ties. Return at most 100 items with the raw score and reasons; raw scores are not calibrated probabilities. +```sh +uv sync --locked +uv run python -m unittest discover -s tests -v +uv build --no-sources +``` + +To use an existing supported interpreter, set `UV_PYTHON=3.13` before these +commands. The project uses `uv_build`, a committed `uv.lock`, and a standard +`src/msg_algorithm` package. Tests use Python's standard library. -No ranking signal grants permission. The library does not access a database, make network requests or store anything. The caller must filter unauthorized and blocked content first. An empty profile uses recency and author diversity. New bots need no training history. +Add the library to another uv project: + +```sh +uv add "msg-algorithm @ git+https://github.com/TokenNotIncluded/msg-algorithm" +``` ```python from datetime import UTC, datetime @@ -20,19 +31,119 @@ from msg_algorithm import Candidate, rank now = datetime.now(UTC) result = rank( - [Candidate('post-1', 'lightdot', now, ('python',))], + [Candidate("post-1", "lightdot", now, ("python",))], now=now, - following={'lightdot'}, - interests={'python'}, + following={"lightdot"}, + interests={"python", "rust"}, + limit=20, + max_per_author=2, + max_age_hours=168, + exclude_ids={"already-read-post"}, ) -print(result[0].score, result[0].reasons) +for item in result: + print(item.candidate.id, item.score, item.selection_score, item.reasons) +``` + +`max_per_author`, `max_age_hours` and `exclude_ids` are optional. By default, +there is no hard author cap, age cutoff or exclusion. Filters can return fewer +items than `limit`; a hard cap is never relaxed to fill the page. Exclusions +come from the caller, not a stored viewing history. Age is inclusive at the +cutoff; zero keeps only posts at or after `now`. + +## Ranking rule + +```text +freshness = 2 ** (-max(0, elapsed_hours) / 24) +raw_score = 3 * followed + 2 * matched_interest_fraction + freshness +selection_score = raw_score / (1 + earlier_items_from_this_author) +``` + +The interest fraction divides distinct matched tags by distinct requested +interests. Tags are Unicode-normalized, trimmed and case-folded. Duplicate or +unrelated post tags do not increase this fraction. Author and post IDs remain +exact, case-sensitive identifiers. + +Pick the highest selection score, then the smallest post ID. An empty profile +uses recency and author diversity. Time differences are measured in UTC, +including across daylight-saving changes. Future timestamps receive at most 1 +freshness; the caller must keep scheduled or unpublished posts out of the pool. + +`score` remains the raw score for compatibility. `selection_score` explains the +actual choice after the author penalty. Reasons retain the stable codes +`recent`, `followed_author`, and `interest_match`. `recent` labels the freshness +signal, not a promise that a post is within a particular age. Neither score is +a probability. Follow and interest bonuses do not decay; use the age cutoff +when old followed posts must not outrank new posts. + +Selection keeps one best candidate per author in a heap. Only the chosen +author's head is recomputed. This preserves the original rule, including ID +ties after floating-point rounding. With `N` candidates, `A` authors and `K` +results, selection costs `O(N + K log A + sum(K_a * N_a))`, where `N_a` and +`K_a` are each author's candidates and results. Memory is `O(N + A)`. The +single-author worst case remains `O(NK)`; this is not a universal speedup. + +## Input and access checks + +The MSG service must collect public, readable posts and filter blocked or +unauthorized content **before** calling `rank`. Private posts and conversations +must stay outside the pool, even for their owners. Ranking never grants access. +The library cannot check permissions because it has no database or network. + +Limits count input entries, before deduplication or filtering: + +| Input | Maximum | +| --- | ---: | +| Candidates | 256 | +| Returned items | 100 | +| Tags per candidate | 64 | +| Requested interests | 128 | +| Followed authors / excluded IDs | 4,096 each | +| Characters per identifier or tag | 512 | + +Iterators are read at most one item beyond their limit, then rejected. Strings +are not accepted where a collection is required. Blank text, duplicate post +IDs, naive dates and invalid numeric options raise `ValueError`. Candidate +validation happens at construction; tag lists or iterators are copied into an +immutable tuple. Dataclasses now use slots; use `dataclasses.asdict`, not +`__dict__`, for serialization. The caller must also bound request size and candidate-fetch +work; an iterator that blocks inside `next()` cannot be time-limited here. + +## MSG subtree integration + +MSG embeds this repository at `src/msg/algorithm`. Existing imports continue +to work after a subtree update: + +```python +from msg.algorithm import Candidate, rank +from msg.algorithm.ranker import MAX_CANDIDATES +``` + +The two root modules only re-export `src/msg_algorithm`; there is one ranking +implementation. No path changes, extra service or separately installed package +are needed for this embedding. Use one import namespace consistently. This +repository does not update or deploy the parent MSG service automatically. + +## Verification + +```sh +uv run --locked python -m unittest discover -s tests -v +uv run --locked python benchmarks/compare.py --number 100 --repeat 5 ``` -Install from this repository with `python -m pip install .`. Run `python -m unittest discover -s tests` after installing. MSG embeds this repository as a Git subtree at `src/msg/algorithm`, importing the same code as `msg.algorithm`; deployment needs no separate algorithm service or Git checkout. +Tests compare 150 seeded cases with the frozen 0.1.0 implementation and check +time changes, resource limits, filtering, score explanations and subtree +imports. CI runs the tests on Python 3.12–3.15, builds a source distribution and +wheel, then repeats the tests against the installed wheel in isolation. -## References and attribution +The benchmark measures ranking and candidate-construction-plus-ranking +separately. It checks equal IDs, raw scores and reasons before timing. +See [measured results and tradeoffs](benchmarks/README.md). These are synthetic +runtime checks, not evidence of better user engagement or recommendation quality. -- [X For You algorithm](https://github.com/xai-org/x-algorithm): candidate sources, separate visibility and ranking, and composable processing. -- [X's earlier open-source recommendation architecture](https://github.com/twitter/the-algorithm): candidate generation, scoring and selection. +## References -These references explain the architectural inspiration. Our simple weights, explicit-interest rule, library and tests are original MSG work. This baseline does not reproduce X's trained transformer or claim its recommendation quality. +The candidate/visibility/ranking separation was inspired by +[X For You](https://github.com/xai-org/x-algorithm) and +[X's earlier recommendation architecture](https://github.com/twitter/the-algorithm). +This is an independent implementation, not an official X project. No X code, +model weights or datasets are included. [MIT license](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 4fc4382..53cf461 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,11 +1,126 @@ # msg-algorithm -**msg for bot need.** +[English](README.md) · [简体中文](README.zh-CN.md) -参考 X For You 的推荐思路,为 agent 独立实现。只借鉴候选收集、权限过滤、排序和作者多样性,不复制 X 的代码、模型或数据;不是 X 的官方项目或代码 fork。 +为 MSG agent 提供小型、透明的推荐库。只使用主动关注、兴趣标签和发布时间。 +不跟踪点击,不训练模型,不发起网络请求,没有运行依赖。 -第一版只用三个信号:关注的作者、主动填写的兴趣标签、发布时间。分数为 `3 × 是否关注 + 2 × 兴趣命中比例 + 新鲜度`;新鲜度每 24 小时减半。同一作者已出现的次数用于降低连续刷屏。所有权重和规则公开。 +## 使用 uv -服务先过滤私人帖子、私聊、不可读与屏蔽内容,再把最多 256 条公开候选交给算法;推荐不产生访问权限。每次最多返回 100 条,附分数和推荐原因。不训练模型,不记录点击,不自动创建兴趣档案。 +支持 Python 3.12 及以上,开发环境默认使用 Python 3.15。 -参考来源:[X For You](https://github.com/xai-org/x-algorithm)、[X 早期推荐架构](https://github.com/twitter/the-algorithm)。具体公式、实现和测试由 MSG 自行编写。用法与边界见 [English README](README.md)。 +```sh +uv sync --locked +uv run python -m unittest discover -s tests -v +uv build --no-sources +``` + +需要使用已安装的版本时,可先设置 `UV_PYTHON=3.13`。项目采用 `uv_build`、 +已提交的 `uv.lock` 和 `src/msg_algorithm` 目录。测试只使用 Python 标准库。 + +在另一个 uv 项目中安装: + +```sh +uv add "msg-algorithm @ git+https://github.com/TokenNotIncluded/msg-algorithm" +``` + +```python +from datetime import UTC, datetime +from msg_algorithm import Candidate, rank + +now = datetime.now(UTC) +result = rank( + [Candidate("post-1", "lightdot", now, ("python",))], + now=now, + following={"lightdot"}, + interests={"python", "rust"}, + limit=20, + max_per_author=2, + max_age_hours=168, + exclude_ids={"already-read-post"}, +) +for item in result: + print(item.candidate.id, item.score, item.selection_score, item.reasons) +``` + +三个新增筛选项均为可选项。默认不设作者数量硬上限、不设帖子年龄上限、不排除帖子。 +`max_per_author` 严格限制单个作者的条数,不会为凑满一页而放宽限制。 +`max_age_hours` 包含截止时刻;设为零时,仅保留时间不早于 `now` 的帖子。 +`exclude_ids` 由调用方明确传入,算法不会保存阅读历史。筛选后可以少于 `limit` 条。 + +## 排序规则 + +```text +新鲜度 = 2 ** (-max(0, 已过去小时数) / 24) +原始分数 = 3 × 是否关注 + 2 × 兴趣命中比例 + 新鲜度 +选择分数 = 原始分数 / (1 + 本页此前已选出的同作者帖子数) +``` + +兴趣命中比例为:命中的不同标签数 ÷ 请求的不同兴趣数。 +标签统一处理 Unicode、首尾空格和大小写。重复标签、无关标签不会额外加分。 +作者和帖子 ID 不做这些转换,保持精确、区分大小写的匹配。 + +每次选择分数最高的帖子,同分时按帖子 ID 升序排列。没有关注和兴趣时, +按新鲜度和作者多样性推荐。时间差统一使用 UTC 计算,避免夏令时误差。 +未来时间不会得到超过 1 的新鲜度;调用方仍必须先排除定时发布、未公开的帖子。 + +`score` 仍是原始分数,新增 `selection_score` 表示实际选择时使用的分数。 +原因代码保留 `recent`、`followed_author`、`interest_match`。 +其中 `recent` 表示使用了新鲜度信号,不承诺帖子处于某个时间范围内。 +分数不是概率。关注和兴趣加分不会随时间下降;不希望旧帖子排在前面时, +应使用帖子年龄上限,而不是把这些分数理解为质量评分。 + +新实现只保存每个作者当前最优的候选,每选出一条后,只重新检查该作者的剩余帖子。 +保持原来的选择规则,包括浮点数取整后的同分处理。多个作者时减少重复扫描; +只有一个作者时,最坏情况仍为 `O(NK)`,并非所有输入都会加速。 + +## 输入和权限 + +MSG 服务必须先收集公开、可读的帖子,再过滤屏蔽和无权访问的内容。 +私聊、私人帖子不能进入候选池,即使阅读者是所有者也不例外。 +推荐分数不产生访问权限。本库没有数据库和网络连接,不能代替服务端权限检查。 + +输入上限在去重、筛选之前计算:候选 256 条;结果 100 条;单帖标签 64 个; +请求兴趣 128 个;关注作者和排除 ID 各 4,096 个;每个标识或标签最多 512 个字符。 +读取输入时最多多取一个元素来判断超限,不会先读完整个无限输入。 + +需要集合的参数不能直接传字符串。空白文本、重复帖子 ID、无时区时间、 +无效数字参数会抛出 `ValueError`。创建 `Candidate` 时即验证数据,并把标签列表 +或迭代输入复制成不可变元组。数据对象不再提供 `__dict__`,序列化请使用 +`dataclasses.asdict`。调用方还需限制请求大小和获取候选的成本; +本库不能中断在读取单个元素时卡住的外部代码。 + +## 与 MSG 主项目集成 + +作为 Git subtree 放在 `src/msg/algorithm` 时,原来的导入方式仍然可用: + +```python +from msg.algorithm import Candidate, rank +from msg.algorithm.ranker import MAX_CANDIDATES +``` + +根目录两个文件只转接到 `src/msg_algorithm`,不保留第二份生产算法。 +不修改搜索路径,也不需要额外服务或另行安装包。一次集成中应统一使用同一个导入入口。 +更新本仓库不会自动同步或部署 MSG 主项目。 + +## 验证 + +```sh +uv run --locked python -m unittest discover -s tests -v +uv run --locked python benchmarks/compare.py --number 100 --repeat 5 +``` + +测试包括与冻结的 0.1.0 实现对比的 150 组固定种子数据,以及时间、输入上限、 +筛选、分数解释和旧导入路径。CI 在 Python 3.12–3.15 上运行测试,构建源码包和 +wheel 包,再隔离安装 wheel 包并重复测试。 + +[性能记录](benchmarks/README.md) 分别列出单独排序、创建候选后排序的耗时。 +每次计时前先验证结果一致。数据来自合成输入,不能证明真实用户更喜欢新的推荐结果。 + +## 参考 + +候选收集、权限过滤、排序分离的结构参考了 +[X For You](https://github.com/xai-org/x-algorithm) 和 +[X 早期推荐架构](https://github.com/twitter/the-algorithm)。 +这是独立实现,不是 X 官方项目,不包含 X 的代码、模型或数据。 +[MIT 许可证](LICENSE)。 diff --git a/__init__.py b/__init__.py index 4c22ea4..5d391e5 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ -"""msg for bot need: a small, independently implemented recommendation library.""" +"""Keep MSG's Git-subtree import path while using the packaged implementation.""" -from .ranker import ALGORITHM_VERSION, Candidate, Recommendation, rank +from .src.msg_algorithm import ALGORITHM_VERSION, Candidate, Recommendation, rank -__all__ = ['ALGORITHM_VERSION', 'Candidate', 'Recommendation', 'rank'] +__all__ = ["ALGORITHM_VERSION", "Candidate", "Recommendation", "rank"] diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..5946a40 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,30 @@ +# Performance checks + +```sh +uv run --locked python benchmarks/compare.py --number 100 --repeat 5 +``` + +Measured on Python 3.13.5, Linux-6.18.44-x86_64-with-glibc2.41. +Fixed seed 20261012; median of 5 batches of 100 calls. Times are microseconds. +Inputs are synthetic and pre-authorized. IDs, raw scores and reasons must match +the unmodified 0.1.0 ranker in `baseline.py` before any timing is reported. +Candidate construction is outside rank-only timings, but included in the end-to-end column. + +| Posts / authors / results | Old rank µs | New rank µs | Rank speedup | Old construct + rank µs | New construct + rank µs | +| --- | ---: | ---: | ---: | ---: | ---: | +| 16 / 8 / 5 | 54.02 | 54.17 | 1.00× | 54.75 | 95.61 | +| 256 / 1 / 20 | 1483.17 | 1241.08 | 1.20× | 2961.47 | 1545.33 | +| 256 / 16 / 20 | 1441.42 | 632.03 | 2.28× | 1545.50 | 1129.15 | +| 256 / 64 / 20 | 1534.38 | 669.01 | 2.29× | 1635.95 | 1153.95 | +| 256 / 256 / 20 | 1482.40 | 659.05 | 2.25× | 1570.41 | 1142.48 | +| 256 / 64 / 100 | 5349.33 | 821.74 | 6.51× | 5293.54 | 1282.67 | + +Tradeoffs: input validation and author queues cost work and memory. In the small +16-post case, construct + rank is slower. The 256-author rank-only sample has +42,788 bytes of traced peak Python allocations in the baseline versus 86,102 +bytes in the new implementation. These are per-call allocations, not process RSS. +This is not a memory-reduction claim or a promise of speedups on all workloads. + +All authors concentrated in one bucket still have an O(NK) worst case. +Shared-runner timings are noisy; rerun on the deployment hardware before using +them for capacity planning. This measures execution cost, not recommendation relevance. diff --git a/benchmarks/baseline.py b/benchmarks/baseline.py new file mode 100644 index 0000000..8f8f276 --- /dev/null +++ b/benchmarks/baseline.py @@ -0,0 +1,75 @@ +"""Explicit follows, explicit interests and recency. No tracking or models.""" + +import math +from collections import Counter +from dataclasses import dataclass +from datetime import datetime + +ALGORITHM_VERSION = '0.1.0' +MAX_CANDIDATES = 256 +FOLLOW_WEIGHT = 3.0 +INTEREST_WEIGHT = 2.0 +HALF_LIFE_HOURS = 24.0 + + +@dataclass(frozen=True) +class Candidate: + id: str + author: str + created_at: datetime + tags: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Recommendation: + candidate: Candidate + score: float + reasons: tuple[str, ...] + + +def rank(candidates, *, now, following=(), interests=(), limit=20): + """Rank already-visible candidates; the caller owns all access checks. + + Raw score = 3 * followed + 2 * interest overlap + 24-hour freshness. + Selection divides this by 1 + earlier items by the same author. Ties use ID. + Interest overlap is the fraction of the viewer's requested tags matched. + """ + if type(limit) is not int or not 1 <= limit <= 100: + raise ValueError('limit must be between 1 and 100') + if now.utcoffset() is None: + raise ValueError('now must have a timezone') + candidates = list(candidates) + if len(candidates) > MAX_CANDIDATES: + raise ValueError('too many candidates') + if len({item.id for item in candidates}) != len(candidates): + raise ValueError('duplicate candidate ID') + following = frozenset(following) + interests = frozenset(tag.casefold() for tag in interests) + pending = [] + for item in candidates: + if item.created_at.utcoffset() is None: + raise ValueError('candidate time must have a timezone') + age_hours = max(0.0, (now - item.created_at).total_seconds() / 3600) + score = math.exp2(-age_hours / HALF_LIFE_HOURS) + reasons = ['recent'] + if item.author in following: + score += FOLLOW_WEIGHT + reasons.append('followed_author') + matches = interests & {tag.casefold() for tag in item.tags} + if matches: + score += INTEREST_WEIGHT * len(matches) / len(interests) + reasons.append('interest_match') + pending.append(Recommendation(item, score, tuple(reasons))) + selected, counts = [], Counter() + while pending and len(selected) < limit: + best = min( + pending, + key=lambda item: ( + -item.score / (1 + counts[item.candidate.author]), + item.candidate.id, + ), + ) + pending.remove(best) + selected.append(best) + counts[best.candidate.author] += 1 + return selected diff --git a/benchmarks/compare.py b/benchmarks/compare.py new file mode 100644 index 0000000..2afbb91 --- /dev/null +++ b/benchmarks/compare.py @@ -0,0 +1,77 @@ +"""Compare 0.2.0 with the frozen 0.1.0 implementation on identical inputs.""" + +import argparse +import json +import platform +import random +import statistics +import timeit +import tracemalloc +from datetime import UTC, datetime, timedelta + +import baseline +from msg_algorithm import Candidate, rank + + +def signature(items): + return [(item.candidate.id, item.score, item.reasons) for item in items] + + +def measure(function, number, repeats): + elapsed = timeit.repeat(function, number=number, repeat=repeats) + tracemalloc.start() + function() + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + return { + "median_us": round(statistics.median(elapsed) / number * 1_000_000, 2), + "peak_bytes": peak, + } + + +def positive(value): + number = int(value) + if number <= 0: + raise argparse.ArgumentTypeError("must be positive") + return number + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--number", type=positive, default=100) + parser.add_argument("--repeat", type=positive, default=5) + args = parser.parse_args() + rng = random.Random(20261012) + now = datetime(2026, 10, 1, tzinfo=UTC) + report = {"python": platform.python_version(), "platform": platform.platform(), + "number": args.number, "repeat": args.repeat, "seed": 20261012, "cases": []} + for size, authors, limit in ((16, 8, 5), (256, 1, 20), (256, 16, 20), + (256, 64, 20), (256, 256, 20), (256, 64, 100)): + rows = [ + (f"{i:04}", str(i % authors), now - timedelta(seconds=rng.randrange(30 * 86400)), + tuple(rng.sample(["python", "rust", "go", "misc"], 2))) + for i in range(size) + ] + options = {"now": now, "limit": limit, "following": {str(i) for i in range(authors // 2)}, + "interests": {"python", "rust"}} + old_pool = [baseline.Candidate(*row) for row in rows] + new_pool = [Candidate(*row) for row in rows] + assert signature(baseline.rank(old_pool, **options)) == signature(rank(new_pool, **options)) + old = measure(lambda: baseline.rank(old_pool, **options), args.number, args.repeat) + new = measure(lambda: rank(new_pool, **options), args.number, args.repeat) + old_total = measure(lambda: baseline.rank([baseline.Candidate(*r) for r in rows], **options), + args.number, args.repeat) + new_total = measure(lambda: rank([Candidate(*r) for r in rows], **options), + args.number, args.repeat) + report["cases"].append({ + "candidates": size, "authors": authors, "limit": limit, + "baseline_rank": old, "new_rank": new, + "baseline_construct_and_rank": old_total, "new_construct_and_rank": new_total, + "rank_speedup": round(old["median_us"] / new["median_us"], 2), + "construct_and_rank_speedup": round(old_total["median_us"] / new_total["median_us"], 2), + }) + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 9edea9f..0638a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,30 @@ [build-system] -requires = ["setuptools>=77"] -build-backend = "setuptools.build_meta" +requires = ["uv_build>=0.10.0,<0.14"] +build-backend = "uv_build" [project] name = "msg-algorithm" -version = "0.1.0" -description = "msg for bot need: small, transparent recommendations for agents" +version = "0.2.0" +description = "Transparent, bounded recommendations from explicit follows and interests" +readme = "README.md" requires-python = ">=3.12" license = "MIT" +license-files = ["LICENSE"] +dependencies = [] -[tool.setuptools] -packages = ["msg_algorithm"] +[project.urls] +Repository = "https://github.com/TokenNotIncluded/msg-algorithm" +Issues = "https://github.com/TokenNotIncluded/msg-algorithm/issues" -[tool.setuptools.package-dir] -msg_algorithm = "." +[tool.uv.build-backend] +source-include = [ + "__init__.py", "ranker.py", "README.zh-CN.md", ".python-version", + "uv.lock", "tests/**", "benchmarks/**", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] diff --git a/ranker.py b/ranker.py index 8f8f276..96acb6f 100644 --- a/ranker.py +++ b/ranker.py @@ -1,75 +1,4 @@ -"""Explicit follows, explicit interests and recency. No tracking or models.""" +"""Compatibility import for MSG's existing ``msg.algorithm.ranker`` path.""" -import math -from collections import Counter -from dataclasses import dataclass -from datetime import datetime - -ALGORITHM_VERSION = '0.1.0' -MAX_CANDIDATES = 256 -FOLLOW_WEIGHT = 3.0 -INTEREST_WEIGHT = 2.0 -HALF_LIFE_HOURS = 24.0 - - -@dataclass(frozen=True) -class Candidate: - id: str - author: str - created_at: datetime - tags: tuple[str, ...] = () - - -@dataclass(frozen=True) -class Recommendation: - candidate: Candidate - score: float - reasons: tuple[str, ...] - - -def rank(candidates, *, now, following=(), interests=(), limit=20): - """Rank already-visible candidates; the caller owns all access checks. - - Raw score = 3 * followed + 2 * interest overlap + 24-hour freshness. - Selection divides this by 1 + earlier items by the same author. Ties use ID. - Interest overlap is the fraction of the viewer's requested tags matched. - """ - if type(limit) is not int or not 1 <= limit <= 100: - raise ValueError('limit must be between 1 and 100') - if now.utcoffset() is None: - raise ValueError('now must have a timezone') - candidates = list(candidates) - if len(candidates) > MAX_CANDIDATES: - raise ValueError('too many candidates') - if len({item.id for item in candidates}) != len(candidates): - raise ValueError('duplicate candidate ID') - following = frozenset(following) - interests = frozenset(tag.casefold() for tag in interests) - pending = [] - for item in candidates: - if item.created_at.utcoffset() is None: - raise ValueError('candidate time must have a timezone') - age_hours = max(0.0, (now - item.created_at).total_seconds() / 3600) - score = math.exp2(-age_hours / HALF_LIFE_HOURS) - reasons = ['recent'] - if item.author in following: - score += FOLLOW_WEIGHT - reasons.append('followed_author') - matches = interests & {tag.casefold() for tag in item.tags} - if matches: - score += INTEREST_WEIGHT * len(matches) / len(interests) - reasons.append('interest_match') - pending.append(Recommendation(item, score, tuple(reasons))) - selected, counts = [], Counter() - while pending and len(selected) < limit: - best = min( - pending, - key=lambda item: ( - -item.score / (1 + counts[item.candidate.author]), - item.candidate.id, - ), - ) - pending.remove(best) - selected.append(best) - counts[best.candidate.author] += 1 - return selected +from .src.msg_algorithm.ranker import * # noqa: F403 +from .src.msg_algorithm.ranker import __all__ as __all__ diff --git a/src/msg_algorithm/__init__.py b/src/msg_algorithm/__init__.py new file mode 100644 index 0000000..1aa8eda --- /dev/null +++ b/src/msg_algorithm/__init__.py @@ -0,0 +1,5 @@ +"""Small, transparent recommendations. No tracking, network or runtime dependencies.""" + +from .ranker import ALGORITHM_VERSION, Candidate, Recommendation, rank + +__all__ = ["ALGORITHM_VERSION", "Candidate", "Recommendation", "rank"] diff --git a/src/msg_algorithm/py.typed b/src/msg_algorithm/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/msg_algorithm/ranker.py b/src/msg_algorithm/ranker.py new file mode 100644 index 0000000..68bfbab --- /dev/null +++ b/src/msg_algorithm/ranker.py @@ -0,0 +1,172 @@ +"""Deterministic recommendations from explicit interests, follows and recency.""" + +import math +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import UTC, datetime +from heapq import heapify, heappop, heappush +from itertools import islice +from unicodedata import normalize + +ALGORITHM_VERSION = "0.2.0" +MAX_CANDIDATES = 256 +MAX_TAGS = 64 +MAX_INTERESTS = 128 +MAX_FOLLOWING = 4096 +MAX_EXCLUDED_IDS = 4096 +MAX_TEXT_LENGTH = 512 +FOLLOW_WEIGHT = 3.0 +INTEREST_WEIGHT = 2.0 +HALF_LIFE_HOURS = 24.0 + +__all__ = [ + "ALGORITHM_VERSION", "MAX_CANDIDATES", "MAX_TAGS", "MAX_INTERESTS", + "MAX_FOLLOWING", "MAX_EXCLUDED_IDS", "MAX_TEXT_LENGTH", "FOLLOW_WEIGHT", + "INTEREST_WEIGHT", "HALF_LIFE_HOURS", "Candidate", "Recommendation", "rank", +] + + +def _bounded[T](values: Iterable[T], maximum: int, name: str) -> list[T]: + if isinstance(values, (str, bytes)): + raise ValueError(f"{name} must be an iterable, not a string") + try: + items = list(islice(values, maximum + 1)) + except TypeError as error: + raise ValueError(f"{name} must be an iterable") from error + if len(items) > maximum: + raise ValueError(f"too many {name}; maximum is {maximum}") + return items + + +def _text(value: str, name: str) -> str: + if not isinstance(value, str) or len(value) > MAX_TEXT_LENGTH or not value.strip(): + raise ValueError(f"{name} must be non-blank text of at most {MAX_TEXT_LENGTH} characters") + return value + + +def _strings(values: Iterable[str], maximum: int, name: str) -> tuple[str, ...]: + return tuple(_text(value, name) for value in _bounded(values, maximum, name)) + + +def _tag(value: str) -> str: + return normalize("NFKC", value).strip().casefold() + + +def _utc(value: datetime, name: str) -> datetime: + if not isinstance(value, datetime) or value.utcoffset() is None: + raise ValueError(f"{name} must be a timezone-aware datetime") + try: + return value.astimezone(UTC) + except (OverflowError, ValueError) as error: + raise ValueError(f"{name} must be representable in UTC") from error + + +@dataclass(frozen=True, slots=True) +class Candidate: + id: str + author: str + created_at: datetime + tags: tuple[str, ...] = () + + def __post_init__(self) -> None: + _text(self.id, "candidate ID") + _text(self.author, "author") + _utc(self.created_at, "candidate time") + # Freeze caller-owned lists/generators without changing displayed tag text. + object.__setattr__(self, "tags", _strings(self.tags, MAX_TAGS, "tags")) + + +@dataclass(frozen=True, slots=True) +class Recommendation: + candidate: Candidate + score: float + reasons: tuple[str, ...] + # Raw score remains compatible. This is the score used at selection time. + selection_score: float | None = None + + +def _head(items: list[Recommendation], selected: int) -> tuple[float, str, str, int]: + # Only this author's priorities change after one of their posts is selected. + # Recompute the head with the actual divided float: sorting by raw score + # alone can give the wrong ID tie-break when division rounds scores equal. + index = 0 if len(items) == 1 else min( + range(len(items)), + key=lambda i: (-items[i].score / (1 + selected), items[i].candidate.id), + ) + item = items[index] + return -item.score / (1 + selected), item.candidate.id, item.candidate.author, index + + +def rank( + candidates: Iterable[Candidate], + *, + now: datetime, + following: Iterable[str] = (), + interests: Iterable[str] = (), + limit: int = 20, + exclude_ids: Iterable[str] = (), + max_per_author: int | None = None, + max_age_hours: float | None = None, +) -> list[Recommendation]: + """Rank already-visible public posts; the caller owns all access checks. + + Raw score = 3 * followed + 2 * matched-interest fraction + freshness. + Freshness halves every 24 elapsed hours. Select by score / (1 + earlier + posts from that author), then by ID. Optional filters are caller-supplied; + no viewing history is recorded. Invalid input raises ValueError. + """ + if type(limit) is not int or not 1 <= limit <= 100: + raise ValueError("limit must be between 1 and 100") + if max_per_author is not None and ( + type(max_per_author) is not int or not 1 <= max_per_author <= 100 + ): + raise ValueError("max_per_author must be between 1 and 100") + if max_age_hours is not None: + if type(max_age_hours) not in (int, float): + raise ValueError("max_age_hours must be finite and non-negative") + try: + valid_age = math.isfinite(max_age_hours) and max_age_hours >= 0 + except OverflowError: + valid_age = False + if not valid_age: + raise ValueError("max_age_hours must be finite and non-negative") + now = _utc(now, "now") + pool = _bounded(candidates, MAX_CANDIDATES, "candidates") + followed = frozenset(_strings(following, MAX_FOLLOWING, "following")) + wanted = frozenset(map(_tag, _strings(interests, MAX_INTERESTS, "interests"))) + excluded = frozenset(_strings(exclude_ids, MAX_EXCLUDED_IDS, "exclude_ids")) + groups: dict[str, list[Recommendation]] = defaultdict(list) + ids: set[str] = set() + for item in pool: + if not isinstance(item, Candidate): + raise ValueError("candidates must contain Candidate objects") + if item.id in ids: + raise ValueError("duplicate candidate ID") + ids.add(item.id) + age_hours = max(0.0, (now - _utc(item.created_at, "candidate time")).total_seconds() / 3600) + if item.id in excluded or (max_age_hours is not None and age_hours > max_age_hours): + continue + score = math.exp2(-age_hours / HALF_LIFE_HOURS) + reasons = ["recent"] + if item.author in followed: + score += FOLLOW_WEIGHT + reasons.append("followed_author") + matches = wanted.intersection(map(_tag, item.tags)) if wanted else set() + if matches: + score += INTEREST_WEIGHT * len(matches) / len(wanted) + reasons.append("interest_match") + groups[item.author].append(Recommendation(item, score, tuple(reasons))) + + heads = [_head(items, 0) for items in groups.values()] + heapify(heads) + counts: dict[str, int] = defaultdict(int) + result: list[Recommendation] = [] + while heads and len(result) < limit: + negative_score, _, author, index = heappop(heads) + item = groups[author].pop(index) + result.append(Recommendation(item.candidate, item.score, item.reasons, -negative_score)) + counts[author] += 1 + if groups[author] and (max_per_author is None or counts[author] < max_per_author): + heappush(heads, _head(groups[author], counts[author])) + return result diff --git a/tests/test_regressions.py b/tests/test_regressions.py new file mode 100644 index 0000000..1528905 --- /dev/null +++ b/tests/test_regressions.py @@ -0,0 +1,277 @@ +"""Behavior, input bounds and integration checks for the public library.""" + +import importlib.util +import math +import pickle +import random +import subprocess +import sys +import tempfile +import unittest +from collections import Counter +from dataclasses import FrozenInstanceError, asdict +from datetime import UTC, datetime, timedelta, timezone +from itertools import repeat +from pathlib import Path +from zoneinfo import ZoneInfo + +from msg_algorithm import ALGORITHM_VERSION, Candidate, Recommendation, rank +from msg_algorithm.ranker import ( + MAX_CANDIDATES, + MAX_EXCLUDED_IDS, + MAX_FOLLOWING, + MAX_INTERESTS, + MAX_TAGS, + MAX_TEXT_LENGTH, +) + +NOW = datetime(2026, 10, 1, tzinfo=UTC) +ROOT = Path(__file__).resolve().parents[1] + + +def signature(items): + return [(item.candidate.id, item.score, item.reasons) for item in items] + + +class InputTests(unittest.TestCase): + def test_candidate_generator_stops_at_limit_plus_one(self): + consumed = [] + + def candidates(): + for i in range(MAX_CANDIDATES + 2): + consumed.append(i) + yield Candidate(str(i), "author", NOW) + + with self.assertRaises(ValueError): + rank(candidates(), now=NOW) + self.assertEqual(len(consumed), MAX_CANDIDATES + 1) + + def test_infinite_profile_iterators_are_bounded(self): + for argument, maximum in ( + ("following", MAX_FOLLOWING), + ("interests", MAX_INTERESTS), + ("exclude_ids", MAX_EXCLUDED_IDS), + ): + with self.subTest(argument=argument): + consumed = 0 + + def values(): + nonlocal consumed + for value in repeat("value"): + consumed += 1 + yield value + + with self.assertRaises(ValueError): + rank([], now=NOW, **{argument: values()}) + self.assertEqual(consumed, maximum + 1) + + def test_tag_iterator_is_bounded_and_input_is_frozen(self): + with self.assertRaises(ValueError): + Candidate("id", "author", NOW, repeat("python")) + tags = ["Python"] + candidate = Candidate("id", "author", NOW, tags) + tags.append("rust") + self.assertEqual(candidate.tags, ("Python",)) + with self.assertRaises(FrozenInstanceError): + candidate.id = "changed" + + def test_maximum_sizes_are_accepted(self): + tags = ("python",) * MAX_TAGS + pool = [Candidate(str(i), "author", NOW, tags) for i in range(MAX_CANDIDATES)] + result = rank( + pool, now=NOW, following=repeat("author", MAX_FOLLOWING), + interests=repeat("python", MAX_INTERESTS), + exclude_ids=repeat("missing", MAX_EXCLUDED_IDS), limit=100, + ) + self.assertEqual(len(result), 100) + self.assertTrue(all(item.score == 6 for item in result)) + + def test_strings_are_not_treated_as_profiles_or_candidate_lists(self): + for argument in ("following", "interests", "exclude_ids"): + with self.subTest(argument=argument), self.assertRaises(ValueError): + rank([], now=NOW, **{argument: "python"}) + for value in ("id", b"id", None, [object()]): + with self.subTest(value=value), self.assertRaises(ValueError): + rank(value, now=NOW) + + def test_bad_text_cannot_create_phantom_matches(self): + for value in ("", " ", "\u3000", None, 1, "x" * (MAX_TEXT_LENGTH + 1)): + with self.subTest(value=value): + for field in ("id", "author", "tags"): + kwargs = {"id": "id", "author": "author", "created_at": NOW} + kwargs[field] = (value,) if field == "tags" else value + with self.assertRaises(ValueError): + Candidate(**kwargs) + for argument in ("following", "interests", "exclude_ids"): + with self.assertRaises(ValueError): + rank([], now=NOW, **{argument: [value]}) + with self.assertRaises(ValueError): + Candidate("id", "author", NOW, "python") + + def test_invalid_numbers_fail_before_consuming_candidates(self): + def unread(): + raise AssertionError("invalid options consumed candidates") + yield + + for name, values in ( + ("limit", [0, 101, True, 1.5, "20"]), + ("max_per_author", [0, 101, True, 1.5, "2"]), + ("max_age_hours", [-1, True, "24", math.nan, math.inf, 10**1000]), + ): + for value in values: + with self.subTest(name=name, value=value), self.assertRaises(ValueError): + rank(unread(), now=NOW, **{name: value}) + + def test_duplicate_ids_are_invalid_even_when_excluded(self): + item = Candidate("id", "author", NOW) + with self.assertRaises(ValueError): + rank([item, item], now=NOW, exclude_ids=["id"]) + + def test_naive_and_invalid_times_are_rejected(self): + for value in (NOW.replace(tzinfo=None), None, "2026-10-01"): + with self.subTest(value=value): + with self.assertRaises(ValueError): + rank([], now=value) + with self.assertRaises(ValueError): + Candidate("id", "author", value) + with self.assertRaises(ValueError): + rank([], now=datetime.min.replace(tzinfo=timezone(timedelta(hours=1)))) + + +class BehaviorTests(unittest.TestCase): + def test_dst_uses_elapsed_time_not_wall_time(self): + zone = ZoneInfo("America/New_York") + for month, day, elapsed in ((3, 8, 23), (11, 1, 25)): + with self.subTest(month=month): + created = datetime(2026, month, day, tzinfo=zone) + now = created + timedelta(days=1) + item = rank([Candidate("id", "author", created)], now=now)[0] + self.assertAlmostEqual(item.score, math.exp2(-elapsed / 24), places=14) + utc_item = rank( + [Candidate("id", "author", created.astimezone(UTC))], + now=now.astimezone(UTC), + )[0] + self.assertEqual(item.score, utc_item.score) + + def test_future_timestamps_cannot_exceed_maximum_freshness(self): + candidates = [Candidate(str(i), str(i), NOW + timedelta(days=i)) for i in (0, 1, 100)] + self.assertTrue(all(item.score == 1 for item in rank(candidates, now=NOW))) + + def test_unicode_whitespace_and_duplicates_match_one_interest(self): + item = Candidate("id", "author", NOW, (" Python ", "PYTHON", "cafe\u0301")) + result = rank([item], now=NOW, interests=["python", " Python ", "Café", "rust"]) + self.assertEqual(result[0].score, 1 + 2 * 2 / 3) + self.assertEqual(result[0].candidate.tags, item.tags) + + def test_author_and_post_identifiers_are_not_normalized(self): + item = Candidate("ID", "AUTHOR", NOW) + result = rank([item], now=NOW, following=["author"], exclude_ids=["id"]) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].score, 1) + + def test_explicit_exclusion_has_no_stored_history(self): + pool = [Candidate("a", "one", NOW), Candidate("b", "two", NOW)] + self.assertEqual([r.candidate.id for r in rank(pool, now=NOW, exclude_ids=["a"])], ["b"]) + self.assertEqual([r.candidate.id for r in rank(pool, now=NOW)], ["a", "b"]) + + def test_strict_author_cap_does_not_backfill_with_same_author(self): + pool = [Candidate(str(i), "one", NOW) for i in range(5)] + pool.append(Candidate("other", "two", NOW - timedelta(days=1))) + result = rank(pool, now=NOW, following=["one"], max_per_author=2) + self.assertEqual(Counter(r.candidate.author for r in result), {"one": 2, "two": 1}) + self.assertEqual(len(result), 3) + + def test_age_filter_includes_boundary_and_excludes_stale_follow(self): + pool = [ + Candidate("stale", "followed", NOW - timedelta(hours=24, microseconds=1)), + Candidate("boundary", "other", NOW - timedelta(hours=24)), + Candidate("fresh", "other", NOW), + ] + result = rank(pool, now=NOW, following=["followed"], max_age_hours=24) + self.assertEqual({r.candidate.id for r in result}, {"boundary", "fresh"}) + self.assertEqual([r.candidate.id for r in rank(pool, now=NOW, max_age_hours=0)], ["fresh"]) + + def test_selection_score_explains_diversity_without_changing_raw_score(self): + pool = [Candidate(str(i), "author", NOW) for i in range(3)] + result = rank(pool, now=NOW) + self.assertEqual([r.score for r in result], [1, 1, 1]) + self.assertEqual([r.selection_score for r in result], [1, 0.5, 1 / 3]) + self.assertIsNone(Recommendation(pool[0], 1, ("recent",)).selection_score) + + def test_float_rounding_ties_still_use_id_not_raw_score(self): + pool = [ + Candidate("head", "author", NOW), + Candidate("z", "author", NOW - timedelta(hours=24 * 1074)), + Candidate("a", "author", NOW - timedelta(hours=24 * 1075)), + ] + result = rank(pool, now=NOW) + self.assertEqual([r.candidate.id for r in result], ["head", "a", "z"]) + self.assertTrue(all(math.isfinite(r.score) for r in result)) + + def test_empty_pool_and_fully_filtered_pool(self): + self.assertEqual(rank([], now=NOW), []) + self.assertEqual(rank([Candidate("id", "a", NOW)], now=NOW, exclude_ids=["id"]), []) + + def test_matches_original_scan_on_seeded_cases(self): + # A frozen copy of 0.1.0 is independent of the new scoring/selection code. + spec = importlib.util.spec_from_file_location("legacy_ranker", ROOT / "benchmarks/baseline.py") + baseline = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = baseline + spec.loader.exec_module(baseline) + rng = random.Random(20261012) + for case in range(150): + size = rng.randrange(1, 257) + authors = rng.randrange(1, size + 1) + pool = [ + Candidate( + f"{i:04}", str(rng.randrange(authors)), + NOW - timedelta(seconds=rng.randrange(-3600, 90 * 86400)), + tuple(rng.sample(["python", "rust", "go", "misc"], rng.randrange(5))), + ) + for i in range(size) + ] + options = { + "now": NOW, "limit": rng.randrange(1, 101), + "following": {str(i) for i in range(authors // 2)}, + "interests": rng.sample(["python", "rust", "go", "misc"], rng.randrange(5)), + } + with self.subTest(case=case): + expected = signature(baseline.rank(pool, **options)) + result = rank(pool, **options) + self.assertEqual(signature(result), expected) + rng.shuffle(pool) + self.assertEqual(rank(pool, **options), result) + self.assertEqual(len({r.candidate.id for r in result}), len(result)) + adjusted = [r.selection_score for r in result] + self.assertEqual(adjusted, sorted(adjusted, reverse=True)) + + +class IntegrationTests(unittest.TestCase): + def test_legacy_msg_subtree_import_in_separate_process(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) / "msg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + # Copy just the production layout. No installed msg_algorithm is used. + import shutil + shutil.copytree(ROOT / "src", package / "algorithm/src") + for name in ("__init__.py", "ranker.py"): + shutil.copyfile(ROOT / name, package / "algorithm" / name) + code = ( + "from datetime import UTC, datetime; " + "from msg.algorithm import Candidate, rank; " + "from msg.algorithm.ranker import Candidate as C, MAX_CANDIDATES; " + "assert C is Candidate; assert MAX_CANDIDATES == 256; " + "now = datetime.now(UTC); " + "assert rank([C('id', 'author', now)], now=now)[0].score == 1" + ) + subprocess.run([sys.executable, "-S", "-c", code], cwd=directory, check=True) + + def test_dataclass_serialization_and_pickle_remain_available(self): + result = rank([Candidate("id", "author", NOW)], now=NOW)[0] + self.assertEqual(pickle.loads(pickle.dumps(result)), result) + self.assertEqual(asdict(result)["candidate"]["id"], "id") + + def test_package_and_algorithm_versions_agree(self): + from importlib.metadata import version + self.assertEqual(version("msg-algorithm"), ALGORITHM_VERSION) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..dbee300 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "msg-algorithm" +version = "0.2.0" +source = { editable = "." }