diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 902a4854..280aafbf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,3 +59,45 @@ jobs: - name: Run frontend checks run: npm run check + + silicon-local-loop: + name: Silicon local loop (public aidb bridge) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: MatCreator + - name: Checkout pinned aidb integration dependency + uses: actions/checkout@v4 + with: + repository: Zikkying/Ai-ready_Database + # Public bridge contract verified at this immutable integration commit. + # Published on codex/matcreator-ci-bridge; never use latest. + ref: 87148777fc9995a6d2e3bd85571ee8e20ff53181 + path: Ai-ready_Database + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install both public packages + run: | + python -m pip install --upgrade pip + python -m venv Ai-ready_Database/.venv + Ai-ready_Database/.venv/bin/python -m pip install -e ./Ai-ready_Database + python -m pip install -e ./MatCreator pytest + - name: Run isolated loop tests with zero skips + working-directory: MatCreator + env: + AI_READY_DB_ROOT: ${{ github.workspace }}/Ai-ready_Database + AI_READY_DB_PYTHON: ${{ github.workspace }}/Ai-ready_Database/.venv/bin/python + run: python script/check_silicon_loop.py tests --output .workspace/ci/silicon-check.json + - name: Retain failure and test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: silicon-local-loop-results + path: MatCreator/.workspace/ci/ + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6e141944 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# AGENTS.md + +This file provides guidance to Qoder (qoder.com) when working with code in this repository. + +## Project Overview + +MatCreator is an agentic AI platform for computational materials science, built as an agent harness around the Google ADK runtime, a markdown-based skill library, a workspace, and a persistent knowledge graph. It offers CLI, API, and Web interfaces. + +## Commands + +### Environment setup + +```bash +pip install uv +uv venv .venv --python 3.12 # Python 3.12+ required (.python-version pins 3.12) +source .venv/bin/activate +uv pip install -e . # includes git deps (mat-bench, mat-know-base) — needs network +``` + +Frontend (only needed for Web UI development): + +```bash +cd web/vite-frontend +npm ci # Node.js >= 24.14.1 required (.nvmrc pins 24.14.1) +``` + +### Tests + +Backend (pytest; no pytest config file — run against `tests/` directly): + +```bash +python -m pytest tests/ -v # full suite +python -m pytest tests/test_ports_config.py -v # single file +python -m pytest tests/test_ports_config.py -k "name_pattern" -v # single test +``` + +Note: CI (`.github/workflows/test.yml`) runs only a subset of test files plus the frontend check. + +Frontend: + +```bash +cd web/vite-frontend +npm run check # runs node --test + vite build (this is what CI enforces) +npm test # node --test only +npm run build # vite build only +``` + +### Running + +```bash +bash script/start_matcreator.sh # Web UI: starts ADK API (:8000) + FastAPI (:8001) + Vite (:5173) +matcreator chat --workspace . # CLI, Flash mode (direct interaction) +matcreator chat --workspace . --plan # CLI, Plan mode (thinking agent -> execution graph -> execution agent) +``` + +The start script writes logs to `~/.matcreator/logs/{api-server,web-main,vite}.log` and resolves hosts/ports with precedence: environment variable > `~/.matcreator/config.yaml` > built-in default (logic in `src/matcreator/ports.py`). + +### Configuration + +Persistent settings live in `~/.matcreator/config.yaml` (relocatable via `MATCREATOR_HOME` or `MATCREATOR_CONFIG_PATH`): + +```bash +matcreator config set llm.model=openai/qwen3-plus +matcreator config set llm.api_key=your-api-key +matcreator config set llm.base_url=https://api.example.com/v1 +matcreator config show +``` + +Precedence rule: in local mode pre-existing environment variables win over `config.yaml`; in server mode (`MATCREATOR_MODE=server`) `config.yaml` wins. Full reference including multi-model `llm.executor_cards`: `docs/configuration.md`. + +## Architecture + +Request flow (see `docs/architecture_components.md` for details): + +``` +Vite frontend (web/vite-frontend) -> FastAPI middleware (web/main.py) -> ADK API server -> MatCreator agent (src/matcreator) +``` + +### Layers + +- **Frontend** (`web/vite-frontend`): Vite app (Svelte + React mix, Ketcher, vis-network). Owns UI state, SSE consumption, and browser-reload reconnect to managed runs. Entry: `src/main.js`. +- **FastAPI middleware** (`web/main.py`): the application backend. Two roles: (1) ADK protocol proxy (`/run_sse`, `/apps/*`, `/list-apps`); (2) MatCreator-specific APIs (sessions, workspace files, structures, skills, settings, cancellation). In server mode it also acts as a Docker worker control plane (one worker container per user). Managed runs are process-local — execution does not survive a middleware restart. +- **Agent runtime** (`src/matcreator`): hosted on Google ADK. Entry `src/matcreator/agent.py`; CLI/API-server startup in `src/matcreator/scripts/start_agent.py`. + +### Agent internals + +- **Orchestrator** (`src/matcreator/agents/orchestrator/agent.py`): every turn starts with planning; if the planning agent approves an execution graph, control moves to the execution agent, which can return to planning within the same ADK invocation. Flash mode bypasses this loop. +- **Thinking/execution agents** (`src/matcreator/agents/thinking_agent/`, `src/matcreator/agents/execution_agent/`): graph planning vs. step executors running tools/skills/remote jobs. +- **Skills** (`src/matcreator/skills/`): modular markdown capabilities. Each skill is a directory with a `SKILL.md` carrying YAML frontmatter (`name`, `description`, `metadata.tools`, `metadata.dependent_skills`, `metadata.tags`). Skills declare dependencies on each other (e.g. `eos` depends on `ase`, `deepmd`, `bohrium`, ...). +- **Knowledge** (`src/matcreator/knowledge/`): self-improving Know-Do Graph (extraction, review, migration, query) that accumulates experience across executions. Extraction/review frequency is configurable (`MATCREATOR_MEMORIZATION_FREQUENCY`, `MATCREATOR_REVIEW_FREQUENCY`; `0` disables). +- **Control plane** (`src/matcreator/control_plane/`): durable remote-job store/service/monitor (E2B sandboxes), benchmark/evaluation runtime, worker supervisor. +- **Workspace** (`src/matcreator/workspace.py`): sessions and artifacts live under the `--workspace` directory; ADK session state under `~/.matcreator/.adk/session.db`. + +### Source layout conventions + +- Python packages live under `src/matcreator` (`[tool.setuptools.packages.find] where = ["src"]`) — the import root is `src/`, not the repo root. `script/start_matcreator.sh` sets `PYTHONPATH` to `src/` so the checkout, not site-packages, is executed. +- Skill files (`.md`, `.py`, `.yaml` under `skills/`) are shipped via `[tool.setuptools.package-data]` — new skill assets in those formats are packaged automatically. +- Port/host resolution for all services is centralized in `src/matcreator/ports.py`. + +## Testing conventions + +- `tests/conftest.py` maintains `ALL_CONFIG_ENV_VARS`, the canonical list of configuration environment variables. When adding a new config env var to `src/matcreator/ports.py`, add it there so config-cleanup tests clear it automatically. +- Frontend tests live in `web/vite-frontend/test/` and use the Node built-in test runner (`node --test`). + +## Docker + +Multi-stage build (`Dockerfile`): `worker` (API only, :8000), `control-plane` (API + built frontend, :8001), `local` (full dev image incl. Node, :8000/:8001/:5173). Port configuration via `.env` (see `.env.example`; copy to `.env` before `docker compose up`). Deployment docs: `docs/deployment/`. diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md new file mode 100644 index 00000000..cbd3429e --- /dev/null +++ b/CODING_STANDARDS.md @@ -0,0 +1,10 @@ +# Scientific result review + +Review scientific feedback against the current structured tool receipt and retained calculation evidence. Automated receipt checks establish provenance and consistency; they do not establish the scientific accuracy of a narrative. + +- Check that the selected structures, calculation stages, methods, parameters, completion status and source jobs support the requested reuse. Explain material compatibility limits when they affect the answer. +- Keep relaxation and static results distinct. State energy units and basis (for example eV per cell), and do not infer comparative physical meaning solely from a numerical difference between stages. +- Assess accuracy and precision claims from convergence evidence, the model and method, and the intended use. Printed decimal places, a successful job, or a verified archive alone do not justify a claim such as “high precision.” +- Keep retrieval defaults distinct from user requirements. A successful lookup is not permission to submit a calculation, and historical development policy A is not the current final B policy. + +The repeatable checks and evidence navigation are documented in [the silicon local-loop guide](docs/testing/silicon-local-loop.md). diff --git a/docs/aidb_preflight_test_results.json b/docs/aidb_preflight_test_results.json new file mode 100644 index 00000000..a129fa95 --- /dev/null +++ b/docs/aidb_preflight_test_results.json @@ -0,0 +1,108 @@ +{ + "date": "2026-10-07", + "stage": "T1 development A", + "baseline_commit": "d92c7a2973831d5da5214befbc888e8587387eb4", + "baseline": { + "summary": "39 failed, 710 passed, 578 warnings in 41.51s", + "failures": [ + "tests/test_benchmark_config.py::test_benchmark_client_registers_and_persists_missing_development_token", + "tests/test_disabled_skills.py::test_set_disabled_skill_names_updates_graph_nodes", + "tests/test_evaluation_routes.py::test_toggle_skill_graph_node_updates_and_reports_graph_state", + "tests/test_kdg_extractor.py::test_extractor_advances_cursor_for_empty_valid_response", + "tests/test_kdg_extractor.py::test_extractor_migrates_legacy_extracted_session", + "tests/test_kdg_extractor.py::test_extractor_skips_unchanged_session_and_processes_only_delta", + "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_routes_through_protected_reviewer_policy", + "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_scopes_memory_review_to_current_session", + "tests/test_kdg_progressive_retrieval.py::test_graph_disabled_skills_are_hidden_from_discovery_and_reads", + "tests/test_skill_graph_seed.py::test_seed_removes_stale_managed_skill_and_uses_virtual_dependency", + "tests/test_skill_graph_seed.py::test_seed_reuses_disabled_skill_node_and_merges_legacy_duplicate", + "tests/test_step_executor_runner.py::test_step_executor_retries_only_malformed_streamed_tool_arguments", + "tests/test_structure_modeling.py::test_crystal_is_created_through_matcraft_bulk_builder", + "tests/test_structure_modeling.py::test_matcraft_perturbation_is_seeded_and_changes_positions", + "tests/test_structure_modeling.py::test_molecule_can_be_created_or_appended", + "tests/test_structure_modeling.py::test_smiles_molecule_can_be_appended_at_requested_center", + "tests/test_structure_modeling.py::test_supercell_supports_general_integer_matrix", + "tests/test_web_concurrent_sessions.py::test_active_session_transitions_recompute_composer_state", + "tests/test_web_concurrent_sessions.py::test_all_chat_disclosures_share_the_reading_position_controller", + "tests/test_web_concurrent_sessions.py::test_bottom_attachment_and_node_toggle_use_one_scroll_policy", + "tests/test_web_concurrent_sessions.py::test_chat_messages_use_one_model_scheduler_and_stable_regions", + "tests/test_web_concurrent_sessions.py::test_completed_request_handoffs_live_turn_before_durable_refresh", + "tests/test_web_concurrent_sessions.py::test_frontend_tracks_requests_per_session", + "tests/test_web_concurrent_sessions.py::test_image_and_all_timeline_updates_preserve_node_disclosures", + "tests/test_web_concurrent_sessions.py::test_new_session_ids_are_not_limited_to_one_second_resolution", + "tests/test_web_concurrent_sessions.py::test_plain_text_blocks_keep_history_order_and_stable_identity", + "tests/test_web_concurrent_sessions.py::test_remote_job_polling_is_scoped_to_the_active_session", + "tests/test_web_concurrent_sessions.py::test_remote_jobs_are_collapsed_and_keep_lifecycle_status_visible", + "tests/test_web_concurrent_sessions.py::test_reselecting_the_active_session_does_not_rebuild_the_view", + "tests/test_web_concurrent_sessions.py::test_roadmap_fits_after_the_popup_has_a_stable_layout", + "tests/test_web_concurrent_sessions.py::test_running_session_switch_discovers_and_reconnects_managed_run", + "tests/test_web_concurrent_sessions.py::test_session_creation_does_not_start_knowledge_review", + "tests/test_web_concurrent_sessions.py::test_session_switch_loads_page_scoped_graph_after_the_transcript_page", + "tests/test_web_concurrent_sessions.py::test_session_switch_renders_cached_snapshot_immediately", + "tests/test_web_concurrent_sessions.py::test_sse_request_uses_captured_session_context", + "tests/test_web_concurrent_sessions.py::test_startup_keeps_session_api_ready_while_maintenance_runs_in_background", + "tests/test_web_concurrent_sessions.py::test_startup_restores_only_an_accessible_session_owner_tuple", + "tests/test_web_concurrent_sessions.py::test_step_cancellation_identifies_the_active_session_owner", + "tests/test_web_concurrent_sessions.py::test_stop_and_plan_refreshes_preserve_open_node_dialogs" + ] + }, + "final": { + "summary": "39 failed, 743 passed, 585 warnings in 61.57s (0:01:01)", + "failures": [ + "tests/test_benchmark_config.py::test_benchmark_client_registers_and_persists_missing_development_token", + "tests/test_disabled_skills.py::test_set_disabled_skill_names_updates_graph_nodes", + "tests/test_evaluation_routes.py::test_toggle_skill_graph_node_updates_and_reports_graph_state", + "tests/test_kdg_extractor.py::test_extractor_advances_cursor_for_empty_valid_response", + "tests/test_kdg_extractor.py::test_extractor_migrates_legacy_extracted_session", + "tests/test_kdg_extractor.py::test_extractor_skips_unchanged_session_and_processes_only_delta", + "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_routes_through_protected_reviewer_policy", + "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_scopes_memory_review_to_current_session", + "tests/test_kdg_progressive_retrieval.py::test_graph_disabled_skills_are_hidden_from_discovery_and_reads", + "tests/test_skill_graph_seed.py::test_seed_removes_stale_managed_skill_and_uses_virtual_dependency", + "tests/test_skill_graph_seed.py::test_seed_reuses_disabled_skill_node_and_merges_legacy_duplicate", + "tests/test_step_executor_runner.py::test_step_executor_retries_only_malformed_streamed_tool_arguments", + "tests/test_structure_modeling.py::test_crystal_is_created_through_matcraft_bulk_builder", + "tests/test_structure_modeling.py::test_matcraft_perturbation_is_seeded_and_changes_positions", + "tests/test_structure_modeling.py::test_molecule_can_be_created_or_appended", + "tests/test_structure_modeling.py::test_smiles_molecule_can_be_appended_at_requested_center", + "tests/test_structure_modeling.py::test_supercell_supports_general_integer_matrix", + "tests/test_web_concurrent_sessions.py::test_active_session_transitions_recompute_composer_state", + "tests/test_web_concurrent_sessions.py::test_all_chat_disclosures_share_the_reading_position_controller", + "tests/test_web_concurrent_sessions.py::test_bottom_attachment_and_node_toggle_use_one_scroll_policy", + "tests/test_web_concurrent_sessions.py::test_chat_messages_use_one_model_scheduler_and_stable_regions", + "tests/test_web_concurrent_sessions.py::test_completed_request_handoffs_live_turn_before_durable_refresh", + "tests/test_web_concurrent_sessions.py::test_frontend_tracks_requests_per_session", + "tests/test_web_concurrent_sessions.py::test_image_and_all_timeline_updates_preserve_node_disclosures", + "tests/test_web_concurrent_sessions.py::test_new_session_ids_are_not_limited_to_one_second_resolution", + "tests/test_web_concurrent_sessions.py::test_plain_text_blocks_keep_history_order_and_stable_identity", + "tests/test_web_concurrent_sessions.py::test_remote_job_polling_is_scoped_to_the_active_session", + "tests/test_web_concurrent_sessions.py::test_remote_jobs_are_collapsed_and_keep_lifecycle_status_visible", + "tests/test_web_concurrent_sessions.py::test_reselecting_the_active_session_does_not_rebuild_the_view", + "tests/test_web_concurrent_sessions.py::test_roadmap_fits_after_the_popup_has_a_stable_layout", + "tests/test_web_concurrent_sessions.py::test_running_session_switch_discovers_and_reconnects_managed_run", + "tests/test_web_concurrent_sessions.py::test_session_creation_does_not_start_knowledge_review", + "tests/test_web_concurrent_sessions.py::test_session_switch_loads_page_scoped_graph_after_the_transcript_page", + "tests/test_web_concurrent_sessions.py::test_session_switch_renders_cached_snapshot_immediately", + "tests/test_web_concurrent_sessions.py::test_sse_request_uses_captured_session_context", + "tests/test_web_concurrent_sessions.py::test_startup_keeps_session_api_ready_while_maintenance_runs_in_background", + "tests/test_web_concurrent_sessions.py::test_startup_restores_only_an_accessible_session_owner_tuple", + "tests/test_web_concurrent_sessions.py::test_step_cancellation_identifies_the_active_session_owner", + "tests/test_web_concurrent_sessions.py::test_stop_and_plan_refreshes_preserve_open_node_dialogs" + ] + }, + "new_failures": [], + "resolved_baseline_failures": [], + "collection_errors_reproduced_at_baseline": [ + "tests/test_execution_grouping.py", + "tests/test_matcreator_phase_and_skills.py" + ], + "focused_tests": { + "passed": 40, + "failed": 0 + }, + "aidb_bridge_tests": { + "passed": 14, + "failed": 0 + }, + "real_calculation_submitted": false +} diff --git a/docs/aidb_preflight_verification_20261007.md b/docs/aidb_preflight_verification_20261007.md new file mode 100644 index 00000000..4a0dd91b --- /dev/null +++ b/docs/aidb_preflight_verification_20261007.md @@ -0,0 +1,64 @@ +# issue #2 开发期 A:实现与验证记录 + +日期:2026-10-07。主规格:[Ai-ready_Database #1](https://github.com/Zikkying/Ai-ready_Database/issues/1); +任务:[T1 / #2](https://github.com/Zikkying/Ai-ready_Database/issues/2)。实现仓库为 MatCreator。 + +已读取两个仓库的 AGENTS、issue tracker / domain 指引、aidb 术语表、公开 adapter +契约和现有需求记录。沿用已有 hook 接线与公开 Skill adapter;相邻数据库仓库 +没有业务代码改动。原工作区存在的其他改动保留。 + +## 已实现行为 + +- 当前自然请求、内部 action / prior_context / 父任务 goal、明确引用的现有结构 + 提供任务信息;goal 未设置也会提取或澄清。已知、检索默认、未知分开反馈。 +- 主 thinking agent 与 step executor 在受支持的新结构/输入准备及远程工具前 + 实际预检。Skill 名规范化与两种加载器一致;完整报告、候选 ID、审计与诊断 + 随 Skill 或直接准备工具的响应返回。 +- 命中、成功空候选、失败明确区分;开发期 A 失败暂停新增准备/计算。 + 同次调用重复与并发入口共用回执;新请求重新查库,当前材料/方法优先。 +- 纯概念请求不查库;材料不明先澄清。“只准备/只生成”不授权提交;内部步骤 + 不能扩大父任务范围;“继续/重算”保留原授权,明确新提交指令才改变范围。 +- 本票仅做候选发现。候选尚未核验可复用;不归档未完成计算,不新增数据库连接。 + +使用说明见 [aidb_skill_hook.md](aidb_skill_hook.md)。这是首期硅任务的有限上下文 +识别,不是通用自然语言解析或任意自由 shell/Python 入口的约束保证。 + +## 测试 + +| 检查 | 结果 | +| --- | --- | +| MatCreator hook + 真实 Skill 加载器针对性回归 | 40 passed | +| 相邻 aidb adapter/client/server 契约回归 | 14 passed | +| Python compileall 与本次 diff whitespace 检查 | 通过 | +| 完整 `pytest tests/` | 两个既有旧导入错误导致收集失败 | +| 排除两个无法收集文件后的完整运行 | 743 passed / 39 failed;失败集合与基线完全相同,见 [基线对比](aidb_preflight_test_results.json) | + +完整测试的两个收集错误为 `test_execution_grouping.py` 和 +`test_matcreator_phase_and_skills.py` 使用过时的 `agents.MatCreator` 导入。 +已在起始提交 `d92c7a2973831d5da5214befbc888e8587387eb4` 的独立临时导出中 +复现同样错误。对可收集的完整测试也运行了原始基线,对比失败用例集合。 +原始基线为 710 passed / 39 failed;失败涵盖既有 benchmark、知识图谱、旧 +retry_config 断言、结构建模和前端文本断言。未为本票修改这些无关行为。 + +未安装或配置 mypy/pyright,本次执行 Python 编译检查与公开流程测试,未宣称 +静态类型检查已通过。没有前端业务改动,未运行前端构建。 +测试的查询使用真实隔离本地库与公开 CLI/client/server;准备和远程提交工具 +为受控测试边界,命中记录为隔离测试样本。没有提交真实 Bohrium 作业,没有 +真实科学结果归档验收,也没有云端数据库上传。 + +## Standards + +按 code-review 技能,由独立规范审查代理检查起始提交后的变更及后续修正。 +最初发现材料澄清绕过、提取失败缺少持久日志、继续/重算识别与授权继承问题。 +均增加公开工具边界测试并修正;统一继续识别规则以防词表漂移。 +最终复审:无剩余可操作规范问题。 + +## Spec + +由独立规格审查代理对照 issue #1 和 #2 检查。 +最初发现只准备授权扩大、旧硅目标污染新材料、直接准备工具缺少查询反馈, +以及继续请求绕过。均已修正,相关公开流程回归通过。 +最终复审:无剩余 T1 开发期 A 规格问题。 + +最终结果为 Standards 0 / Spec 0 个剩余可操作发现。 +完整计算、自动归档/重查、独立会话复用与最终 B 切换仍属于后续 T2–T5。 diff --git a/docs/aidb_skill_hook.md b/docs/aidb_skill_hook.md new file mode 100644 index 00000000..65c0ab77 --- /dev/null +++ b/docs/aidb_skill_hook.md @@ -0,0 +1,85 @@ +# aidb 计算前预检(最终 B) + +主规格为 [Ai-ready_Database #1](https://github.com/Zikkying/Ai-ready_Database/issues/1), +初始实现为 [T1 / #2](https://github.com/Zikkying/Ai-ready_Database/issues/2), +当前默认行为已按 [T5 / #6](https://github.com/Zikkying/Ai-ready_Database/issues/6) +切换为最终 B;[切换验收记录](silicon_vasp_t5.md)。 + +主 thinking agent 和内部 step executor 共用工具回调。加载 `vasp-pymatgen`、 +硅任务的 `atomic-structure`,以及受支持任务直接调用准备或远程工具时, +先建立当前任务上下文,再通过 ai-ready-db Skill 的公开 adapter 执行本地预检。 +技能名称去除首尾空白并忽略大小写。纯概念请求与训练技能不触发。 + +信息来源为当前自然请求、内部步骤的 action / prior_context / 父任务 goal, +以及请求中明确引用的工作区结构文件。后续“继续 / 改用 / 重算”请求可以补充 +前一用户请求,但当前方法优先。未设置 goal 不会跳过提取。 +材料身份无法确定时先澄清;已确定 Si 时可先发现候选。PBE、bulk、diamond +缺失时只是检索默认值,反馈中的 known / retrieval_defaults / unknown 分别保存。 +准备计算前仍需明确结构模型或提供已有结构。非首期晶相、体系或方法会暂停并说明支持边界。 +这是固定硅样例的有限识别,不是通用自然语言或结构等价判定器。 + +## 配置与公开接口 + +优先使用已安装 `ai-ready-db` Skill 的 `scripts/aidb_loop_adapter.py`。 +也可使用 `AI_READY_DB_ROOT` 指定仓库的标准 Skill 包;禁用 Skill 时不绕过。 +client/server 的根目录、Python、配置、socket 等配置沿用 aidb 自身契约。 +例如本地开发设置 `AI_READY_DB_ROOT` 与 `AI_READY_DB_PYTHON`,隔离测试额外 +设置 `AI_READY_DB_CONFIG` 和 `AI_READY_DB_BRIDGE_SOCK`。 + +不导入 aidb 内部模块、不连接数据库表。adapter 调用公开 preflight,单材料 +min_records=1,无相关材料扩展。每个 CLI 调用等待最多 20 秒,整个 adapter +最多 50 秒;取消或超时终止本次 adapter 客户端,数据库桥接服务仍按自身生命周期运行。 + +## 反馈与阻断 + +Skill 正文正常加载;after_tool_callback 将 `aidb_preflight` 加入实际工具响应, +模型能够读取并向用户反馈;直接调用准备工具时其响应也携带同样证据。反馈同时写入 session_log 和运行日志。 +成功响应读取完整 `preflight-report.json`,保留完整候选 ID、报告、审计位置。 +`found` 是候选命中,适用性与复用由独立的 `reuse` 回执给出。 +`not_found` 是成功查询空候选;`failed` 是真实配置、连接、超时或协议失败。 +任务上下文与原始 adapter 交换记录保存在工作区 `.aidb/requests/`,完整查询 +报告和 audit 保存在 adapter 的 `.aidb/runs/` 下。失败诊断也保留位置。 + +最终 B 中,查询失败先说明数据存在性未知与重复计算风险,再保持准备/提交 +暂停。用户可回复回执给出的 `确认继续 `,明确接受此次失败风险, +仅继续原授权范围。仅准备授权仍不允许提交。拒绝、普通“继续/yes”、未确认、 +旧会话或旧失败事件的确认保持暂停;提取错误与材料澄清不能绕过。 +确认绑定原任务、条件及此次失败,已引用结构文件变化也会使其失效。 +受支持 executor 只能由 runner 继承本轮已确认范围,不能用内部 action 伪造同意。 +查询回执始终为 `failed`,`loop_complete=false`;计算完成仍自动尝试本地归档, +分别报告科学结果和实际保存结果。状态/结果读取工具仍可使用。 +同任务同次 invocation 的重复入口共用回执;独立新请求与条件变化重新查库。 +该确认针对已有失败事件,所以确认轮次沿用该事件而不制造新的查询失败。 +此约束覆盖登记的工具入口,不承诺约束任意自由 shell/Python 入口。 + +## 验证 + +```bash +PYTHONPATH=src .venv/bin/python -m pytest tests/test_aidb_skill_hook.py -q +``` + +测试使用确定性 ADK runner、实际两种 agent 接线、受控准备/提交工具边界, +和相邻 Ai-ready_Database 的真实公开桥接及隔离本地库。命中样本仅用于测试, +不是本任务提交的真实计算。没有相邻 aidb checkout/runtime 时集成用例跳过。 +不提交 Bohrium 作业、不归档未完成计算、不自动上传云端数据库。 + +## T4 verified candidate reuse + +[T4 evidence and rules](silicon_vasp_t4.md) extend the original T1 discovery +behavior. Public preflight now exports the exact candidate IDs and verifies +scientific content before selecting. `found` remains lookup status; +`reuse.status` separately reports `reused`, `recalculate`, `unavailable`, or +`needs_clarification`. The response includes candidate diagnostics, selected +structures and separate eV/cell energies, source metadata and choice reason. + +A compatible selection prevents preparation and new submission, including the +Flash executor entry. Explicit recalculation still queries first and respects +preparation-only authorization. Same-condition duplicates use persistent +archive times; ambiguity or meaningful condition differences require +clarification. Task changes trigger a new lookup. T4's historical A evidence is +retained; the current default is [T5 final B](silicon_vasp_t5.md). No cloud +database upload is performed. + +## 重复验证入口 + +隔离回归、全量失败基线和真实记录只读验收统一使用 [silicon local-loop checks](testing/silicon-local-loop.md)。科学结论由 [review 标准](../CODING_STANDARDS.md)核对;工具校验不代替科学判断。 diff --git a/docs/learning/vasp/MISSION.md b/docs/learning/vasp/MISSION.md new file mode 100644 index 00000000..ed8bc698 --- /dev/null +++ b/docs/learning/vasp/MISSION.md @@ -0,0 +1,17 @@ +# Mission: 理解 PBE 赝势如何进入 Bohrium VASP 作业 + +## Why +用户正在用 MatCreator 完成真实硅计算,已下载 PBE 文件,但尚不清楚它们与 VASP 的联动。学习目标是能判断输入是否齐全、文件应该放在哪里,以及计算实际使用了哪套势。 + +## Success looks like +- 区分 PBE 泛函、PAW 数据集、VASP 程序和 Bohrium 提交工具。 +- 指出本机赝势库、单次作业输入目录、远端运行目录的区别。 +- 能从 Si POTCAR 的 TITEL、ZVAL、ENMAX 读出基本含义。 + +## Constraints +- 从本次 Si 文件入手,用短课和即时反馈练习解释。 +- 实际计算继续由 MatCreator 主导,保留轨迹和作业身份。 +- 本目录是独立教学工作区,避免占用软件仓库根目录的教学状态文件。 + +## Out of scope +- 首课不推导 PAW 方程,不设计新的收敛研究。 diff --git a/docs/learning/vasp/NOTES.md b/docs/learning/vasp/NOTES.md new file mode 100644 index 00000000..23d982ed --- /dev/null +++ b/docs/learning/vasp/NOTES.md @@ -0,0 +1,3 @@ +# 教学笔记 + +用户明确表示不清楚下载的 PBE 如何与 VASP 联动,希望解释文件位置、参与方式及作用。先建立文件流和角色,再介绍最少的物理概念。尚未收到练习答案,不记录为已经掌握。 diff --git a/docs/learning/vasp/RESOURCES.md b/docs/learning/vasp/RESOURCES.md new file mode 100644 index 00000000..516f601d --- /dev/null +++ b/docs/learning/vasp/RESOURCES.md @@ -0,0 +1,30 @@ +# VASP / PBE 资料 + +## Knowledge + +- [VASP Wiki:POTCAR](https://vasp.at/wiki/index.php/POTCAR) + 文件内容、元素顺序、TITEL/ZVAL/ENMAX;解释实际 Si 文件的首选。 +- [VASP Wiki:GGA](https://vasp.at/wiki/index.php/GGA) + 计算泛函的选择及 POTCAR 默认值;用于区分 PBE 方法和 PAW 数据。 +- [VASP Wiki:PAW](https://vasp.at/wiki/index.php/Projector-augmented-wave_formalism) + 平滑波函数与原子附近的重构;进阶解释赝势为何提高效率。 +- [VASP Wiki:Preparing a POTCAR](https://vasp.at/wiki/index.php/Preparing_a_POTCAR) + 从库挑选并组合 POTCAR 的官方操作说明。 +- [VASP Wiki:ENCUT](https://vasp.at/wiki/index.php/ENCUT) + 截断能与收敛;ENMAX 不等于所有体系都已验证的精度。 +- [pymatgen:安装与 POTCAR 配置](https://pymatgen.org/installation.html) + 本机库供输入生成器查找;实际路径仍以当前安装版本源码为准。 +- [本仓库的 Bohrium adapter](../../../src/matcreator/control_plane/providers/bohr_batchjob.py) + `input_path` 到 CLI `--input` 的真实调用边界。此相对链接从本目录指向项目源代码。 +- [本机文件实测](../../../.workspace/silicon-t2/debug-pbe/check.json) + 用户下载的压缩 Si 文件及 pymatgen 解析结果;不包含 POTCAR 正文。 + +## Wisdom (Communities) + +- [VASP 官方论坛](https://www.vasp.at/forum/) + 用于向维护团队确认特定势版本和程序报错;没有代用户发帖。 + +## Gaps + +- 官方知识和文件校验不替代本次真实 VASP 成功及收敛验证。 +- 镜像目录名不能证明其内部程序路径;以实际作业启动记录为准。 diff --git a/docs/learning/vasp/assets/course.css b/docs/learning/vasp/assets/course.css new file mode 100644 index 00000000..b73f6c9f --- /dev/null +++ b/docs/learning/vasp/assets/course.css @@ -0,0 +1,2 @@ +:root{color-scheme:light;--ink:#17313c;--muted:#536771;--paper:#faf8f2;--line:#d9dfda;--accent:#08786b;--pale:#e8f3ef} +*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:17px/1.8 system-ui,"Microsoft YaHei",sans-serif}main{max-width:940px;margin:auto;padding:46px 30px 72px}header{border-bottom:2px solid var(--ink);padding-bottom:26px}.eyebrow{font-size:12px;letter-spacing:.16em;color:var(--accent);font-weight:700}h1{font-size:clamp(28px,4vw,42px);line-height:1.35;margin:12px 0}h2{font-size:23px;margin:34px 0 12px}h3{font-size:18px;margin:0 0 8px}p{margin:12px 0}a{color:var(--accent);text-underline-offset:4px}code{font-size:.88em;background:#edf0e8;padding:2px 5px;border-radius:4px;overflow-wrap:anywhere}pre{overflow:auto;padding:16px;background:#edf0e8;font-size:14px;line-height:1.65;border-left:3px solid var(--accent)}pre code{padding:0}.lead{font-size:20px;max-width:770px}.note{border-left:3px solid var(--accent);background:var(--pale);padding:14px 18px}.flow{display:grid;grid-template-columns:1fr auto 1fr auto 1fr;gap:12px;align-items:center;margin:22px 0}.card{border:1px solid var(--line);background:white;padding:18px;min-height:175px;border-radius:8px}.arrow{font-size:28px;color:var(--accent)}.small{font-size:14px;color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:15px}td,th{text-align:left;padding:12px;border-bottom:1px solid var(--line);vertical-align:top}th{background:#edf0e8}button{font:inherit;color:var(--ink);border:1px solid #9aaca5;background:white;border-radius:6px;padding:9px 15px;cursor:pointer}button:hover,button:focus-visible{background:var(--pale);outline:2px solid var(--accent);outline-offset:2px}button[aria-pressed=true]{background:var(--accent);color:white}.quiz{padding:20px;border:1px solid var(--line);margin:16px 0;background:white;border-radius:8px}.choices{display:flex;gap:10px;flex-wrap:wrap}.feedback{min-height:2.2em;color:var(--accent);font-weight:600}footer{border-top:1px solid var(--line);margin-top:32px;padding-top:20px;font-size:14px;color:var(--muted)}@media(max-width:700px){main{padding:25px 18px}.flow{grid-template-columns:1fr}.arrow{text-align:center;transform:rotate(90deg)}.card{min-height:0}table{font-size:13px}td,th{padding:8px}}@media print{body{background:white;font-size:11pt}main{padding:0;max-width:none}h1{font-size:24pt}a{color:inherit}.card,.quiz{break-inside:avoid}.flow{grid-template-columns:1fr auto 1fr auto 1fr}.choices button{font-size:10pt;padding:5px}footer{font-size:9pt}} diff --git a/docs/learning/vasp/assets/quiz.js b/docs/learning/vasp/assets/quiz.js new file mode 100644 index 00000000..dc8f4e2c --- /dev/null +++ b/docs/learning/vasp/assets/quiz.js @@ -0,0 +1,9 @@ +document.querySelectorAll('.quiz').forEach(quiz => { + quiz.querySelectorAll('button').forEach(button => { + button.setAttribute('aria-pressed', 'false'); + button.addEventListener('click', () => { + quiz.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b === button))); + quiz.querySelector('.feedback').textContent = button.dataset.correct === 'true' ? quiz.dataset.success : quiz.dataset.retry; + }); + }); +}); diff --git a/docs/learning/vasp/lessons/0001-pbe-potcar-bohrium.html b/docs/learning/vasp/lessons/0001-pbe-potcar-bohrium.html new file mode 100644 index 00000000..f6029f11 --- /dev/null +++ b/docs/learning/vasp/lessons/0001-pbe-potcar-bohrium.html @@ -0,0 +1,13 @@ +PBE 文件怎样进入 Bohrium VASP
+
MATCREATOR · VASP 首课 · 约 5 分钟

