From e017160c45ef25f7d01ab3451557a93558b0888d Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 9 Aug 2026 21:28:59 -0400 Subject: [PATCH] =?UTF-8?q?build(repo):=20=E8=90=BD=E6=88=90=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE=E3=80=81=E4=BE=9D=E8=B5=96=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=8E=E5=8D=81=E4=B8=AA=E6=A8=A1=E5=9D=97=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 ③ 阶段剩下的那半:架构文档之外,import-linter 契约也落地了。 pyproject.toml 把九条依赖规则里的七条写成五条 import-linter 契约。 分层用一条 layers 契约表达规则 1、2、3、8,`|` 表示同层互不 import; 另外四条 forbidden 分别管下游反向 import、polygateway 的唯一入口、 以及三个纯逻辑模块不碰 asyncio 与 pathlib。 契约不是平凡的绿:故意注入两处违规验证过,都被点名到行号。 先建十个模块的空包,是为了避开 PolyGateway bootstrap 期那段 Makefile 门控—— 它当时没有包,lint-imports 报 module not found 而红,只好加一段跳过逻辑。 空包让契约从第一天就真的在跑。 剩下两条规则落不进契约,写成了 tests/unit 下的测试: 规则 6「types 与 ports 不许 import 任何第三方」判据要反过来写(只许标准库和自己), 规则 9「import polyloop 之后 sys.modules 里没有 polygateway」是运行时事实。 另加硬约束 §1.1 零业务假设的黑名单扫描——它第一次跑就抓到 _assembly 的 docstring 里写了 dissect 的业务词,已改。三个扫描类测试都带 fail-closed 守卫, 防止目录搬走之后扫到空列表安静地绿。 工程约定取自实验室已有项目:setuptools + src layout、ruff 十一项 select、 line-length 100 来自 PolyGateway;dev 工具链版本钉死、--strict-markers 与 --import-mode=importlib 来自 CHSAnalyzer 与 dissect 踩过的坑,各自的理由写在配置注释里。 e2e 默认不跑,它打真实网关要花钱。 CLAUDE.md §0 那句「契约还没写,要等 src/ 落地」已过期,改掉; README 勾掉第 ②③ 阶段,补上本地检查命令与 GovDoc-Editor 那一行。 Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 +- CLAUDE.md | 10 +- Makefile | 28 +++++ README.md | 23 ++-- pyproject.toml | 161 +++++++++++++++++++++++++ src/polyloop/__init__.py | 18 +++ src/polyloop/_assembly/__init__.py | 9 ++ src/polyloop/_recovery/__init__.py | 7 ++ src/polyloop/_stopping/__init__.py | 7 ++ src/polyloop/adapters/__init__.py | 5 + src/polyloop/ports/__init__.py | 7 ++ src/polyloop/py.typed | 0 src/polyloop/serialization/__init__.py | 5 + src/polyloop/session/__init__.py | 7 ++ src/polyloop/stores/__init__.py | 5 + src/polyloop/tools/__init__.py | 11 ++ src/polyloop/types/__init__.py | 8 ++ tests/unit/test_import_purity.py | 71 +++++++++++ tests/unit/test_no_business_terms.py | 67 ++++++++++ tests/unit/test_package.py | 58 +++++++++ 20 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 src/polyloop/__init__.py create mode 100644 src/polyloop/_assembly/__init__.py create mode 100644 src/polyloop/_recovery/__init__.py create mode 100644 src/polyloop/_stopping/__init__.py create mode 100644 src/polyloop/adapters/__init__.py create mode 100644 src/polyloop/ports/__init__.py create mode 100644 src/polyloop/py.typed create mode 100644 src/polyloop/serialization/__init__.py create mode 100644 src/polyloop/session/__init__.py create mode 100644 src/polyloop/stores/__init__.py create mode 100644 src/polyloop/tools/__init__.py create mode 100644 src/polyloop/types/__init__.py create mode 100644 tests/unit/test_import_purity.py create mode 100644 tests/unit/test_no_business_terms.py create mode 100644 tests/unit/test_package.py diff --git a/.gitignore b/.gitignore index 0cedd4f..d2445cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# 参考资料:五个克隆下来的仓库 + agent-core.md(CLAUDE.md §0)。 +# 参考资料:六个克隆下来的仓库 + agent-core.md(CLAUDE.md §0)。 # 它们各自带着自己的 .git,提交进来会变成一堆不可用的嵌套仓库, # 而且体积接近 100MB。需要的人自己按 README 的表格克隆。 /reference/ @@ -21,5 +21,6 @@ __pycache__/ /dist/ .pytest_cache/ .ruff_cache/ +.import_linter_cache/ .coverage htmlcov/ diff --git a/CLAUDE.md b/CLAUDE.md index 65fa605..bf845ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ | 这类事实 | 权威处 | |---|---| | **哪些事归本库管、哪些不归**,以及判据 | `research-wiki/explanation/scope.md` | -| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,将由 `pyproject.toml` 的 import-linter 契约机器断言。**那些契约还没写**(要等 `src/` 落地才写得出来),在它们出现之前这份文档就是权威,没有机器兜底 | +| 分层、依赖方向、模块边界 | `research-wiki/explanation/architecture.md`,由 `pyproject.toml` 的 import-linter 契约机器断言。九条依赖规则里有两条落不进契约(「不许 import 任何第三方」不是可枚举清单,「import 之后 `sys.modules` 里没有谁」是运行时事实),它们是 `tests/unit/` 里的测试 | | 公共 API 的行为契约:一次 `run` 到底保证什么、边界条件怎么结算 | `tests/contract/` 的公共契约套件。它同时是任何新适配器的准入标准 | | 公共类型的字段、不变量、枚举取值 | `src/polyloop/` 的代码与其测试。**不另写一份参考文档复述它们**——那份文档不重复代码的内容太少,而它腐烂的速度和代码一样快 | | 每个下游项目要迁走什么、迁完算不算数 | `research-wiki/migrations/` 下对应那份 | @@ -29,7 +29,7 @@ | 文档体系怎么组织、新文档该放哪 | `research-wiki/README.md` | | 协作规则 | 本文件 | -**`reference/` 不在上表里,因为它不是任何东西的权威。** 那里的五个仓库和 `agent-core.md` 地位相同,都是参考资料。`agent-core.md` 是别人为本项目写的一份架构提案,它不是我们的设计,也不是常青文档——其中任何一条在被我们自己的 design doc 明确采纳之前都不作数。引用它时必须写成「agent-core.md 的说法是……」,不能写成「我们决定……」。代价是每次多写一句话,收益是不会长出「大家都以为这个决定已经做过了」的状态——那种状态在上表的裁决规则下最难修,因为它没有一个错的地方可以指。 +**`reference/` 不在上表里,因为它不是任何东西的权威。** 那里的六个仓库和 `agent-core.md` 地位相同,都是参考资料。`agent-core.md` 是别人为本项目写的一份架构提案,它不是我们的设计,也不是常青文档——其中任何一条在被我们自己的 design doc 明确采纳之前都不作数。引用它时必须写成「agent-core.md 的说法是……」,不能写成「我们决定……」。代价是每次多写一句话,收益是不会长出「大家都以为这个决定已经做过了」的状态——那种状态在上表的裁决规则下最难修,因为它没有一个错的地方可以指。 `reference/` 只读、不入库,也不改。 @@ -46,7 +46,7 @@ ## 1. 硬约束 1. **零业务假设。** 库内禁止出现下游的业务词汇(公文、审核点、超声、CHS、benchmark、实验轮次、得分)与业务 fixtures;扩展点一律用 Protocol。三个下游的领域互不相交,一个业务词进来就等于替其中一个项目做了另外两个不需要的假设。这类假设很难删——它会长出配套的字段、分支和测试,删的时候要一起动。 -2. **不反向 import 任何下游项目。** 由 import-linter 契约断言(契约还没写,见 §0 第一行)。 +2. **不反向 import 任何下游项目。** 由 import-linter 契约断言。 3. **公共类型的字段只增不删不改名,新增字段必带默认值。** 三个下游各自 `pip install` 本库,改名会让已经在跑的代码直接 `ImportError` 或静默拿到默认值。要删要改就发新 major 并写迁移指引。 4. **持久化结构的 schema 变更走显式版本,不靠默认值补齐。** 会被下游存进数据库或实验数据集的结构(运行结果、逐步轨迹)必须带独立的 schema 版本,读到未知 major 直接失败。dissect 的轨迹是论文实验数据,一次静默的默认值填充会把「这件事没发生过」改写成「发生了但值为空」,而这种损坏要到统计阶段才暴露,那时已经分不清哪些行是真的。 5. **模型调用一律走 PolyGateway**(实验室共用库)。不在本项目里另写重试 / 限流 / 熔断 / 缓存 / 遥测。缺能力就给 PolyGateway 提 PR。 @@ -110,7 +110,7 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。** 只列需要解释的。`src/`、`tests/`、`.github/workflows/` 这类看名字就知道装什么的不列。 ``` -★ reference/ 参考资料:五个仓库 + agent-core.md +★ reference/ 参考资料:六个仓库 + agent-core.md 只读、不改、不入库,且不是任何东西的权威(§0) ★ research-wiki/README.md 文档体系怎么组织、新文档该放哪 ★ research-wiki/design/ 动工前的方案与权衡,只增不改;决策变更 = 新写一份标 supersedes @@ -122,7 +122,7 @@ Codex 是 OpenAI 的编码模型,本仓库通过 `codex` 插件调用它。** ★ research-wiki/scratch/ 一次性草稿。进 git,但由人在每轮工作会话结束前清理(AI 不要自动删) ★ tests/contract/ 公共 Protocol 的行为一致性套件,是那份契约的权威(§0), 也是任何新适配器的准入标准。目录已建、测试还没写 - src/polyloop/ 库本体 +★ src/polyloop/ 库本体。十个模块的空骨架已建,内容还没写 ``` 常青层与记录层的分界、各类的更新触发点、`scratch/` 那条人工清理规则的已知风险,都在 `research-wiki/README.md`。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..23aae4a --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: install lint format check test ci + +ENV := PolyLoop + +# 命令形状是固定的:PYTHONUNBUFFERED=1 加 conda run --live-stream。conda 和 Python 各缓冲 +# 一层,两层都得拆——只加其中一个,长跑命令仍然全程无输出,直到进程结束才一次性吐出。 +RUN := PYTHONUNBUFFERED=1 conda run --live-stream -n $(ENV) + +install: + $(RUN) pip install -e ".[dev]" + +# lint 带 --fix 会改工作区,check 只读。CI 用 check,人手修用 lint。 +lint: + $(RUN) ruff check src/ tests/ --fix + $(RUN) lint-imports + +format: + $(RUN) ruff format src/ tests/ + +check: + $(RUN) ruff format --check src/ tests/ + $(RUN) ruff check src/ tests/ + $(RUN) lint-imports + +test: + $(RUN) pytest + +ci: check test diff --git a/README.md b/README.md index 62568b3..8ff85a1 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ --- -## ⚠️ 项目尚未开工(2026-08-07 起) +## ⚠️ 项目还没有可用的功能(2026-08-07 起) -当前仓库只有协作规范和文档骨架,`src/` 一行代码都没有。下面的阶段清单是唯一的进度权威。 +`src/polyloop/` 下是十个模块的空骨架——目录和依赖契约先于代码存在,模块里一个类一个函数都 +还没有。下面的阶段清单是唯一的进度权威。 ## 消费者与验收标准 @@ -32,11 +33,11 @@ 不会在别处留下过期条文。 - [x] ① 协作规范 —— 见 [CLAUDE.md](CLAUDE.md) 与 [research-wiki/README.md](research-wiki/README.md)(文档体系) -- [ ] ② 需求对齐 —— 从 dissect 的 `harness/agent/` 与 GovDoc-SaaS 的 `packages/docagent-core/` +- [x] ② 需求对齐 —— 从 dissect 的 `harness/agent/` 与 GovDoc-SaaS 的 `packages/docagent-core/` 提取真实需求,产出 `research-wiki/migrations/` 下两份迁移文档(删除清单 + 组件映射 + 验收口径), 并检查 CHSAnalyzer 的 agent 方案落在边界内还是边界外。**这一阶段的产物决定库的边界**, 所以它排在架构前面:边界画错,后面每一份架构文档都要重写 -- [ ] ③ 架构 —— `research-wiki/explanation/architecture.md` 与 `pyproject.toml` 的 import-linter 契约。 +- [x] ③ 架构 —— `research-wiki/explanation/architecture.md` 与 `pyproject.toml` 的 import-linter 契约。 架构文档先于代码存在,此期间它是一份规格而不是描述,文档开头须写明这一点 - [ ] ④ 测试框架 —— unit / integration / e2e / contract 四层骨架(划分判据是「依赖什么」, 见 [CLAUDE.md](CLAUDE.md) §1.9),以及 `tests/contract/` 那套公共行为一致性用例的形状 @@ -45,13 +46,20 @@ ## 本地检查 -CI 会跑的几项,本地随时可以自己跑(**命令与工具链尚未落地,等第 ③ 阶段**)。 +``` +make check # ruff format --check + ruff check + lint-imports +make test # pytest(e2e 默认不跑,它打真实网关要花钱) +make ci # 上面两条 +``` -文档质量不走 CI,走 [CLAUDE.md](CLAUDE.md) §3 的「硕士生阅读」评审。 +仓库还没有 remote,所以没有 CI workflow。`make ci` 就是当前的全部机器闸,和 PolyGateway +一样。等仓库推上去之后按 `research-wiki/guides/` 补 workflow(那份也还没写)。 + +文档质量不走机器检查,走 [CLAUDE.md](CLAUDE.md) §3 的「硕士生阅读」评审。 ## 参考资料 -`reference/` 下的五个仓库与 `agent-core.md` **都只是参考,不是本项目的设计,也不是任何事实的权威** +`reference/` 下的六个仓库与 `agent-core.md` **都只是参考,不是本项目的设计,也不是任何事实的权威** (理由见 [CLAUDE.md](CLAUDE.md) §0)。它们只读、不改、不入库。 | 位置 | 是什么 | @@ -59,6 +67,7 @@ CI 会跑的几项,本地随时可以自己跑(**命令与工具链尚未落 | `reference/agent-core.md` | 别人为本项目写的一份架构提案。其中任何一条在被我们自己的 design doc 采纳前都不作数 | | `reference/dissect/` | 消费者,已有 ReAct 循环实现 | | `reference/GovDoc-SaaS/`(`background` 分支) | 消费者,已有第一次抽库尝试 `packages/docagent-core/` | +| `reference/GovDoc-Editor/` | GovDoc-SaaS 重构之前的那一版,今天跑在生产上。需求来源,不是迁移对象 | | `reference/CHSAnalyzer/` | 远期消费者;同时是本仓库协作规范的蓝本 | | `reference/PolyGateway/` | 本库的依赖,也是「实验室共用库该怎么做」的蓝本 | | `reference/pi/` | 外部参考实现 | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..977f952 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,161 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "polyloop" +version = "0.0.0" +description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入" +requires-python = ">=3.11" +# 3.11 是被下游钉死的:dissect 与 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。 +dependencies = [] +# 核心没有运行时依赖,这是刻意的。公共类型与接缝签名上不许出现第三方类型(依赖规则 6), +# 否则那个包的 major 就是我们的 major。 + +[project.optional-dependencies] +# 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同 +# 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。 +gateway = ["polygateway>=1.1,<2"] +# dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、 +# CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在 +# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。 +# 升级流程:改这里的版本号 → 本地重装 → 修掉新规则的告警 → 一起提交。 +dev = [ + "pytest==9.1.1", + "pytest-asyncio==1.4.0", + "ruff==0.16.2", + "import-linter==2.13", +] + +[tool.setuptools.packages.find] +where = ["src"] + +# py.typed 让下游装上之后拿得到类型信息。一个库不带它,下游的类型检查器会把整个包当成 +# 无注解的黑盒,签名和公共类型上写的东西一条都用不上——而这个库对下游的承诺大半就写在 +# 签名里(CLAUDE.md §6:下游看不到实现,只能靠签名和类型判断该传什么)。 +[tool.setuptools.package-data] +polyloop = ["py.typed"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +# S110(try-except-pass)与 E722(裸 except)断言 CLAUDE.md §1.7「禁止吞掉错误」。 +# 这两条是硬约束的机器形式,不许在本文件或行内 noqa 里放开。 +select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "TCH", "S110"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["polyloop"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +asyncio_mode = "auto" + +# 四层的判据是「依赖什么」,不是「叫什么」(CLAUDE.md §1.9)。按名字分层的话,改个函数名 +# 就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。 +# +# unit 里会出现少数只读源码文本、根本不执行被测代码的测试(依赖规则 6 那条「types 与 ports +# 禁止 import 任何第三方包」就是这种,它写不成 import-linter 契约)。它们不连任何外部服务, +# 按上面那条判据就是 unit,不为它们单开一层。哪天这类测试多到想单独跑,再拆不迟。 +markers = [ + "unit: 用测试替身,不连任何外部服务", + "integration: 连真 PolyGateway", + "e2e: 打真实模型网关,会产生真实调用与费用", + "contract: 公共 Protocol 的行为一致性套件,也是任何新适配器的准入标准", +] + +# --strict-markers:拼错的标记直接报错,不给警告。默认配置下 @pytest.mark.uint 只是一条 +# 警告,而那个文件会从此不属于任何一层、被所有筛选漏掉,静默消失。 +# --import-mode=importlib:按文件路径建模块名,不按 basename。默认的 prepend 模式下 +# tests/unit/test_stopping.py 与 tests/integration/test_stopping.py 会被当成同一个模块, +# 收集时报 import file mismatch——而且挂掉的是**整次收集**,连没被 -m 选中的层也一起挂。 +# 另一种解法是给每个层目录补 __init__.py,选这个是因为它不要求以后新建目录的人记得补。 +# -m 'not e2e':e2e 打真实网关、要花钱,默认不跑。显式 `pytest -m e2e` 覆盖(CLI 的 -m +# 后到优先)。这一条是刻意的默认排除,不是静默降级——它写在这里,且 markers 里注明了原因。 +addopts = "--strict-markers --import-mode=importlib -m 'not e2e'" + +# --------------------------------------------------------------------------- +# 依赖规则的机器形式。九条规则本身与它们各自的理由在 +# research-wiki/design/0003-public-api-shape.md 决策八,当前形状在 +# research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。 +# +# 九条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试: +# 规则 6(types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。 +# 规则 9(import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。 +# --------------------------------------------------------------------------- +[tool.importlinter] +root_packages = ["polyloop"] +# 有几条契约要禁止 import 外部包(下游项目、polygateway、asyncio / pathlib), +# 而外部包默认不进 import 图,所以必须在顶层打开这个开关。 +include_external_packages = true + +# 规则 1、2、3、8。`|` 表示同层且互不 import,`:` 表示同层可以互相 import。 +# 装配层那三个用 `|`:session 一旦 import 了某个存储实现,那个实现就成了隐式默认。 +# 逻辑层那五个也用 `|`:它们之间的编织只能发生在 session 里。 +[[tool.importlinter.contracts]] +name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import" +type = "layers" +layers = [ + "polyloop.session | polyloop.stores | polyloop.adapters", + "polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization", + "polyloop.ports", + "polyloop.types", +] + +# 规则 8 单列。上一条已经覆盖它,重复一遍是为了让违规信息直接指向 +# 「接缝定义模块被污染了」,而不是一条读起来要绕一圈的分层报错。 +[[tool.importlinter.contracts]] +name = "ports 只依赖 types,不依赖任何其他 polyloop 模块" +type = "forbidden" +source_modules = ["polyloop.ports"] +forbidden_modules = [ + "polyloop.session", + "polyloop.stores", + "polyloop.adapters", + "polyloop.tools", + "polyloop._assembly", + "polyloop._stopping", + "polyloop._recovery", + "polyloop.serialization", +] + +# 规则 4。三个下游的顶层包名:dissect 是 harness,GovDoc-SaaS 是 docagent_core, +# CHSAnalyzer 还没写到 agent 那一步,等它有了包名再加进来。 +[[tool.importlinter.contracts]] +name = "不反向 import 任何下游项目" +type = "forbidden" +source_modules = ["polyloop"] +forbidden_modules = ["harness", "docagent_core"] + +# 规则 5。适配器是唯一允许碰网关的地方。 +[[tool.importlinter.contracts]] +name = "除 adapters 外一切禁止 import polygateway" +type = "forbidden" +source_modules = [ + "polyloop.session", + "polyloop.stores", + "polyloop.tools", + "polyloop._assembly", + "polyloop._stopping", + "polyloop._recovery", + "polyloop.serialization", + "polyloop.ports", + "polyloop.types", +] +forbidden_modules = ["polygateway"] + +# 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它, +# 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒 +# (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。 +[[tool.importlinter.contracts]] +name = "三个纯逻辑模块不碰 I/O 与事件循环" +type = "forbidden" +source_modules = [ + "polyloop._assembly", + "polyloop._stopping", + "polyloop._recovery", +] +forbidden_modules = ["asyncio", "pathlib"] diff --git a/src/polyloop/__init__.py b/src/polyloop/__init__.py new file mode 100644 index 0000000..279481a --- /dev/null +++ b/src/polyloop/__init__.py @@ -0,0 +1,18 @@ +"""PolyLoop:Agent 执行内核。 + +治理单位是一次运行——围绕一个目标的有界多轮「模型决策 → 动作 → 观察」循环。 +一次模型调用不归它管,那是 PolyGateway 的治理单位。 + +**这里只再导出五个公开模块**:`types`、`ports`、`tools`、`serialization`、`session`。 +`stores` 与 `adapters` 必须由使用者显式 import——顺手提供的默认实现会让每个进程在 +import 时把网关连同它的 provider 目录一起拉起来,而不传存储的人不会知道自己这次运行 +没有恢复能力。 + +**当前是空骨架。** 目录与依赖契约先于代码存在,形状见 +`research-wiki/explanation/architecture.md`。 +""" + +#: 与 `pyproject.toml` 的 `project.version` 必须一致,由 `tests/unit/test_package.py` 断言。 +#: 两处双写是因为运行时读不到构建元数据(未安装的源码树里 `importlib.metadata` 查不到), +#: 而下游报 bug 时第一件事就是问版本号。 +__version__ = "0.0.0" diff --git a/src/polyloop/_assembly/__init__.py b/src/polyloop/_assembly/__init__.py new file mode 100644 index 0000000..9c88399 --- /dev/null +++ b/src/polyloop/_assembly/__init__.py @@ -0,0 +1,9 @@ +"""段序、注入槽、规模度量。内部模块。 + +渲染格式不在这里:那是调用方自己要改的东西。库一旦在内部按某个条件拼出自己的文本, +那段文本就成了库替调用方定的内容,而且不会出现在调用方的参数快照里。归这里的只有段的 +顺序与注入槽的位置,那是纯函数不是扩展点。 + +**纯逻辑,不碰 I/O 与事件循环。** 机器只拦得住 `asyncio` 与 `pathlib` 这两个最常见的 +入口,真正守住纯度的是这个模块的测试形态:不许用 fixture 起外部资源、不许有 `async def`。 +""" diff --git a/src/polyloop/_recovery/__init__.py b/src/polyloop/_recovery/__init__.py new file mode 100644 index 0000000..3a99afc --- /dev/null +++ b/src/polyloop/_recovery/__init__.py @@ -0,0 +1,7 @@ +"""恢复状态判定与运行身份校验。内部模块。 + +读到结构上说不通的状态就失败,不修复也不带着它继续——一个被猜着修好的日志,会让后面 +每一个基于它的判断都建立在猜测上,而且不会有任何地方提示这件事发生过。 + +**纯逻辑,约束同 `_assembly`。** +""" diff --git a/src/polyloop/_stopping/__init__.py b/src/polyloop/_stopping/__init__.py new file mode 100644 index 0000000..79085f5 --- /dev/null +++ b/src/polyloop/_stopping/__init__.py @@ -0,0 +1,7 @@ +"""停止判定与预算结算。内部模块。 + +判定顺序是有序的,不是一组独立条件——「恰好在最后一步做完」和「预算耗尽」的轨迹长度 +一模一样,顺序错了两者会互换,而且不会有任何地方报错。 + +**纯逻辑,约束同 `_assembly`。** +""" diff --git a/src/polyloop/adapters/__init__.py b/src/polyloop/adapters/__init__.py new file mode 100644 index 0000000..39d695a --- /dev/null +++ b/src/polyloop/adapters/__init__.py @@ -0,0 +1,5 @@ +"""PolyGateway 模型适配器。**须由使用者显式 import。** + +**这是全库唯一允许 import `polygateway` 的地方。** 不进 `polyloop/__init__.py`:一个 +顺手提供的默认模型客户端会让每个进程在 import 时把网关连同它的 provider 目录一起拉起来。 +""" diff --git a/src/polyloop/ports/__init__.py b/src/polyloop/ports/__init__.py new file mode 100644 index 0000000..06b2101 --- /dev/null +++ b/src/polyloop/ports/__init__.py @@ -0,0 +1,7 @@ +"""全部 Protocol 与它们的入参 / 返回结构体。 + +用 `typing.Protocol` 而不是抽象基类:下游的对象往往已经是它自己的类、还要同时满足项目 +自己更宽的接口,只有结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图。 + +**签名上不许出现第三方类型**——出现了,那个包的 major 就是我们的 major。 +""" diff --git a/src/polyloop/py.typed b/src/polyloop/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/polyloop/serialization/__init__.py b/src/polyloop/serialization/__init__.py new file mode 100644 index 0000000..8d9bab1 --- /dev/null +++ b/src/polyloop/serialization/__init__.py @@ -0,0 +1,5 @@ +"""记录的编解码与 schema major 校验。 + +读到未知 major 直接失败,不靠默认值补齐。一次静默的默认值填充会把「这件事没发生过」 +改写成「发生了但值为空」,而这种损坏要到统计阶段才暴露。 +""" diff --git a/src/polyloop/session/__init__.py b/src/polyloop/session/__init__.py new file mode 100644 index 0000000..7e11d34 --- /dev/null +++ b/src/polyloop/session/__init__.py @@ -0,0 +1,7 @@ +"""定义、请求,以及 `run` 与 `resume` 两个动词。 + +**唯一的驱动入口**:不导出低阶循环,也不导出单步。 + +逻辑层那五个模块之间的编织只能发生在这里——它们互不 import,这个模块是它们唯一的会合处。 +代价是这个模块会长,这是接受了的。 +""" diff --git a/src/polyloop/stores/__init__.py b/src/polyloop/stores/__init__.py new file mode 100644 index 0000000..6cf8167 --- /dev/null +++ b/src/polyloop/stores/__init__.py @@ -0,0 +1,5 @@ +"""库自带的存储实现。**须由使用者显式 import。** + +不进 `polyloop/__init__.py`,也不许被 `session` import:`session` 一旦 import 了某个 +存储实现,那个实现就成了隐式默认,而不传存储的人不会知道自己这次运行没有恢复能力。 +""" diff --git a/src/polyloop/tools/__init__.py b/src/polyloop/tools/__init__.py new file mode 100644 index 0000000..876ca77 --- /dev/null +++ b/src/polyloop/tools/__init__.py @@ -0,0 +1,11 @@ +"""工具规格与注册表,以及由注册表派生的动作执行器。 + +一个模块持有关于工具的全部六件事:注册、模型可见 schema 的生成、存在性与参数校验、分发、 +重放策略声明、完成标记。 + +**六件必须同源,不许把其中任何一件挪出去。** 挪出去的那份清单会跟注册表漂移,而漂移的 +表现是「模型明明提交了,运行却没停」——它看起来像模型不听话,不像配置错了。 + +注册表是不可变值对象,取子集返回新实例。不能是进程级单例:同一进程里可能同时持有多份 +不同的窄集合。 +""" diff --git a/src/polyloop/types/__init__.py b/src/polyloop/types/__init__.py new file mode 100644 index 0000000..42ec1a2 --- /dev/null +++ b/src/polyloop/types/__init__.py @@ -0,0 +1,8 @@ +"""公共值类型、枚举、持久化记录。 + +读者是所有人:下游读步记录与停止原因重建轨迹,读停止原因决定一次运行算不算可挽救, +写适配器的人要构造这里的结构体。 + +**这个模块的字段只增不删不改名,新增字段必带默认值**,持久化结构的 schema 变更走显式 +版本。理由见 `CLAUDE.md` §1.3 与 §1.4。 +""" diff --git a/tests/unit/test_import_purity.py b/tests/unit/test_import_purity.py new file mode 100644 index 0000000..518688c --- /dev/null +++ b/tests/unit/test_import_purity.py @@ -0,0 +1,71 @@ +"""依赖规则 6:`types` 与 `ports` 不许 import 任何第三方包。 + +写不成 import-linter 契约,因为「任何第三方」不是一份可枚举的清单——契约要求你把禁止 +的包名列出来,而这条规则要禁的是**清单之外的一切**。所以判据反过来写:允许的只有标准库 +和 `polyloop` 自己,其余一律违规。 + +**这个文件只读源码文本,不执行被测代码。** 它仍然属于 unit,因为分层判据是「依赖什么」 +(`CLAUDE.md` §1.9),而它不连任何外部服务。 + +规则本身与它的理由在 `research-wiki/design/0003-public-api-shape.md` 决策八:公共类型与 +接缝签名上一旦出现第三方类型,那个包的 major 就是我们的 major。 + +`TYPE_CHECKING` 分支里的 import 同样算数。那种 import 在运行时不发生,但它会出现在签名 +的类型注解里,下游的类型检查器要解析它——于是那个包照样成了我们对外承诺的一部分。 +""" + +import ast +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "src" / "polyloop" + +#: 这两个模块受本规则约束。其余模块允许用第三方包,由 import-linter 的分层契约管。 +PURE_MODULES = ("types", "ports") + +pytestmark = pytest.mark.unit + + +def _imported_root_packages(source: str) -> set[str]: + """收集一段源码里所有 import 的顶层包名,含 `TYPE_CHECKING` 分支里的。 + + 相对 import(`from . import x`)不产生顶层包名,直接跳过——它指向的一定是本包内部。 + """ + roots: set[str] = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + roots.add(node.module.split(".")[0]) + return roots + + +def _python_files(module_name: str) -> list[Path]: + return sorted((PACKAGE_ROOT / module_name).rglob("*.py")) + + +@pytest.mark.parametrize("module_name", PURE_MODULES) +def test_module_directory_is_scannable(module_name: str) -> None: + """守卫自身的 fail-closed 检查:扫描目标必须真的存在且有 `.py` 文件。 + + 没有这一条,哪天目录被改名或搬走,上面那条断言会扫到一个空列表然后安静地绿—— + 而绿的含义从「没有违规」变成了「没有检查」,两者在输出上分不出来。 + """ + directory = PACKAGE_ROOT / module_name + assert directory.is_dir(), f"扫描目标不存在:{directory}" + assert _python_files(module_name), f"扫描目标里没有任何 .py 文件:{directory}" + + +@pytest.mark.parametrize("module_name", PURE_MODULES) +def test_pure_module_imports_only_stdlib_or_polyloop(module_name: str) -> None: + """`types` 与 `ports` 的每一处 import 都必须落在标准库或 `polyloop` 内。""" + allowed = set(sys.stdlib_module_names) | {"polyloop"} + violations: list[str] = [] + for path in _python_files(module_name): + for root in sorted(_imported_root_packages(path.read_text(encoding="utf-8")) - allowed): + violations.append(f"{path.relative_to(REPO_ROOT)}: {root}") + assert violations == [], f"{module_name} 里出现了第三方 import:{violations}" diff --git a/tests/unit/test_no_business_terms.py b/tests/unit/test_no_business_terms.py new file mode 100644 index 0000000..ca3d0f3 --- /dev/null +++ b/tests/unit/test_no_business_terms.py @@ -0,0 +1,67 @@ +"""硬约束 §1.1 零业务假设:库内不许出现下游的业务词汇。 + +三个下游的领域互不相交(公文审查、超声诊断、agent 自我进化实验),一个业务词进来就等于 +替其中一个项目做了另外两个不需要的假设。这类假设很难删——它会长出配套的字段、分支和测试, +删的时候要一起动。 + +**这个文件只读源码文本,不执行被测代码。** 它仍然属于 unit,因为分层判据是「依赖什么」 +(`CLAUDE.md` §1.9),而它不连任何外部服务。 + +**黑名单挡不住没被列出来的词。** 它拦得住最可能发生的那种——从下游搬代码时把词一起搬 +过来;拦不住一个新造的业务词。真正守住这条的是评审,这里只是把最常见的入口堵上。 +""" + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "src" / "polyloop" + +#: 词来自三个下游各自的领域。加词的时机是「从某个下游搬了一段代码进来」,那时把它领域里 +#: 最扎眼的几个词补进来。 +BANNED_TERMS = ( + # GovDoc:公文审查 + "公文", + "审核点", + "招标", + "投标", + "标书", + "checkpoint_id", + "tender", + # CHSAnalyzer:超声诊断 + "超声", + "切面", + "病灶", + "ultrasound", + # dissect:agent 自我进化实验 + "实验轮次", + "因子", + "预注册", + "benchmark", + "rollout", + "appworld", +) + +pytestmark = pytest.mark.unit + + +def test_source_tree_is_scannable() -> None: + """守卫自身的 fail-closed 检查:包目录必须存在且有 `.py` 文件。 + + 没有这一条,包被改名或搬走之后这条断言会扫到空列表然后安静地绿,而绿的含义从 + 「没有业务词」变成了「没有检查」。 + """ + assert PACKAGE_ROOT.is_dir(), f"扫描目标不存在:{PACKAGE_ROOT}" + assert list(PACKAGE_ROOT.rglob("*.py")), f"扫描目标里没有任何 .py 文件:{PACKAGE_ROOT}" + + +def test_no_business_terms_in_source() -> None: + """库源码(含 docstring 与注释)里不许出现下游业务词汇。""" + violations: list[str] = [] + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + text = path.read_text(encoding="utf-8").casefold() + for term in BANNED_TERMS: + if term.casefold() in text: + violations.append(f"{path.relative_to(REPO_ROOT)}: {term}") + assert violations == [], f"库内出现业务词汇:{violations}" diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py new file mode 100644 index 0000000..9dda813 --- /dev/null +++ b/tests/unit/test_package.py @@ -0,0 +1,58 @@ +"""包基线:版本号双写一致,以及 import 本库不会把网关拉起来。 + +**这个文件读源码文本和 `pyproject.toml`,不是只跑被测代码。** 它仍然属于 unit,因为 +分层判据是「依赖什么」(`CLAUDE.md` §1.9),而它不连任何外部服务。 +""" + +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +import polyloop + +REPO_ROOT = Path(__file__).resolve().parents[2] + +pytestmark = pytest.mark.unit + + +def test_version_matches_pyproject() -> None: + """`__version__` 与 `pyproject.toml` 的 version 必须一致。 + + 两处双写是刻意的(见 `polyloop/__init__.py`),代价就是会漂移,所以用这条断言钉住。 + 漂移的表现是下游报 bug 时说的版本号和实际装的不是一个,而那种错查起来要绕很远。 + """ + declared = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert polyloop.__version__ == declared["project"]["version"] + + +def test_importing_polyloop_does_not_import_polygateway() -> None: + """依赖规则 9:`import polyloop` 之后 `sys.modules` 里不许出现 `polygateway`。 + + 这条写不成 import-linter 契约——它是运行时事实,不是静态图上的边。适配器模块允许 + import 网关(规则 5),所以静态图上那条边合法;这里要禁的是**顶层 import 时就把它 + 拉起来**。一个顺手提供的默认模型客户端会让每个进程在 import 本库时,把网关连同它的 + provider 目录一起加载。 + + 在子进程里跑,因为本进程早就 import 过别的东西了,`sys.modules` 不干净。 + """ + code = "import polyloop, sys; print('polygateway' in sys.modules)" + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + cwd=REPO_ROOT, + ) + assert result.stdout.strip() == "False", result.stdout + + +def test_py_typed_marker_ships_with_the_package() -> None: + """`py.typed` 必须在包根里。 + + 少了它,下游的类型检查器把整个包当成无注解的黑盒——而这个库对下游的承诺大半写在 + 签名里。这个文件是空的,很容易在某次移动目录时丢掉且不报错。 + """ + assert (REPO_ROOT / "src" / "polyloop" / "py.typed").is_file()