下载的 PBE,怎样进入一次 VASP 计算?

对这次纯硅计算,需要从本机库取出 Si 的数据,形成一个未压缩的 POTCAR,随作业输入上传。VASP 在远端作业目录里读取它。

+

先认清四个角色

角色做什么
PBE 泛函一种计算电子交换与关联能的近似方法。它由 VASP 实现,不是下载后运行的程序。
你下载的 PAW_PBE 数据每种元素的一套原子数据,用 PBE 生成;被选入 POTCAR,供 VASP 的 PAW 计算使用。
VASP / vasp_std读取结构、参数和原子数据,迭代计算电子态、能量与力的程序。
bohr把输入交给 Bohrium 的机器和镜像,并管理作业;它本身不执行电子结构求解。

依据:VASP GGA、POTCAR;bohr 的角色来自本仓库 Batch Job adapter 与安装版本命令帮助。

+

三个位置,一次文件传递

① 本机原始库

9f9b5b-main/
psudopotential/
paw_pbe/Si/POTCAR.Z

保留原件。MatCreator 解压并验证所需的 Si 文件。

② 本机作业目录

INCAR
POSCAR
POTCAR
KPOINTS · run.sh

POTCAR 是完整数据文件;不是写着库路径的说明。

③ Bohrium 作业目录

上传的输入文件
+ 镜像中的 vasp_std
+ 分配的 CPU

启动脚本在这里运行 VASP,并生成结果。

镜像存程序,作业输入带数据。采用本次上传方案,整个 PBE 库可以留在本机,不必安装进 Bohrium 镜像。远端 VASP 需要的是当前作业的 POTCAR,而不是本机 ~/projects/... 路径。已有镜像也可能自带库,但本次没有这方面的证据。

文件读取要求见 VASP Input;上传及解包行为见项目研究报告中的 bohr 2.6.100 与 adapter 证据。

+

本机 PMG_VASP_PSP_DIR 有什么用?

它告诉 pymatgen 输入生成器去哪里找库。当前版本默认期望如下布局:

pseudopotentials/             ← PMG_VASP_PSP_DIR 指向这里
+└── POT_GGA_PAW_PBE/
+    └── Si/
+        └── POTCAR           ← 解压后的完整文件

你下载的文件夹叫 paw_pbe,里面是 POTCAR.Z。所以资源已经存在,但目录约定、压缩格式和配置仍需接好。MatCreator 会在工作区暂存所需文件;原库保持原样。

准备完成后,pymatgen 把选中的数据写进单次作业目录的 POTCAR。VASP 的常规启动直接读这个文件,不依赖 pymatgen 的配置变量。依据:pymatgen 配置文档与当前安装版本 PotcarSingle.from_symbol_and_functional 的源码。

+

它在物理上起什么作用?

原子核附近的电子波函数变化很快,直接用平面波描述会很昂贵。PAW 用较平滑的辅助波函数计算,并利用原子数据在核附近重构所需信息;VASP 的实现采用冻结芯近似。POTCAR 提供相关原子数据,帮助建立计算中的原子描述。PBE 则决定交换与关联能采用哪种近似。官方 PAW 说明

因此,换 POTCAR 会影响价电子选择、所需平面波基组和能量基准;比较能量或接续静态计算时,要记录并保持势的一致性。它不会把体系能量预先写死:晶体的能量仍需 VASP 根据结构和参数实际求解。

+

读懂你这份 Si 文件

本机实测含义
PAW_PBE Si 05Jan2001Si 的 PBE PAW 数据集及生成日期。日期不等于 VASP 程序版本。
ZVAL = 4每个 Si 原子的价电子数。中性 Si₂ 晶胞有 8 个价电子;不是每个 Si 只有 4 个总电子。
ENMAX = 245.345 eV这份势推荐的平面波截断能。实际 INCAR 的 ENCUT 可以更高,精度仍需按任务验证。

以上数值来自本机文件解压后的 pymatgen 实测,is_valid=True。这证明数据可识别,不证明某次计算已收敛。字段定义见 POTCAR 和 ENCUT。

+

用三个判断把文件流记牢

1. 本次方案中,VASP 运行时从哪里读取 POTCAR?

2. PBE 泛函主要描述哪一项?

3. 如果 POSCAR 的元素顺序是 Si O,POTCAR 应怎么拼?

+
下次复习时,先不看图,试着说出“三个目录各放什么”。
速查:VASP 输入与 PBE 文件卡片。首选延伸阅读:官方 Preparing a POTCAR。
任何不清楚的地方都可以继续问我;练习接触不等于已经掌握,我们可根据你的回答调整下一课。
+
diff --git a/docs/learning/vasp/reference/vasp-inputs.html b/docs/learning/vasp/reference/vasp-inputs.html new file mode 100644 index 00000000..4599f9e0 --- /dev/null +++ b/docs/learning/vasp/reference/vasp-inputs.html @@ -0,0 +1,7 @@ +VASP 输入与 PBE 速查
可打印参考卡片

一次 VASP 作业,需要哪些输入?

文件 / 设置职责本次要点
POSCAR晶格、元素、数量与坐标金刚石结构 Si
POTCAR按元素顺序组成的 PAW / 赝势数据只需一份 Si 数据块;两个 Si 原子也不是拼两份
INCAR计算方法、精度与优化参数PBE 泛函可通过 GGA=PE 明确选择;不改 POTCAR 的 LEXCH
KPOINTS / KSPACING倒空间采样采用 KSPACING 的输入可不提供 KPOINTS
run.sh初始化程序环境、启动 MPI / VASP属于运行方式,不能替代 POTCAR
PMG_VASP_PSP_DIR让 pymatgen 找到本机势库指库根目录;不是单次 POTCAR 文件,也不会让远端自动访问本机路径

记住这条路径

原始库:paw_pbe/Si/POTCAR.Z
+  ↓ 解压、校验、适配当前 pymatgen 目录
+工作库:pseudopotentials/POT_GGA_PAW_PBE/Si/POTCAR
+  ↓ MPRelaxSet / MPStaticSet 生成输入
+作业:INCAR + POSCAR + POTCAR + KPOINTS(按需)+ run.sh
+  ↓ MatCreator tracked submit → bohr --input
+远端:在作业目录运行镜像中的 VASP

三个字段

TITEL:势身份。ZVAL:每原子价电子数。ENMAX:势建议的截断能,INCAR 的 ENCUT 可覆盖它。

更换势后不能直接混用不同势的总能量基准。静态计算应沿用松弛步骤的势,保留 TITEL 与 SHA256。POTCAR 原始数据不应随意改写。

diff --git a/docs/pbe_bohrium_research.md b/docs/pbe_bohrium_research.md new file mode 100644 index 00000000..1cef9a84 --- /dev/null +++ b/docs/pbe_bohrium_research.md @@ -0,0 +1,84 @@ +# 本机 PBE PAW 数据与 Bohrium VASP 联动 + +核验日期:2026-10-07。资料来自 VASP / pymatgen 官方文档、已安装的 bohr 命令帮助及 MatCreator 代码。本报告解释输入联动;云端 VASP 启动和真实计算是否成功,以会话工具轨迹和作业结果为准。 + +## 当前已确认的本机资源 + +本机检查报告 [check.json](../.workspace/silicon-t2/debug-pbe/check.json) 记录:`/home/shik-mechrevo-wsl/projects/9f9b5b-main/psudopotential/paw_pbe/Si/POTCAR.Z` 是 UNIX compress 数据,`gzip -cd` 可以解压。真实 `PotcarSingle` 解析结果为 Si / PBE,`TITEL = PAW_PBE Si 05Jan2001`,`ZVAL = 4`,`ENMAX = 245.345 eV`,具有完整的数据集结束标记,`is_valid = True`。解压字节的 SHA256 为 `52dbe99da884e0191b2d348dfcc281aaba36e2eacd3019a49d93cf07d84da86b`。这支持文件身份与解析完整性,不证明下载来源授权,也不把它称为最新推荐版本。[本机检查证据](../.workspace/silicon-t2/debug-pbe/check.json) + +此前不是完全没有 Si 赝势:资源放在另一个项目,MatCreator 的 pymatgen 配置没有指向可读取的目录;原始 `paw_pbe/Si/POTCAR.Z` 与当前 pymatgen 所期待的目录、文件命名还不同。把原始目录路径直接赋给 `PMG_VASP_PSP_DIR` 不一定解决问题。官方方法先通过 `pmg config -p <原始库> <转换后的库>` 重排数据,再设置 `PMG_VASP_PSP_DIR` 为转换后根目录。本次可以只准备 Si,具体路径以 MatCreator 留存的准备记录为准。[pymatgen POTCAR setup](https://pymatgen.org/installation.html#potcar-setup) + +## PBE 到底是什么 + +PBE(Perdew–Burke–Ernzerhof)是一种描述电子交换与关联能的近似方法,属于 GGA。VASP 本身实现这种计算方法;`INCAR` 中的 `GGA = PE` 选择 PBE。如果没有其它交换关联方法设置,VASP 会依据 POTCAR 的 `LEXCH` 选择默认方法。[VASP GGA](https://vasp.at/wiki/GGA) + +你下载的“PBE 库”是用 PBE 构建的各元素 PAW 原子数据。它不是 PBE 算法插件,也不是硅晶体的计算结果。PAW 用平滑的辅助波函数减少平面波计算量,再在原子周围恢复所需的全电子信息;VASP 的实现采用冻结芯电子近似。这里的数据提供原子参考态、投影函数等信息,让 VASP 在求解晶体电子状态时正确处理核附近的贡献。[VASP PAW formalism](https://vasp.at/wiki/index.php/Projector-augmented-wave_formalism) + +对于你这个 Si 数据,`TITEL` 识别数据类型、元素和生成日期;`ZVAL = 4` 表示每个 Si 原子显式处理四个价电子;`ENMAX` 是该数据推荐的平面波截断能,实际 `ENCUT` 可以更高,仍须依所需精度判断收敛。不要手改 POTCAR 内部数值或 `LEXCH` 来“切换方法”。[VASP POTCAR](https://vasp.at/wiki/POTCAR) + +## 从本地库到云端计算 + +```text +WSL 中保存的 PBE PAW 库 + │ pymatgen 读取本次结构需要的元素数据 + ▼ +本次作业输入目录:INCAR、POSCAR、POTCAR、KPOINTS + │ MatCreator tracked submit → bohr --input 打包上传 + ▼ +Bohrium 作业工作目录中的同名输入文件 + │ 容器镜像提供 VASP 程序和运行环境 + ▼ +VASP 读取输入 → 计算能量/力 → 输出 OUTCAR、vasprun.xml 等 +``` + +这是根据官方输入定义和本仓库提交实现得出的联动流程:镜像负责程序,作业目录负责本次数据。因此,使用输入上传方式时,无需把整套 PBE 库放入镜像;本次 Si 的 POTCAR 随输入送到作业工作目录即可。WSL 的库路径不会自动变成远端容器路径。[VASP input](https://vasp.at/wiki/index.php/Input)、[MatCreator 提交适配器](../src/matcreator/control_plane/providers/bohr_batchjob.py)、[本仓库 Bohrium 技能](../src/matcreator/skills/bohrium/SKILL.md) + +已实际运行 `bohr batchjob submit --help`:安装版本的 `--input` 说明是本地文件或目录打包并上传;`--image` 指容器镜像,`--command` 指远端执行命令,`--dry-run` 只校验不执行。适配器把输入目录传给 `--input`,先做同参数 dry-run,再提交。库目录与 VASP 可执行文件路径互相独立;镜像内到底如何调用 VASP / MPI,应通过该镜像的启动信息核验,不能仅凭镜像名字推断。[MatCreator 提交适配器](../src/matcreator/control_plane/providers/bohr_batchjob.py)、[VASP Batch Job 参考](../src/matcreator/skills/vasp-pymatgen/references/bohr-batchjob.md) + +## 四个输入如何协作 + +| 文件 | 告诉 VASP 什么 | 硅任务中的含义 | +|---|---|---| +| POSCAR | 晶胞、元素数量、原子位置 | 这次研究哪一个硅结构 | +| INCAR | 计算任务、电子求解与精度参数 | 结构弛豫还是静态能量、PBE、收敛标准等 | +| POTCAR | 每种元素所用 PAW 原子数据 | 本次只有 Si,所以只含一份 Si 数据集 | +| KPOINTS | 周期体系布里渊区的采样 | 电子状态在倒空间如何采样;也可以用 INCAR 的 KSPACING 自动生成 | + +这些文件相互配合才构成完整的标准任务;KSPACING 的自动采样只在没有 KPOINTS 时使用。[VASP input](https://vasp.at/wiki/index.php/Input)、[VASP KSPACING](https://vasp.at/wiki/KSPACING) + +POTCAR 按 POSCAR 中的元素类型顺序排列,按元素类型一次拼接,不按每个原子重复。比如纯 Si 有两个原子也只需一份 Si 数据;含 Si、O 的结构若 POSCAR 元素顺序是 Si O,则 POTCAR 是 Si 数据接 O 数据。选错顺序会使结构与原子数据对应错误。[VASP Preparing a POTCAR](https://vasp.at/wiki/Preparing_a_POTCAR) + +可以把这四份文件理解为“地点与原子布局、计算要求、元素资料、采样方案”;VASP 是实际计算者,bohr 是把工作送到 Bohrium 并管理作业的入口,MatCreator 负责准备、提交、检查与留存。 + +## 复现与留存 + +结果应留存实际 POTCAR 的 TITEL 和字节哈希、INCAR、POSCAR、采样、镜像与启动命令,以及 MatCreator 的会话、工具轨迹和 durable job ID / provider batchjob ID。更换 POTCAR 版本可能改变结果,比较能量时要确认输入方法一致;当前输入校验工具会绑定提交时的哈希。[VASP POTCAR](https://vasp.at/wiki/POTCAR)、[本仓库结果验证工具](../src/matcreator/tools/silicon_vasp.py) + +本报告与提交记录只记录身份和核验信息,不把整套库放入代码仓库。pymatgen 官方也明确不能随软件分发 VASP 赝势。文件可解析与用户的授权来源是两项不同事实。[pymatgen POTCAR setup](https://pymatgen.org/installation.html#potcar-setup) + +后续仍由 MatCreator 在已有计算授权下执行硅工作流;输入准备成功、CLI 提交成功、科学计算收敛是三个不同的检查点。这里的库核验并不等于 issue #3 已通过真实计算验收。 + +## 实际输入准备结果 + +MatCreator 已在原会话 `silicon-t2-20261007-env2` 解压并核验所需 Si 文件,暂存于 +`.workspace/silicon-t2/pseudopotentials/POT_GGA_PAW_PBE/Si/POTCAR`,随后用 +`MPRelaxSet` 生成 `vasp-relax-Si/` 的 INCAR、POSCAR、POTCAR、KPOINTS 和 run.sh。 +作业 POTCAR 与原文件解压结果的 SHA256 相同。实际 INCAR 的 ENCUT 为 520 eV, +与 POTCAR 的 ENMAX 245.345 eV 是两个不同数值:前者是本次输入选择,后者是势的建议值。 +这项输入准备已解决此前 `PmgVaspPspDirError`,但不能据此宣称云端启动或收敛成功。 +证据:[MatCreator 输入准备报告](../.workspace/silicon-t2/pbe-input-preparation-report.json)、 +[势准备证据](../.workspace/silicon-t2/pseudopotential-evidence.json)。 + +配套教学:[互动首课](learning/vasp/lessons/0001-pbe-potcar-bohrium.html)与 +[输入速查卡片](learning/vasp/reference/vasp-inputs.html)。 + +## 真实运行核验 + +原会话中的 Si 松弛与静态总能均已完成科学验证:松弛 durable job +`56072cf15fe94e639d7b71f563ed320a` / batchjob +`5ed5945230884cf88f30475c1afed598`,能量 -10.84124445 eV;静态 durable job +`c266a691a9874ab9ad097bfd09dca9b6` / batchjob +`6ce96bb830764a01976fec7ef9d16a99`,能量 -10.84601452 eV(均为 Si2 晶胞总能)。 +实际输出证明该私有镜像可以运行 VASP 6.3.0,所暂存的 Si POTCAR 确实参与两步计算。 +静态结构来自成功松弛结构,势哈希相同。完整证据与测试见 +[T2 验收记录](silicon_vasp_t2.md)。本研究不宣称数据库归档或完整本地闭环已完成。 diff --git a/docs/silicon_vasp_t2.md b/docs/silicon_vasp_t2.md new file mode 100644 index 00000000..0b893344 --- /dev/null +++ b/docs/silicon_vasp_t2.md @@ -0,0 +1,112 @@ +# Silicon VASP T2: tracked collection and scientific completion + +Scope: [Ai-ready_Database #3](https://github.com/Zikkying/Ai-ready_Database/issues/3), +with [#1](https://github.com/Zikkying/Ai-ready_Database/issues/1) as the main spec. +T1 (#2) is closed. Development A remains in force: a failed aidb lookup pauses +new preparation and submission. T2 does not implement local archive, reuse or B. + +MatCreator owns the calculation. The existing atomic-structure / vasp-pymatgen +skills choose and generate the structure and pymatgen input sets. Bohrium +discovery, tracked submission, monitoring, reconnect and deduplicated collection +continue to use the existing remote-job tools. No new submission platform or +VASP parameter framework is introduced. + +For fixed diamond bulk Si / ordinary PBE, the VASP skill directs both agents to +`collect_silicon_vasp_result`. The tool is registered on the thinking agent and +the isolated step executor. It checks session ownership, respects user controls, +and collects only a platform-successful Batch Job. Running, failed, unknown and +unsupported jobs return a needs-replanning receipt without submitting anything. +Already-collected jobs reuse their durable artifact paths after reconnect. + +The parser requires complete XML, original and retained INCAR/POSCAR, CONTCAR, +OUTCAR, OSZICAR and original POTCAR provenance. Tracked Batch Job submission +captures SHA-256 checksums for materialized VASP inputs in its durable +specification before invoking the provider. Collection verifies local/retained +inputs against that snapshot, and matches submitted POTCAR TITEL metadata with +the actual XML potential identity. Old jobs without submission evidence remain +unverified. Replaying a submit does not overwrite existing provenance. +It checks electronic convergence, +ionic convergence and the OUTCAR accuracy/termination evidence for relaxation, +separate static-step settings, PBE pseudopotential/method evidence, diamond Si +symmetry, finite energy, and agreement between XML and structure files. These +are fixed-example checks, not a general scientific compatibility scorer. + +Static total energy additionally requires the existing relaxation job ID in the +same session. The source output is parsed again; cached success JSON alone does +not establish success. The static input must match that relaxed structure and +use the same pseudopotentials. Changed, incomplete or unverified sources are +rejected. The two steps retain their separate identities and energies. + +`silicon-result.json` retains success/invalid status, tracked/provider identities, +actual structure, energy in eV, INCAR and effective parameters, pseudopotential +identity/hash, available KPOINTS, submission image/machine/settings, raw artifact +paths/checksums and relaxation source. Original outputs are preserved on failure. +`archived` is always false. T3 must only consume verified successful handoffs; +T2 never claims a record was saved or a local loop was completed. + +## Validation and real-run evidence + +Final related regression run: **286 passed**, covering ADK preflight callbacks, +workspace subprocesses, scientific collection, tracked jobs and the Bohrium +Batch Job adapter. Synthetic XML tests use the actual pymatgen parser; agent +cases use a real isolated aidb bridge/database. They are not real calculations. + +The final full suite had **786 passed, 39 failed and 2 collection errors**. +Failure names exactly match the initial baseline snapshot. Logs are retained in +`.workspace/silicon-t2/debug-pbe/final-full-fixed.log` and +`.workspace/silicon-t2/baseline-tests.log`. Unrelated legacy failures were not +changed. Standards and Spec reviews found zero new implementation findings. + +The real run remains in session `silicon-t2-20261007-env2`. A real preflight +succeeded with empty Si/PBE/bulk candidates; no database records were deleted. +The user-supplied potential and the account-private image resolved the original +resource/configuration blockers. MatCreator owns input generation and submission; +Codex only diagnosed the runtime, repaired platform validation and resumed the +existing session. See [runtime diagnosis](vasp_runtime_diagnosis.md) and +[PBE research](pbe_bohrium_research.md). + +Verified real relaxation: + +- Durable job ID: `56072cf15fe94e639d7b71f563ed320a`. +- Provider batchjob ID: `5ed5945230884cf88f30475c1afed598`. +- VASP image: `registry.dp.tech/dptech/prod-28882/vasp:6.3.0`. +- POTCAR: `PAW_PBE Si 05Jan2001`, SHA256 + `52dbe99da884e0191b2d348dfcc281aaba36e2eacd3019a49d93cf07d84da86b`. +- Electronic and ionic convergence, three ionic steps, completed OUTCAR. +- Total energy: **-10.84124445 eV per Si2 cell**. +- Verified report: `silicon-t2-results/relax-collected/silicon-result.json`. + +All paths above are relative to `.workspace/silicon-t2/`. Actual source outputs, +submission input hashes, query reports/audits, `events-pbe.jsonl` and trajectories +are retained. The relaxation was never resubmitted. MatCreator completed the +static continuation using the verified CONTCAR and the same potential. +No archive or full local-loop completion is claimed. Issue #3 remains open; +both steps now have real scientific completion evidence. + +Verified real static continuation: + +- Durable job ID: `c266a691a9874ab9ad097bfd09dca9b6`. +- Provider batchjob ID: `6ce96bb830764a01976fec7ef9d16a99`. +- Total energy: **-10.84601452 eV per Si2 cell**. +- Electronic convergence, one static step and completed OUTCAR. +- `NSW=0`; effective `IBRION=-1`; actual ENCUT 520 eV. +- Static input matches the verified relaxed structure; unchanged POTCAR SHA256. +- Verified report: `silicon-t2-results/static-collected/silicon-result.json`, + with the relaxation IDs and energy retained in `relaxation_source`. + +Both authorized steps now have real scientific completion evidence. No third +job, probe computation, archive or cloud database upload was performed. The +first collection round produced the successful report but ended with ADK +`StaleSessionError`; a subsequent child collection reported a workspace-path +error. A public-tool replay in the original workspace returned success from +both original VASP output sets without altering inputs or outputs. These +runner incidents and recovery logs remain in `debug-pbe/`; this does not claim +a general fix for ADK concurrent session updates. + +T2's real-computation acceptance is evidenced. T3 archive/requery and the final +A-to-B switch in the main specification remain separate work. Issue #3 was not +closed by this session. + +MatCreator's final report is +`.workspace/silicon-t2/silicon-t2-results/silicon-t2-final-report.json`. +Its listed output and scientific-report checksums match the retained files. diff --git a/docs/silicon_vasp_t3.md b/docs/silicon_vasp_t3.md new file mode 100644 index 00000000..51148d5e --- /dev/null +++ b/docs/silicon_vasp_t3.md @@ -0,0 +1,108 @@ +# Silicon VASP T3: automatic local archive and content verification + +Scope: [Ai-ready_Database #4](https://github.com/Zikkying/Ai-ready_Database/issues/4), +using [#1](https://github.com/Zikkying/Ai-ready_Database/issues/1) as the main spec. +Development A remains active. Natural candidate reuse (T4) and final B are +separate work. This implementation submits no calculation and uploads nothing +to a cloud database. No database internals are imported by MatCreator. + +## T2 gate and workflow + +Before implementation, both existing T2 jobs were verified against the durable +job store, immutable submitted-input checksums, complete original VASP outputs +and the T2 final report. Electronic/ionic completion and the static-to-relaxed +structure/potential link passed. Original report/output checksums still match +after T3. Issue #3 remains open; its real successful artifacts, rather than its +issue state alone, establish this prerequisite. See [T2 evidence](silicon_vasp_t2.md). + +The registered main-agent and step-executor after-tool callback automatically +archives successful `collect_silicon_vasp_result` responses. The public +`archive_silicon_vasp_result` tool retries storage for an existing job. Both +revalidate raw outputs and tracked provenance, then use the existing aidb Skill +adapter, client/server, query, archive-and-requery and JSONL export interfaces. +Failed/running/unknown/unconverged calculations retain outputs and do not write +successful records. Scientific success and storage verification have separate +receipts; `silicon-result.json` remains the unchanged scientific handoff. + +Records contain final structure, cell total energy (eV), actual INCAR and +effective conditions, successful completion evidence, tracked/provider IDs, +session, submission settings, source paths and hashes. Additional properties +are not invented. Static metadata links the verified parent record and its +original relaxation job; the two energies remain separate. + +Each attempt queries and exports first. Matching job identities are compared +before reuse; differing content is reported without overwriting. A new result +uses archive-and-requery, verifies the returned ID and compares exported +structure, energy/unit, conditions, completion and provenance. Per-job file +locks serialize duplicate notifications. A timed-out write stays unverified; +the next attempt queries durable state before another write. Adapter failures, +receipts and exported content remain under `.aidb/archives//`. Only a +verified readback is reported as archived. A UTC `archived_at` value persists in +record metadata and is preserved on duplicate processing. + +## Real acceptance, 2026-10-07 + +MatCreator resumed the original session `silicon-t2-20261007-env2` and called +the public archive tool for each original collected job, relaxation first. +Its tool events, graph/trajectories and final report remain in the original +workspace. Both real archive receipts passed ID requery and content comparison. + +| Step | Tracked job ID | Local record ID | Cell energy (eV) | Archive time (UTC) | +| --- | --- | --- | ---: | --- | +| Relaxation | `56072cf15fe94e639d7b71f563ed320a` | `694772dca0777de504b25ba70366fc3d` | -10.84124445 | 2026-10-07 13:30:20.838212 | +| Static | `c266a691a9874ab9ad097bfd09dca9b6` | `1a1e16e62710d4bec6721ad350ee1f05` | -10.84601452 | 2026-10-07 13:30:46.863940 | + +A separate process subsequently queried/exported these same records through +the public adapter. Its independent checks matched CONTCAR, INCAR, the known +T2 energy values and units, completion, parent record ID and every retained +output hash. This proves persistent round trips, not T4 natural-language reuse. + +Evidence paths relative to `.workspace/silicon-t2/`: + +- `t3-t2-verification.json`: prerequisite reparse and hash verification. +- `silicon-t2-results/silicon-t3-final-report.json`: MatCreator's real report, + with both archive IDs and complete receipt/query/export/audit paths. +- `events-pbe.jsonl`, `t3-runner.log`, `trajectories/`: original-session execution. +- `t3-independent-query.json`, `t3-independent-export/`, + `t3-independent-verification.json`: independent persistent readback. + +## Regression and limitations + +Nine new full-tool/callback tests use synthetic completed VASP outputs and real +isolated aidb databases/public bridges. They cover both registered ADK agents, +automatic saving, duplicates, static parent links, invalid scientific outputs, +lookup failure/recovery, uncertain write recovery and changed readback units. +They are regression evidence, not real calculation evidence. + +Related regression: **356 passed**. Full suite: **795 passed, 39 failed, +2 collection errors**; the exact failure/error names match the T2 baseline +(`debug-pbe/final-full-fixed.log`). Logs: `t3-targeted-tests.log` and +`t3-full-tests.log`. Changed Python modules compile successfully. No repository +typechecking command is configured. No aidb code/schema change was needed: +its public record metadata/export contract retained the required content. + +One regression corruption call initially omitted its isolated config and wrote +synthetic record `e506b6d0b06ebb6b3a866195dec9a4b8` to the default local database. +The test now supplies the config explicitly. That exact record was corrected +through public archive-local to `rejected_records`, `status=invalid`, +`test_only=true`, `real_computation=false`. It was not counted as real evidence; +no user records were deleted. Recovery payload: `t3-accidental-fixture.json`. + +## Standards + +Zero findings in `c0169fef352cc8110e701faf0eca65b92ffbaac6...a1e7499`. +Source layout, skill metadata and testing conventions conform to AGENTS.md. +The archive/recovery/readback workflow is cohesive; no material baseline smell +was found. Pre-existing uncommitted instructions were excluded from review. + +## Spec + +Pass, zero actionable findings for #4. The review verified automatic registered +callbacks, raw scientific revalidation, public interfaces, content comparison, +parent linkage, timestamps, duplicate/timeout recovery and retained errors. +The real MatCreator report and independent public export support acceptance. +Synthetic regression fixtures are clearly separated from real Bohrium evidence. +T4 and B remain outside this task as explicitly requested. + +Review total: Standards 0, Spec 0; neither axis has an outstanding issue. +#4 and #3 have not been closed or commented on by this session. diff --git a/docs/silicon_vasp_t4.md b/docs/silicon_vasp_t4.md new file mode 100644 index 00000000..7f6a159d --- /dev/null +++ b/docs/silicon_vasp_t4.md @@ -0,0 +1,126 @@ +# Silicon T4: verified reuse in a new MatCreator session + +Scope: [Ai-ready_Database #5](https://github.com/Zikkying/Ai-ready_Database/issues/5), +main specification [#1](https://github.com/Zikkying/Ai-ready_Database/issues/1). +Development A remains active; final B belongs to T5. No new real computation +or cloud database upload was performed. Database access uses only the existing +Skill adapter/client/server and public query/export contracts. + +## T3 prerequisite verified before implementation + +Issue #4 is still open with no comments; that alone is not evidence of failure +or completion. A fresh public preflight/export read both real T3 records from +the default local database. The verifier reparsed retained raw VASP outputs, +checked durable collected-job/provider identities and submitted inputs, original +report and artifact hashes, archive receipts, parent linkage, structure, energy, +units, conditions and scientific completion. All checks passed. Evidence: +`.workspace/silicon-t4/t3-gate.json`, `gate-query.json`, `gate-export/`, +`verify-t3.py`. Original T2/T3 artifacts were retained without resubmission. + +## Behavior + +Preflight now exports and checks the exact current candidate set, then separates +candidate discovery from scientific applicability. Reuse requires ordered, +periodic diamond Si (space group 227), ordinary PBE without corrections, +successful electronic/ionic completion, final structure, finite eV/cell energy, +source identity, potential, cutoff and k-point evidence. Known input structure +and explicit INCAR/k-mesh constraints must agree. Retrieval defaults remain +visible and do not silently become reuse requirements. + +The current task and all extracted conditions bind the lookup/selection. A +fresh turn queries again; a changed material, method, cutoff or target cannot +reuse an old choice. Preparation, tracked submission and Flash executor entry +return existing results without starting work when reuse succeeds. Explicit +recalculation still queries first; internal executors inherit the human's +recalculation intent and preparation-only limits. Negated recalculation is not +interpreted as permission to recompute. + +Exact same-condition options are ordered by persistent timezone-aware archive +time. Missing/tied times or materially different complete conditions require +scientific clarification. Recency does not imply higher precision. Linked +relaxation/static records require the persisted parent and job relationship, +common potential/cutoff/spin and matching final structure. Both stages keep +their own settings and energies. Explicit static goals cannot be satisfied by +a relaxation energy. No general structural-equivalence or ranking system was +introduced. + +## Real natural-language acceptance + +A new process created a fresh SQLite ADK session +`silicon-t4-natural-final-20261007`, with no inherited conversation, receipt or +record ID in the user request. It ran the production MatCreator app/model in +Flash mode. Request: + +> 请给我金刚石结构单质硅体相的普通 PBE 弛豫后结构和总能。如果已有兼容且成功的结果,请直接使用并说明结构、能量单位、来源和选择依据。本次仅获取已有结果,不提交任何新计算,也不上传云端数据库。 + +The agent naturally reached the registered hook, queried/exported the same +local database, selected both associated real T3 records and reported their +structure, separate energies, conditions, sources and selection basis: + +| Stage | Record ID | Job ID | Energy (eV / 2-atom cell) | +| --- | --- | --- | ---: | +| Relaxation | `694772dca0777de504b25ba70366fc3d` | `56072cf15fe94e639d7b71f563ed320a` | -10.84124445 | +| Static | `1a1e16e62710d4bec6721ad350ee1f05` | `c266a691a9874ab9ad097bfd09dca9b6` | -10.84601452 | + +The primitive cell has vectors (0, 2.73483634, 2.73483634) and permutations, +fractional Si positions (0,0,0), (1/4,1/4,1/4), volume 40.90948591 ų. +The final agent response explicitly says selection does not establish highest +precision and no convergence comparison was performed. The initial acceptance +run had overstated static accuracy; shared agent guidance was corrected and +this second independent session verified the final feedback. + +Evidence under `.workspace/silicon-t4-natural-final/`: `request.txt`, `run.py`, +`session.db`, `events.jsonl`, `runner.log`, `result.json` and fresh `.aidb/` +query/export/audit artifacts. The harness retained the production model and +hook, with an additional no-computation/no-archive guard for acceptance only. +No guard denial occurred. This is real persistent reuse of the already completed +Bohrium results, not a replay or another real calculation. Synthetic regression +fixtures below are separate evidence. + +## Regression + +28 new task-level tests pass using ADK, both registered agents, controlled +remote tools and real isolated aidb databases/public bridges. They cover reuse +without preparation/submission, explicit/negated recalculation, parent intent, +recent archive order, ambiguous order, different conditions, linked/unlinked +stages, explicit static targets, missing/invalid scientific evidence, query +failure, parameter/target changes and Flash entry. The original archive suite +retains failure, missing output, unconverged, duplicate and uncertain-write +recovery coverage; archive errors preserve outputs rather than resubmitting. +Related T1–T4 run: 102 passed before the last seven additional regression cases. +Changed modules compile. Full-suite results and independent review follow. + +Full suite: **823 passed, 39 failed, 2 collection errors**. Failure/error names +exactly match the T3 baseline; no new failures. Machine-readable verification: +`.workspace/silicon-t4/acceptance.json` and `verify-acceptance.py` compare the +current public query, tool feedback and final answer against the verified T3 +records, prove the request contains no IDs, and find zero jobs owned by the new +session. Full log: `.workspace/silicon-t4/full-final-alias.log`. No typechecking command is +configured in this repository; changed modules pass compileall and diff checks. + + +Final related preflight/reuse regression: **73 passed**, including all 28 reuse +cases. KPOINTS, KMESH and K-MESH matching syntax is covered by the same task +regression. The uppercase parser collision was reproduced, then fixed by +excluding the already recognized mesh span from INCAR constraints. + +## Standards + +Final review of `7ea0698...100b17a`: zero documented-standard violations and +zero unresolved judgment-call suggestions. The initial duplicated candidate +setup suggestion was resolved by `derive_candidate`; mesh aliases share one +recognizer and no per-alias special cases. Source/test layout follows AGENTS. + +## Spec + +Final review against #1 and the live #5 body: zero remaining actionable +findings. The initial P2 matching-KPOINTS rejection was fixed and checked for +all three supported spellings. All eight #5 acceptance criteria are supported +by the public-interface implementation, isolated regression and real independent +session evidence. The extra acceptance guard denied no calls and did not +manufacture reuse. Development A remains active; B has not been delivered here. + +Review total: Standards 0 unresolved, Spec 0 unresolved; neither axis has an +outstanding issue. Current evidence summary is committed as +[silicon_vasp_t4_acceptance.json](silicon_vasp_t4_acceptance.json). +No issue comment or closure was performed in this implementation session. diff --git a/docs/silicon_vasp_t4_acceptance.json b/docs/silicon_vasp_t4_acceptance.json new file mode 100644 index 00000000..82639607 --- /dev/null +++ b/docs/silicon_vasp_t4_acceptance.json @@ -0,0 +1,22 @@ +{ + "phase": "A", + "t3_prerequisite": "verified", + "independent_session": "silicon-t4-natural-final-20261007", + "natural_request_has_no_record_ids": true, + "current_public_query": "/home/shik-mechrevo-wsl/projects/MatCreator/.workspace/silicon-t4-natural-final/.aidb/runs/5c62b6a6ec9d22b746615a1a/1791381714-0e5724e9/preflight-report.json", + "public_exported_reuse_ids": [ + "694772dca0777de504b25ba70366fc3d", + "1a1e16e62710d4bec6721ad350ee1f05" + ], + "tool_feedback_and_final_response_verified": true, + "new_real_jobs_in_session": 0, + "cloud_database_upload": false, + "new_task_tests_passed": 28, + "full_suite": { + "passed": 823, + "failed": 39, + "collection_errors": 2, + "new_failures": [], + "failure_names_equal_t3_baseline": true + } +} \ No newline at end of file diff --git a/docs/silicon_vasp_t5.md b/docs/silicon_vasp_t5.md new file mode 100644 index 00000000..61b1acad --- /dev/null +++ b/docs/silicon_vasp_t5.md @@ -0,0 +1,118 @@ +# Silicon T5: final B after verified T4 + +Scope: [Ai-ready_Database #6](https://github.com/Zikkying/Ai-ready_Database/issues/6), +main specification [#1](https://github.com/Zikkying/Ai-ready_Database/issues/1). +The implementation lives in MatCreator. The default is now final B; no A +configuration switch remains. Historical T1–T4 evidence stays unchanged. + +## Prerequisite and switch + +Starting point: `df6116dcb9886e85f287702812bf44be80bcfe10`. +Before changing behavior, checked its `docs/silicon_vasp_t4.md` and committed +`docs/silicon_vasp_t4_acceptance.json` against the retained acceptance JSON. +Re-ran `.workspace/silicon-t4/verify-acceptance.py` and `verify-t3.py`. +Both passed: the independent natural-language session had no supplied record +IDs, found both real T3 records, and owned zero new jobs. Fresh public +query/export, reparsed raw VASP outputs and artifact hashes again verified: + +| Stage | Record ID | Job ID | Energy (eV/cell) | +| --- | --- | --- | ---: | +| Relaxation | `694772dca0777de504b25ba70366fc3d` | `56072cf15fe94e639d7b71f563ed320a` | -10.84124445 | +| Static | `1a1e16e62710d4bec6721ad350ee1f05` | `c266a691a9874ab9ad097bfd09dca9b6` | -10.84601452 | + +T4's two-axis review had zero unresolved findings; its 39 test failures and +two collection errors matched the pre-existing T3 baseline. Issue status alone +was not used to decide completion. No real calculation was resubmitted and +no cloud database was accessed for this implementation. + +## Current behavior + +Lookup failure explains that data existence is unknown and continuing may +duplicate computation. Preparation/submission pauses until the human responds +with the displayed `确认继续 ` (English `confirm continue ` +also works). The ordinary-language reply contains no internal query JSON. +The event ID makes consent unambiguous; plain “continue”, “yes”, negated replies +and internal executor text do not grant it. The session must contain the actual +warning response from the immediately preceding human invocation, and the +saved task must still match, including known conditions and the digest of an +explicitly supplied file. New targets, methods, cutoff, k mesh or failure +events invalidate old consent. Standalone updates such as `ENCUT=600` establish +new conditions; intervening human turns invalidate the old event even when no +tools were called during that turn. + +Confirmation resumes the original scope without querying again for the same +failed event. Preparation-only remains preparation-only. Registered Flash/Plan +step runners may delegate the current invocation's grant to a fresh isolated +executor session; changed step conditions revoke that grant. A copied previous +session receipt cannot grant consent in a new session. + +The lookup stays `failed`, `query_executed=false`, with failure ID and diagnostics; +the feedback and collected result retain `loop_complete=false`. Scientific +completion still triggers the existing local archive-and-requery/readback. +Archive failure is reported with retained artifacts; recovery can save and +compare the local record without resubmission. Even successful local saving +after bypass is not a successful complete lookup/compute/archive loop. +Material clarification and context extraction errors remain blocking. + +## Verification + +The first red task test failed because A lacked a confirmation event. After +the switch and review repair, 29 task cases passed using ADK, real isolated aidb configuration, +SQLite and public bridge calls, and controlled calculation tools. They cover +confirmation, refusal/absence, target/condition/event/session changes, original +preparation-only scope, internal text, both agent entries, actual Flash-to-step +delegation, archive failure and recovered local readback. The controlled +provider's synthetic VASP outputs are orchestration evidence only. The real +calculation/reuse prerequisite remains the separate T3/T4 evidence above. + +Commands: + +```bash +.venv/bin/python -m pytest tests/test_silicon_lookup_consent.py -q +.venv/bin/python -m pytest tests/ -q --continue-on-collection-errors +``` + +Final full suite: **852 passed, 39 failed, 2 collection errors**. All 41 +failure/error names exactly equal the T4 baseline; no new failures. The known +step-executor retry-config failure also appears in the focused runner regression. +There is no configured typechecking command; changed modules pass compileall. +Diff checks cover only this change, preserving the user's unrelated AGENTS.md +and untracked files. + +Final logs: `.workspace/silicon-t5-consent-final.log` and +`.workspace/silicon-t5-full-final.log`. Original red and intermediate debugging +logs remain separate. A retained-evidence run initially exceeded the Unix +socket path limit; using a short isolated `/tmp/mct5-final-20261007` path passed +all 29 cases. Its exact artifacts were copied into +`.workspace/silicon-t5-final-evidence/`: `task-feedback.json` contains actual +human requests, failure/confirmation receipts and controlled tool calls; +the recovered archive case retains `local.db`, synthetic outputs, public +query/export reports, archive receipt and audit. Paths in copied receipts +continue to refer to the original short-path run; no receipt was rewritten. + +`.workspace/verify-silicon-t5.py` verifies the retained traces, failure binding, +failed lookup/incomplete-loop status, archive failure and recovered content, +actual nonempty isolated SQLite and exact full-suite baseline. Its checked +summary, source/log hashes and copied export identity are committed in +[silicon_vasp_t5_acceptance.json](silicon_vasp_t5_acceptance.json). + +## Standards + +Independent final review of `df6116d...4dc04d3`: zero documented-standard +violations and zero actionable baseline smells. The initial optional dictionary +grant suggestion was withdrawn after verifying JSON-serializable ADK state is +the repository convention and construction is centralized. No production +schema/type layer was added solely for that suggestion. + +## Spec + +Independent final review against #1/#6: zero unresolved findings. Initial P1: +standalone `ENCUT=600` updates retained old consent. Reproduced at the ADK +boundary, fixed in `4dc04d3`, and covered with standalone condition updates plus +an update turn with no tool calls. The reviewer independently reran 14 related +cases successfully. Earlier intermediate logs are not final acceptance. + +Review total: Standards 0 unresolved; Spec 0 unresolved. Final B is implemented +and tested after verified T4 prerequisites, including the review repair; A-only +acceptance is not used as proof of T5 completion. Implementation commits: +`587becd` and `4dc04d3`. No issue comment or closure was performed. diff --git a/docs/silicon_vasp_t5_acceptance.json b/docs/silicon_vasp_t5_acceptance.json new file mode 100644 index 00000000..c5372b70 --- /dev/null +++ b/docs/silicon_vasp_t5_acceptance.json @@ -0,0 +1,44 @@ +{ + "phase": "B", + "previous_phase": "A", + "default_policy": "B", + "prerequisite_commit": "df6116dcb9886e85f287702812bf44be80bcfe10", + "implementation_commits": [ + "587becd", + "4dc04d3" + ], + "t4_prerequisite": "verified", + "t4_acceptance_sha256": "134732e8f7b63dd28611962f4ad815a09222c73eb625a874be9d9ced4e0aad35", + "t3_raw_outputs_reparsed_and_fresh_public_readback": true, + "confirmed_failure_remains_failed": true, + "bypass_loop_complete": false, + "isolated_local_archive_failure_and_recovery_verified": true, + "retained_readback": [ + { + "record_id": "b3b0a7f19f9b5274012346c89a30130c", + "export": ".workspace/silicon-t5-final-evidence/test_failed_lookup_bypass_stil1/.aidb/archives/6602c701f4e64b408c9f5d6722966105/2e7d27b8bf674acdaaa49e2844473e58/after/target-Si/records.jsonl", + "sha256": "3e95c97d441ddb2e46242932a584c969404145409bca867c11f9cdfe84cf6203", + "local_database": ".workspace/silicon-t5-final-evidence/test_failed_lookup_bypass_stil1/local.db" + } + ], + "task_tests_passed": 29, + "full_suite": { + "passed": 852, + "failed": 39, + "collection_errors": 2, + "new_failures": [], + "failure_names_equal_t4_baseline": true, + "log": ".workspace/silicon-t5-full-final.log", + "sha256": "cdaaa24d27be857d7f51bb1afd5bf43b343c83e0708d95b90a325fb2e5e116af" + }, + "review": { + "reviewed_head": "4dc04d3", + "standards_unresolved": 0, + "spec_unresolved": 0, + "initial_spec_p1": "fixed: standalone conditions and intervening human turns invalidate old confirmation", + "independent_spec_tests_passed": 14 + }, + "new_real_calculation_submissions": 0, + "cloud_database_upload": false, + "controlled_results_are_real_calculation_acceptance": false +} diff --git a/docs/testing/known-failures.json b/docs/testing/known-failures.json new file mode 100644 index 00000000..5eea0007 --- /dev/null +++ b/docs/testing/known-failures.json @@ -0,0 +1,336 @@ +{ + "recorded_on": "2026-10-08", + "source_commit": "5f88f95", + "source_log": ".workspace/silicon-t5-full-final.log", + "policy": "Exact identity comparison only; this is not a clean full-suite result or a resolved defect.", + "failures": [ + { + "outcome": "failed", + "nodeid": "tests/test_benchmark_config.py::test_benchmark_client_registers_and_persists_missing_development_token", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_disabled_skills.py::test_set_disabled_skill_names_updates_graph_nodes", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_evaluation_routes.py::test_toggle_skill_graph_node_updates_and_reports_graph_state", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_extractor.py::test_extractor_skips_unchanged_session_and_processes_only_delta", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_extractor.py::test_extractor_advances_cursor_for_empty_valid_response", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_extractor.py::test_extractor_migrates_legacy_extracted_session", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_progressive_retrieval.py::test_graph_disabled_skills_are_hidden_from_discovery_and_reads", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_routes_through_protected_reviewer_policy", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_kdg_progressive_retrieval.py::test_graph_agent_tool_scopes_memory_review_to_current_session", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_skill_graph_seed.py::test_seed_reuses_disabled_skill_node_and_merges_legacy_duplicate", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_skill_graph_seed.py::test_seed_removes_stale_managed_skill_and_uses_virtual_dependency", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_step_executor_runner.py::test_step_executor_retries_only_malformed_streamed_tool_arguments", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_structure_modeling.py::test_supercell_supports_general_integer_matrix", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_structure_modeling.py::test_molecule_can_be_created_or_appended", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_structure_modeling.py::test_crystal_is_created_through_matcraft_bulk_builder", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_structure_modeling.py::test_smiles_molecule_can_be_appended_at_requested_center", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_structure_modeling.py::test_matcraft_perturbation_is_seeded_and_changes_positions", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_frontend_tracks_requests_per_session", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_session_creation_does_not_start_knowledge_review", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_sse_request_uses_captured_session_context", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_completed_request_handoffs_live_turn_before_durable_refresh", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_roadmap_fits_after_the_popup_has_a_stable_layout", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_running_session_switch_discovers_and_reconnects_managed_run", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_stop_and_plan_refreshes_preserve_open_node_dialogs", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_bottom_attachment_and_node_toggle_use_one_scroll_policy", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_all_chat_disclosures_share_the_reading_position_controller", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_image_and_all_timeline_updates_preserve_node_disclosures", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_plain_text_blocks_keep_history_order_and_stable_identity", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_chat_messages_use_one_model_scheduler_and_stable_regions", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_step_cancellation_identifies_the_active_session_owner", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_remote_job_polling_is_scoped_to_the_active_session", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_remote_jobs_are_collapsed_and_keep_lifecycle_status_visible", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_session_switch_loads_page_scoped_graph_after_the_transcript_page", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_session_switch_renders_cached_snapshot_immediately", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_reselecting_the_active_session_does_not_rebuild_the_view", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_new_session_ids_are_not_limited_to_one_second_resolution", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_active_session_transitions_recompute_composer_state", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_startup_restores_only_an_accessible_session_owner_tuple", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "failed", + "nodeid": "tests/test_web_concurrent_sessions.py::test_startup_keeps_session_api_ready_while_maintenance_runs_in_background", + "reason": "Existing failure recorded at T5; root cause still requires diagnosis.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "error", + "nodeid": "tests/test_execution_grouping.py", + "reason": "Legacy agents.MatCreator import no longer matches the current src package.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + }, + { + "outcome": "error", + "nodeid": "tests/test_matcreator_phase_and_skills.py", + "reason": "Legacy agents.MatCreator import no longer matches the current src package.", + "owner": "unassigned", + "tracking_issue": null, + "status": "needs-triage" + } + ] +} diff --git a/docs/testing/silicon-consent-retro-proof.json b/docs/testing/silicon-consent-retro-proof.json new file mode 100644 index 00000000..b4a641dc --- /dev/null +++ b/docs/testing/silicon-consent-retro-proof.json @@ -0,0 +1,47 @@ +{ + "historical_hook_commit": "587becd", + "repair_commit": "4dc04d3", + "results": { + "historical": { + "exit_code": 1, + "counts": { + "passed": 2, + "failed": 4, + "error": 0, + "skipped": 0 + }, + "failures": [ + { + "nodeid": "tests/test_silicon_lookup_consent.py::test_changed_target_or_conditions_invalidates_failure_confirmation[ENCUT=600]", + "outcome": "failed" + }, + { + "nodeid": "tests/test_silicon_lookup_consent.py::test_changed_target_or_conditions_invalidates_failure_confirmation[KPOINTS=6x6x6]", + "outcome": "failed" + }, + { + "nodeid": "tests/test_silicon_lookup_consent.py::test_changed_target_or_conditions_invalidates_failure_confirmation[HSE06]", + "outcome": "failed" + }, + { + "nodeid": "tests/test_silicon_lookup_consent.py::test_condition_update_without_tools_still_invalidates_old_confirmation", + "outcome": "failed" + } + ] + }, + "current": { + "exit_code": 0, + "counts": { + "passed": 6, + "failed": 0, + "error": 0, + "skipped": 0 + }, + "failures": [] + } + }, + "checkout_runtime_changed": false, + "shared_ci_or_git_changed": false, + "real_jobs_submitted": 0, + "cloud_database_upload": false +} diff --git a/docs/testing/silicon-consent-retro.md b/docs/testing/silicon-consent-retro.md new file mode 100644 index 00000000..faf4516d --- /dev/null +++ b/docs/testing/silicon-consent-retro.md @@ -0,0 +1,58 @@ +# T5 consent retrospective + +This supplements [the T4 retrospective tooling](silicon-local-loop.md) for +[Ai-ready_Database #6](https://github.com/Zikkying/Ai-ready_Database/issues/6), +under [the main specification](https://github.com/Zikkying/Ai-ready_Database/issues/1). + +The initial implementation tested `继续,ENCUT=600`, but missed standalone +`ENCUT=600`: task classification returned no calculation task and retained the +old failure receipt. A later exact confirmation resumed the old authorization. +An intervening user update without tool calls exposed the same lifecycle gap. +The independent Spec review reproduced this at the ADK task boundary; +`4dc04d3` fixed both paths. Confirmation now checks the preceding human +invocation, the exact failure event, session and complete task conditions. + +## Deterministic regression evidence + +On 2026-10-08, the same six public task-boundary tests were run in independent +pytest processes. One process loaded the historical `587becd` hook from Git; +the other used the current hook. The checkout and shared branch were unchanged. + +| Cases | Historical hook | Current hook | +| --- | --- | --- | +| Standalone ENCUT, KPOINTS and HSE06 updates, followed by old confirmation | 3 failures | 3 passes | +| Condition update with no tool calls, followed by old confirmation | 1 failure | 1 pass | +| Current valid confirmation and original preparation-only authorization | 2 passes | 2 passes | +| Errors / skipped tests | 0 / 0 | 0 / 0 | + +The four historical failure IDs exactly match the negative cases. Positive +controls show the guard still allows valid confirmation and respects original +preparation-only scope. Tests observe controlled preparation/submission tools +and actual ADK feedback, rather than private guard calls or prompt strings. +The public aidb bridge intentionally reports a missing isolated config to +produce lookup failure; `_query` is not replaced with a mock. No real +calculation or cloud upload was performed. + +Exact counts and failure identities are in +[silicon-consent-retro-proof.json](silicon-consent-retro-proof.json). +The temporary replay driver and detailed logs remain under +`.workspace/silicon-t5-retro-20261008/`. This historical replay establishes +regression sensitivity; it is not another permanent CI harness. + +## Deduplication with T4 retro + +The existing `script/check_silicon_loop.py tests` command and the +`silicon-local-loop` CI job already run all 29 consent cases, require the +public integration dependency, reject unexpected skips/failures and retain +reports. Reuse them; no second CI job, dependency branch, baseline file or +global steering paragraph is needed for T5. The T4 retro also owns scientific +claim review, navigation and the PowerShell/WSL entry point. + +For future authorization changes, keep standalone condition updates and human +turns without callbacks in the task suite. Permission lifetime crosses language +classification, session history and executor delegation; a syntax linter alone +cannot establish it. Retain the independent Spec review that detected the gap. + +Publication/local validation of the pinned aidb dependency is separate from +successful remote CI. The combined delivery PR reports the actual remote +checks separately from these historical/local results. diff --git a/docs/testing/silicon-local-loop.md b/docs/testing/silicon-local-loop.md new file mode 100644 index 00000000..f6e7fbc0 --- /dev/null +++ b/docs/testing/silicon-local-loop.md @@ -0,0 +1,60 @@ +# Silicon local-loop checks + +This is the repeatable validation entry point for MatCreator's silicon local loop. The current runtime policy is [final B / T5](../silicon_vasp_t5.md). [T4](../silicon_vasp_t4.md) records historical acceptance under development policy A; these checks do not change either policy. + +## Isolated regression + +From the repository root, with both Python environments installed: + +```bash +.venv/bin/python script/check_silicon_loop.py tests --output .workspace/checks/loop.json +``` + +From the Windows PowerShell checkout on WSL: + +```powershell +./script/check_silicon_loop.ps1 tests --output .workspace/checks/loop.json +``` + +Use a new output filename for every run. The command refuses an existing pytest report, writes JSON counts and exact failure identities, a log and JUnit XML, and fails on skipped tests or any failure. Missing aidb dependencies fail rather than quietly skipping. `AI_READY_DB_ROOT` and `AI_READY_DB_PYTHON` select the public bridge checkout and its Python; defaults use the adjacent `Ai-ready_Database` checkout and `.venv/bin/python`. + +The `silicon-local-loop` job in [the workflow](../../.github/workflows/test.yml) pins the aidb commit, installs both packages, runs this same command against isolated test databases and retains its artifacts. The published dependency version is `87148777fc9995a6d2e3bd85571ee8e20ff53181`, available on [codex/matcreator-ci-bridge](https://github.com/Zikkying/Ai-ready_Database/tree/codex/matcreator-ci-bridge) and retained locally in `.workspace/aidb-ci`; it contains the public bridge and bounded preflight contract. A checkout of GitHub's older aidb main alone lacks that bridge. Its remote branch SHA was verified on 2026-10-08; publication is separate from a successful Actions run. + +## Full-suite failure baseline + +```bash +.venv/bin/python script/check_silicon_loop.py tests --full \ + --baseline docs/testing/known-failures.json --output .workspace/checks/full.json +``` + +[known-failures.json](known-failures.json) records the exact failed test IDs and collection errors observed at T5's verified commit `5f88f95`. Comparison uses identity and outcome, not an old test count. New failures, skips, interrupted runs and inconsistent reports fail. Resolved baseline entries appear in the report. A run accepted against this baseline still reports `all_tests_passed: false` while failures remain; the isolated CI job never uses this baseline. + +Each baseline entry carries reason, owner, tracking issue and status. Existing failures remain `needs-triage`, with owner `unassigned` and tracking issue `null` until someone takes responsibility. Assign the owner and link a diagnosis issue during triage; remove an entry only after its test passes. Do not add new entries just to make a check green. The two collection errors currently refer to obsolete `agents.MatCreator` imports. Fixing the existing unrelated failures is separate work. + +## Read-only real reuse audit + +```bash +.venv/bin/python script/check_silicon_loop.py verify-reuse \ + --workspace .workspace/silicon-t4-natural-final \ + --source-report .workspace/silicon-t2/silicon-t2-results/silicon-t3-final-report.json \ + --output .workspace/checks/reuse.json +``` + +Supply `--config` if the retained real records live in a nondefault aidb configuration. Both real source files and the existing tracked-job store must still be present. This command audits the retained independent MatCreator session; it does not start a new LLM session. It derives identities from the session's tool feedback and original request, performs fresh public query/export and reuse selection, compares the complete selected receipt, rereads raw VASP evidence and verifies artifact hashes and archive receipts. No record ID is supplied on the command line. + +The audit writes reports, public exports and the actual final response into a unique subdirectory beside `--output`. It neither submits computations nor archives records nor uploads to a cloud database. Its job-store check verifies that the retained acceptance session owns zero tracked jobs; it is not a claim about unrelated sessions or all provider history. + +Read the saved final response with [the scientific review standard](../../CODING_STANDARDS.md). The report deliberately marks `scientific_claim_review: required`; matching hashes and energy values cannot decide whether a prose claim of scientific accuracy is justified. + +For the evidence chain, start with [T3 real archive verification](../silicon_vasp_t3.md), then [T4 independent discovery and reuse](../silicon_vasp_t4.md), and [T5 policy switch](../silicon_vasp_t5.md). The runtime interface and configuration remain documented in [aidb_skill_hook.md](../aidb_skill_hook.md). +## Implementation verification, 2026-10-08 + +Validated against a clean checkout of the pinned dependency, rather than its developer working tree: + +- Isolated loop: 150 passed, no failures, errors or skips (`.workspace/retro-checks/loop-v4.json`). +- Full suite: 864 passed, 39 failed, two collection errors, no skips. Exact failures match the recorded baseline; the suite is still not clean (`full.json`). +- Public bridge and normalization identity contract: 15 passed in the prepared aidb checkout. The pin includes bounded preflight support and the missing Hubbard-U identity argument; no unrelated developer changes were copied. +- Fresh readback of both retained real records passed (`reuse-final.json`), with zero tracked jobs in the independent session. A changed structure was rejected; a missing store was rejected without creating it. +- PowerShell wrapper, workflow YAML loading, Python compilation and authored-file whitespace checks passed. + +Reports are local under `.workspace/retro-checks/`; original T3/T4 evidence is retained. The pinned dependency was published to `codex/matcreator-ci-bridge` on 2026-10-08 and its remote SHA verified. GitHub Actions has not run this new MatCreator job; local validation and dependency publication do not establish remote CI success. diff --git a/docs/vasp_runtime_diagnosis.md b/docs/vasp_runtime_diagnosis.md new file mode 100644 index 00000000..49c9eba1 --- /dev/null +++ b/docs/vasp_runtime_diagnosis.md @@ -0,0 +1,185 @@ +# Local VASP runtime diagnosis — 2026-10-07 + +Issue #3 remains unaccepted until a real silicon workflow succeeds. This report +corrects the earlier environment report; it does not establish VASP completion. + +## Reproduction and causal probes + +From the WSL repository, with the project venv and NVM directory on PATH: + +```bash +PYTHONPATH=src .venv/bin/python .workspace/silicon-t2/debug-runtime/repro.py +``` + +Two runs before the fix failed identically: `run_python` used project Python +3.12.13, whereas `run_bash` resolved `~/.local/bin/python` (3.10.12) and raised +`ModuleNotFoundError: No module named 'ase'`. The Bash login profile prepended +`~/.local/bin`, replacing the intended interpreter. Independently, a shadow +`python` on PATH made both Python tools execute the wrong interpreter. Three +regression cases failed before the fix and pass afterward. + +Python tools now use `sys.executable`; Bash uses a non-login shell with inherited +configuration and the runtime interpreter directory prepended to PATH. Shell +profile initialization must happen at harness startup, rather than separately +inside each tool. The original reproduction passes after the fix. + +The provider PATH probe reproduces `The 'bohr' CLI is not installed or not on +PATH` with `/usr/local/bin:/usr/bin:/bin`. Changing only PATH to include +`~/.nvm/versions/node/v24.14.1/bin` makes the same provider call return 2.6.100. +This was a launcher environment error, not a missing installation. Future local +launches need the NVM directory on PATH; the tools now retain that startup PATH. + +## Environment comparison + +| Boundary | bohr | Python chosen by tools | Config and authentication | +| --- | --- | --- | --- | +| Interactive WSL | NVM bohr 2.6.100 | bare `python`: user shim 3.10.12; explicit project interpreter: 3.12.13 | bohr project query succeeds without either credential environment variable | +| MatCreator `run_python` | same path/version | project 3.12.13 | MatCreator config loads; project query succeeds | +| MatCreator `run_bash`, before fix | same path/version with corrected launcher | user shim 3.10.12; ASE missing | same HOME/config; project query succeeds | +| MatCreator `run_bash`, after fix | same path/version | project 3.12.13; ASE import succeeds | inherited configuration retained | +| Actual `run_bohr_json` provider | same path/version | parent project runtime | version and project query succeed with provider flags | + +MatCreator reads `/home/shik-mechrevo-wsl/.matcreator/config.yaml`. A file-open +trace shows bohr reads `/home/shik-mechrevo-wsl/.bohr-cli/config.yaml`. These are +different configurations. The CLI reports the default configuration while its +named profile list is empty; `profile show default` fails, which does not negate +the successful project authentication. In an isolated temporary HOME, both +`BOHRIUM_ACCESS_KEY` alone and adding `ACCESS_KEY` fail with `AUTH_REQUIRED` in +this installed version. Existing CLI config is the verified working credential +source. Credential values were neither displayed nor copied into artifacts. + +## PBE potentials + +Minimal independent check in the real project interpreter: + +```python +from pymatgen.core import Lattice, Structure +from pymatgen.io.vasp.sets import MPRelaxSet +MPRelaxSet(Structure.from_spacegroup( + "Fd-3m", Lattice.cubic(5.43), ["Si"], [[0, 0, 0]] +)).potcar +``` + +It raises `PmgVaspPspDirError: Set PMG_VASP_PSP_DIR= in +.pmgrc.yaml (needed to find POTCARs)`. INCAR generation succeeds. Interactive +and tool environments, MatCreator's `env` configuration, and pymatgen's loaded +settings contain no PSP directory. Neither default pymatgen YAML file exists; +there is no `PMG_CONFIG_FILE` override. Thus this is a genuinely absent +configuration, not a configured value silently ignored by the tool. + +The bounded home search (depth four, excluding caches, virtual environments, +Node dependencies and repositories' `.git`) found only +`~/.matcreator/workspace/vasp/relax_001/POTCAR.note`, an instruction file, not a +potential. `/opt` contains containerd/docker-desktop; `/data` is absent and +`/srv` empty. This establishes no usable source in the inspected scope, not +global absence on all host disks or inside remote images. No dummy POTCAR was +substituted. + +## VASP image source + +MatCreator's `compute.vasp_image` is empty and `BOHRIUM_VASP_IMAGE` unset in all +compared environments. The current account-private catalog nevertheless has +image **24280**, `vasp:6.3.0`, at +`registry.dp.tech/dptech/prod-28882/vasp:6.3.0`; `bohr image get 24280` succeeds. +The earlier diagnostic did not query this catalog and overstated the blocker. +The resource exists; its selection is not configured. No tag was invented. + +`bohr image search vasp` returns gvasp and VASPKIT, which do not prove presence +of the intended executable. `bohr image list -t VASP` is an invalid category in +2.6.100 and even exits zero with non-JSON diagnostic text. The private catalog +query is the correct discovery command. MatCreator independently queried the +image metadata and `bohr image dockerfile 24280`; no Dockerfile is stored +(`RESOURCE_NOT_FOUND`). Neither executable/MPI paths nor bundled potentials +can be established from that metadata. No extra paid probe job was created. + +The image-detail response reports zero CNY balance and `sufficient:false`. +This alone does not establish whether the selected project's photon payment +can fund the workflow; payment is not yet tested by a real submission. + +## Retained execution and remaining information + +The existing MatCreator session **silicon-t2-20261007-env2** was resumed with the +fixed tools. MatCreator owns the calculation plan and independently reproduced +the potential error, inspected the existing image, and recorded its decision +in `.workspace/silicon-t2/matcreator-runtime-diagnosis.json`. It stopped before +input preparation/submission. Development A remains active: query failures +must pause; the retained earlier real query succeeded with zero candidates. + +Retained evidence under `.workspace/silicon-t2/`: + +- `events-diagnosis.jsonl`, existing `events-env2.jsonl` and `trajectories/`; +- the existing `.aidb/runs/material-Si/1791354576-ebf79559/` report and audit; +- `debug-runtime/` clearly marked diagnostic harnesses, redacted environment + comparisons, PATH/config-source probes, and test log; +- the MatCreator report's historical legacy job ID **22947186**, group + **16319214**. This is not a new tracked T2 job or evidence of success. + +No new job was submitted; there is no new durable job ID or batchjob ID to +report. Any eventual submission must save both IDs and query existing status +before retrying. Issue #3 has not been closed or marked successful. + +The user-facing missing-information request is consolidated to a real PBE Si +potential source (local directory, documented in-image path, or accessible host +entry point), plus special VASP/MPI startup instructions if this image needs +them. Existing project and calculation authorization remains valid; no repeat +approval is required. Program availability and payment must be verified by the +eventual authorized workflow rather than assumed from the catalog entry. + +Validation after the fix: **231 passed** across workspace tools, silicon +workflow, remote-job tools and Bohrium Batch Job adapter tests. The original +Python environment reproduction passes. The independent POTCAR reproduction +still fails due to the unresolved resource configuration. + +Follow-up Standards and Spec reviews found zero new issues. They preserve the +same acceptance limitation: the real silicon calculation has not succeeded. + +## Follow-up after the user supplied the PBE source + +The preceding blocked state is historical. The supplied library contains a +valid `PAW_PBE Si 05Jan2001` potential at +`~/projects/9f9b5b-main/psudopotential/paw_pbe/Si/POTCAR.Z`. It uses UNIX +compress; `gzip -cd` decodes it. The raw layout and compression differ from +current pymatgen's lookup layout. MatCreator staged only the required Si +potential under `pseudopotentials/POT_GGA_PAW_PBE/Si/POTCAR`, with a per-process +`PMG_VASP_PSP_DIR`. This was a missing configuration and layout mismatch, +not a missing potential resource. See [source research](pbe_bohrium_research.md). + +The account-private VASP image was selected explicitly in the resumed process. +The authorized real relaxation proved its VASP/MPI startup and payment path: +durable job `56072cf15fe94e639d7b71f563ed320a`, provider batchjob +`5ed5945230884cf88f30475c1afed598`, terminal success, exit code zero. +Scientific verification confirms electronic and ionic convergence, three ionic +steps and total energy -10.84124445 eV for the Si2 cell. The session remains +`silicon-t2-20261007-env2`; no relaxation resubmission was required. + +Three additional platform bugs were reproduced and repaired: + +- Broad `only`/`仅` authorization matching treated unrelated qualifiers as + input-only restrictions. Matching now ties restrictions to preparation/input + requests while retaining genuine input-only blocks at both agent entries. +- VASP 6.3.0 emitted XML `GGA=--` when INCAR omitted GGA. Official VASP rules + select the functional from POTCAR LEXCH in this case. The validator now accepts + this only with submission-hash-bound PBE potentials, matching XML identities + and `LEXCH=PE`; explicit non-PBE GGA or XC overrides remain rejected. Original + XML parameters are retained. [VASP GGA](https://vasp.at/wiki/index.php/GGA). +- Preflight feedback's nested `structure_path` caused the session artifact + collector to misclassify an existing input as a current step output. Exported + feedback now uses `input_structure_path`; internal task evidence and all + authorization/query checks remain intact. Output roots were not broadened. + +Each fix has red-to-green regression evidence in `debug-pbe/`. The real +relaxation validator reproduction also passed without changing scientific +inputs or outputs. Final related tests: **286 passed**; both review axes found +zero new issues. MatCreator resumed the static step after the final fix. + +The static continuation also completed and passed scientific checks: +durable job `c266a691a9874ab9ad097bfd09dca9b6`, provider batchjob +`6ce96bb830764a01976fec7ef9d16a99`, total energy -10.84601452 eV per Si2 +cell, electronic convergence, one static step and completed OUTCAR. Static +input matches the verified relaxed structure and uses the same POTCAR hash. +Both output sets and the parent-child source link are retained. Final complete +suite: 786 passed, 39 failures and two collection errors, with the exact same +failure names as the starting baseline. No new job was needed for recovery. +ADK session-version conflicts and a child replay workspace-path incident remain +recorded separately; a public-tool replay in the original workspace succeeded. +This is real T2 computation evidence, not proof of T3 archiving or final B behavior. diff --git a/run.md b/run.md new file mode 100644 index 00000000..c48e86e5 --- /dev/null +++ b/run.md @@ -0,0 +1,3 @@ +启动命令 bash script/start_matcreator.sh,ctrl-C一键停止。 + +然后 http://localhost:5173 处前端口,日志在~/.matcreator/logs diff --git a/script/check_silicon_loop.ps1 b/script/check_silicon_loop.ps1 new file mode 100644 index 00000000..e052bda0 --- /dev/null +++ b/script/check_silicon_loop.ps1 @@ -0,0 +1,10 @@ +param([Parameter(ValueFromRemainingArguments=$true)][string[]]$CheckArgs) +$ErrorActionPreference = 'Stop' +$repoPath = (Resolve-Path (Join-Path $PSScriptRoot '..')).ProviderPath +if ($repoPath -notmatch '^\\\\wsl(?:\$|\.localhost)\\([^\\]+)\\(.+)$') { + throw 'Run this wrapper from a WSL checkout; on Linux use python script/check_silicon_loop.py.' +} +$distribution = $Matches[1] +$linuxPath = '/' + $Matches[2].Replace('\', '/') +& wsl.exe -d $distribution --cd $linuxPath --exec ./.venv/bin/python script/check_silicon_loop.py @CheckArgs +exit $LASTEXITCODE diff --git a/script/check_silicon_loop.py b/script/check_silicon_loop.py new file mode 100644 index 00000000..57d78a74 --- /dev/null +++ b/script/check_silicon_loop.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Repeatable local-loop regression and read-only persistent reuse acceptance.""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import uuid + +REPO = Path(__file__).resolve().parents[1] +LOOP_TESTS = ['tests/test_aidb_skill_hook.py', 'tests/test_silicon_vasp_workflow.py', + 'tests/test_silicon_archive.py', 'tests/test_silicon_reuse.py', + 'tests/test_silicon_lookup_consent.py', 'tests/test_silicon_checks.py'] + + +class CheckError(ValueError): + pass + + +def read_json(path): + return json.loads(Path(path).read_text(encoding='utf-8-sig')) + + +def require(condition, message): + if not condition: + raise CheckError(message) + + +def digest(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def emit(path, payload): + path = Path(path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp') + temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8') + temporary.replace(path) + print(json.dumps({'ok': payload.get('ok'), 'report_path': str(path), + 'error': payload.get('error')}, ensure_ascii=False)) + + +def check_results(report, baseline=None): + require(report['exit_code'] in (0, 1), f"pytest did not complete: exit {report['exit_code']}") + counts = report['counts'] + require(all(type(counts[k]) is int and counts[k] >= 0 for k in ('passed', 'failed', 'error', 'skipped')), 'invalid test counts') + require((report['exit_code'] == 0) == (counts['failed'] + counts['error'] == 0), 'pytest exit code contradicts failures') + require(counts['passed'] > 0, 'no tests passed') + require(counts['skipped'] == 0, 'unexpected skipped tests') + actual = {(r['outcome'], r['nodeid']) for r in report['failures']} + expected = {(r['outcome'], r['nodeid']) for r in (baseline or {}).get('failures', [])} + new = sorted(actual - expected) + require(not new, f'new failures: {new}') + require(counts['failed'] + counts['error'] == len(report['failures']), 'failure tally inconsistent') + return {'counts': counts, 'new_failures': new, 'resolved_baseline_failures': sorted(expected - actual), + 'baseline_used': baseline is not None, + 'all_tests_passed': counts['failed'] + counts['error'] == 0} + + +def runtime(args): + root = Path(args.aidb_root or os.environ.get('AI_READY_DB_ROOT') or REPO.parent / 'Ai-ready_Database').expanduser().resolve() + python = Path(args.aidb_python or os.environ.get('AI_READY_DB_PYTHON') or root / '.venv/bin/python').expanduser().absolute() + scripts = root / 'skills/ai-ready-db/scripts' + require(python.is_file(), f'aidb Python missing: {python}') + require(all((scripts / name).is_file() for name in ('aidb_loop_adapter.py', 'aidb_client.py', 'aidb_server.py')), + f'public bridge missing: {scripts}') + env = {**os.environ, 'AI_READY_DB_ROOT': str(root), 'AI_READY_DB_PYTHON': str(python)} + help_result = subprocess.run([str(python), str(scripts / 'aidb_loop_adapter.py'), 'preflight', '--help'], + capture_output=True, text=True, env=env, timeout=20) + require(help_result.returncode == 0 and '--command-timeout' in help_result.stdout, + 'aidb adapter lacks bounded preflight; use the pinned integration version') + return root, python, env + + +def run_tests(args): + _, _, env = runtime(args) + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + report = output.with_suffix('.pytest.json') + require(not report.exists(), f'refuse stale pytest report: {report}; choose a new output path') + env.update(MATCREATOR_REQUIRE_AIDB='1', MATCREATOR_TEST_REPORT=str(report), + PYTHONPATH=os.pathsep.join([str(REPO), str(REPO / 'src'), env.get('PYTHONPATH', '')])) + command = [sys.executable, '-m', 'pytest', *( ['tests/'] if args.full else LOOP_TESTS), + '-q', '--continue-on-collection-errors', '-p', 'script.silicon_test_report', + '--junitxml', str(output.with_suffix('.xml'))] + with output.with_suffix('.log').open('w', encoding='utf-8') as log: + process = subprocess.run(command, cwd=REPO, env=env, stdout=log, stderr=subprocess.STDOUT) + require(report.exists(), f'pytest produced no report; inspect {output.with_suffix(".log")}') + results = read_json(report) + require(results['exit_code'] == process.returncode, 'pytest exit code mismatch') + baseline = read_json(args.baseline) if args.baseline else None + return {'ok': True, 'scope': 'full' if args.full else 'silicon-local-loop', + **check_results(results, baseline), 'pytest_report': str(report), + 'junit': str(output.with_suffix('.xml')), 'log': str(output.with_suffix('.log'))} + + +def verify_reuse(args): + root, python, env = runtime(args) + # The bridge may initialize a missing store even during local-status. + # Validate configuration paths before starting a supposedly read-only audit. + config_path = Path(args.config or env.get('AI_READY_DB_CONFIG') or root / 'configs/base_database.yaml').resolve() + import yaml + config = yaml.safe_load(config_path.read_text(encoding='utf-8-sig')) + store = Path(config['ase_sqlite']['path']).expanduser() + if not store.is_absolute(): + store = root / store + require(store.is_file(), f'existing local store required for read-only audit: {store}') + env['AI_READY_DB_CONFIG'] = str(config_path) + workspace = args.workspace.resolve() + evidence = read_json(workspace / 'result.json') + feedback = evidence['feedback'] + reuse = feedback['reuse'] + require(evidence.get('independent_session') is True, 'independent-session evidence absent') + require(not evidence.get('guard_denied'), 'acceptance guard intervened; cannot claim natural reuse') + require(feedback.get('query_executed') is True and reuse.get('status') == 'reused', 'current evidence is not successful reuse') + require(reuse.get('new_calculation_needed') is False, 'receipt requests computation') + require(len(set(reuse['record_ids'])) == len(reuse['record_ids']) > 0, 'invalid selected record identities') + request = (workspace / 'request.txt').read_text(encoding='utf-8-sig') + require(all(r not in request for r in reuse['record_ids']), 'request contains selected record ID') + events = [json.loads(line) for line in (workspace / 'events.jsonl').read_text().splitlines() if line.strip()] + responses = [p['function_response'] for e in events for p in (e.get('content') or {}).get('parts', []) if p.get('function_response')] + require(any((r.get('response') or {}).get('aidb_preflight', {}).get('reuse', {}).get('record_ids') == reuse['record_ids'] for r in responses), + 'no matching live tool feedback in session events') + messages = [e for e in events if (e.get('content') or {}).get('role') == 'model' + and any(p.get('text') and not p.get('thought') for p in e['content'].get('parts', []))] + require(bool(messages), 'final response absent') + final = '\n'.join(p['text'] for p in messages[-1]['content']['parts'] if p.get('text') and not p.get('thought')) + require(all(r in final for r in reuse['record_ids']), 'final response omits selected IDs') + require('eV' in final, 'final response omits energy unit') + # Scientific accuracy claims require reviewer judgement; no keyword test declares + # a statement scientifically sound. Save the actual final response for it. + source = read_json(args.source_report) + stages = {step['archive']['record_id']: step for step in source['steps']} + require(all(r in stages for r in reuse['record_ids']), 'selection is not in the source archive evidence') + verification_dir = args.output.resolve().parent / ('reuse-check-' + uuid.uuid4().hex) + verification_dir.mkdir(parents=True) + task_path = verification_dir / 'task.json' + task = feedback['task_context'] + task_path.write_text(json.dumps({'task_id': 'verify-persistent-reuse', 'target': { + 'formula': task['known']['formula'], 'functional': task['known']['functional'], + 'domain': task['known']['domain'], 'min_records': 1}})) + if args.config: + env['AI_READY_DB_CONFIG'] = str(args.config.resolve()) + command = [str(python), str(root / 'skills/ai-ready-db/scripts/aidb_loop_adapter.py'), + '--workspace', str(verification_dir), 'preflight', '--task-json', str(task_path), + '--command-timeout', '20', '--export-dir', str(verification_dir / 'export'), '--export-format', 'jsonl'] + process = subprocess.run(command, capture_output=True, text=True, env=env, timeout=90) + (verification_dir / 'adapter-result.json').write_text(json.dumps({'returncode': process.returncode, + 'stdout': process.stdout, 'stderr': process.stderr}, indent=2)) + require(process.returncode == 0, 'fresh public preflight failed; see adapter-result.json') + receipt = json.loads(process.stdout) + require(receipt.get('ok') is True, 'public preflight returned failure') + query = read_json(receipt['report_path']) + records = [json.loads(line) for export in query['exports'] for line in Path(export['output_path']).read_text().splitlines() if line.strip()] + require(sorted(r['record_id'] for r in records) == sorted(query['queries'][0]['record_ids']), 'public export identities incomplete') + sys.path.insert(0, str(REPO / 'src')) + from matcreator.agents.silicon_reuse import select_reuse + from matcreator.tools.silicon_vasp import _validate + from matcreator.tools.silicon_archive import _compare + selected = select_reuse(records, task) + require(selected.get('status') == 'reused' and selected['record_ids'] == reuse['record_ids'], + 'fresh public selection changed; review current candidates') + compared = [] + for record_id in selected['record_ids']: + saved = next(r for r in records if r['record_id'] == record_id) + step = stages[record_id] + report_path = Path(step['source_result_json']['path']) + require(digest(report_path) == step['source_result_json']['sha256'], 'source result report changed') + report = read_json(report_path) + require(report.get('status') == 'success', 'source scientific completion is not success') + input_dir = Path(source['workspace']) / report['submission']['input_path'] + parsed = _validate(report_path.parent, step['calculation_type'], input_dir, report['conditions']['input_sha256']) + expected = {'structure': parsed['structure'], 'energy': parsed['total_energy']['value'], + 'functional': 'PBE', 'calculation_type': step['calculation_type'], + 'calculation_parameters': parsed['conditions']['incar'], 'source_dataset': 'matcreator-bohrium', + 'source_id': step['job_id'], 'source_metadata': {'status': 'success', 'completion': parsed['completion'], + 'conditions': parsed['conditions'], 'energy_unit': 'eV', 'energy_basis': 'cell'}} + _compare(saved, expected) + previous = next(r for r in reuse['results'] if r['record_id'] == record_id) + current = next(r for r in selected['results'] if r['record_id'] == record_id) + require(previous == current, 'old tool receipt differs from current saved result') + require(str(saved['energy']) in final, 'final response omits actual energy') + archive = read_json(step['archive']['receipt_path']) + require(archive.get('status') == 'verified' and archive['record_id'] == record_id, 'source archive unverified') + for name, artifact in saved['source_metadata']['artifacts'].items(): + require(digest(report_path.parent / name) == artifact['sha256'], f'source artifact changed: {name}') + compared.append({'record_id': record_id, 'job_id': step['job_id'], 'energy_eV': saved['energy']}) + from types import SimpleNamespace + from matcreator.workspace import ADK_DIR + from matcreator.agents.execution_agent.remote_job_tools import list_remote_jobs + require((ADK_DIR / 'remote-jobs.db').is_file(), 'tracked-job store missing; cannot verify zero jobs') + context = SimpleNamespace(state={'session_id': evidence['session_id']}, _invocation_context=SimpleNamespace(user_id=args.user_id)) + jobs = list_remote_jobs(context) + require(jobs.get('status') == 'ok' and jobs['job_count'] == 0, 'acceptance session owns remote jobs') + final_path = verification_dir / 'final-response.txt' + final_path.write_text(final, encoding='utf-8') + return {'ok': True, 'verification': 'fresh-public-readback-and-retained-real-artifacts', + 'session_id': evidence['session_id'], 'records': compared, 'new_jobs_in_session': 0, + 'cloud_database_upload': False, 'query_report': receipt['report_path'], + 'final_response': str(final_path), 'scientific_claim_review': 'required'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + test = sub.add_parser('tests', help='run isolated regression; fail on skips or new failures') + test.add_argument('--full', action='store_true') + test.add_argument('--baseline', type=Path) + verify = sub.add_parser('verify-reuse', help='read back existing real records; never submit or archive') + verify.add_argument('--workspace', type=Path, required=True) + verify.add_argument('--source-report', type=Path, required=True) + verify.add_argument('--config', type=Path) + verify.add_argument('--user-id', default='user') + compare = sub.add_parser('compare-results', help='validate a pytest JSON report without running tests') + compare.add_argument('--pytest-report', type=Path, required=True) + compare.add_argument('--baseline', type=Path) + for command in (test, verify): + command.add_argument('--aidb-root') + command.add_argument('--aidb-python') + for command in (test, verify, compare): + command.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + try: + if args.command == 'tests': + payload = run_tests(args) + elif args.command == 'verify-reuse': + payload = verify_reuse(args) + else: + payload = {'ok': True, **check_results(read_json(args.pytest_report), read_json(args.baseline) if args.baseline else None)} + except (CheckError, OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as exc: + emit(args.output, {'ok': False, 'error': str(exc)}) + return 1 + emit(args.output, payload) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/script/silicon_test_report.py b/script/silicon_test_report.py new file mode 100644 index 00000000..6d7e4ac8 --- /dev/null +++ b/script/silicon_test_report.py @@ -0,0 +1,26 @@ +"""Pytest JSON reporting: counts and exact failure IDs, never a copied tally.""" +import json +import os +from pathlib import Path + +_results = [] + + +def pytest_runtest_logreport(report): + if report.when == 'call' or report.failed or report.skipped: + kind = 'error' if report.failed and report.when != 'call' else report.outcome + _results.append({'nodeid': report.nodeid, 'outcome': kind}) + + +def pytest_collectreport(report): + if report.failed or report.skipped: + _results.append({'nodeid': report.nodeid, 'outcome': 'error' if report.failed else 'skipped'}) + + +def pytest_sessionfinish(session, exitstatus): + output = os.environ.get('MATCREATOR_TEST_REPORT') + if output: + counts = {kind: sum(r['outcome'] == kind for r in _results) + for kind in ('passed', 'failed', 'error', 'skipped')} + Path(output).write_text(json.dumps({'exit_code': int(exitstatus), 'counts': counts, + 'failures': [r for r in _results if r['outcome'] in {'failed', 'error'}]}, indent=2)) diff --git a/src/matcreator/agents/aidb_skill_hook.py b/src/matcreator/agents/aidb_skill_hook.py new file mode 100644 index 00000000..abf04bab --- /dev/null +++ b/src/matcreator/agents/aidb_skill_hook.py @@ -0,0 +1,475 @@ +"""Material preflight with final B consent through the public aidb Skill adapter. + +Candidate discovery and fixed Si/PBE reuse use public exports. Lookup never +authorizes a new remote calculation. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +from pathlib import Path +import re +import sys +import uuid +import weakref + +from .session_log import append_session_log_entry + +AIDB_REUSE_INSTRUCTION = """ +For a silicon calculation request, consult the current `aidb_preflight` response +before preparing or submitting work. On reuse.status="reused", return its +verified results and record/source IDs; do not run preparation or computation. +Report relaxation and linked static energies separately with eV/cell units, +structure and actual conditions. Use the selection reason from the receipt. +Archive recency and a denser k mesh do not establish greater accuracy: never +claim a selected/static record is more accurate or highest precision. Current +public readback is authoritative; old memory/logs are not this turn's lookup. +On needs_clarification explain scientific differences or missing evidence; +On failed explain that existing data is unknown and computation may duplicate +existing work. Pause until the human replies with the exact confirmation shown +in this failure's message. Never generate that confirmation as an executor +action. Confirmation allows only the original scope and never marks lookup or +the full loop successful. Explicit recalculation still requires lookup. +""" + +logger = logging.getLogger(__name__) +# Locks live only while callbacks are running; no Futures enter ADK state. +_preflight_locks = weakref.WeakValueDictionary() +_CONTINUATION_RE = re.compile(r'继续|照旧|这个|该结构|改用|换成|切换材料|重算|重新计算|same|continue|recalculat|recompute|\bit\b', re.I) +AIDB_PREFLIGHT_SKILLS = frozenset({'vasp-pymatgen', 'atomic-structure'}) +_PREPARATION_TOOLS = frozenset({'run_python', 'run_bash', 'run_skill_script', + 'submit_bohr_batchjob', 'submit_bohr_sandbox', 'upload_remote_job_input', + 'start_remote_job_command', 'run_remote_job_command', 'run_sub_agent', 'run_flash_step'}) +_REMOTE_TOOLS = frozenset({'submit_bohr_batchjob', 'submit_bohr_sandbox', + 'upload_remote_job_input', 'start_remote_job_command', 'run_remote_job_command'}) +_CONFIRM_RE = re.compile(r'(?:确认继续|confirm continue)\s+([0-9a-f]{32})[。.!]?\s*', re.I) + + +def _failure_warning(feedback, tool_context, *, can_confirm): + failure_id = uuid.uuid4().hex + feedback.update(failure_id=failure_id, loop_complete=False, + bypass={'status': 'pending', 'can_confirm': can_confirm}, + message='本地查库失败,无法判断已有数据是否存在,继续可能重复计算;新增计算保持暂停。') + if can_confirm: + feedback['message'] += f'若明确接受风险并继续原授权范围,请回复「确认继续 {failure_id}」。拒绝或未确认保持暂停;仅准备授权不允许提交。' + else: + feedback['message'] += '任务信息提取失败,请先修复或澄清;不能绕过此错误。' + feedback['failure_session_id'] = tool_context._invocation_context.session.id + feedback['failure_invocation_id'] = tool_context.invocation_id + + +def _confirm_failure(text, task, saved, tool_context): + """Consent comes only from a later human turn after the exact warning.""" + feedback = saved.get('feedback') or {} + match = _CONFIRM_RE.fullmatch(text.partition('\n[AIDB_PRIOR_CONTEXT]\n')[0].strip()) + if (not match or tool_context.state['temp:aidb_internal_step'] + or feedback.get('status') != 'failed' + or not feedback.get('bypass', {}).get('can_confirm') + or match[1] != feedback.get('failure_id') + or feedback.get('failure_session_id') != tool_context._invocation_context.session.id + or task != feedback.get('task_context')): + return None + # State alone, including copied state from another session, is not evidence + # that this human has received the warning for this event. + events = tool_context._invocation_context.session.events + previous_user = next((event for event in reversed(events) if event.content + and event.content.role == 'user' and event.invocation_id != tool_context.invocation_id), None) + if not previous_user or previous_user.invocation_id != feedback.get('failure_invocation_id'): + return None + if not any(event.invocation_id != tool_context.invocation_id and event.content + and any(part.function_response and + (part.function_response.response.get('aidb_preflight') or {}).get('failure_id') == match[1] + for part in (event.content.parts or [])) for event in events): + return None + return {**feedback, 'bypass': {**feedback['bypass'], 'status': 'confirmed'}, + 'message': '已明确确认此次查询失败及重复计算风险;仅继续原授权范围。查询仍失败,不代表未命中、预检成功或完整闭环通过;计算后仍尝试本地归档并如实报告。'} + + +def inherit_aidb_confirmation(state, tool_context, child_session_id): + """The executor runner delegates this turn's grant to one fresh child.""" + saved = tool_context.state.get('aidb_preflight') or {} + feedback = saved.get('feedback') or {} + state.pop('aidb_parent_bypass', None) + if (saved.get('invocation_id') == tool_context.invocation_id + and feedback.get('bypass', {}).get('status') == 'confirmed'): + state['aidb_parent_bypass'] = {'child_session_id': child_session_id, + 'feedback': feedback, 'request': saved['request']} + + +def _delegated_failure(text, workspace, tool_context): + grant = tool_context.state.get('aidb_parent_bypass') or {} + if (not tool_context.state['temp:aidb_internal_step'] + or grant.get('child_session_id') != tool_context._invocation_context.session.id): + return None + feedback = grant['feedback'] + parent = feedback['task_context'] + task = _task_context(text, workspace, parent) + if (not task or task['known'] != parent['known'] + or not set(task['targets']).issubset(parent['targets']) + or (task['require_static'] and 'total_energy' not in parent['targets'])): + tool_context.state['aidb_parent_bypass'] = None + return None + task['prepare_only'] = task['prepare_only'] or parent['prepare_only'] + return {**feedback, 'task_context': task} + + +def _request(tool_context): + """Current user content wins over a previous goal or persisted receipt.""" + invocation = tool_context._invocation_context + content = invocation.user_content + text = '\n'.join(p.text for p in (content.parts or []) if p.text) + try: + step = json.loads(text) + except (ValueError, TypeError): + step = None + tool_context.state['temp:aidb_internal_step'] = isinstance(step, dict) and 'action' in step + if tool_context.state['temp:aidb_internal_step']: + text = str(step['action']) + '\n[AIDB_PRIOR_CONTEXT]\n' + str(step.get('prior_context') or '') + # Step requests inherit the parent's goal; it is current task context, + # rather than a prior user turn, in this isolated executor invocation. + text += '\n' + str(tool_context.state.get('goal') or '') + elif _CONTINUATION_RE.search(text): + for event in reversed(invocation.session.events): + prior = event.content + if prior and prior.role == 'user' and event.invocation_id != tool_context.invocation_id: + text += '\n[AIDB_PRIOR_CONTEXT]\n' + '\n'.join(p.text for p in (prior.parts or []) if p.text) + break + if not tool_context.state['temp:aidb_internal_step']: + tool_context.state['aidb_user_request'] = text.partition('\n[AIDB_PRIOR_CONTEXT]\n')[0] + return text + + +def _known_context(text, workspace): + lower = text.lower() + known = {} + if re.search(r'单质硅|硅晶|硅体|硅单体|金刚石.*硅|\bsilicon\b|(? LlmAgent: "Executes a single plan step in an isolated session. " "Receives structured input with action and skill name; loads skill instructions autonomously." ), - instruction=_STEP_EXECUTOR_INSTRUCTION, + instruction=_STEP_EXECUTOR_INSTRUCTION + AIDB_REUSE_INSTRUCTION, input_schema=StepExecutorInput, + before_tool_callback=before_aidb_skill_load, + after_tool_callback=after_aidb_skill_load, tools=[ FunctionTool(run_sub_agent), FunctionTool(submit_step_result), @@ -317,6 +322,8 @@ def build_step_executor_agent(llm_card: LLMCard) -> LlmAgent: FunctionTool(upload_remote_job_input), FunctionTool(download_remote_job_output), FunctionTool(collect_remote_job_outputs), + FunctionTool(collect_silicon_vasp_result), + FunctionTool(archive_silicon_vasp_result), FunctionTool(pause_remote_job), FunctionTool(terminate_remote_job), ALL_SKILLS_TOOLSET, diff --git a/src/matcreator/agents/execution_agent/step_executor_runner.py b/src/matcreator/agents/execution_agent/step_executor_runner.py index 3e85767b..c672569d 100644 --- a/src/matcreator/agents/execution_agent/step_executor_runner.py +++ b/src/matcreator/agents/execution_agent/step_executor_runner.py @@ -5,6 +5,7 @@ import mimetypes import os import re +import uuid from contextlib import aclosing from datetime import datetime, timezone from pathlib import Path @@ -19,6 +20,7 @@ from ...llm_cards import LLMCard, select_executor_llm_card from ...workspace import get_session_workdir +from ..aidb_skill_hook import inherit_aidb_confirmation from .step_executor import ( STEP_EXECUTOR_AGENT_NAME, StepExecutorInput, @@ -841,9 +843,12 @@ async def run_step_executor( state_dict["_graph_exec_node_id"] = step_id state_dict["_step_label_path"] = step_label_path + child_session_id = uuid.uuid4().hex + inherit_aidb_confirmation(state_dict, tool_context, child_session_id) session = await runner.session_service.create_session( app_name=child_app_name, user_id=invocation_context.user_id, + session_id=child_session_id, state=state_dict, ) diff --git a/src/matcreator/agents/silicon_reuse.py b/src/matcreator/agents/silicon_reuse.py new file mode 100644 index 00000000..a27806e1 --- /dev/null +++ b/src/matcreator/agents/silicon_reuse.py @@ -0,0 +1,146 @@ +"""Conservative reuse of the fixed diamond-Si/PBE workflow from public exports.""" +from datetime import datetime +import json +import math + +from pymatgen.core import Structure +from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + +from matcreator.tools.silicon_vasp import _same_structure + + +def _validate(record, task): + meta = record.get('source_metadata') or {} + conditions = meta.get('conditions') or {} + completion = meta.get('completion') or {} + stage = record.get('calculation_type') + if meta.get('test_only') or meta.get('status') != 'success': + raise ValueError('successful scientific status absent') + if completion.get('electronic_converged') is not True or completion.get('outcar_termination') is not True: + raise ValueError('electronic completion evidence absent') + if stage not in {'relaxation', 'static'} or (stage == 'relaxation' and completion.get('ionic_converged') is not True): + raise ValueError('requested calculation completion absent') + structure = Structure.from_dict(record['structure']) + if (not structure.is_ordered or set(str(s) for s in structure.species) != {'Si'} + or not all(structure.lattice.pbc) + or SpacegroupAnalyzer(structure, symprec=0.01).get_space_group_number() != 227): + raise ValueError('structure is not diamond bulk Si') + if record.get('functional') != 'PBE' or any(conditions.get(k) != v for k, v in + {'functional': 'PBE', 'domain': 'bulk', 'structure_model': 'diamond'}.items()): + raise ValueError('method or system differs from ordinary PBE bulk diamond') + incar = conditions.get('incar') or {} + parameters = conditions.get('parameters') or {} + if not incar or record.get('calculation_parameters') != incar: + raise ValueError('basic calculation conditions absent or inconsistent') + for values in (incar, parameters): + if (values.get('LDAU') or values.get('LHFCALC') or values.get('ML_LMLFF') + or values.get('METAGGA', '') not in {'', 'None', 'NONE'} + or values.get('IVDW') or values.get('LUSE_VDW') or values.get('XC') + or str(values.get('GGA', 'PE')).upper() not in {'PE', '--'}): + raise ValueError('ordinary PBE excludes U, hybrid and other corrections') + if not conditions.get('potcar_sha256') or not conditions.get('potcar_symbols') or not all( + s.startswith('PAW_PBE Si ') for s in conditions['potcar_symbols']): + raise ValueError('PBE potential evidence absent') + if not conditions.get('kpoints') and not conditions.get('actual_kpoints'): + raise ValueError('k-point conditions absent') + if not isinstance(incar.get('ENCUT'), (int, float)) or incar['ENCUT'] <= 0: + raise ValueError('cutoff condition absent') + if 'encut' in task['known'] and incar['ENCUT'] != task['known']['encut']: + raise ValueError('cutoff differs from current request') + for key, value in task['known'].get('incar_constraints', {}).items(): + if incar.get(key, parameters.get(key)) != value: + raise ValueError(f'{key} differs from current request or is unknown') + if 'kpoint_mesh' in task['known']: + from pymatgen.io.vasp.inputs import Kpoints + mesh = Kpoints.from_str(conditions.get('kpoints') or '').kpts + if len(mesh) != 1 or list(mesh[0]) != task['known']['kpoint_mesh']: + raise ValueError('k-point mesh differs from current request') + if task['known'].get('structure_path'): + if not _same_structure(structure, Structure.from_file(task['known']['structure_path'])): + raise ValueError('given structure differs; no generic equivalence assumed') + if not isinstance(record.get('energy'), (int, float)) or isinstance(record['energy'], bool) or not math.isfinite(record['energy']): + raise ValueError('total energy absent or non-finite') + if meta.get('energy_unit') != 'eV' or meta.get('energy_basis') != 'cell': + raise ValueError('energy unit or basis unsupported/absent') + if not record.get('source_dataset') or not record.get('source_id') or not meta.get('job_id'): + raise ValueError('source identity absent') + return structure + + +def _compatible_pair(parent, child): + pm, cm = parent['source_metadata'], child['source_metadata'] + relation = cm.get('relaxation_source') or {} + if cm.get('parent_record_id') != parent['record_id'] or relation.get('job_id') != pm['job_id']: + return False + pc, cc = pm['conditions'], cm['conditions'] + # These stages deliberately have different ionic/smearing/k-point settings. + # Preserve both; require common method/potential/cutoff/spin and final cell. + return (pc['potcar_sha256'] == cc['potcar_sha256'] + and all(pc['incar'].get(k) == cc['incar'].get(k) for k in ('ENCUT', 'ISPIN', 'LDAU', 'LHFCALC')) + and _same_structure(Structure.from_dict(parent['structure']), Structure.from_dict(child['structure']))) + + +def _signature(records): + return json.dumps([{'structure': r['structure'], 'stage': r['calculation_type'], + 'incar': r['source_metadata']['conditions']['incar'], + 'kpoints': r['source_metadata']['conditions'].get('kpoints'), + 'actual_kpoints': r['source_metadata']['conditions'].get('actual_kpoints'), + 'potential': r['source_metadata']['conditions']['potcar_sha256']} for r in records], sort_keys=True) + + +def select_reuse(records, task): + """Separate candidates from applicability, without a database connection.""" + valid, candidates = [], [] + for record in records: + candidate = {'record_id': record.get('record_id'), 'calculation_type': record.get('calculation_type'), + 'energy': record.get('energy'), 'functional': record.get('functional'), + 'source_dataset': record.get('source_dataset'), 'source_id': record.get('source_id'), + 'structure': record.get('structure'), 'conditions': (record.get('source_metadata') or {}).get('conditions'), + 'completion': (record.get('source_metadata') or {}).get('completion'), + 'energy_unit': (record.get('source_metadata') or {}).get('energy_unit')} + try: + _validate(record, task) + valid.append(record) + candidate.update(compatible=True, reason='scientific content and basic conditions verified') + except (ValueError, KeyError, TypeError, AttributeError) as exc: + candidate.update(compatible=False, reason=str(exc)) + candidates.append(candidate) + base = {'candidates': candidates, 'record_ids': [], 'new_calculation_needed': False} + if not task['targets'] or any(k in task['unknown'] for k in ('structure_model', 'domain', 'functional')): + return {**base, 'status': 'needs_clarification', 'reason': '请明确目标、结构、体系及方法;检索默认值不能作为复用条件。'} + relax = [r for r in valid if r['calculation_type'] == 'relaxation'] + static = [r for r in valid if r['calculation_type'] == 'static'] + options = [] + if 'relaxation' in task['targets']: + if 'total_energy' in task['targets']: + options = [[p, c] for p in relax for c in static if _compatible_pair(p, c)] + if not options and not task.get('require_static'): + options = [[p] for p in relax] + elif 'total_energy' in task['targets']: + options = [[c] for c in static] + if not options and not task.get('require_static'): + options = [[p] for p in relax] + if not options: + return {**base, 'status': 'unavailable', 'new_calculation_needed': True, + 'reason': '候选尚未核验为满足当前目标的可复用结果;保留缺失/不符证据。'} + if len({_signature(option) for option in options}) > 1: + return {**base, 'status': 'needs_clarification', 'reason': '完整候选的结构或计算条件有实质差异,请选择科学条件。'} + if len(options) > 1: + try: + times = [datetime.fromisoformat(option[-1]['source_metadata']['archived_at']) for option in options] + if any(t.tzinfo is None for t in times) or len(set(times)) != len(times): + raise ValueError('archive order ambiguous') + options = [options[max(range(len(times)), key=times.__getitem__)]] + except (KeyError, ValueError, TypeError): + return {**base, 'status': 'needs_clarification', 'reason': '持久归档时间不足以确定最近成功结果,请澄清。'} + chosen = options[0] + energy = chosen[-1] + return {**base, 'status': 'recalculate' if task['recalculate'] else 'reused', + 'record_ids': [r['record_id'] for r in chosen], 'structure': chosen[0]['structure'], + 'total_energy': {'value': energy['energy'], 'unit': 'eV', 'basis': 'cell'}, + 'results': [{'record_id': r['record_id'], 'calculation_type': r['calculation_type'], + 'structure': r['structure'], 'total_energy': {'value': r['energy'], 'unit': 'eV', 'basis': 'cell'}, + 'source_dataset': r['source_dataset'], 'source_id': r['source_id'], + 'source_metadata': r['source_metadata']} for r in chosen], + 'reason': '公开读回核验成功、结构/方法/目标兼容;同条件优先最近成功归档,不代表精度最高。', + 'new_calculation_needed': task['recalculate']} diff --git a/src/matcreator/agents/thinking_agent/agent.py b/src/matcreator/agents/thinking_agent/agent.py index a1adf72a..93c79c11 100644 --- a/src/matcreator/agents/thinking_agent/agent.py +++ b/src/matcreator/agents/thinking_agent/agent.py @@ -12,6 +12,7 @@ from google.adk.tools.tool_context import ToolContext from google.adk.agents.callback_context import CallbackContext +from ..aidb_skill_hook import AIDB_REUSE_INSTRUCTION, before_aidb_skill_load, after_aidb_skill_load from ...constants import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL from ...adk_compat import install_lenient_tool_argument_parsing from .planning import validate_plan, validate_graph @@ -46,6 +47,8 @@ ) from .history_tools import read_session_log from ..execution_agent.remote_job_tools import list_remote_jobs +from ...tools.silicon_vasp import collect_silicon_vasp_result +from ...tools.silicon_archive import archive_silicon_vasp_result logger = logging.getLogger(__name__) @@ -405,7 +408,7 @@ async def run_flash_step( or `node_id="..."` for one executor of interest; do not request bulk detail by default. """ -_MATCREATOR_INSTRUCTION = "{instruction_body}" +_MATCREATOR_INSTRUCTION = "{instruction_body}" + AIDB_REUSE_INSTRUCTION # --------------------------------------------------------------------------- # before_agent_callback: inject dynamic context into session state @@ -499,10 +502,14 @@ def before_agent_callback(callback_context: CallbackContext) -> None: FunctionTool(run_flash_step), FunctionTool(read_session_log), FunctionTool(list_remote_jobs), + FunctionTool(collect_silicon_vasp_result), + FunctionTool(archive_silicon_vasp_result), FunctionTool(load_skill), show_artifact, show_plot, show_structure, ], before_agent_callback=before_agent_callback, + before_tool_callback=before_aidb_skill_load, + after_tool_callback=after_aidb_skill_load, ) diff --git a/src/matcreator/skill.py b/src/matcreator/skill.py index 89eed7db..686778a9 100644 --- a/src/matcreator/skill.py +++ b/src/matcreator/skill.py @@ -397,6 +397,9 @@ class MatCreatorLoadSkillTool(skill_toolset.LoadSkillTool): """Augment ADK skill loads with bundle-file and L3/L4 attachment metadata.""" async def run_async(self, *, args, tool_context): + requested = (args.get('skill_name') or '').strip().lower() + loaded = self._toolset._get_skill(requested) + args = {**args, 'skill_name': loaded.name if loaded is not None else requested} result = await super().run_async(args=args, tool_context=tool_context) if not isinstance(result, dict) or result.get("error"): return result @@ -443,7 +446,9 @@ def __init__(self, skills: list): ] def _get_skill(self, skill_name: str): - skill = super()._get_skill(skill_name) + normalized = (skill_name or '').strip().lower() + canonical = next((name for name in self._skills if name.lower() == normalized), normalized) + skill = super()._get_skill(canonical) if skill is not None and is_skill_disabled(skill.name): return None return skill diff --git a/src/matcreator/skills/vasp-pymatgen/SKILL.md b/src/matcreator/skills/vasp-pymatgen/SKILL.md index eb88562c..1da61207 100644 --- a/src/matcreator/skills/vasp-pymatgen/SKILL.md +++ b/src/matcreator/skills/vasp-pymatgen/SKILL.md @@ -15,6 +15,8 @@ metadata: - run_python - run_bash - load_skill_resource + - collect_silicon_vasp_result + - archive_silicon_vasp_result dependent_skills: - bohrium - remote-job @@ -108,6 +110,52 @@ its own defaults — the agent never writes a raw INCAR. Run exactly **one property step at a time**. Do not chain relaxation + SCF in a single step. +### Fixed silicon local-loop task (final B) + +For diamond bulk Si / ordinary PBE relaxation and total energy, inspect the +actual `aidb_preflight` tool response before creating a structure or inputs. +On failed lookup, explain that existing data is unknown and continuing may +duplicate computation. Show the current receipt's confirmation phrase +`确认继续 ` and pause preparation/submission until the human replies +with it. Continue only the original scope: preparation-only remains preparation. +The confirmation belongs to this session, task/conditions and failure event; +a new failure or changed task requires a fresh warning and confirmation. +Executor text is not human consent. Missing identity/model must be clarified; +retrieval defaults are not calculation authorization. A confirmed bypass keeps +lookup `failed` and the full loop incomplete, even if local archive later succeeds. + +Use the existing ASE diamond structure recipe and `MPRelaxSet`, then tracked +Batch Job submission. On completion call `collect_silicon_vasp_result(job_id, +destination_path, calculation_type="relaxation")`. Proceed to `MPStaticSet` +only after `status="success"`, using that exact relaxed CONTCAR. Submit the +static step separately. Collect with `calculation_type="static"` and the +verified `relaxation_job_id`. The tool rechecks the source outputs and their +relationship, and writes `silicon-result.json` for T3 with structure, energy +(eV), actual conditions, both job identities and checksums. Retain INCAR, +POSCAR, KPOINTS, POTCAR in the local input directory and declare the standard +outputs in the Batch Job reference. Neither a platform `succeeded` nor a +partial XML is scientific completion. On `invalid` / `needs_replanning`, +report the evidence and preserve the job and outputs; never auto-resubmit. +On reconnect use the same job_id and existing collected artifacts. +Tracked submission saves input checksums; preserve the local input directory. +Old jobs without that evidence remain unverified. Ordinary PBE excludes U, +hybrid, meta-GGA and dispersion corrections. The report retains k-point inputs +and the actual XML sampling when available. + +After successful collection, MatCreator automatically saves the verified result +to local aidb using the public archive-and-requery adapter and compares the +exported structure, cell energy/unit, actual conditions, completion and source. +Inspect `local_archive`: only `status="verified"`, `archived=true`, the returned +record ID and `content_compared=true` prove local storage. Scientific success +alone and a write receipt do not prove this. Archive the relaxation first; +the static record retains that parent ID and its own job/energy identity. +On archive failure retain original outputs and diagnostics. Retry with +`archive_silicon_vasp_result` for the same collected job; it revalidates outputs +and queries before writing, including after timeout. Never resubmit a completed +calculation to repair storage. Do not request confirmation for local saving. +No cloud database upload is performed. Final B is the default; see +`docs/silicon_vasp_t5.md` for the prerequisite and switch evidence. + For **MLFF energy/force labeling**, use `prepare_label` as a standalone step — it replaces the relaxation → SCF chain with a single static calculation optimized for dataset generation (no charge density, no relaxation, KSPACING-based k-points). --- diff --git a/src/matcreator/tools/silicon_archive.py b/src/matcreator/tools/silicon_archive.py new file mode 100644 index 00000000..4c4f284e --- /dev/null +++ b/src/matcreator/tools/silicon_archive.py @@ -0,0 +1,181 @@ +"""Local Si result round trips through the aidb Skill's public adapter only.""" +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import subprocess +import sys +import uuid + +from google.adk.tools.tool_context import ToolContext +from monty.json import MontyEncoder + + +def _write(path, payload): + temporary = path.with_name(f'.{path.name}.{uuid.uuid4().hex}.tmp') + temporary.write_text(json.dumps(payload, cls=MontyEncoder, indent=2), encoding='utf-8') + temporary.replace(path) + + +def _compare(saved, source): + from pymatgen.core import Structure + from .silicon_vasp import _same_structure + source = json.loads(json.dumps(source, cls=MontyEncoder)) + mismatches = [] + if not _same_structure(Structure.from_dict(saved['structure']), Structure.from_dict(source['structure'])): + mismatches.append('structure') + for key in ('source_dataset', 'source_id', 'energy', 'functional', 'calculation_type', 'calculation_parameters'): + if saved.get(key) != source.get(key): + mismatches.append(key) + metadata = saved.get('source_metadata') or {} + for key, value in source['source_metadata'].items(): + if metadata.get(key) != value: + mismatches.append(f'source_metadata.{key}') + if not metadata.get('archived_at'): + mismatches.append('source_metadata.archived_at') + if mismatches: + raise ValueError('Archived content differs from verified output: ' + ', '.join(mismatches)) + + +def archive_verified_result(report: dict, tool_context: ToolContext) -> dict: + """Archive a freshly scientifically verified handoff; never submit a job. + + Query/export before each write, including recovery after an uncertain + timeout. Never overwrite a differing record for the same tracked job. + """ + from matcreator.agents.aidb_skill_hook import _adapter_path + workspace = Path(tool_context.state['workspace_dir']).resolve() + directory = workspace / '.aidb' / 'archives' / report['job_id'] + directory.mkdir(parents=True, exist_ok=True) + attempt = directory / uuid.uuid4().hex + attempt.mkdir() + receipt = {'status': 'failed', 'archived': False, 'job_id': report['job_id'], + 'source_report_path': str(Path(report['output_path']) / 'silicon-result.json'), + 'receipt_path': str(attempt / 'receipt.json'), 'cloud_upload': False} + try: + if report.get('status') != 'success': + raise ValueError('Scientific success is required before local archive.') + # This Unix lock matches the supported Unix-socket aidb bridge. It also + # prevents concurrent completion callbacks from racing the same write. + import fcntl + with (directory / 'archive.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + task_path = attempt / 'task.json' + _write(task_path, {'task_id': f"silicon-{report['job_id']}", 'target': { + 'formula': 'Si', 'functional': 'PBE', 'domain': 'bulk', 'min_records': 1}, + 'related_materials': []}) + adapter = _adapter_path() + + def command(stage, arguments): + env = {**os.environ, 'AI_READY_DB_BRIDGE_TIMEOUT': '20', 'AI_READY_DB_BRIDGE_STARTUP_TIMEOUT': '15'} + try: + proc = subprocess.run([sys.executable, str(adapter), '--workspace', str(workspace), + *arguments], capture_output=True, text=True, env=env, timeout=75) + except subprocess.TimeoutExpired as exc: + receipt['write_completion_unknown'] = stage == 'archive' + _write(attempt / f'{stage}.json', {'timed_out': True, 'stage': stage, + 'stdout': str(exc.stdout or ''), 'stderr': str(exc.stderr or '')}) + raise + _write(attempt / f'{stage}.json', {'returncode': proc.returncode, + 'stdout': proc.stdout, 'stderr': proc.stderr}) + payload = json.loads(proc.stdout) + receipt[f'{stage}_audit_path'] = payload.get('audit_path') + receipt[f'{stage}_report_path'] = payload.get('report_path') + if proc.returncode or payload.get('ok') is not True: + raise ValueError(f'{stage} failed: {payload}') + return payload + + preflight = command('recovery_query', ['preflight', '--task-json', str(task_path), + '--command-timeout', '20', '--export-dir', str(attempt / 'before'), '--export-format', 'jsonl']) + query = json.loads(Path(preflight['report_path']).read_text()) + records = [] + for export in query['exports']: + records.extend(json.loads(line) for line in Path(export['output_path']).read_text().splitlines() if line.strip()) + existing = [r for r in records if r.get('source_dataset') == 'matcreator-bohrium' + and r.get('source_id') == report['job_id']] + if len(existing) > 1: + raise ValueError('Multiple archive identities for this tracked job; resolve conflict before writing.') + conditions = report['conditions'] + metadata = {'job_id': report['job_id'], 'batchjob_id': report['batchjob_id'], + 'session_id': str(tool_context.state.get('session_id') or ''), + 'status': 'success', 'completion': report['completion'], 'energy_unit': report['total_energy']['unit'], + 'energy_basis': 'cell', 'conditions': conditions, 'artifacts': report['artifacts'], + 'submission': report['submission'], 'output_path': report['output_path'], + 'relaxation_source': report.get('relaxation_source'), + 'parent_record_id': None, 'origin': 'local_calculation', 'sync_status': 'local_only'} + parent = report.get('relaxation_source') + if parent: + parents = [r for r in records if r.get('source_dataset') == 'matcreator-bohrium' + and r.get('source_id') == parent['job_id'] and r.get('calculation_type') == 'relaxation'] + if len(parents) != 1: + raise ValueError('Archive and verify preceding relaxation before static result.') + from matcreator.agents.execution_agent.remote_job_tools import _service + from .silicon_vasp import _validate, _input_directory + parent_spec = _service().store.get_job(parent['job_id'])['specification'] + parent_report = _validate(Path(parent['output_path']), 'relaxation', + _input_directory(parent_spec, workspace), parent_spec.get('vasp_input_sha256', {})) + parent_source = {'source_dataset': 'matcreator-bohrium', 'source_id': parent['job_id'], + 'structure': parent_report['structure'], 'energy': parent_report['total_energy']['value'], + 'functional': 'PBE', 'calculation_type': 'relaxation', + 'calculation_parameters': parent_report['conditions']['incar'], + 'source_metadata': {'conditions': parent_report['conditions'], 'status': 'success', + 'completion': parent_report['completion'], 'energy_unit': 'eV', 'job_id': parent['job_id']}} + _compare(parents[0], parent_source) + metadata['parent_record_id'] = parents[0]['record_id'] + source = {'source_dataset': 'matcreator-bohrium', 'source_id': report['job_id'], + 'structure': report['structure'], 'energy': report['total_energy']['value'], + 'functional': 'PBE', 'calculation_type': report['calculation_type'], + 'calculation_parameters': conditions['incar'], 'source_metadata': metadata, + 'provenance': {'provider': 'bohr_batchjob', 'job_id': report['job_id'], 'batchjob_id': report['batchjob_id']}} + # Normalize JSON exactly as it travels through the public contract. + source = json.loads(json.dumps(source, cls=MontyEncoder)) + if existing: + _compare(existing[0], source) + saved = existing[0] + export_path = attempt / 'existing.jsonl' + _write(export_path, saved) + receipt['previously_present'] = True + else: + record_path = directory / 'record.json' + if record_path.exists(): + pending = json.loads(record_path.read_text()) + _compare(pending, source) + source['source_metadata']['archived_at'] = datetime.now(timezone.utc).isoformat() + _write(record_path, source) + archived = command('archive', ['archive-and-requery', '--task-json', str(task_path), + '--record-json', str(record_path), '--origin', 'local_calculation', '--sync-status', 'local_only', + '--export-dir', str(attempt / 'after'), '--export-format', 'jsonl']) + receipt['record_id'] = archived['record_id'] + export_path = Path(archived['export_path']) + readback = [json.loads(line) for line in export_path.read_text().splitlines() if line.strip()] + if len(readback) != 1 or readback[0]['record_id'] != archived['record_id']: + raise ValueError('Archive export does not contain exactly the returned record ID.') + saved = readback[0] + _compare(saved, source) + receipt['previously_present'] = False + receipt.update(status='verified', archived=True, record_id=saved['record_id'], + export_path=str(export_path), archived_at=saved['source_metadata']['archived_at'], + verified_in_requery=True, content_compared=True) + except Exception as exc: + receipt['error'] = str(exc) or type(exc).__name__ + receipt['message'] = 'Local archive unverified; retain outputs and retry lookup before writing. Do not resubmit calculation.' + _write(attempt / 'receipt.json', receipt) + return receipt + + +def archive_silicon_vasp_result(job_id: str, destination_path: str, calculation_type: str, + tool_context: ToolContext, relaxation_job_id: str = '') -> dict: + """Retry local archive of an existing Si job after rechecking original outputs. + + Does not submit any computation or upload to a cloud database. + """ + from .silicon_vasp import collect_silicon_vasp_result + collected = collect_silicon_vasp_result(job_id, destination_path, calculation_type, tool_context, relaxation_job_id) + if collected.get('status') != 'success': + return collected + report = json.loads(Path(collected['report_path']).read_text()) + archive = archive_verified_result(report, tool_context) + return {**collected, 'archived': archive['archived'], 'local_archive': archive, + 'message': 'Scientific result verified; local archive ' + archive['status'] + '.'} diff --git a/src/matcreator/tools/silicon_vasp.py b/src/matcreator/tools/silicon_vasp.py new file mode 100644 index 00000000..4dff1cca --- /dev/null +++ b/src/matcreator/tools/silicon_vasp.py @@ -0,0 +1,205 @@ +"""Scientific silicon handoff for tracked Bohrium jobs. + +Submission, monitoring and collection remain owned by remote-job. This module +does not archive results or upload to a cloud database. +""" +from __future__ import annotations + +import hashlib +import json +import math +import re +from pathlib import Path +from typing import Literal + +from monty.json import MontyEncoder + +from google.adk.tools.tool_context import ToolContext + + +def _digest(path: Path) -> str: + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def capture_vasp_input_hashes(source: Path) -> dict: + """Capture materialized VASP inputs before tracked submission, without contents.""" + if not source.is_dir() or not all((source / name).is_file() for name in ('INCAR', 'POSCAR', 'POTCAR')): + return {} + files = [source / name for name in ('INCAR', 'POSCAR', 'POTCAR', 'KPOINTS') if (source / name).exists()] + if any(p.is_symlink() for p in files): + raise ValueError('VASP inputs must be regular files, not symbolic links.') + return {p.name: _digest(p) for p in files} + + +def _input_directory(spec: dict, workspace: Path) -> Path: + path = (Path(spec['input_root']) / spec['input_path']).resolve() + if not path.is_relative_to(workspace): + raise ValueError('Original inputs are outside this workspace.') + return path + + +def _same_structure(actual, expected) -> bool: + import numpy as np + return (actual.species == expected.species + and np.allclose(actual.lattice.matrix, expected.lattice.matrix, atol=1e-5, rtol=0) + and np.allclose(actual.frac_coords, expected.frac_coords, atol=1e-5, rtol=0)) + + +def _output_directory(artifacts: list, workspace: Path) -> Path: + paths = [Path(a['destination']).resolve() for a in artifacts if a.get('destination')] + if not paths or any(not p.is_relative_to(workspace) for p in paths): + raise ValueError('Collected outputs are unavailable in this workspace.') + candidates = {p if p.is_dir() else p.parent for p in paths} + matches = [p for p in candidates if (p / 'vasprun.xml').is_file()] + if len(matches) == 1: + return matches[0] + if len(candidates) == 1: + return candidates.pop() + raise ValueError('Cannot identify a unique VASP output directory.') + + +def _validate(directory: Path, calculation_type: str, input_directory: Path, input_hashes: dict) -> dict: + from pymatgen.io.vasp.inputs import Incar, Poscar + from pymatgen.io.vasp.outputs import Vasprun + from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + + required = ['vasprun.xml', 'OUTCAR', 'OSZICAR', 'CONTCAR', 'INCAR', 'POSCAR'] + missing = [name for name in required if not (directory / name).is_file()] + if missing: + raise ValueError(f'Missing VASP outputs: {", ".join(missing)}; scientific completion unknown.') + if not all(input_hashes.get(name) for name in ('INCAR', 'POSCAR', 'POTCAR')): + raise ValueError('Submission-time VASP input hashes unavailable; provenance unknown.') + for name, digest in input_hashes.items(): + if name not in {'INCAR', 'POSCAR', 'POTCAR', 'KPOINTS'}: + raise ValueError('Unrecognized VASP input provenance.') + if not (input_directory / name).is_file() or _digest(input_directory / name) != digest: + raise ValueError(f'Original {name} changed since tracked submission.') + if (name != 'POTCAR' or (directory / name).exists()) and (not (directory / name).is_file() or _digest(directory / name) != digest): + raise ValueError(f'Retained {name} differs from submitted job input.') + run = Vasprun(directory / 'vasprun.xml', parse_dos=False, parse_eigen=False, + parse_projected_eigen=False, parse_potcar_file=False, exception_on_bad_xml=True) + incar = Incar.from_file(directory / 'INCAR') + if not run.ionic_steps or any(not step.get('electronic_steps') for step in run.ionic_steps): + raise ValueError('Electronic/ionic step evidence missing.') + if not run.converged_electronic or any(len(step['electronic_steps']) >= int(run.parameters['NELM']) for step in run.ionic_steps): + raise ValueError('Electronic convergence not reached.') + outcar = (directory / 'OUTCAR').read_text(errors='replace') + if 'General timing and accounting informations for this job' not in outcar: + raise ValueError('VASP termination evidence missing from OUTCAR.') + nsw = int(run.parameters.get('NSW', 0)) + ibrion = int(run.parameters.get('IBRION', -1)) + if calculation_type == 'relaxation': + if nsw <= 1 or ibrion not in {1, 2} or not run.converged_ionic or 'reached required accuracy' not in outcar: + raise ValueError('Ionic relaxation convergence not established.') + elif nsw != 0 or ibrion != -1: + raise ValueError('Expected a separate static total-energy step (NSW=0, IBRION=-1).') + if (run.parameters.get('LDAU', False) or run.parameters.get('LHFCALC', False) + or run.incar.get('ML_LMLFF', False) or run.parameters.get('METAGGA', '') not in {'', 'None', 'NONE'} + or run.parameters.get('IVDW', 0) or run.parameters.get('LUSE_VDW', False) + or incar.get('IVDW', 0) or incar.get('LUSE_VDW', False) + or incar.get('XC') or run.incar.get('XC') or run.parameters.get('XC')): + raise ValueError('Only ordinary PBE is supported; corrections/hybrid/meta-GGA detected.') + potcar_text = (input_directory / 'POTCAR').read_text(errors='replace') + gga = str(run.parameters.get('GGA', 'PE')).strip().upper() + lexch = re.findall(r'\bLEXCH\s*=\s*(\S+)', potcar_text) + # VASP emits '--' when GGA is not selected explicitly: the functional then + # comes from the immutable submitted POTCAR's LEXCH, not the placeholder. + default_pbe = (gga == '--' and len(lexch) == len(run.potcar_symbols) + and bool(lexch) and all(value.upper() == 'PE' for value in lexch)) + if (gga != 'PE' and not default_pbe + or any(str(values.get('GGA', 'PE')).strip().upper() != 'PE' for values in (incar, run.incar)) + or not run.potcar_symbols or any(not s.startswith('PAW_PBE Si ') for s in run.potcar_symbols)): + raise ValueError('Ordinary PBE Si pseudopotential evidence missing or incompatible.') + titles = re.findall(r'^\s*TITEL\s*=\s*(.+?)\s*$', potcar_text, re.M) + if [' '.join(t.split()) for t in titles] != [' '.join(t.split()) for t in run.potcar_symbols]: + raise ValueError('Submitted POTCAR identity disagrees with actual VASP output.') + for structure in (run.initial_structure, run.final_structure): + if not structure.is_ordered or set(str(s) for s in structure.species) != {'Si'} or SpacegroupAnalyzer(structure, symprec=0.01).get_space_group_number() != 227: + raise ValueError('Output is outside fixed diamond bulk silicon scope.') + initial = Poscar.from_file(directory / 'POSCAR').structure + final = Poscar.from_file(directory / 'CONTCAR').structure + for actual, expected in ((initial, run.initial_structure), (final, run.final_structure)): + if not _same_structure(actual, expected): + raise ValueError('POSCAR/CONTCAR disagrees with vasprun.xml structure.') + if calculation_type == 'static' and not _same_structure(initial, final): + raise ValueError('Static calculation changed its input structure.') + energy = float(run.final_energy) + if not math.isfinite(energy): + raise ValueError('Final total energy unavailable or non-finite.') + return {'structure': run.final_structure.as_dict(), 'initial_structure': run.initial_structure.as_dict(), + 'total_energy': {'value': energy, 'unit': 'eV'}, + 'conditions': {'functional': 'PBE', 'domain': 'bulk', 'structure_model': 'diamond', + 'functional_selection': 'POTCAR LEXCH' if default_pbe else 'GGA', + 'software': 'VASP', 'version': run.vasp_version, 'incar': dict(incar), + 'parameters': run.parameters, 'potcar_symbols': run.potcar_symbols, + 'potcar_sha256': input_hashes['POTCAR'], 'input_sha256': input_hashes, + 'kpoints': (directory / 'KPOINTS').read_text() if (directory / 'KPOINTS').is_file() else None, + 'actual_kpoints': getattr(run, 'actual_kpoints', None), + 'actual_kpoint_weights': getattr(run, 'actual_kpoints_weights', None)}, + 'completion': {'electronic_converged': True, + 'ionic_converged': True if calculation_type == 'relaxation' else None, + 'ionic_steps': len(run.ionic_steps), 'outcar_termination': True}, + 'artifacts': {p.name: {'path': str(p), 'sha256': _digest(p)} for p in directory.iterdir() if p.is_file() and p.name != 'silicon-result.json'}} + + +def collect_silicon_vasp_result(job_id: str, destination_path: str, + calculation_type: Literal['relaxation', 'static'], tool_context: ToolContext, relaxation_job_id: str = '') -> dict: + """Collect an existing tracked Si Batch Job and verify scientific completion. + + Never submits or replaces a job. A platform success alone is not a + scientific success. Replays use the original durable collection paths. + """ + from matcreator.agents.execution_agent.remote_job_tools import ( + get_remote_job_status, collect_remote_job_outputs, _service) + if calculation_type not in {'relaxation', 'static'}: + return {'status': 'invalid', 'job_id': job_id, 'message': 'Expected relaxation or static.'} + job = get_remote_job_status(job_id, tool_context) + if job.get('status') == 'error': + return job + if job['provider'] != 'bohr_batchjob' or job.get('user_control') or job['status'] not in {'succeeded', 'collected'}: + return {'status': 'needs_replanning', 'job_id': job_id, 'platform_status': job['status'], + 'message': f"Existing job is {job['status']}; do not resubmit.", 'error': job.get('error')} + collected = collect_remote_job_outputs(job_id, destination_path, tool_context) + if collected.get('status') != 'collected': + return {**collected, 'status': 'needs_replanning'} + workspace = Path(tool_context.state['workspace_dir']).resolve() + try: + directory = _output_directory(collected['artifacts'], workspace) + except ValueError as exc: + return {'status': 'invalid', 'job_id': job_id, 'message': str(exc)} + report = {'job_id': job_id, 'batchjob_id': job['external_id'], 'calculation_type': calculation_type, + 'output_path': str(directory), 'archived': False} + try: + spec = _service().store.get_job(job_id)['specification'] + report['submission'] = {k: spec[k] for k in ('image', 'machine_type', 'sku_id', 'project_id', + 'command', 'input_path', 'out_files', 'max_run_time', 'max_wait_time') if k in spec} + input_directory = _input_directory(spec, workspace) + report.update(_validate(directory, calculation_type, input_directory, spec.get('vasp_input_sha256', {}))) + if calculation_type == 'static': + if not relaxation_job_id or relaxation_job_id == job_id: + raise ValueError('Provide the preceding verified relaxation_job_id.') + parent = get_remote_job_status(relaxation_job_id, tool_context) + if parent.get('status') != 'collected' or parent.get('provider') != 'bohr_batchjob' or parent.get('user_control'): + raise ValueError('Preceding relaxation is not collected in this session.') + parent_job = _service().store.get_job(relaxation_job_id) + parent_dir = _output_directory(parent_job['artifacts'], workspace) + parent_spec = parent_job['specification'] + parent_input = _input_directory(parent_spec, workspace) + parent_result = _validate(parent_dir, 'relaxation', parent_input, parent_spec.get('vasp_input_sha256', {})) + from pymatgen.core import Structure + relaxed = Structure.from_dict(parent_result['structure']) + static_input = Structure.from_dict(report['initial_structure']) + if not _same_structure(relaxed, static_input): + raise ValueError('Static input does not match the verified relaxed structure.') + if report['conditions']['potcar_sha256'] != parent_result['conditions']['potcar_sha256']: + raise ValueError('Static and relaxation pseudopotentials differ.') + report['relaxation_source'] = {'job_id': relaxation_job_id, 'batchjob_id': parent['external_id'], + 'output_path': str(parent_dir), 'total_energy': parent_result['total_energy']} + report['status'] = 'success' + except Exception as exc: + report.update(status='invalid', message=str(exc)) + report_path = directory / 'silicon-result.json' + report_path.write_text(json.dumps(report, cls=MontyEncoder, ensure_ascii=False, indent=2), encoding='utf-8') + return {key: report[key] for key in ('status', 'job_id', 'output_path', 'archived')} | { + 'report_path': str(report_path), 'message': report.get('message', 'Scientific completion verified; ready for T3, not archived.')} diff --git a/src/matcreator/tools/workspace_tools.py b/src/matcreator/tools/workspace_tools.py index 67a6c945..7bda36fd 100644 --- a/src/matcreator/tools/workspace_tools.py +++ b/src/matcreator/tools/workspace_tools.py @@ -18,6 +18,7 @@ import asyncio import os +import sys import textwrap from pathlib import Path @@ -330,7 +331,7 @@ async def run_python(code: str, tool_context: ToolContext) -> str: if session_id: cwd = str(get_session_workdir(session_id)) proc = await asyncio.create_subprocess_exec( - "python", "-c", code, + sys.executable, "-c", code, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -368,11 +369,15 @@ async def run_bash(script: str, tool_context: ToolContext) -> str: session_id = tool_context.state.get("session_id") if session_id: cwd = str(get_session_workdir(session_id)) + # Keep the harness configuration; login profiles can replace its venv PATH. + env = os.environ.copy() + env["PATH"] = str(Path(sys.executable).parent) + os.pathsep + env.get("PATH", "") proc = await asyncio.create_subprocess_exec( - "bash", "-lc", script, + "bash", "-c", script, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, + env=env, ) try: timeout = _execution_timeout_seconds() @@ -407,7 +412,7 @@ async def run_python_file(relative_path: str) -> str: if not target.exists(): return f"File not found: {target}" proc = await asyncio.create_subprocess_exec( - "python", str(target), + sys.executable, str(target), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) diff --git a/tests/test_aidb_skill_hook.py b/tests/test_aidb_skill_hook.py new file mode 100644 index 00000000..a77c0c63 --- /dev/null +++ b/tests/test_aidb_skill_hook.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +import pytest +from google.adk.agents import LlmAgent +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types +from pydantic import PrivateAttr + +from matcreator.agents import aidb_skill_hook as hook + + +@pytest.fixture +def local_aidb(tmp_path, monkeypatch): + root = Path(os.environ.get('AI_READY_DB_ROOT') or Path(__file__).resolve().parents[2] / 'Ai-ready_Database').expanduser().resolve() + python = Path(os.environ.get('AI_READY_DB_PYTHON') or root / '.venv/bin/python').expanduser().absolute() + if not python.is_file() or not (root / 'skills/ai-ready-db/scripts/aidb_loop_adapter.py').is_file(): + message = 'aidb public bridge/runtime required; configure AI_READY_DB_ROOT and AI_READY_DB_PYTHON' + if os.environ.get('MATCREATOR_REQUIRE_AIDB') == '1': + pytest.fail(message) + pytest.skip(message) + monkeypatch.setenv('AI_READY_DB_ROOT', str(root)) + monkeypatch.setenv('AI_READY_DB_PYTHON', str(python)) + monkeypatch.setenv('AI_READY_DB_BRIDGE_SOCK', str(tmp_path / 'bridge.sock')) + config = tmp_path / 'config.json' + config.write_text(json.dumps({'ase_sqlite': {'path': str(tmp_path / 'local.db'), + 'json_sidecar_path': str(tmp_path / 'sidecar.json')}, 'work_dir': str(tmp_path), + 'xc_functional': 'PBE', 'dft_u': False, 'system_type': 'bulk'})) + monkeypatch.setenv('AI_READY_DB_CONFIG', str(config)) + monkeypatch.setenv('AI_READY_DB_BRIDGE_IDLE_TIMEOUT', '1') + yield root + # Ask the isolated read-only bridge to exit; never touch the user's server. + import signal + pid_file = Path(str(tmp_path / 'bridge.sock') + '.pid') + if pid_file.exists(): + try: + os.kill(int(pid_file.read_text()), signal.SIGTERM) + except (OSError, ValueError): + pass + + +def run_task(tmp_path, request, calls, state=None, entry=None, followup=None, parallel=False, extra_tools=(), turns=(), final_state=None, trace_path=None): + class Model(BaseLlm): + _index: int = PrivateAttr(default=0) + async def generate_content_async(self, llm_request, stream=False): + if parallel: + parts = [types.Part(function_call=types.FunctionCall(name=name, args=args)) for name, args in calls] if self._index == 0 else [types.Part(text='finished')] + self._index += 1 + yield LlmResponse(content=types.Content(role='model', parts=parts)) + return + if self._index < len(calls): + name, args = calls[self._index] + part = types.Part(function_call=types.FunctionCall(name=name, args=args)) + else: + if followup and self._index == len(calls) + 1: + name, args = calls[(self._index - len(calls) - 1) % len(calls)] + part = types.Part(function_call=types.FunctionCall(name=name, args=args)) + else: + part = types.Part(text='finished') + self._index += 1 + yield LlmResponse(content=types.Content(role='model', parts=[part])) + + prepared = [] + def load_skill(skill_name: str) -> dict: + return {'instructions': 'VASP instructions', 'skill': skill_name} + def run_python(code: str) -> dict: + prepared.append(code) + return {'status': 'prepared'} + def submit_bohr_batchjob(name: str) -> dict: + prepared.append('remote submission') + return {'status': 'submitted'} + + async def run(): + nonlocal calls + service = InMemorySessionService() + session = await service.create_session(app_name='preflight_test', user_id='test', + state={'workspace_dir': str(tmp_path), **(state or {})}) + agent = LlmAgent(name='task_agent', model=Model(model='test'), + tools=[load_skill, run_python, submit_bohr_batchjob, *extra_tools], + before_tool_callback=hook.before_aidb_skill_load, + after_tool_callback=hook.after_aidb_skill_load) + if entry == 'thinking': + from matcreator.agents.thinking_agent.agent import thinking_agent + agent = thinking_agent.model_copy(update={'name': 'thinking_test', 'model': Model(model='test'), + 'tools': [load_skill, run_python, submit_bohr_batchjob, *extra_tools], 'instruction': '', + 'before_agent_callback': None, 'sub_agents': []}) + elif entry == 'step': + from matcreator.agents.execution_agent.step_executor import build_step_executor_agent + from matcreator.llm_cards import LLMCard + agent = build_step_executor_agent(LLMCard(name='test', model='openai/test')).model_copy( + update={'model': Model(model='test'), 'tools': [load_skill, run_python, submit_bohr_batchjob, *extra_tools], + 'instruction': '', 'input_schema': None, 'sub_agents': []}) + runner = Runner(agent=agent, app_name='preflight_test', session_service=service) + responses = [] + human_turns = [request] + async for event in runner.run_async(user_id='test', session_id=session.id, + new_message=types.Content(role='user', parts=[types.Part(text=request)])): + for part in (event.content.parts if event.content else []): + if part.function_response: + responses.append(part.function_response.response) + if followup: + human_turns.append(followup) + async for event in runner.run_async(user_id='test', session_id=session.id, + new_message=types.Content(role='user', parts=[types.Part(text=followup)])): + for part in (event.content.parts if event.content else []): + if part.function_response: + responses.append(part.function_response.response) + for message, turn_calls in turns: + calls = turn_calls + agent.model._index = 0 + if callable(message): + message = message(responses) + human_turns.append(message) + async for event in runner.run_async(user_id='test', session_id=session.id, + new_message=types.Content(role='user', parts=[types.Part(text=message)])): + for part in (event.content.parts if event.content else []): + if part.function_response: + responses.append(part.function_response.response) + if final_state is not None: + current = await service.get_session(app_name='preflight_test', user_id='test', session_id=session.id) + final_state.update(current.state) + if trace_path is not None: + Path(trace_path).write_text(json.dumps({'human_turns': human_turns, + 'responses': responses, 'performed_tools': prepared}, ensure_ascii=False), encoding='utf-8') + return responses + return asyncio.run(run()), prepared + + +def test_natural_silicon_request_queries_before_preparation_without_goal(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '请准备金刚石结构单质硅体相的 VASP 弛豫与总能输入,普通 PBE,不提交计算', + [('load_skill', {'skill_name': ' VASP-PYMATGEN '}), ('run_python', {'code': 'prepare'})]) + feedback = responses[0]['aidb_preflight'] + assert feedback['status'] == 'not_found' + assert Path(feedback['report_path']).is_file() + assert json.loads(Path(feedback['report_path']).read_text())['queries'][0]['record_ids'] == [] + assert prepared == ['prepare'] + +@pytest.mark.parametrize('prompt', ['请解释硅的 VASP 弛豫概念', 'What is VASP relaxation of silicon?']) +def test_concept_only_does_not_query(local_aidb, tmp_path, prompt): + responses, _ = run_task(tmp_path, prompt, [('load_skill', {'skill_name': 'vasp-pymatgen'})]) + assert 'aidb_preflight' not in responses[0] + assert not (tmp_path / '.aidb').exists() + + +def test_missing_identity_pauses_preparation_without_query(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '请用 VASP 准备弛豫和总能输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'needs_clarification' + assert prepared == [] + assert not (tmp_path / '.aidb').exists() + + +def test_failure_is_not_empty_and_blocks_preparation_and_submission(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, prepared = run_task(tmp_path, '计算金刚石硅体相 PBE 弛豫和总能', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'}), + ('submit_bohr_batchjob', {'name': 'Si'})]) + assert responses[0]['aidb_preflight']['status'] == 'failed' + assert 'config does not exist' in responses[0]['aidb_preflight']['detail'] + assert prepared == [] + + +def test_repeated_entry_reuses_one_current_report_and_preserves_unknowns(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备硅的 VASP 弛豫与总能输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('load_skill', {'skill_name': 'atomic-structure'})]) + assert responses[0]['aidb_preflight']['report_path'] == responses[1]['aidb_preflight']['report_path'] + assert len(list((tmp_path / '.aidb/runs').glob('*/*/audit.json'))) == 1 + context = responses[0]['aidb_preflight']['task_context'] + assert context['known'] == {'formula': 'Si'} + assert context['retrieval_defaults']['functional'] == 'PBE' + assert 'structure_model' in context['unknown'] + + +def test_prepare_only_never_submits_even_after_success(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '只准备金刚石硅晶体 PBE 弛豫输入,不提交计算', + [('submit_bohr_batchjob', {'name': 'Si'})]) + assert responses[0]['status'] == 'blocked' + assert prepared == [] + + +def test_internal_executor_uses_inherited_context_without_requiring_json_from_user(local_aidb, tmp_path): + step = json.dumps({'action': 'Prepare VASP inputs for relaxation and total energy', + 'prior_context': 'Diamond bulk silicon, ordinary PBE', 'workspace_dir': str(tmp_path)}) + responses, prepared = run_task(tmp_path, step, + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert prepared == ['prepare'] + + +def test_non_diamond_phase_is_visible_and_does_not_silently_prepare(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '计算 beta-tin 硅体相 PBE 总能', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'needs_clarification' + assert prepared == [] + +@pytest.mark.parametrize('entry', ['thinking', 'step']) +def test_production_agents_query_at_the_registered_tool_boundary(local_aidb, tmp_path, entry): + responses, _ = run_task(tmp_path, 'Prepare diamond bulk silicon PBE VASP relaxation and total energy inputs', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], entry=entry) + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert Path(responses[0]['aidb_preflight']['audit_path']).is_file() + + +def test_new_turn_queries_again_and_method_switch_invalidates_old_receipt(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备金刚石硅体相 PBE VASP 弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='改用 HSE06 重算硅晶体弛豫') + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert responses[1]['aidb_preflight']['status'] == 'needs_clarification' + assert responses[1]['aidb_preflight']['task_context']['known']['functional'] == 'HSE06' + + +def test_new_independent_request_queries_instead_of_using_old_logs(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备金刚石硅体相 PBE 弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='准备金刚石硅体相 PBE 弛豫输入') + assert responses[0]['aidb_preflight']['report_path'] != responses[1]['aidb_preflight']['report_path'] + + +def test_concept_followup_does_not_receive_previous_receipt(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备金刚石硅体相 PBE 弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='解释硅弛豫概念') + assert 'aidb_preflight' not in responses[1] + + +def test_found_returns_all_candidate_ids_and_report_without_claiming_reuse(local_aidb, tmp_path): + import subprocess + from ase.build import bulk + from pymatgen.io.ase import AseAtomsAdaptor + record = tmp_path / 'sample.json' + record.write_text(json.dumps({'source_id': 'silicon-fixture', 'source_dataset': 'test-only', + 'formula_reduced': 'Si', 'formula_pretty': 'Si', 'composition': {'Si': 2}, + 'functional': 'PBE', 'energy': -10.0, + 'structure': AseAtomsAdaptor.get_structure(bulk('Si', 'diamond', a=5.43)).as_dict()})) + result = subprocess.run([os.environ['AI_READY_DB_PYTHON'], + str(local_aidb / 'skills/ai-ready-db/scripts/aidb_client.py'), 'archive-local', + '--record-json', str(record), '--namespace', 'bulk_base_pbe', + '--origin', 'local_calculation', '--sync-status', 'local_only', '--config', os.environ['AI_READY_DB_CONFIG']], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + responses, _ = run_task(tmp_path, '准备金刚石硅体相普通 PBE 弛豫和总能输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'})]) + feedback = responses[0]['aidb_preflight'] + assert feedback['status'] == 'found' + assert feedback['candidate_ids'] + assert feedback['report']['queries'][0]['record_ids'] == feedback['candidate_ids'] + assert '尚未核验' in feedback['message'] + + +def test_parallel_skill_entries_share_the_same_actual_query(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备金刚石硅体相 PBE VASP 弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('load_skill', {'skill_name': 'atomic-structure'})], + parallel=True) + assert responses[0]['aidb_preflight']['report_path'] == responses[1]['aidb_preflight']['report_path'] + assert len(list((tmp_path / '.aidb/runs').glob('*/*/audit.json'))) == 1 + + +def test_missing_identity_remains_blocked_without_literal_vasp(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '请准备弛豫和总能输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'needs_clarification' + assert responses[1]['status'] == 'blocked' + assert prepared == [] + + +@pytest.mark.parametrize('prompt', ['只生成金刚石硅晶体 PBE VASP 弛豫输入', '只准备金刚石硅晶体 PBE VASP 输入,先不要计算']) +def test_generation_only_does_not_authorize_remote_submission(local_aidb, tmp_path, prompt): + responses, prepared = run_task(tmp_path, prompt, [('submit_bohr_batchjob', {'name': 'Si'})]) + assert responses[0]['status'] == 'blocked' + assert prepared == [] + + +def test_internal_submission_cannot_expand_parent_preparation_authorization(local_aidb, tmp_path): + step = json.dumps({'action': 'Submit VASP job', 'prior_context': 'diamond bulk Si PBE'}) + responses, prepared = run_task(tmp_path, step, [('submit_bohr_batchjob', {'name': 'Si'})], + state={'goal': '只准备金刚石硅体相 PBE 弛豫输入'}) + assert responses[0]['status'] == 'blocked' + assert prepared == [] + + +def test_explicit_new_material_supersedes_prior_silicon(local_aidb, tmp_path): + responses, _ = run_task(tmp_path, '准备金刚石硅体相 PBE VASP 弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='改用金刚石碳体相 PBE 计算总能') + feedback = responses[1]['aidb_preflight'] + assert feedback['status'] == 'needs_clarification' + assert feedback['task_context']['known']['formula'] == 'C' + assert len(list((tmp_path / '.aidb/runs').glob('*/*/audit.json'))) == 1 + + +def test_direct_preparation_returns_actual_query_evidence(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '准备金刚石硅体相 PBE VASP 弛豫输入', + [('run_python', {'code': 'prepare'})]) + assert responses[0]['status'] == 'prepared' + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert Path(responses[0]['aidb_preflight']['report_path']).is_file() + assert prepared == ['prepare'] + + +def test_chinese_adjacent_method_name_is_not_defaulted_to_pbe(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '请用HSE06计算金刚石硅体相总能', + [('run_python', {'code': 'prepare'})]) + assert responses[0]['status'] == 'blocked' + assert responses[0]['aidb_preflight']['task_context']['known']['functional'] == 'HSE06' + assert prepared == [] + + +def test_given_structure_supplies_identity_without_manual_json(local_aidb, tmp_path): + from ase.build import bulk + from ase.io import write + write(tmp_path / 'POSCAR', bulk('Si', 'diamond', a=5.43)) + responses, prepared = run_task(tmp_path, '用现有POSCAR准备VASP弛豫和总能输入', + [('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert responses[0]['aidb_preflight']['task_context']['known']['formula'] == 'Si' + assert prepared == ['prepare'] + + +def test_preflight_input_structure_is_not_a_step_output(local_aidb, tmp_path, monkeypatch): + from ase.build import bulk + from ase.io import write + from matcreator.agents.session_log import collect_artifact_paths + write(tmp_path / 'POSCAR', bulk('Si', 'diamond', a=5.43)) + entries = [] + original = hook.append_session_log_entry + def record(context, entry): + entries.append(entry) + return original(context, entry) + monkeypatch.setattr(hook, 'append_session_log_entry', record) + responses, prepared = run_task(tmp_path, '用现有POSCAR准备VASP弛豫和总能输入', + [('run_python', {'code': 'prepare'})], entry='step') + assert prepared == ['prepare'] + assert str(tmp_path / 'POSCAR') not in collect_artifact_paths(responses[0]) + assert all(str(tmp_path / 'POSCAR') not in collect_artifact_paths(entry) for entry in entries) + assert responses[0]['aidb_preflight']['task_context']['known']['input_structure_path'] == str(tmp_path / 'POSCAR') + + +def test_structure_read_failure_keeps_diagnostic_and_blocks_tools(local_aidb, tmp_path): + (tmp_path / 'broken.cif').write_text('not a CIF') + responses, prepared = run_task(tmp_path, '用broken.cif准备VASP弛豫输入', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'})]) + assert responses[0]['aidb_preflight']['status'] == 'failed' + assert Path(responses[0]['aidb_preflight']['diagnostic_path']).is_file() + assert prepared == [] + + +@pytest.mark.parametrize('followup', ['继续', '继续弛豫', 'continue', '重算', '照旧', 'recalculate']) +def test_continuation_preserves_prior_prepare_only_authorization(local_aidb, tmp_path, followup): + responses, prepared = run_task(tmp_path, '只准备金刚石硅体相 PBE VASP 弛豫输入', + [('submit_bohr_batchjob', {'name': 'Si'})], followup=followup) + assert responses[0]['status'] == responses[1]['status'] == 'blocked' + assert responses[1]['aidb_preflight']['status'] == 'not_found' + assert prepared == [] + + +def test_explicit_new_submission_authorization_overrides_prior_prepare_only(local_aidb, tmp_path): + responses, prepared = run_task(tmp_path, '只准备金刚石硅体相 PBE VASP 弛豫输入', + [('submit_bohr_batchjob', {'name': 'Si'})], followup='继续并提交计算') + assert responses[0]['status'] == 'blocked' + assert responses[1]['status'] == 'submitted' + assert responses[1]['aidb_preflight']['status'] == 'not_found' + assert prepared == ['remote submission'] + + +@pytest.mark.parametrize('action', [ + 'ONLY after relaxation succeeds: prepare static VASP inputs and submit the job.', + 'Record POTCAR path and hash only, no body. Submit VASP with input_path=vasp-Si.', + '环境设置仅本次运行有效。准备并提交金刚石硅 PBE VASP 输入。', +]) +def test_authorized_step_only_qualifiers_do_not_forbid_submission(local_aidb, tmp_path, action): + step = json.dumps({'action': action, 'prior_context': 'diamond bulk Si PBE relaxation total energy'}) + responses, prepared = run_task(tmp_path, step, + [('submit_bohr_batchjob', {'name': 'Si'})], entry='step', + state={'goal': '继续准备并提交金刚石硅体相 PBE 弛豫和总能重算', + 'aidb_user_request': '继续准备并提交金刚石硅体相 PBE 弛豫和总能重算', + 'aidb_user_prepare_only': False}) + assert responses[0]['status'] == 'submitted' + assert prepared == ['remote submission'] + + +@pytest.mark.parametrize('action', [ + 'Only prepare diamond bulk Si PBE VASP inputs.', + 'Generate only diamond bulk Si PBE VASP inputs.', + '只生成金刚石硅体相 PBE VASP 输入。', +]) +def test_actual_prepare_only_step_still_blocks_submission(local_aidb, tmp_path, action): + step = json.dumps({'action': action, 'prior_context': 'diamond bulk Si PBE'}) + responses, prepared = run_task(tmp_path, step, + [('submit_bohr_batchjob', {'name': 'Si'})], entry='step', + state={'goal': '计算金刚石硅体相 PBE 弛豫和总能'}) + assert responses[0]['status'] == 'blocked' + assert prepared == [] + + +@pytest.mark.parametrize('user_text', [ + '我只要生成金刚石硅体相普通 PBE VASP 输入文件。', + '只需生成金刚石硅体相 PBE VASP 输入文件。', + 'Only the diamond bulk Si PBE VASP input files, please.', + 'Only a VASP input set for diamond bulk Si PBE, please.', +]) +def test_user_input_only_restriction_survives_executor_submit_action(local_aidb, tmp_path, user_text): + step = json.dumps({'action': 'Submit the VASP Batch Job for diamond bulk Si PBE relaxation.', + 'prior_context': user_text}) + responses, prepared = run_task(tmp_path, step, + [('submit_bohr_batchjob', {'name': 'Si'})], entry='step', + state={'goal': user_text, 'aidb_user_request': user_text, + 'aidb_user_prepare_only': False}) + assert responses[0]['status'] == 'blocked' + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert prepared == [] + + +@pytest.mark.parametrize('user_text', [ + 'Only the diamond bulk Si PBE VASP input files, please.', + 'Only a VASP input set for diamond bulk Si PBE, please.', +]) +def test_input_only_noun_request_blocks_submission_at_main_entry(local_aidb, tmp_path, user_text): + responses, prepared = run_task(tmp_path, user_text, + [('submit_bohr_batchjob', {'name': 'Si'})]) + assert responses[0]['status'] == 'blocked' + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert prepared == [] diff --git a/tests/test_silicon_archive.py b/tests/test_silicon_archive.py new file mode 100644 index 00000000..3672007f --- /dev/null +++ b/tests/test_silicon_archive.py @@ -0,0 +1,140 @@ +"""Full tool/callback round trips; synthetic outputs are not real computation.""" +import json +from pathlib import Path +from types import SimpleNamespace +import pytest +import hashlib +import subprocess +import os +import sys + +from matcreator.agents.aidb_skill_hook import after_aidb_skill_load +from matcreator.tools.silicon_vasp import collect_silicon_vasp_result +from test_aidb_skill_hook import local_aidb, run_task +from test_silicon_vasp_workflow import completed_job, silicon_outputs + + +def test_successful_collection_automatically_archives_and_reads_back(local_aidb, tmp_path, monkeypatch): + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + args = {'job_id': job_id, 'destination_path': 'outputs', 'calculation_type': 'relaxation'} + response = collect_silicon_vasp_result(**args, tool_context=context) + response = after_aidb_skill_load(SimpleNamespace(name='collect_silicon_vasp_result'), args, context, response) + assert response['archived'] is True + archive = response['local_archive'] + assert archive['status'] == 'verified' + saved = json.loads(Path(archive['export_path']).read_text()) + assert saved['record_id'] == archive['record_id'] + assert saved['energy'] == -10.1 + assert saved['source_metadata']['energy_unit'] == 'eV' + assert saved['source_metadata']['completion']['electronic_converged'] is True + assert saved['source_metadata']['job_id'] == job_id + assert saved['calculation_parameters']['ENCUT'] == 520 + assert saved['source_metadata']['archived_at'] + assert saved['source_metadata']['sync_status'] == 'local_only' + assert saved['forces'] is None and saved['stress'] is None + + +@pytest.mark.parametrize('entry', ['thinking', 'step']) +def test_registered_agent_collection_automatically_archives(local_aidb, tmp_path, monkeypatch, entry): + _, _, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + responses, prepared = run_task(tmp_path, '取回已有金刚石硅 PBE 弛豫成功产物并自动本地归档,不重新计算', + [('collect_silicon_vasp_result', {'job_id': job_id, 'destination_path': 'outputs', 'calculation_type': 'relaxation'})], + state={'session_id': 'session'}, entry=entry, extra_tools=(collect_silicon_vasp_result,)) + assert responses[0]['local_archive']['content_compared'] is True + assert responses[0]['archived'] is True + assert prepared == [] + + +def test_repeated_completion_keeps_record_and_archive_time(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + first = archive_silicon_vasp_result(job_id, 'outputs', 'relaxation', context)['local_archive'] + second = archive_silicon_vasp_result(job_id, 'ignored', 'relaxation', context)['local_archive'] + assert first['status'] == second['status'] == 'verified' + assert second['previously_present'] is True + assert first['record_id'] == second['record_id'] + assert first['archived_at'] == second['archived_at'] + + +def test_unknown_scientific_completion_does_not_archive(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs(ionic_marker=False)) + result = archive_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' and result['archived'] is False + assert not (tmp_path / '.aidb/archives').exists() + assert (tmp_path / 'outputs/vasprun.xml').is_file() + + +def test_static_archive_keeps_parent_record_and_separate_energy(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + service, context, parent = completed_job(tmp_path, monkeypatch, silicon_outputs()) + relaxed = archive_silicon_vasp_result(parent, 'relax-outputs', 'relaxation', context) + outputs = silicon_outputs(stage='static') + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace('-10.1', '-10.2') + source = tmp_path / 'static' + source.mkdir() + for name in ('INCAR', 'POSCAR', 'KPOINTS'): + (source / name).write_text(outputs[name]) + (source / 'POTCAR').write_bytes((tmp_path / 'relax/POTCAR').read_bytes()) + service.adapter_for('bohr_batchjob').outputs = outputs + job = service.submit_job(owner_id='test', session_id='session', provider='bohr_batchjob', + idempotency_key='static', spec={'input_root': str(tmp_path), 'input_path': 'static', + 'vasp_input_sha256': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in source.iterdir()}}) + service.store.transition_job(job['job_id'], 'succeeded') + result = archive_silicon_vasp_result(job['job_id'], 'static-outputs', 'static', context, parent) + assert result['archived'] is True + saved = json.loads(Path(result['local_archive']['export_path']).read_text()) + assert saved['energy'] == -10.2 + assert saved['source_metadata']['parent_record_id'] == relaxed['local_archive']['record_id'] + assert saved['source_metadata']['relaxation_source']['job_id'] == parent + assert saved['source_metadata']['completion']['ionic_converged'] is None + + +def test_lookup_failure_retains_outputs_and_can_recover_without_resubmission(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + with monkeypatch.context() as missing: + missing.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + result = archive_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['archived'] is False + assert result['local_archive']['status'] == 'failed' + assert not list((tmp_path / '.aidb/runs').glob('**/archive-report.json')) + assert (tmp_path / 'outputs/OUTCAR').is_file() + recovered = archive_silicon_vasp_result(job_id, 'ignored', 'relaxation', context) + assert recovered['archived'] is True + + +def test_uncertain_write_is_queried_and_read_back_before_retry(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + real_run = subprocess.run + def lose_receipt(argv, **kwargs): + result = real_run(argv, **kwargs) + if 'archive-and-requery' in argv: + raise subprocess.TimeoutExpired(argv, 75, output=result.stdout, stderr=result.stderr) + return result + with monkeypatch.context() as network: + network.setattr(subprocess, 'run', lose_receipt) + first = archive_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert first['archived'] is False + second = archive_silicon_vasp_result(job_id, 'ignored', 'relaxation', context) + assert second['archived'] is True + assert second['local_archive']['previously_present'] is True + assert len(list((tmp_path / '.aidb/runs').glob('**/archive-report.json'))) == 1 + + +def test_readback_difference_is_reported_without_overwriting_record(local_aidb, tmp_path, monkeypatch): + from matcreator.tools.silicon_archive import archive_silicon_vasp_result + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + first = archive_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + saved = json.loads(Path(first['local_archive']['export_path']).read_text()) + saved['source_metadata']['energy_unit'] = 'Ha' + edited = tmp_path / 'edited.json' + edited.write_text(json.dumps(saved)) + subprocess.run([sys.executable, str(local_aidb / 'skills/ai-ready-db/scripts/aidb_client.py'), + 'archive-local', '--record-json', str(edited), '--namespace', 'bulk_base_pbe', + '--origin', 'local_calculation', '--sync-status', 'local_only', '--config', os.environ['AI_READY_DB_CONFIG']], check=True, capture_output=True) + result = archive_silicon_vasp_result(job_id, 'ignored', 'relaxation', context) + assert result['archived'] is False + assert 'energy_unit' in result['local_archive']['error'] + assert len(list((tmp_path / '.aidb/runs').glob('**/archive-report.json'))) == 1 diff --git a/tests/test_silicon_checks.py b/tests/test_silicon_checks.py new file mode 100644 index 00000000..7546a1d2 --- /dev/null +++ b/tests/test_silicon_checks.py @@ -0,0 +1,104 @@ +"""Command-level checks of the regression guard; no database or remote jobs.""" +import json +from pathlib import Path +import subprocess +import sys +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / 'script/check_silicon_loop.py' + + +def compare(tmp_path, counts, failures=(), baseline=None, exit_code=0): + report = tmp_path / 'pytest.json' + report.write_text(json.dumps({'exit_code': exit_code, 'counts': counts, 'failures': failures})) + output = tmp_path / 'acceptance.json' + args = [sys.executable, str(SCRIPT), 'compare-results', '--pytest-report', str(report), '--output', str(output)] + if baseline is not None: + path = tmp_path / 'baseline.json' + path.write_text(json.dumps({'failures': baseline})) + args += ['--baseline', str(path)] + process = subprocess.run(args, capture_output=True, text=True) + return process.returncode, json.loads(output.read_text()) + + +def test_counts_come_from_completed_pytest_report(tmp_path): + code, result = compare(tmp_path, {'passed': 7, 'failed': 0, 'error': 0, 'skipped': 0}) + assert code == 0 and result['counts']['passed'] == 7 + assert result['all_tests_passed'] is True + + +@pytest.mark.parametrize('counts, exit_code', [ + ({'passed': 7, 'failed': 0, 'error': 0, 'skipped': 1}, 0), + ({'passed': 0, 'failed': 0, 'error': 0, 'skipped': 0}, 0), + ({'passed': 7, 'failed': 0, 'error': 0, 'skipped': 0}, 3), +]) +def test_incomplete_or_skipped_run_cannot_pass(tmp_path, counts, exit_code): + code, result = compare(tmp_path, counts, exit_code=exit_code) + assert code == 1 and result['ok'] is False + + +def test_exact_baseline_is_reported_without_claiming_clean_suite(tmp_path): + known = [{'nodeid': 'tests/test_old.py::test_old', 'outcome': 'failed'}] + code, result = compare(tmp_path, {'passed': 7, 'failed': 1, 'error': 0, 'skipped': 0}, known, known, 1) + assert code == 0 and result['all_tests_passed'] is False + assert result['new_failures'] == [] + + +def test_equal_failure_count_cannot_hide_new_failure(tmp_path): + known = [{'nodeid': 'tests/test_old.py::test_old', 'outcome': 'failed'}] + new = [{'nodeid': 'tests/test_new.py::test_new', 'outcome': 'failed'}] + code, result = compare(tmp_path, {'passed': 7, 'failed': 1, 'error': 0, 'skipped': 0}, new, known, 1) + assert code == 1 and 'test_new' in result['error'] + + +def test_collection_error_is_a_failure_without_baseline(tmp_path): + errors = [{'nodeid': 'tests/test_import.py', 'outcome': 'error'}] + code, result = compare(tmp_path, {'passed': 7, 'failed': 0, 'error': 1, 'skipped': 0}, errors, exit_code=1) + assert code == 1 and 'test_import' in result['error'] + + +def test_missing_dependency_fails_before_tests_can_skip(tmp_path): + output = tmp_path / 'report.json' + process = subprocess.run([sys.executable, str(SCRIPT), 'tests', '--aidb-root', str(tmp_path / 'missing'), + '--aidb-python', str(tmp_path / 'missing-python'), '--output', str(output)], capture_output=True, text=True) + assert process.returncode == 1 + assert 'aidb Python missing' in json.loads(output.read_text())['error'] + + +def test_pytest_plugin_reports_failures_errors_and_collection_skips(tmp_path): + import os + (tmp_path / 'test_examples.py').write_text('''import pytest + +def test_pass(): + assert True + +def test_fail(): + assert False + +@pytest.fixture +def broken(): + raise ValueError("setup failed") + +def test_setup(broken): + pass +''') + (tmp_path / 'test_skipped.py').write_text('import pytest\npytest.skip("unavailable", allow_module_level=True)\n') + report_path = tmp_path / 'report.json' + env = {**os.environ, 'MATCREATOR_TEST_REPORT': str(report_path), + 'PYTHONPATH': str(SCRIPT.parents[1])} + process = subprocess.run([sys.executable, '-m', 'pytest', str(tmp_path), '-q', + '-p', 'script.silicon_test_report'], capture_output=True, text=True, env=env, cwd=tmp_path) + assert process.returncode == 1 + report = json.loads(report_path.read_text()) + assert report['counts'] == {'passed': 1, 'failed': 1, 'error': 1, 'skipped': 1} + assert {r['outcome'] for r in report['failures']} == {'failed', 'error'} + + +@pytest.mark.parametrize('counts,exit_code', [ + ({'passed': 1, 'failed': 0, 'error': 0, 'skipped': 0}, 1), + ({'passed': 1, 'failed': 1, 'error': 0, 'skipped': 0}, 0), + ({'passed': 1, 'failed': -1, 'error': 0, 'skipped': 0}, 0), +]) +def test_contradictory_counts_are_rejected(tmp_path, counts, exit_code): + code, result = compare(tmp_path, counts, exit_code=exit_code) + assert code == 1 and result['ok'] is False diff --git a/tests/test_silicon_lookup_consent.py b/tests/test_silicon_lookup_consent.py new file mode 100644 index 00000000..879b1ab9 --- /dev/null +++ b/tests/test_silicon_lookup_consent.py @@ -0,0 +1,215 @@ +"""Final B at the ADK task boundary, with real isolated public aidb access.""" +import json +from pathlib import Path + +import pytest + +from test_aidb_skill_hook import local_aidb, run_task + + +REQUEST = '计算金刚石单质硅体相普通 PBE VASP 弛豫和总能' +LOAD = ('load_skill', {'skill_name': 'vasp-pymatgen'}) +PREPARE = ('run_python', {'code': 'prepare'}) +SUBMIT = ('submit_bohr_batchjob', {'name': 'Si'}) + + +def confirm(responses): + return '确认继续 ' + responses[-1]['aidb_preflight']['failure_id'] + + +def test_confirmation_continues_original_scope_without_claiming_lookup_success(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, performed = run_task(tmp_path, REQUEST, [LOAD, PREPARE, SUBMIT], + turns=[(confirm, [LOAD, PREPARE, SUBMIT])], trace_path=tmp_path / 'task-feedback.json') + warning = responses[0]['aidb_preflight'] + assert '无法判断' in warning['message'] and '重复计算' in warning['message'] + assert performed == ['prepare', 'remote submission'] + continued = responses[-1]['aidb_preflight'] + assert continued['status'] == 'failed' and continued['query_executed'] is False + assert continued['failure_id'] == warning['failure_id'] + assert continued['bypass']['status'] == 'confirmed' + assert continued['loop_complete'] is False + + +@pytest.mark.parametrize('reply', ['拒绝', '不确认', '继续', 'yes', '解释一下风险', '我不确认继续']) +def test_rejection_or_no_explicit_confirmation_keeps_tools_paused(local_aidb, tmp_path, monkeypatch, reply): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, performed = run_task(tmp_path, REQUEST, [LOAD], turns=[(reply, [PREPARE, SUBMIT])]) + assert performed == [] + assert all(response['status'] == 'blocked' for response in responses[1:]) + + +def test_confirmation_preserves_preparation_only(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, performed = run_task(tmp_path, '仅准备金刚石硅体相 PBE VASP 弛豫输入,不提交计算', + [LOAD], turns=[(confirm, [PREPARE, SUBMIT])]) + assert performed == ['prepare'] + assert responses[-1]['status'] == 'blocked' + assert '仅授权准备' in responses[-1]['message'] + + +@pytest.mark.parametrize('change', ['改用金刚石碳体相 PBE 总能', '改用 HSE06 计算硅总能', + '继续,ENCUT=600', '继续,KPOINTS=6x6x6', '继续,只计算静态总能', + 'ENCUT=600', 'KPOINTS=6x6x6', 'HSE06']) +def test_changed_target_or_conditions_invalidates_failure_confirmation(local_aidb, tmp_path, monkeypatch, change): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + token = {} + def remember(responses): + token['old'] = confirm(responses) + return change + responses, performed = run_task(tmp_path, REQUEST, [LOAD], + turns=[(remember, [LOAD, PREPARE]), (lambda _: token['old'], [PREPARE, SUBMIT])]) + assert performed == [] + assert responses[-1]['status'] == 'blocked' + + +def test_condition_update_without_tools_still_invalidates_old_confirmation(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + token = {} + def change(responses): + token['old'] = confirm(responses) + return 'ENCUT=600' + responses, performed = run_task(tmp_path, REQUEST, [LOAD], + turns=[(change, []), (lambda _: token['old'], [PREPARE, SUBMIT])]) + assert performed == [] + assert responses[-1]['aidb_preflight']['task_context']['known']['encut'] == 600 + + +def test_old_event_token_cannot_confirm_a_new_failure(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + token = {} + def remember(responses): + token['old'] = confirm(responses) + return REQUEST + responses, performed = run_task(tmp_path, REQUEST, [LOAD], + turns=[(remember, [LOAD]), (lambda _: token['old'], [PREPARE, SUBMIT])]) + assert responses[0]['aidb_preflight']['failure_id'] != responses[1]['aidb_preflight']['failure_id'] + assert performed == [] + + +def test_copied_session_state_does_not_authorize_old_consent(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + state = {} + responses, _ = run_task(tmp_path, REQUEST, [LOAD], final_state=state) + responses, performed = run_task(tmp_path, confirm(responses), [PREPARE, SUBMIT], state=state) + assert performed == [] + + +def test_executor_text_cannot_supply_human_consent(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, performed = run_task(tmp_path, REQUEST, [LOAD], turns=[ + (lambda responses: json.dumps({'action': confirm(responses), 'prior_context': REQUEST}), [PREPARE, SUBMIT])]) + assert performed == [] + + +def test_changed_supplied_structure_invalidates_confirmation(local_aidb, tmp_path, monkeypatch): + from ase.build import bulk + from ase.io import write + write(tmp_path / 'POSCAR', bulk('Si', 'diamond', a=5.43)) + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + def reply(responses): + write(tmp_path / 'POSCAR', bulk('Si', 'diamond', a=5.6)) + return confirm(responses) + responses, performed = run_task(tmp_path, REQUEST + ' 使用POSCAR', [LOAD], + turns=[(reply, [PREPARE, SUBMIT])]) + assert performed == [] + assert responses[-1]['aidb_preflight']['failure_id'] != responses[0]['aidb_preflight']['failure_id'] + + +def test_previously_confirmed_event_does_not_authorize_later_failure(local_aidb, tmp_path, monkeypatch): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + token = {} + def reply(responses): + token['old'] = confirm(responses) + return token['old'] + responses, performed = run_task(tmp_path, REQUEST, [LOAD], + turns=[(reply, [PREPARE, SUBMIT]), (REQUEST, [LOAD, PREPARE]), + (lambda _: token['old'], [PREPARE, SUBMIT])]) + assert performed == ['prepare', 'remote submission'] + assert responses[-1]['status'] == 'blocked' + + +@pytest.mark.parametrize('entry', ['thinking', 'step']) +def test_final_b_is_registered_on_both_agent_entries(local_aidb, tmp_path, monkeypatch, entry): + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, performed = run_task(tmp_path, REQUEST, [LOAD], entry=entry, + turns=[(confirm, [PREPARE, SUBMIT])]) + assert performed == ['prepare', 'remote submission'] + + +@pytest.mark.parametrize('recover', [False, True]) +def test_failed_lookup_bypass_still_attempts_local_archive(local_aidb, tmp_path, monkeypatch, recover): + from test_silicon_vasp_workflow import completed_job, silicon_outputs + from matcreator.tools.silicon_vasp import collect_silicon_vasp_result + _, _, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + config = str(tmp_path / 'config.json') + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + collect = ('collect_silicon_vasp_result', {'job_id': job_id, + 'destination_path': 'outputs', 'calculation_type': 'relaxation'}) + def reply(responses): + if recover: + monkeypatch.setenv('AI_READY_DB_CONFIG', config) + return confirm(responses) + responses, performed = run_task(tmp_path, REQUEST, [LOAD], state={'session_id': 'session'}, + turns=[(reply, [PREPARE, collect])], extra_tools=[collect_silicon_vasp_result], + trace_path=tmp_path / 'task-feedback.json') + assert performed == ['prepare'] + assert responses[-1]['archived'] is recover + assert responses[-1]['local_archive']['status'] == ('verified' if recover else 'failed') + assert responses[-1]['loop_complete'] is False + if recover: + saved = json.loads(Path(responses[-1]['local_archive']['export_path']).read_text()) + assert saved['energy'] == -10.1 + assert saved['source_metadata']['sync_status'] == 'local_only' + else: + assert 'error' in responses[-1]['local_archive'] + assert (tmp_path / 'outputs/OUTCAR').is_file() + + +@pytest.mark.parametrize('prepare_only,changed', [(False, False), (True, False), (False, True)]) +def test_confirmed_parent_can_delegate_only_original_scope_to_real_executor( + local_aidb, tmp_path, monkeypatch, prepare_only, changed +): + from google.adk.models.base_llm import BaseLlm + from google.adk.models.llm_response import LlmResponse + from google.genai import types + from pydantic import PrivateAttr + from matcreator.agents.execution_agent import step_executor_runner, recovery + from matcreator.agents.execution_agent.step_executor import build_step_executor_agent, submit_step_result + from matcreator.agents.thinking_agent.agent import run_flash_step + from matcreator.agents import graph_logger + from matcreator.llm_cards import LLMCard + + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + monkeypatch.setattr(recovery, 'ADK_DIR', tmp_path) + monkeypatch.setattr(graph_logger, 'ADK_DIR', tmp_path) + performed = [] + def run_python(code: str) -> dict: + performed.append('prepare') + return {'status': 'prepared'} + def submit_bohr_batchjob(name: str) -> dict: + performed.append('submission') + return {'status': 'submitted'} + child_calls = [PREPARE, SUBMIT, ('submit_step_result', {'status': 'success', + 'key_results': 'Controlled tools completed.', 'concise_summary': 'Controlled tools completed.'})] + class Model(BaseLlm): + _index: int = PrivateAttr(default=0) + async def generate_content_async(self, llm_request, stream=False): + if self._index < len(child_calls): + name, args = child_calls[self._index] + part = types.Part(function_call=types.FunctionCall(name=name, args=args)) + else: + part = types.Part(text='finished') + self._index += 1 + yield LlmResponse(content=types.Content(role='model', parts=[part])) + card = LLMCard(name='test', model='openai/test') + monkeypatch.setattr(step_executor_runner, 'select_executor_llm_card', lambda **_: card) + monkeypatch.setattr(step_executor_runner, 'build_step_executor_agent', lambda _: build_step_executor_agent(card).model_copy( + update={'model': Model(model='test'), 'tools': [run_python, submit_bohr_batchjob, submit_step_result], + 'instruction': '', 'input_schema': None, 'sub_agents': []})) + action = REQUEST + (' ENCUT=600' if changed else '') + request = '仅准备' + REQUEST if prepare_only else REQUEST + run_task(tmp_path, request, [LOAD], state={'session_id': 'controlled-child'}, + turns=[(confirm, [('run_flash_step', {'action': action, 'suggested_skills': ['vasp-pymatgen']})])], + extra_tools=[run_flash_step]) + assert performed == ([] if changed else ['prepare'] if prepare_only else ['prepare', 'submission']) diff --git a/tests/test_silicon_reuse.py b/tests/test_silicon_reuse.py new file mode 100644 index 00000000..33c9c2be --- /dev/null +++ b/tests/test_silicon_reuse.py @@ -0,0 +1,254 @@ +"""Task-level reuse tests through ADK and the public isolated local database.""" +import copy +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from test_aidb_skill_hook import local_aidb, run_task +from test_silicon_vasp_workflow import completed_job, silicon_outputs +from matcreator.tools.silicon_archive import archive_silicon_vasp_result + + +def seed_relaxation(tmp_path, monkeypatch): + _, context, job = completed_job(tmp_path, monkeypatch, silicon_outputs()) + result = archive_silicon_vasp_result(job, 'outputs', 'relaxation', context) + assert result['archived'], result + return json.loads(Path(result['local_archive']['export_path']).read_text()) + + +def test_compatible_success_reused_before_preparation_or_submission(local_aidb, tmp_path, monkeypatch): + saved = seed_relaxation(tmp_path, monkeypatch) + responses, prepared = run_task(tmp_path, '请计算金刚石硅体相普通 PBE 弛豫和总能', + [('load_skill', {'skill_name': 'vasp-pymatgen'}), ('run_python', {'code': 'prepare'}), + ('submit_bohr_batchjob', {'name': 'Si'})]) + feedback = responses[0]['aidb_preflight'] + assert feedback['reuse']['status'] == 'reused' + assert feedback['reuse']['record_ids'] == [saved['record_id']] + assert feedback['reuse']['total_energy'] == {'value': -10.1, 'unit': 'eV', 'basis': 'cell'} + assert feedback['reuse']['structure'] == saved['structure'] + assert prepared == [] + +def derive_candidate(record, source_id): + candidate = copy.deepcopy(record) + candidate.pop('record_id') + candidate['source_id'] = source_id + candidate['source_metadata']['job_id'] = source_id + return candidate + + +def public_archive(local_aidb, tmp_path, record): + path = tmp_path / 'candidate.json' + path.write_text(json.dumps(record)) + proc = subprocess.run([os.environ['AI_READY_DB_PYTHON'], + str(local_aidb / 'skills/ai-ready-db/scripts/aidb_client.py'), 'archive-local', + '--record-json', str(path), '--namespace', 'bulk_base_pbe', '--origin', 'local_calculation', + '--sync-status', 'local_only', '--config', os.environ['AI_READY_DB_CONFIG']], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + + +def query(tmp_path, request='请计算金刚石硅体相普通 PBE 弛豫和总能', **kwargs): + return run_task(tmp_path, request, [('load_skill', {'skill_name': 'vasp-pymatgen'}), + ('submit_bohr_batchjob', {'name': 'Si'})], **kwargs) + + +def test_explicit_recalculation_queries_first_and_preserves_authorization(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, '请重新计算并提交金刚石硅体相 PBE 弛豫和总能') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'recalculate' + assert Path(responses[0]['aidb_preflight']['report_path']).is_file() + assert prepared == ['remote submission'] + responses, prepared = query(tmp_path, '只准备金刚石硅体相 PBE 弛豫重算输入,不提交') + assert prepared == [] + + +def test_recent_archive_selected_without_claiming_accuracy(local_aidb, tmp_path, monkeypatch): + first = seed_relaxation(tmp_path, monkeypatch) + second = derive_candidate(first, 'later-job') + second['source_metadata']['archived_at'] = '2026-10-08T00:00:00+00:00' + second['energy'] = -10.3 + public_archive(local_aidb, tmp_path, second) + responses, prepared = query(tmp_path) + reuse = responses[0]['aidb_preflight']['reuse'] + assert reuse['status'] == 'reused' + assert reuse['total_energy']['value'] == -10.3 + assert '不代表精度最高' in reuse['reason'] + assert prepared == [] + + +def test_missing_archive_order_requires_clarification(local_aidb, tmp_path, monkeypatch): + first = seed_relaxation(tmp_path, monkeypatch) + second = derive_candidate(first, 'undated-job') + second['source_metadata'].pop('archived_at') + public_archive(local_aidb, tmp_path, second) + responses, prepared = query(tmp_path) + assert responses[0]['aidb_preflight']['reuse']['status'] == 'needs_clarification' + assert prepared == [] + + +def test_different_conditions_require_scientific_choice(local_aidb, tmp_path, monkeypatch): + first = seed_relaxation(tmp_path, monkeypatch) + second = derive_candidate(first, 'other-cutoff') + second['source_metadata']['conditions']['incar']['ENCUT'] = 600 + second['calculation_parameters']['ENCUT'] = 600 + public_archive(local_aidb, tmp_path, second) + responses, prepared = query(tmp_path) + assert responses[0]['aidb_preflight']['reuse']['status'] == 'needs_clarification' + assert prepared == [] + responses, prepared = query(tmp_path, '计算金刚石硅体相 PBE ENCUT=520 弛豫和总能') + assert responses[0]['aidb_preflight']['reuse']['record_ids'] == [first['record_id']] + assert prepared == [] + + +def test_linked_static_and_relaxation_keep_distinct_energies(local_aidb, tmp_path, monkeypatch): + parent = seed_relaxation(tmp_path, monkeypatch) + child = derive_candidate(parent, 'static-job') + child['calculation_type'] = 'static' + child['energy'] = -10.2 + meta = child['source_metadata'] + meta['parent_record_id'] = parent['record_id'] + meta['relaxation_source'] = {'job_id': parent['source_id']} + meta['completion']['ionic_converged'] = None + meta['conditions']['incar']['NSW'] = 0 + meta['conditions']['incar']['IBRION'] = -1 + child['calculation_parameters'] = meta['conditions']['incar'] + public_archive(local_aidb, tmp_path, child) + responses, prepared = query(tmp_path) + reuse = responses[0]['aidb_preflight']['reuse'] + assert reuse['status'] == 'reused' and len(reuse['record_ids']) == 2 + assert [r['total_energy']['value'] for r in reuse['results']] == [-10.1, -10.2] + assert prepared == [] + meta['parent_record_id'] = 'unrelated' + public_archive(local_aidb, tmp_path, child) + responses, prepared = query(tmp_path) + assert responses[0]['aidb_preflight']['reuse']['record_ids'] == [parent['record_id']] + assert prepared == [] + + +def test_target_change_does_not_reuse_previous_selection(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = run_task(tmp_path, '计算金刚石硅体相 PBE 弛豫和总能', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='改用 HSE06 计算金刚石硅体相总能') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'reused' + assert responses[1]['aidb_preflight']['status'] == 'needs_clarification' + assert not responses[1]['aidb_preflight'].get('reuse') + assert prepared == [] + + +def test_negative_recompute_intent_still_reuses(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, '计算金刚石硅体相 PBE 弛豫和总能,不重新计算') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'reused' + assert prepared == [] +@pytest.mark.parametrize('defect', ['electronic', 'ionic', 'unit', 'energy', 'hybrid', 'potential', 'kpoints', 'phase', 'failed']) +def test_incomplete_or_incompatible_records_remain_candidates(local_aidb, tmp_path, monkeypatch, defect): + saved = seed_relaxation(tmp_path, monkeypatch) + meta = saved['source_metadata'] + if defect == 'electronic': + meta['completion']['electronic_converged'] = False + elif defect == 'ionic': + meta['completion']['ionic_converged'] = None + elif defect == 'unit': + meta['energy_unit'] = 'Ha' + elif defect == 'energy': + saved['energy'] = None + elif defect == 'hybrid': + meta['conditions']['parameters']['LHFCALC'] = True + elif defect == 'potential': + meta['conditions'].pop('potcar_sha256') + elif defect == 'kpoints': + meta['conditions']['kpoints'] = None + meta['conditions']['actual_kpoints'] = None + elif defect == 'phase': + saved['structure']['sites'][1]['abc'] = [.3, .25, .25] + else: + meta['status'] = 'failed' + # A fresh isolated database lets the public contract store this malformed + # candidate without merging it with the already valid archived record. + config_path = Path(os.environ['AI_READY_DB_CONFIG']) + config = json.loads(config_path.read_text()) + config['ase_sqlite'] = {'path': str(tmp_path / 'malformed.db'), 'json_sidecar_path': str(tmp_path / 'malformed.json')} + config_path.write_text(json.dumps(config)) + public_archive(local_aidb, tmp_path, saved) + responses, prepared = query(tmp_path, '只准备金刚石硅体相 PBE 弛豫和总能输入,不提交') + feedback = responses[0]['aidb_preflight'] + assert feedback['status'] == 'found' + assert feedback['reuse']['status'] == 'unavailable' + assert feedback['reuse']['candidates'][0]['compatible'] is False + assert prepared == [] + + +@pytest.mark.parametrize('entry', ['thinking', 'step']) +def test_registered_entry_reuses_and_respects_parent_recalculation(local_aidb, tmp_path, monkeypatch, entry): + seed_relaxation(tmp_path, monkeypatch) + if entry == 'step': + request = json.dumps({'action': '准备硅弛豫与总能输入', 'prior_context': ''}) + state = {'goal': '计算金刚石硅体相 PBE 弛豫和总能'} + else: + request, state = '计算金刚石硅体相 PBE 弛豫和总能', {} + responses, prepared = query(tmp_path, request, entry=entry, state=state) + assert responses[0]['aidb_preflight']['reuse']['status'] == 'reused' + assert prepared == [] + if entry == 'step': + state = {'goal': '重新计算并提交金刚石硅体相 PBE 弛豫和总能'} + request = json.dumps({'action': '提交硅弛豫计算', 'prior_context': ''}) + responses, prepared = query(tmp_path, request, entry=entry, state=state) + assert responses[0]['aidb_preflight']['reuse']['status'] == 'recalculate' + assert prepared == ['remote submission'] + + +def test_missing_identity_conditions_and_query_error_never_submit(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, '请计算硅弛豫和总能') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'needs_clarification' + assert prepared == [] + monkeypatch.setenv('AI_READY_DB_CONFIG', str(tmp_path / 'missing.json')) + responses, prepared = query(tmp_path) + assert responses[0]['aidb_preflight']['status'] == 'failed' + assert prepared == [] + + +def test_changed_cutoff_does_not_keep_old_result(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = run_task(tmp_path, '计算金刚石硅体相 PBE 弛豫和总能', + [('load_skill', {'skill_name': 'vasp-pymatgen'})], followup='改用 ENCUT=600 计算总能') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'reused' + assert responses[1]['aidb_preflight']['reuse']['status'] == 'unavailable' + assert responses[0]['aidb_preflight']['report_path'] != responses[1]['aidb_preflight']['report_path'] + assert prepared == [] + +def test_explicit_static_goal_never_substitutes_relaxation_energy(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, '只准备金刚石硅体相 PBE 弛豫和静态总能输入,不提交') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'unavailable' + assert prepared == [] + + +def test_flash_entry_does_not_spawn_computation_when_reused(local_aidb, tmp_path, monkeypatch): + seed_relaxation(tmp_path, monkeypatch) + called = [] + def run_flash_step(action: str) -> dict: + called.append(action) + return {'status': 'executed'} + responses, prepared = run_task(tmp_path, '计算金刚石硅体相 PBE 弛豫和总能', + [('run_flash_step', {'action': 'compute Si'})], extra_tools=(run_flash_step,)) + assert responses[0]['status'] == 'reused' + assert called == prepared == [] + + +@pytest.mark.parametrize('setting', ['EDIFF=0.000001', 'k网格=8x8x8']) +def test_requested_parameters_are_not_replaced_with_candidate_conditions(local_aidb, tmp_path, monkeypatch, setting): + seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, f'只准备金刚石硅体相 PBE 弛豫总能 {setting} 输入,不提交') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'unavailable' + assert prepared == [] + +@pytest.mark.parametrize('mesh_input', ['KPOINTS=4x4x4', 'KMESH=4x4x4', 'K-MESH=4x4x4']) +def test_matching_uppercase_kpoints_condition_reuses(local_aidb, tmp_path, monkeypatch, mesh_input): + saved = seed_relaxation(tmp_path, monkeypatch) + responses, prepared = query(tmp_path, f'计算金刚石硅体相 PBE 弛豫和总能 {mesh_input}') + assert responses[0]['aidb_preflight']['reuse']['status'] == 'reused' + assert responses[0]['aidb_preflight']['reuse']['record_ids'] == [saved['record_id']] + assert prepared == [] diff --git a/tests/test_silicon_vasp_workflow.py b/tests/test_silicon_vasp_workflow.py new file mode 100644 index 00000000..3108e584 --- /dev/null +++ b/tests/test_silicon_vasp_workflow.py @@ -0,0 +1,273 @@ +from types import SimpleNamespace +from pathlib import Path +import json +import hashlib + +import pytest + +from matcreator.tools.silicon_vasp import collect_silicon_vasp_result +from matcreator.agents.execution_agent import remote_job_tools +from matcreator.control_plane.remote_jobs import RemoteJobStore +from matcreator.control_plane.remote_job_service import RemoteJobService +from matcreator.control_plane.providers.base import RemoteJobAdapter, RemoteJobCapability, RemoteJobStatus +from test_aidb_skill_hook import local_aidb, run_task + + +class Provider(RemoteJobAdapter): + provider = 'bohr_batchjob' + capabilities = frozenset({RemoteJobCapability.BATCH_COLLECT}) + + def create(self, spec): + return 'test-batch' + + def status(self, external_id): + return RemoteJobStatus(normalized_status='succeeded' if getattr(self, 'finished', False) else 'running', snapshot={}) + + def cancel(self, external_id): + pass + + def collect_outputs(self, external_id, destination_dir): + destination_dir.mkdir() + for name, content in getattr(self, 'outputs', {}).items(): + (destination_dir / name).write_text(content) + return [{'destination': str(p)} for p in destination_dir.iterdir()] or [{'destination': str(destination_dir)}] + + +def job_context(tmp_path, monkeypatch, outputs=None, lexch='PE'): + service = RemoteJobService(RemoteJobStore(tmp_path / 'jobs.db'), + adapter_overrides={'bohr_batchjob': Provider()}) + monkeypatch.setattr(remote_job_tools, '_service', lambda: service) + context = SimpleNamespace(state={'workspace_dir': str(tmp_path), 'session_id': 'session'}, + _invocation_context=SimpleNamespace(user_id='test')) + spec = {'input_path': 'relax', 'input_root': str(tmp_path)} + if outputs is not None: + source = tmp_path / 'relax' + source.mkdir() + for name in ('INCAR', 'POSCAR', 'KPOINTS'): + (source / name).write_text(outputs[name]) + header = f'LEXCH = {lexch}\n' if lexch is not None else '' + (source / 'POTCAR').write_text('TITEL = PAW_PBE Si 05Jan2001\n' + header + 'synthetic test potential, not for computation\n') + spec['vasp_input_sha256'] = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in source.iterdir()} + job = service.submit_job(owner_id='test', session_id='session', provider='bohr_batchjob', + idempotency_key='silicon-relax', spec=spec) + return service, context, job['job_id'] + + +def test_running_job_is_not_scientific_success_or_resubmitted(tmp_path, monkeypatch): + service, context, job_id = job_context(tmp_path, monkeypatch) + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'needs_replanning' + assert result['job_id'] == job_id + assert not (tmp_path / 'outputs').exists() + + +def test_platform_success_with_missing_outputs_is_not_scientific_success(tmp_path, monkeypatch): + service, context, job_id = job_context(tmp_path, monkeypatch) + service.store.transition_job(job_id, 'succeeded') + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' + assert 'vasprun.xml' in result['message'] + assert (tmp_path / 'outputs').exists() + + +def silicon_outputs(nelm=60, electronic_steps=2, ionic_marker=True, stage='relaxation'): + # Deliberately synthetic provider output; never evidence of real compute. + from pymatgen.core import Lattice, Structure + from pymatgen.io.vasp.inputs import Poscar + structure = Structure(Lattice([[0, 2.715, 2.715], [2.715, 0, 2.715], [2.715, 2.715, 0]]), + ['Si', 'Si'], [[0, 0, 0], [.25, .25, .25]]) + positions = '0 0 0.25 .25 .25' + cell = '0 2.715 2.7152.715 0 2.7152.715 2.715 0' + nsw, ibrion = (99, 2) if stage == 'relaxation' else (0, -1) + params = f'{nelm}{nsw}{ibrion}-.02520' + energy = '-10.1-10.1-10.1' + xml = f'''6.4 + {params}{params} + SiSi + 2Si28.0854PAW_PBE Si 05Jan2001 + {cell}{positions} + {''.join(''+energy+'' for _ in range(electronic_steps))}{energy} + {cell}{positions}0 0 00 0 0 + {cell}{positions}''' + return {'vasprun.xml': xml, 'POSCAR': str(Poscar(structure)), 'CONTCAR': str(Poscar(structure)), + 'INCAR': f'NELM = {nelm}\nNSW = {nsw}\nIBRION = {ibrion}\nENCUT = 520\nEDIFFG = -0.02\n', + 'KPOINTS': 'Synthetic 4x4x4\n0\nGamma\n4 4 4\n0 0 0\n', + 'OUTCAR': ('reached required accuracy - stopping structural energy minimisation\n' if ionic_marker else '') + 'General timing and accounting informations for this job:\n', + 'OSZICAR': ' 1 F= -.10100000E+02 E0= -.10100000E+02\n'} + + +def completed_job(tmp_path, monkeypatch, outputs, lexch='PE'): + service, context, job_id = job_context(tmp_path, monkeypatch, outputs, lexch) + service.adapter_for('bohr_batchjob').outputs = outputs + service.store.transition_job(job_id, 'succeeded') + return service, context, job_id + + +@pytest.mark.parametrize('gga,explicit,lexch,expected', [ + ('--', None, 'PE', 'success'), + ('--', 'PE', 'PE', 'success'), + ('--', 'PS', 'PE', 'invalid'), + ('--', None, 'CA', 'invalid'), + ('--', None, None, 'invalid'), + ('PS', None, 'PE', 'invalid'), + ('XX', None, 'PE', 'invalid'), +]) +def test_vasp_default_gga_requires_verified_pbe_potential_and_no_override( + tmp_path, monkeypatch, gga, explicit, lexch, expected +): + outputs = silicon_outputs() + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace('', + f'{gga}') + if explicit: + outputs['INCAR'] += f'GGA = {explicit}\n' + _, context, job_id = completed_job(tmp_path, monkeypatch, outputs, lexch) + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == expected + if expected == 'success': + report = json.loads(Path(result['report_path']).read_text()) + assert report['conditions']['parameters']['GGA'] == '--' + assert report['conditions']['functional_selection'] == 'POTCAR LEXCH' + + +@pytest.mark.parametrize('location', ['INCAR', 'xml_incar', 'xml_parameters']) +def test_exchange_correlation_override_is_not_inferred_from_pbe_potcar(tmp_path, monkeypatch, location): + outputs = silicon_outputs() + if location == 'INCAR': + outputs['INCAR'] += 'XC = PBE0\n' + else: + closing = '' if location == 'xml_incar' else '' + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace(closing, + 'PBE0' + closing) + _, context, job_id = completed_job(tmp_path, monkeypatch, outputs) + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' + assert 'ordinary PBE' in result['message'] + + +def test_verified_relaxation_has_energy_structure_provenance_and_replays(tmp_path, monkeypatch): + service, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'success' + report = json.loads(Path(result['report_path']).read_text()) + assert report['total_energy'] == {'value': -10.1, 'unit': 'eV'} + assert report['job_id'] == job_id and report['batchjob_id'] == 'test-batch' + assert report['structure']['sites'][0]['species'][0]['element'] == 'Si' + assert report['conditions']['parameters']['ENCUT'] == 520 + assert report['archived'] is False + # Same durable store in a new service instance simulates reconnect. + monkeypatch.setattr(remote_job_tools, '_service', lambda: RemoteJobService(service.store)) + replay = collect_silicon_vasp_result(job_id, 'other-output-path', 'relaxation', context) + assert replay['report_path'] == result['report_path'] + assert not (tmp_path / 'other-output-path').exists() + + +def test_static_without_verified_relaxation_is_not_a_completed_two_step_task(tmp_path, monkeypatch): + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs(stage='static')) + result = collect_silicon_vasp_result(job_id, 'outputs', 'static', context) + assert result['status'] == 'invalid' + assert 'relaxation_job_id' in result['message'] + + +@pytest.mark.parametrize('failure', ['electronic', 'ionic', 'truncated', 'wrong_method', 'wrong_structure', 'changed_input']) +def test_bad_scientific_outputs_are_preserved_but_rejected(tmp_path, monkeypatch, failure): + outputs = silicon_outputs(nelm=2) if failure == 'electronic' else silicon_outputs(ionic_marker=failure != 'ionic') + if failure == 'truncated': + outputs['vasprun.xml'] = outputs['vasprun.xml'][:-12] + if failure == 'wrong_method': + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace('PAW_PBE Si', 'PAW_LDA Si') + if failure == 'wrong_structure': + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace('Si', 'Ge') + _, context, job_id = completed_job(tmp_path, monkeypatch, outputs) + if failure == 'changed_input': + (tmp_path / 'relax/INCAR').write_text('NSW=0') + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' + assert (tmp_path / 'outputs/vasprun.xml').read_text() == outputs['vasprun.xml'] + report = json.loads(Path(result['report_path']).read_text()) + assert report['status'] == 'invalid' and report['archived'] is False + + +def test_static_total_energy_keeps_verified_relaxation_source(tmp_path, monkeypatch): + service, context, parent_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + assert collect_silicon_vasp_result(parent_id, 'relax-outputs', 'relaxation', context)['status'] == 'success' + outputs = silicon_outputs(stage='static') + source = tmp_path / 'static' + source.mkdir() + for name in ('INCAR', 'POSCAR', 'KPOINTS'): + (source / name).write_text(outputs[name]) + (source / 'POTCAR').write_bytes((tmp_path / 'relax/POTCAR').read_bytes()) + service.adapter_for('bohr_batchjob').outputs = outputs + job = service.submit_job(owner_id='test', session_id='session', provider='bohr_batchjob', + idempotency_key='silicon-static', spec={'input_path': 'static', 'input_root': str(tmp_path), + 'vasp_input_sha256': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in source.iterdir()}}) + service.store.transition_job(job['job_id'], 'succeeded') + result = collect_silicon_vasp_result(job['job_id'], 'static-outputs', 'static', context, parent_id) + assert result['status'] == 'success' + report = json.loads(Path(result['report_path']).read_text()) + assert report['relaxation_source']['job_id'] == parent_id + assert report['completion']['ionic_converged'] is None + # Source outputs are checked again; editing a cached JSON cannot confer success. + (tmp_path / 'relax-outputs/OUTCAR').write_text('incomplete output') + replay = collect_silicon_vasp_result(job['job_id'], 'ignored', 'static', context, parent_id) + assert replay['status'] == 'invalid' + + +def test_modified_potential_cannot_change_submitted_provenance(tmp_path, monkeypatch): + _, context, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + (tmp_path / 'relax/POTCAR').write_text('TITEL = PAW_PBE Si 05Jan2001\nchanged potential\n') + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' + assert 'POTCAR' in result['message'] + + +@pytest.mark.parametrize('correction', ['IVDW', 'LUSE_VDW']) +def test_dispersion_corrected_pbe_is_outside_ordinary_pbe(tmp_path, monkeypatch, correction): + outputs = silicon_outputs() + tag = '12' if correction == 'IVDW' else 'T' + outputs['vasprun.xml'] = outputs['vasprun.xml'].replace('', tag + '') + _, context, job_id = completed_job(tmp_path, monkeypatch, outputs) + result = collect_silicon_vasp_result(job_id, 'outputs', 'relaxation', context) + assert result['status'] == 'invalid' + assert 'ordinary PBE' in result['message'] + + +def test_tracked_submit_binds_potential_before_transfer_and_replay(tmp_path, monkeypatch): + provider = Provider() + provider.outputs = silicon_outputs() + provider.finished = True + service = RemoteJobService(RemoteJobStore(tmp_path / 'jobs.db'), adapter_overrides={'bohr_batchjob': provider}) + monkeypatch.setattr(remote_job_tools, '_service', lambda: service) + from matcreator.agents.execution_agent import recovery + monkeypatch.setattr(recovery, 'ADK_DIR', tmp_path) + context = SimpleNamespace(state={'workspace_dir': str(tmp_path), 'session_id': 'snapshot-test'}, + _invocation_context=SimpleNamespace(user_id='test')) + source = tmp_path / 'relax' + source.mkdir() + for name in ('INCAR', 'POSCAR', 'KPOINTS'): + (source / name).write_text(provider.outputs[name]) + (source / 'POTCAR').write_text('TITEL = PAW_PBE Si 05Jan2001\nsynthetic potential\n') + args = dict(name='test-si', image='test-image', machine_type='test-cpu', project_id=1, + command='test provider', input_path='relax') + submission = remote_job_tools.submit_bohr_batchjob(context, **args) + result = collect_silicon_vasp_result(submission['job_id'], 'outputs', 'relaxation', context) + assert result['status'] == 'success' + (source / 'POTCAR').write_text('TITEL = PAW_PBE Si 05Jan2001\nchanged potential\n') + repeated = remote_job_tools.submit_bohr_batchjob(context, **args) + assert repeated['job_id'] == submission['job_id'] + checked = collect_silicon_vasp_result(repeated['job_id'], 'unused', 'relaxation', context) + assert checked['status'] == 'invalid' and 'POTCAR' in checked['message'] + assert not (tmp_path / 'unused').exists() + + +@pytest.mark.parametrize('entry', ['thinking', 'step']) +def test_natural_agent_request_queries_then_reads_scientific_result(local_aidb, tmp_path, monkeypatch, entry): + _, _, job_id = completed_job(tmp_path, monkeypatch, silicon_outputs()) + calls = [('load_skill', {'skill_name': 'vasp-pymatgen'}), + ('run_python', {'code': 'prepare'}), + ('collect_silicon_vasp_result', {'job_id': job_id, 'destination_path': 'outputs', 'calculation_type': 'relaxation'})] + responses, prepared = run_task(tmp_path, '重新计算金刚石单质硅体相普通 PBE 弛豫和总能', + calls, state={'session_id': 'session'}, entry=entry, extra_tools=[collect_silicon_vasp_result]) + assert responses[0]['aidb_preflight']['status'] == 'not_found' + assert prepared == ['prepare'] + assert responses[-1]['status'] == 'success' + assert Path(responses[-1]['report_path']).is_file() diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index f30154b9..6864a2f3 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio + +import pytest from types import SimpleNamespace from google.adk.skills import load_skill_from_dir @@ -170,8 +172,9 @@ def test_skill_bundle_info_unknown_skill_returns_none(): assert skill.format_skill_bundle_hint("missing-skill", None) is None +@pytest.mark.parametrize("requested_name", ["demo-skill", " DEMO-SKILL "]) def test_load_skill_tool_result_carries_bundled_files_and_graph_context( - tmp_path, monkeypatch + tmp_path, monkeypatch, requested_name ): from know_do_graph import EdgeRelation, EntryType, KnowDoGraph @@ -210,7 +213,7 @@ def test_load_skill_tool_result_carries_bundled_files_and_graph_context( ) result = asyncio.run( - load_tool.run_async(args={"skill_name": "demo-skill"}, tool_context=tool_context) + load_tool.run_async(args={"skill_name": requested_name}, tool_context=tool_context) ) assert result["skill_name"] == "demo-skill" diff --git a/tests/test_workspace_tools.py b/tests/test_workspace_tools.py index 0e35e7a8..f7c76d79 100644 --- a/tests/test_workspace_tools.py +++ b/tests/test_workspace_tools.py @@ -1,8 +1,14 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor +import asyncio +import os +import shutil +import sys from pathlib import Path +import pytest + from matcreator.tools import workspace_tools @@ -11,6 +17,46 @@ def __init__(self): self.state = {} +@pytest.mark.parametrize("file_tool", [False, True]) +def test_python_tools_use_runtime_interpreter_not_path(tmp_path, monkeypatch, file_tool): + monkeypatch.setenv("MATCLAW_WORKSPACE", str(tmp_path)) + # A stale shell Python must not replace the interpreter running MatCreator. + shadow = tmp_path / "python" + shadow.write_text("#!/bin/sh\nprintf 'wrong-python'\n", encoding="utf-8") + shadow.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + code = "import sys; print(sys.executable)" + if file_tool: + (tmp_path / "probe.py").write_text(code, encoding="utf-8") + output = asyncio.run(workspace_tools.run_python_file("probe.py")) + else: + context = _FakeToolContext() + context.state["workspace_dir"] = str(tmp_path) + output = asyncio.run(workspace_tools.run_python(code, context)) + assert output.strip() == sys.executable + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash required") +def test_bash_tool_preserves_runtime_environment_and_python(tmp_path, monkeypatch): + shadow = tmp_path / "python" + shadow.write_text("#!/bin/sh\nprintf 'wrong-python'\n", encoding="utf-8") + shadow.chmod(0o755) + parent_path = str(tmp_path) + os.pathsep + os.environ["PATH"] + monkeypatch.setenv("PATH", parent_path) + monkeypatch.setenv("PMG_VASP_PSP_DIR", str(tmp_path / "configured-potentials")) + context = _FakeToolContext() + context.state["workspace_dir"] = str(tmp_path) + output = asyncio.run(workspace_tools.run_bash( + "shopt -q login_shell && echo unexpected-login; " + "printf '%s\\n' \"$PATH\" \"$PMG_VASP_PSP_DIR\" \"$PWD\"; " + "python -c 'import sys; print(sys.executable)'", context + )) + assert output.splitlines() == [ + str(Path(sys.executable).parent) + os.pathsep + parent_path, + str(tmp_path / "configured-potentials"), str(tmp_path), sys.executable, + ] + + def test_execution_timeout_uses_valid_user_setting(monkeypatch): monkeypatch.delenv("MATCREATOR_EXEC_TIMEOUT_SECONDS", raising=False) assert workspace_tools._execution_timeout_seconds() == 3600