記事の概要
本稿ではローカルLLMを、単なるチャット相手ではなく、計算化学ワークフローを制御するためのJSON生成器として使えるかを検証した。具体的には、Ollama上で qwen2.5-coder:14b1 を動かし、以下の観点について評価した。
- 化学・計算化学に関する基本的な推論ができるか
- YAML/JSON形式のプロジェクト設定を正しく読めるか
- 指定したJSON Schemaに従って、機械可読なJSONだけを返せるか
- 生成したJSONを他のモジュールに渡して実行できるか
- RDKit、xtb、ASE、DFT、MLIPなどのMI・計算化学ワークフローにおける「出力整形機」として使えるか
この記事では、導入手順、検証用スクリプト、テストプロンプト、評価観点、結果の整理方法をまとめる。
背景
計算化学やマテリアルズインフォマティクス(MI)では、実際の計算や構造生成そのものはRDKit、xTB、ASE、Gaussian、ORCA、VASP、fairchem、MACEなどの既存ツールに任せることが多い。
一方で、それらのツールをつなぐワークフローでは、以下のような作業が頻繁に発生する。
- SMILESやXYZファイルのリスト化
- 計算条件の補完
- 電荷・スピン多重度・計算手法の指定
- 正しい順序での計算実行・結果の処理
- 出力ファイル名の整形
- 入力YAMLの解釈
- 結果集計用JSON/CSVの生成
- エラー時の再試行条件の決定
... etc.
これらは完全に決定論的な処理だけではやや柔軟性に欠ける。一方、LLMにすべてを任せると、説明文やMarkdown記法などが混入し、後続のモジュールが読み取れない出力になってしまうことがある。
そこで本稿では、ローカルLLMを「化学計算エンジン」ではなく、計算化学ワークフロー用の構造化JSON生成器として評価する。この方針が有望であれば、計算化学ワークフローを自動化する「計算化学AIエージェント」のインターフェース層として、ローカルLLMを活用できる可能性がある。
Qwen2.5-Coder:14Bの選択理由
今回LLMとして qwen2.5-coder:14b を選んだ理由は以下の通りである。
- Coder/Instruct系のモデルであり、JSON、Python、CLI、設定ファイルを扱う際の堅牢性に優れる
- パラメータサイズが14B規模と控えめであり、16GB VRAM級のGPUでの動作が現実的である
- reasoning特化モデルと異なり、余計な思考過程を出力しにくい
- Ollamaから簡単にpullできる
- 構造化出力の検証対象として扱いやすい
1つ目の理由は重要である。reasoning/thinking系のモデルは推論トレースを標準出力に吐き出す(セクションが出力される)ため、構造化された出力が要求される場合に使いにくい。厳密な入力フォーマットが求められる今回のようなケースではCoder/Instructモデルを利用する方が合理的である。
2つ目の理由はGPUのVRAM(メモリ)容量に関するものである。現実的な制約として、16 GB程度のGPU(5070Tiや5080など)に載せて動かせるモデルのパラメータ数は限定的である。例えば最近では qwen3-coder シリーズが公開されているが、2026年5月現在で最小のモデルは30bパラメータ(Q4_K_M)のものであり、これはVRAMが 32 GBのRTX5090クラスでなければそもそもモデルを読み込めない。qwen2.5-coder:14b-instruct は速度・安定性・要求VRAMのバランスが良いため選択している。
一方で、Qwen2.5-Coderは化学特化モデルではない。そのため、本記事ではLLMに化学的な最終判断を任せるのではなく、以下のように役割を分ける。
(低レイヤーの)LLMに任せることの例:
- YAML/JSON設定の読解
- workflow JSONの生成
- パラメータ候補の補完
- 不足情報の警告化
- 実行ステップの自然言語的解釈
(低レイヤーの)LLMに任せないことの例:
- SMILESの妥当性検証
- 電荷・スピン多重度の最終決定
- xtb/DFT/MLIP計算そのもの
- 物理化学的な妥当性の最終判定
- JSONスキーマの検証
化学のドメイン知識を必要とする推論には、より高いレイヤーに推論専用のモデルを配置して運用するのが良い。ここでは、Qwen2.5-Coderが低レイヤーの「計算化学の雑事担当2」として機能することを期待している。
Qwen2.5はやや古い(とはいえ約1年前)小規模なモデルであるが、JSON生成器としての用途でわざわざ最新の巨大なモデルを使う必要性は無い。
検証環境
OS:
Windows 11 + WSL2 Ubuntu 24.04.1
CPU:
AMD Ryzen 7 5800X3D 8-Core Processor
GPU:
NVIDIA GeForce RTX 5070 Ti 16GB
NVIDIA Driver:
596.36
CUDA:
13.2
RAM:
64 GB
Python:
Python 3.11.15
Ollama:
0.24.0
検証日:
2026-05-16
インストール&動作確認
Ollama
curl -fsSL https://ollama.com/install.sh | sh
>>> Cleaning up old version at /usr/local/lib/ollama
[sudo] password for hogehoge:
>>> Installing ollama to /usr/local
>>> Downloading ollama-linux-amd64.tar.zst
######################################################################## 100.0%
>>> Adding ollama user to render group...
>>> Adding ollama user to video group...
>>> Adding current user to ollama group...
>>> Creating ollama systemd service...
>>> Enabling and starting ollama service...
>>> Nvidia GPU detected.
>>> The Ollama API is now available at 127.0.0.1:11434.
>>> Install complete. Run "ollama" from the command line.
$ ollama --version
ollama version is 0.24.0
Qwen
ファイルサイズは 9 GB程度。
ollama pull qwen2.5-coder:14b
pulling manifest
pulling ac9bc7a69dab: 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 9.0 GB
pulling 66b9ea09bd5b: 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 68 B
pulling 1e65450c3067: 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 1.6 KB
pulling 832dd9e00a68: 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 11 KB
pulling 0578f229f23a: 100% ▕██████████████████████████████████████████████████████████████████████████████████████████████████████████▏ 488 B
verifying sha256 digest
writing manifest
モデル一覧を確認する。
$ ollama list
NAME ID SIZE MODIFIED
qwen2.5-coder:14b 9ec8897f747e 9.0 GB About an hour ago
Ollamaサーバーが起動しておりモデルの読み込みに成功しているか確認する。
$ curl http://localhost:11434/api/tags
{"models":[{"name":"qwen2.5-coder:14b","model":"qwen2.5-coder:14b","modified_at":"2026-05-16T20:21:56.526874245+09:00","size":8988124298,"digest":"9ec8897f747e246e970bc5cfdda85d22f1123dc2e3d34978a010a75968716849","details":{"parent_model":"","format":"gguf","family":"qwen2","families":["qwen2"],"parameter_size":"14.8B","quantization_level":"Q4_K_M"}}
$ ollama run qwen2.5-coder:14b
>>> Return only JSON. Create a JSON object with keys "status" and "message".
```json
{
"status": "success",
"message": "Request processed successfully."
}
```
json形式として正しく、これは期待される挙動となっている。
Pythonクライアントの準備
PythonからOllamaを呼び出すため、必要なライブラリを導入する。 .venv がホームに存在すると面倒なので、ollamaなどの適当なディレクトリを作成してその中で検証するのが良い。
mkdir ~/ollama
cd ~/ollama
python -m venv .venv
source .venv/bin/activate
pip install ollama pydantic pyyaml
$ pip freeze | grep -E "ollama|pydantic|PyYAML"
ollama==0.6.2
pydantic==2.13.4
pydantic_core==2.46.4
PyYAML==6.0.3
以下、 source .venv/bin/activate を実行した状態で検証を実施する。
検証1:単純なJSON出力
from ollama import chat
response = chat(
model="qwen2.5-coder:14b",
messages=[
{
"role": "system",
"content": "Return only valid JSON. Do not use Markdown. Do not explain."
},
{
"role": "user",
"content": "Create a JSON object describing methane with formula, charge, spin_multiplicity, and atom_count."
}
],
options={
"temperature": 0
}
)
print(response["message"]["content"])
$ python eval1.py
{
"formula": "CH4",
"charge": 0,
"spin_multiplicity": 1,
"atom_count": 5
}
✅期待される挙動になっている。
検証2:JSON SchemaによるStructured Outputs
次に、JSON Schemaを指定して、出力形式を制約する。
import json
from ollama import chat
from pydantic import BaseModel, Field, ValidationError
class MoleculeInfo(BaseModel):
name: str
formula: str
charge: int
spin_multiplicity: int
atom_count: int
warnings: list[str] = Field(default_factory=list)
schema = MoleculeInfo.model_json_schema()
response = chat(
model="qwen2.5-coder:14b",
messages=[
{
"role": "system",
"content": (
"Return only valid JSON matching the given schema. "
"Do not use Markdown. Do not explain."
)
},
{
"role": "user",
"content": "Create molecule metadata for ethanol."
}
],
format=schema,
options={
"temperature": 0
}
)
raw = response["message"]["content"]
print(raw)
try:
parsed = MoleculeInfo.model_validate_json(raw)
print(parsed.model_dump_json(indent=2))
except ValidationError as e:
print("VALIDATION_FAILED")
print(e)
$ python eval2.py
{
"name": "Ethanol",
"formula": "C2H5OH",
"charge": 0,
"spin_multiplicity": 1,
"atom_count": 9,
"warnings": []
}
{
"name": "Ethanol",
"formula": "C2H5OH",
"charge": 0,
"spin_multiplicity": 1,
"atom_count": 9,
"warnings": []
}
✅期待される挙動になっている。
1つ目がLLMの直接出力、2つ目がPydantic検証後の正規化出力である。この程度の構造であればLLMが直接出力しても崩れることはないようである。
また、分子式として厳密な出力が要求されるならC2H6Oの方が良い可能性はある。
検証3:計算化学ワークフローJSONの生成
次に、計算化学ワークフローを表すJSONを生成させる。想定する処理は以下である。
SMILES CSV
↓
RDKitで3D構造生成
↓
xtbで構造最適化
↓
エネルギー・構造情報をsummary JSONに保存
まず、以下のような CSV ファイルを ~/ollama/examples/ に設置する。(examples という名前のディレクトリは事前に作っておく)
id,name,smiles,charge,spin_multiplicity,category,notes
mol_001,methane,C,0,1,closed_shell,"simple neutral closed-shell molecule"
mol_002,ethanol,CCO,0,1,closed_shell,"common organic molecule"
mol_003,benzene,c1ccccc1,0,1,aromatic,"aromatic neutral molecule"
mol_004,acetone,CC(=O)C,0,1,closed_shell,"neutral carbonyl compound"
mol_005,acetic_acid,CC(=O)O,0,1,closed_shell,"neutral carboxylic acid"
mol_006,pyridine,n1ccccc1,0,1,heteroaromatic,"heteroaromatic molecule"
mol_007,acetate,CC(=O)[O-],-1,1,anion,"charged species; charge must be propagated to xtb"
mol_008,ammonium,[NH4+],1,1,cation,"charged species; charge must be propagated to xtb"
mol_009,methyl_radical,[CH3],0,2,radical,"open-shell species; spin/uhf handling required"
mol_010,nitric_oxide,[N]=O,0,2,radical,"open-shell small molecule; spin handling required"
mol_011,ferrocenium_like_placeholder,[Fe+3].[C-]1=CC=C1.[C-]1=CC=C1,1,2,organometallic_warning,"intentionally approximate; should trigger warning rather than blind execution"
その後、以下のスクリプトを実行する。
import csv
import json
from pathlib import Path
from ollama import chat
from pydantic import BaseModel, Field
class WorkflowInput(BaseModel):
type: str
path: str
id_column: str
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
class WorkflowStep(BaseModel):
step_id: str
tool: str
description: str
input_keys: list[str] = Field(default_factory=list)
output_keys: list[str] = Field(default_factory=list)
parameters: dict = Field(default_factory=dict)
warnings: list[str] = Field(default_factory=list)
class WorkflowSpec(BaseModel):
workflow_id: str
objective: str
input: WorkflowInput
steps: list[WorkflowStep]
expected_outputs: dict
global_warnings: list[str] = Field(default_factory=list)
def read_csv_preview(path: str, n: int = 5) -> dict:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = []
for i, row in enumerate(reader):
if i >= n:
break
rows.append(row)
return {
"path": path,
"columns": reader.fieldnames,
"preview_rows": rows,
}
csv_info = read_csv_preview("examples/smiles_list.csv")
project_request = {
"task": "Create a workflow JSON for computational chemistry automation.",
"input_files": {
"smiles_csv": csv_info,
"planned_workflow": {
"workflow_id": "smiles_to_xtb_relax",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv"
},
"outputs": {
"summary_csv": True,
"summary_json": True,
"keep_intermediate_runs": True,
"fail_fast": False
}
},
"tool_registry": {
"tools": [
{
"name": "rdkit_generate_3d",
"description": "Generate 3D conformers from SMILES."
},
{
"name": "xtb_optimize",
"description": "Run geometry optimization using xtb."
},
{
"name": "collect_results",
"description": "Collect optimized structures, energies, and calculation status."
}
]
}
},
"requirements": [
"Preserve the CSV column names.",
"Set id_column to 'id'.",
"Set smiles_column to 'smiles'.",
"Set charge_column to 'charge' if present.",
"Set spin_multiplicity_column to 'spin_multiplicity' if present.",
"Use only tools listed in tool_registry.",
"If summary_csv or summary_json is requested, include a collect_results step.",
"Do not silently assume all molecules are neutral closed-shell species.",
"Add warnings for charged, radical, organometallic, or transition-metal-like entries."
]
}
response = chat(
model="qwen2.5-coder:14b",
messages=[
{
"role": "system",
"content": (
"You are a JSON generator for computational chemistry workflows. "
"Return only valid JSON matching the schema. "
"Do not use Markdown. "
"Do not explain. "
"Use only the registered tools. "
"Preserve important CSV columns."
)
},
{
"role": "user",
"content": json.dumps(project_request, ensure_ascii=False)
}
],
format=WorkflowSpec.model_json_schema(),
options={
"temperature": 0,
"num_ctx": 8192
}
)
raw = response["message"]["content"]
parsed = WorkflowSpec.model_validate_json(raw)
print(
parsed.model_dump_json(
indent=2,
ensure_ascii=False
)
)
$ python eval3.py
{
"workflow_id": "smiles_to_xtb_relax",
"objective": "Generate 3D conformers from SMILES and optimize geometries using xtb.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"steps": [
{
"step_id": "generate_3d_conformers",
"tool": "rdkit_generate_3d",
"description": "Generate 3D conformers from SMILES.",
"input_keys": [],
"output_keys": [],
"parameters": {},
"warnings": []
},
{
"step_id": "optimize_geometry",
"tool": "xtb_optimize",
"description": "Run geometry optimization using xtb.",
"input_keys": [
"conformers"
],
"output_keys": [
"optimized_structures"
],
"parameters": {},
"warnings": []
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true
},
"global_warnings": [
"Charged, radical, organometallic, or transition-metal-like entries may require special handling."
]
}
JSON Schema(スクリプトのclass WorkflowInputの部分)には type、path、id_column、smiles_column、charge_column、spin_multiplicity_column のみを規定しているので、csvの列情報の一部(name、category、notes)は "input" には保持されていない。これは正常な挙動である。
JSON Schemaの記載内容はLLMの解釈に影響するため、並び順を含めて慎重に選択する必要がある。
例えば、JSON Schemaに以下のように name、path、category を追加すると "input" の属性にも反映される。これらの値はcsvの内容に対応している。
class WorkflowInput(BaseModel):
type: str
path: str
id_column: str
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
name: str
category: str
notes: str
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"name": "name",
"category": "category",
"notes": "notes"
},
では、追加項目の並び順を変えてみるとどうなるだろうか。
class WorkflowInput(BaseModel):
type: str
path: str
name: str
category: str
notes: str
id_column: str
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"name": "smiles_input",
"category": "SMILES input file",
"notes": "Input CSV containing molecule details.",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
この場合は、LLMが独自に smiles_list.csv の内容から記載すべき事項を判断して追記しており、その値はcsvに対応していない。このように、JSON Schemaの記載内容および並び順はLLMの解釈に影響する恐れがある。
このようになる理由として、
typeとpathが「ファイルそのものの属性」であるため、LLMの内部ではこの直後の項目も「ファイルそのものの属性」として認識されてしまっている可能性が考えられる。
csvファイルの列情報を正しく取得するには、以下のように _column のラベルを付記して列情報であることを明示すべきである。
class WorkflowInput(BaseModel):
type: str
path: str
name_column: str
category_column: str
notes_column: str
id_column: str
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"name_column": "name",
"category_column": "category",
"notes_column": "notes",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
notes_column のようにラベル付けしておけば、例えば notes の部分が tags や labels になっていても "notes_column": "labels" のように割り当てられる。
とはいえ、似たような名前・意味の列が複数ある場合にはLLMが正しく推測できない可能性は十分にあり得る。また、LLMの出力は確率的に決まるため、期待される項目名と実際の項目名が乖離している場合は特に、LLMが予期しない内容のテキストを出力してしまう可能性にも注意したい。
検証4:YAML/CSV → workflow JSON生成
次に、ローカルのYAMLファイルをPython側で読み込んでLLMに渡す操作を行う。この検証は「LLMが計算化学ワークフローをゼロから設計できるか」ではなく、「仕様書・ツール定義・入力CSV情報をもとに、下流モジュールが読める構造化JSONへ変換できるか」を評価するものである。
ここでは、LLMに計算化学ワークフローを直接実行させるのではなく、ワークフローを表すJSONを生成させる構成とした。そのため、LLMに渡す入力ファイルとして以下を用意した。
-
planned_workflow.yaml:人間が意図する計算手順を記述したファイル -
canonical_workflow.yaml:LLMの生成するJSONが満たすべき正規化仕様・検証条件を記述したファイル -
tool_registry.yaml:LLMが使用可能なツール名、入出力、パラメータを定義したファイル
このうち canonical_workflow.yaml は、完全な正解JSONそのものではないが、LLMが生成すべきworkflow JSONの「正解に近い仕様」を規定するファイルである。LLMがこれを正しく読めれば、ある程度堅牢なデータ入出力が実現する。
以下に示す eval4.py のスクリプトでは、Python側で planned_workflow.yaml、canonical_workflow.yaml、tool_registry.yaml、および examples/smiles_list.csv を読み込み、CSVヘッダーと先頭行をLLMに渡している。
yamlファイルの内容
workflow_id: smiles_to_xtb_relax
description: >
Generate 3D structures from a SMILES CSV, optimize each structure using xtb,
and collect optimized structures and energies into summary files.
input:
type: smiles_csv
path: examples/smiles_list.csv
columns:
id: id
name: name
smiles: smiles
charge: charge
spin_multiplicity: spin_multiplicity
steps:
- tool: rdkit_generate_3d
description: Generate initial 3D conformers from SMILES.
required: true
- tool: xtb_optimize
description: Optimize each generated structure using xtb.
required: true
- tool: collect_results
description: Collect optimized structures, energies, and status information.
required: true
defaults:
rdkit_generate_3d:
num_conformers: 20
max_embed_attempts: 1000
optimize_initial_geometry: true
random_seed: 20260516
xtb_optimize:
method: gfn2-xtb
solvent: null
max_steps: 500
fmax: 0.05
executable: xtb
outputs:
output_dir: runs/smiles_to_xtb_relax
summary_csv: true
summary_json: true
keep_intermediate_runs: true
fail_fast: false
safety_policy:
unknown_tool: reject
invalid_smiles: skip_and_report
missing_charge_or_spin: warn_and_use_default
organometallic_or_transition_metal: warn_and_require_review
metadata:
created_for: qwen2.5-coder-14b-instruct-evaluation
author: <YOUR_NAME_OR_HANDLE>
date: <YYYY-MM-DD>
workflow_id: smiles_to_xtb_relax
objective: >
Convert a SMILES CSV into initial 3D structures, optimize them with xtb,
and generate machine-readable summary files.
input:
type: smiles_csv
path: examples/smiles_list.csv
id_column: id
name_column: name
smiles_column: smiles
charge_column: charge
spin_multiplicity_column: spin_multiplicity
allowed_tools:
- rdkit_generate_3d
- xtb_optimize
- collect_results
required_step_order:
- rdkit_generate_3d
- xtb_optimize
- collect_results
expected_outputs:
summary_csv: true
summary_json: true
optimized_xyz_files: true
per_molecule_status_json: true
validation_rules:
- rule_id: preserve_input_columns
description: >
The output workflow JSON must preserve id_column, smiles_column,
charge_column, and spin_multiplicity_column when present.
- rule_id: no_unregistered_tools
description: >
The output workflow JSON must use only tools listed in allowed_tools.
- rule_id: charged_species_warning
description: >
Charged molecules must propagate charge to xtb parameters or per-molecule metadata.
- rule_id: open_shell_warning
description: >
Species with spin_multiplicity greater than 1 must trigger an open-shell warning
and must not be silently treated as closed-shell.
- rule_id: organometallic_review
description: >
Organometallic or transition-metal-containing entries should be flagged for manual review.
tools:
- name: rdkit_generate_3d
version: "0.1.0"
category: structure_generation
description: Generate one or more initial 3D conformers from SMILES using RDKit.
inputs:
- name: smiles_csv
type: file
format: csv
required: true
- name: smiles_column
type: string
required: true
- name: id_column
type: string
required: true
- name: charge_column
type: string
required: false
- name: spin_multiplicity_column
type: string
required: false
outputs:
- name: initial_xyz_files
type: file_list
format: xyz
- name: conformer_metadata_json
type: file
format: json
parameters:
num_conformers:
type: integer
default: 20
max_embed_attempts:
type: integer
default: 1000
optimize_initial_geometry:
type: boolean
default: true
random_seed:
type: integer
default: 20260516
failure_modes:
- invalid_smiles
- embedding_failed
- unsupported_element
- name: xtb_optimize
version: "0.1.0"
category: quantum_chemistry
description: Optimize molecular geometry using xtb.
inputs:
- name: initial_xyz_files
type: file_list
format: xyz
required: true
- name: molecule_metadata_json
type: file
format: json
required: false
outputs:
- name: optimized_xyz_files
type: file_list
format: xyz
- name: xtb_energy_files
type: file_list
format: text
- name: xtb_status_json
type: file
format: json
parameters:
method:
type: string
enum:
- gfn2-xtb
- gfn1-xtb
- gfnff
default: gfn2-xtb
charge:
type: integer
default: 0
uhf:
type: integer
default: 0
solvent:
type:
- string
- "null"
default: null
max_steps:
type: integer
default: 500
fmax:
type: number
default: 0.05
executable:
type: string
default: xtb
failure_modes:
- xtb_not_found
- optimization_failed
- unsupported_charge_spin_combination
- scf_not_converged
- name: collect_results
version: "0.1.0"
category: postprocessing
description: Collect optimized structures, energies, warnings, and status values.
inputs:
- name: optimized_xyz_files
type: file_list
format: xyz
required: true
- name: xtb_energy_files
type: file_list
format: text
required: false
- name: xtb_status_json
type: file
format: json
required: false
outputs:
- name: summary_csv
type: file
format: csv
- name: summary_json
type: file
format: json
- name: per_molecule_status_json
type: file
format: json
parameters:
keep_intermediate_runs:
type: boolean
default: true
fail_fast:
type: boolean
default: false
import csv
import json
from pathlib import Path
from typing import Any, Literal
import yaml
from ollama import chat
from pydantic import BaseModel, Field, ValidationError, model_validator
MODEL = "qwen2.5-coder:14b"
PROJECT_ROOT = Path(".")
PLANNED_WORKFLOW_PATH = PROJECT_ROOT / "planned_workflow.yaml"
CANONICAL_WORKFLOW_PATH = PROJECT_ROOT / "canonical_workflow.yaml"
TOOL_REGISTRY_PATH = PROJECT_ROOT / "tool_registry.yaml"
SMILES_CSV_PATH = PROJECT_ROOT / "examples" / "smiles_list.csv"
NUM_CTX = 8192
TEMPERATURE = 0
class WorkflowInput(BaseModel):
type: Literal["smiles_csv", "xyz_dir", "sdf"]
path: str
id_column: str
name_column: str | None = None
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
metadata_columns: list[str] = Field(default_factory=list)
class WorkflowStep(BaseModel):
step_id: str
tool: str
description: str
input_keys: list[str] = Field(default_factory=list)
output_keys: list[str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
warnings: list[str] = Field(default_factory=list)
class WorkflowSpec(BaseModel):
workflow_id: str
objective: str
input: WorkflowInput
steps: list[WorkflowStep]
expected_outputs: dict[str, Any] = Field(default_factory=dict)
global_warnings: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def validate_basic_workflow_consistency(self) -> "WorkflowSpec":
tools = [step.tool for step in self.steps]
if self.expected_outputs.get("summary_csv") or self.expected_outputs.get("summary_json"):
if "collect_results" not in tools:
raise ValueError("summary_csv or summary_json is requested, but collect_results step is missing.")
if self.input.type == "smiles_csv":
if not self.input.id_column:
raise ValueError("input.id_column is required for smiles_csv.")
if not self.input.smiles_column:
raise ValueError("input.smiles_column is required for smiles_csv.")
return self
def read_yaml(path: Path) -> Any:
if not path.exists():
raise FileNotFoundError(f"YAML file not found: {path}")
return yaml.safe_load(path.read_text(encoding="utf-8"))
def read_csv_preview(path: Path, n: int = 8) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"CSV file not found: {path}")
with path.open(newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows: list[dict[str, str]] = []
for i, row in enumerate(reader):
if i >= n:
break
rows.append(row)
return {"path": str(path.as_posix()), "columns": reader.fieldnames or [], "preview_rows": rows}
def extract_tool_registry_summary(tool_registry: dict[str, Any]) -> dict[str, Any]:
tools = []
for tool in tool_registry.get("tools", []):
tools.append({
"name": tool.get("name"),
"description": tool.get("description"),
"inputs": tool.get("inputs", []),
"outputs": tool.get("outputs", []),
"parameters": tool.get("parameters", {}),
})
return {"tools": tools}
def build_payload() -> dict[str, Any]:
planned_workflow = read_yaml(PLANNED_WORKFLOW_PATH)
canonical_workflow = read_yaml(CANONICAL_WORKFLOW_PATH)
tool_registry = read_yaml(TOOL_REGISTRY_PATH)
csv_preview = read_csv_preview(SMILES_CSV_PATH)
return {
"task": "Create a workflow JSON for computational chemistry automation.",
"important_rules": [
"Return only JSON matching the schema.",
"Do not use Markdown.",
"Use only tools listed in tool_registry.",
"Preserve CSV column names exactly.",
"Map CSV column 'id' to id_column.",
"Map CSV column 'name' to name_column.",
"Map CSV column 'smiles' to smiles_column.",
"Map CSV column 'charge' to charge_column.",
"Map CSV column 'spin_multiplicity' to spin_multiplicity_column.",
"Map CSV columns 'category' and 'notes' to metadata_columns.",
"Do not use name or category as file-level metadata.",
"If summary_csv or summary_json is requested, include a collect_results step.",
"For input_keys and output_keys, use names consistent with tool_registry outputs and inputs.",
"Do not invent generic keys such as 'conformers' if the registry defines 'initial_xyz_files'.",
"Charged, radical, organometallic, and transition-metal-like entries should produce warnings.",
],
"planned_workflow": planned_workflow,
"canonical_workflow": canonical_workflow,
"tool_registry": extract_tool_registry_summary(tool_registry),
"smiles_csv": csv_preview,
"desired_output_schema": WorkflowSpec.model_json_schema(),
}
def call_model(payload: dict[str, Any]) -> str:
response = chat(
model=MODEL,
messages=[
{"role": "system", "content": (
"You are a strict JSON generator for computational chemistry workflows. "
"Return only valid JSON matching the provided schema. "
"Do not use Markdown. Do not explain. "
"Use only registered tools and preserve CSV column mappings exactly."
)},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
format=WorkflowSpec.model_json_schema(),
options={"temperature": TEMPERATURE, "num_ctx": NUM_CTX},
)
return response["message"]["content"]
def main() -> None:
payload = build_payload()
raw = call_model(payload)
print("=== raw model output ===")
print(raw)
print("\n=== validated output ===")
try:
parsed = WorkflowSpec.model_validate_json(raw)
except ValidationError as exc:
print("VALIDATION_FAILED")
print(exc)
raise SystemExit(1)
print(parsed.model_dump_json(indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
build_payload() は、LLMに完全な正解JSONを渡しているわけではないが、各yamlファイルや出力Schemaをまとめて渡しているため、LLMはかなり強く制約された状態でworkflow JSONを生成している。
以下では、raw model output(LLMが実際に返したJSON文字列そのもの)に続けて validated output(Pydanticの WorkflowSpec に通した後、Python側で正規化して再出力したもの)を出力している。
$ python eval4.py
=== raw model output ===
{
"workflow_id": "smiles_to_xtb_relax",
"objective": "Convert a SMILES CSV into initial 3D structures, optimize them with xtb, and generate machine-readable summary files.\n",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"steps": [
{
"step_id": "rdkit_generate_3d_step",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from SMILES.",
"input_keys": ["smiles_csv", "smiles_column", "id_column", "charge_column", "spin_multiplicity_column"],
"output_keys": ["initial_xyz_files", "conformer_metadata_json"],
"parameters": {
"num_conformers": 20,
"max_embed_attempts": 1000,
"optimize_initial_geometry": true,
"random_seed": 20260516
}
},
{
"step_id": "xtb_optimize_step",
"tool": "xtb_optimize",
"description": "Optimize each generated structure using xtb.",
"input_keys": ["initial_xyz_files", "molecule_metadata_json"],
"output_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"parameters": {
"method": "gfn2-xtb",
"solvent": null,
"max_steps": 500,
"fmax": 0.05,
"executable": "xtb"
}
},
{
"step_id": "collect_results_step",
"tool": "collect_results",
"description": "Collect optimized structures, energies, warnings, and status values.",
"input_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"output_keys": ["summary_csv", "summary_json", "per_molecule_status_json"],
"parameters": {
"keep_intermediate_runs": true,
"fail_fast": false
}
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"optimized_xyz_files": true,
"per_molecule_status_json": true
},
"global_warnings": [
"Charged molecules must propagate charge to xtb parameters or per-molecule metadata.",
"Species with spin_multiplicity greater than 1 must trigger an open-shell warning and must not be silently treated as closed-shell.",
"Organometallic or transition-metal-containing entries should be flagged for manual review."
]
}
=== validated output ===
{
"workflow_id": "smiles_to_xtb_relax",
"objective": "Convert a SMILES CSV into initial 3D structures, optimize them with xtb, and generate machine-readable summary files.\n",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"name_column": null,
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"metadata_columns": []
},
"steps": [
{
"step_id": "rdkit_generate_3d_step",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from SMILES.",
"input_keys": [
"smiles_csv",
"smiles_column",
"id_column",
"charge_column",
"spin_multiplicity_column"
],
"output_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"parameters": {
"num_conformers": 20,
"max_embed_attempts": 1000,
"optimize_initial_geometry": true,
"random_seed": 20260516
},
"warnings": []
},
{
"step_id": "xtb_optimize_step",
"tool": "xtb_optimize",
"description": "Optimize each generated structure using xtb.",
"input_keys": [
"initial_xyz_files",
"molecule_metadata_json"
],
"output_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"parameters": {
"method": "gfn2-xtb",
"solvent": null,
"max_steps": 500,
"fmax": 0.05,
"executable": "xtb"
},
"warnings": []
},
{
"step_id": "collect_results_step",
"tool": "collect_results",
"description": "Collect optimized structures, energies, warnings, and status values.",
"input_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"output_keys": [
"summary_csv",
"summary_json",
"per_molecule_status_json"
],
"parameters": {
"keep_intermediate_runs": true,
"fail_fast": false
},
"warnings": []
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"optimized_xyz_files": true,
"per_molecule_status_json": true
},
"global_warnings": [
"Charged molecules must propagate charge to xtb parameters or per-molecule metadata.",
"Species with spin_multiplicity greater than 1 must trigger an open-shell warning and must not be silently treated as closed-shell.",
"Organometallic or transition-metal-containing entries should be flagged for manual review."
]
}
これを見る限り、概ね良い精度で既定のJSON形式を返せている。一方で、name_column が null になっているなど、CSV列情報の一部が十分に保持されていない部分も見受けられる。
理由としては、以下の点が考えられる。
- JSON Schemaにおいて、
name_columnやmetadata_columnsが任意項目であり、未指定でもvalidationを通ること -
nameという語が、分子名、ファイル名、workflow名など複数の意味を持ちうること - タスクの目的が「全CSV列情報の取得・保持」ではなく、「計算ワークフローJSONの生成」として定義されていること
-
name、category、notesのような補助列が、計算実行に直接必要な列として強く指定されていなかったこと
実際、id、smiles、charge、spin_multiplicity のような計算の実行に直結する列情報は保持されている。一方で、name、category、notes のような補助的な列情報はSchema上必須ではなく、プロンプト上の優先度も低かったため、省略されたと考えられる。
この結果は単純なモデルの性能不足というより、Schema設計、プロンプト設計、列名の曖昧さ、タスクの目的による影響が大きい。全CSV列を確実に保持したい場合は、プロンプトで明示するだけでなく、name_column や metadata_columns を必須化する、またはPython側でCSV列マッピングを決定論的に作成してLLMに渡す設計が望ましい。
検証5:生成したJSONを別のモジュールに渡す
検証5では、LLMが生成したworkflow JSONを別モジュールで受け取り、実行可能な形式になっているかを確認する。具体的には、生成したJSONをそのまま実計算に渡すのではなく、まずPydanticモデルと tool_registry.yaml によって検証し、dry-runを行う。さらに、実運用に近い条件として、約40個のダミーモジュールを含むlarge版のtool registryを用意し、多数の候補の中からLLMが正しいモジュールの組み合わせと順序を選択できるかを検証する。
ここでは、仕様を明示的に与える場合から自然言語によるプロンプトのみの場合まで、以下の3段階に分けて評価する。
-
Level 1:guided test
expected_tools.jsonをLLMにも渡し、必要ツール、推奨順序、期待パラメータ値を明示した状態でworkflow JSONを生成させる検証。LLMの完全自律的なツール選択能力ではなく、仕様書・tool registry・制約条件に従って安定したJSONを生成できるかを見る。 -
Level 2:semi-blind test
expected_tools.jsonはLLMには渡さず、validator側の採点基準としてのみ使う検証。LLMにはplanned_workflow_large.yaml、CSV情報、tool registryだけを渡し、計算目的とregistryの説明から必要なツールを選べるかを見る。 -
Level 3:natural-language planning test
planned_workflow_large.yamlも使わず、人間の自然言語依頼、CSV情報、tool registryだけをLLMに渡す検証。実運用に最も近く、自然言語の要求から必要モジュールを自律的に選択し、実行可能なworkflow JSONを構成できるかを見る。
yaml、jsonファイルの内容
-
tool_registry_large.yaml:LLMが使用可能なツール一覧を定義するファイル。各ツールの入力、出力、パラメータを記述する。 -
planned_workflow_large.yaml:人間が意図する計算目的を記述するファイル。Level 2でLLMに渡す。 -
expected_tools.json:採点用の期待仕様を記述するファイル。Level 1ではLLMにも渡し、Level 2/3ではvalidator側だけが使う。 -
examples/smiles_list.csv:検証用のSMILES CSV。列情報をLLMに渡し、workflow JSONに反映できるかを見る。 -
user_request.txt:Level 3で使う自然言語依頼ファイル。人間の依頼文からツール選択できるかを検証する。 -
generated_large_registry_workflow.json:LLM生成結果を保存したJSON。再検証や比較に使う。
id,name,smiles,charge,spin_multiplicity,category,notes
mol_001,methane,C,0,1,closed_shell,simple neutral molecule
mol_002,ethanol,CCO,0,1,closed_shell,common organic molecule
mol_003,acetate,CC(=O)[O-],-1,1,anion,charged molecule
mol_004,methyl_radical,[CH3],0,2,radical,open-shell molecule
{
"required_tools": [
"read_smiles_csv",
"rdkit_validate_smiles",
"rdkit_generate_3d",
"xtb_optimize",
"collect_results"
],
"preferred_step_order": [
"read_smiles_csv",
"rdkit_validate_smiles",
"rdkit_generate_3d",
"xtb_optimize",
"collect_results"
],
"discouraged_tools": [
"openbabel_convert",
"rdkit_compute_descriptors",
"mordred_compute_descriptors",
"mace_single_point",
"fairchem_uma_optimize",
"ase_neb_setup",
"ase_neb_optimize",
"gaussian_input_writer",
"orca_input_writer",
"vasp_poscar_writer",
"generate_dimer_distance_scan_xyz",
"random_dimer_pose_sampler",
"substituent_replacement_generator",
"rank_by_aromatic_ring_count",
"vdw_surface_area_estimator",
"solvent_accessible_surface_area",
"conformer_rmsd_clusterer",
"torsion_scan_input_generator",
"distance_constraint_scan_generator",
"fragment_molecule_by_brics",
"enumerate_tautomers",
"enumerate_protonation_states",
"generate_stereoisomers",
"pharmacophore_feature_counter",
"molecular_shape_asphericity",
"electrostatic_grid_sampler",
"molecular_dipole_estimator",
"pairwise_molecular_similarity",
"substructure_filter",
"reactive_site_tagging",
"reaction_product_enumerator",
"binding_pocket_grid_generator",
"conformer_energy_ranker",
"boltzmann_average_properties",
"xyz_centroid_alignment",
"steric_clash_detector",
"molecular_volume_estimator"
],
"sensitive_parameters": {
"rdkit_generate_3d": [
"num_conformers",
"max_embed_attempts",
"optimize_initial_geometry",
"random_seed"
],
"xtb_optimize": [
"method",
"charge_column",
"spin_multiplicity_column",
"max_steps",
"fmax",
"solvent",
"executable"
],
"collect_results": [
"summary_csv",
"summary_json",
"keep_intermediate_runs",
"fail_fast"
]
},
"expected_parameter_values": {
"read_smiles_csv": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"rdkit_validate_smiles": {
"strict": false
},
"rdkit_generate_3d": {
"num_conformers": 20,
"max_embed_attempts": 1000,
"optimize_initial_geometry": true,
"random_seed": 20260516
},
"xtb_optimize": {
"method": "gfn2-xtb",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 500,
"fmax": 0.05,
"solvent": null,
"executable": "xtb"
},
"collect_results": {
"summary_csv": true,
"summary_json": true,
"keep_intermediate_runs": true,
"fail_fast": false
}
},
"initial_input_keys": [
"smiles_csv"
],
"allowed_missing_optional_outputs": [],
"max_allowed_steps": 6
}
Level 2 以降の検証では planned_workflow_large.yaml から以下を削除する:
- equired_tools:
- preferred_step_order:
- parameter_policy:
workflow_id: smiles_to_xtb_relax_large_registry_test
description: >
Generate initial 3D structures from a SMILES CSV, optimize them with xtb,
and collect summary CSV/JSON outputs. The tool registry intentionally contains
many chemically meaningful but irrelevant modules, so this test checks whether
the LLM selects only the minimal tools needed for the requested workflow.
input:
type: smiles_csv
path: examples/smiles_list.csv
columns:
id: id
name: name
smiles: smiles
charge: charge
spin_multiplicity: spin_multiplicity
category: category
notes: notes
expected_behavior:
required_tools:
- read_smiles_csv
- rdkit_validate_smiles
- rdkit_generate_3d
- xtb_optimize
- collect_results
preferred_step_order:
- read_smiles_csv
- rdkit_validate_smiles
- rdkit_generate_3d
- xtb_optimize
- collect_results
forbidden_behavior:
- Do not invent tools not listed in tool_registry_large.yaml.
- Do not invent parameters not listed for each tool.
- Do not use unrelated modules for this simple workflow.
- Do not perform descriptor calculation, enumeration, docking, DFT, NEB, MLIP, molecular editing, or reaction enumeration unless explicitly requested.
- Do not infer solvent, convergence threshold, number of conformers, or random seed unless specified here or in the registry defaults.
- Parameter values must exactly match registry enum values when enum values are defined.
parameter_policy:
read_smiles_csv:
id_column: id
smiles_column: smiles
charge_column: charge
spin_multiplicity_column: spin_multiplicity
rdkit_validate_smiles:
strict: false
rdkit_generate_3d:
num_conformers: 20
max_embed_attempts: 1000
optimize_initial_geometry: true
random_seed: 20260516
xtb_optimize:
method: gfn2-xtb
charge_column: charge
spin_multiplicity_column: spin_multiplicity
max_steps: 500
fmax: 0.05
solvent: null
executable: xtb
collect_results:
summary_csv: true
summary_json: true
keep_intermediate_runs: true
fail_fast: false
outputs:
summary_csv: true
summary_json: true
per_molecule_status_json: true
notes:
- This is a large-registry stress test, not a test of whether descriptor, DFT, MLIP, docking, or enumeration modules work.
- The correct behavior is to ignore chemically meaningful but irrelevant modules.
tools:
- name: read_smiles_csv
version: "0.1.0"
category: io
description: Read a CSV file containing molecule identifiers, SMILES, charge, and spin information.
inputs:
- name: smiles_csv
type: file
format: csv
required: true
outputs:
- name: molecule_table
type: table
parameters:
id_column:
type: string
smiles_column:
type: string
charge_column:
type: string
spin_multiplicity_column:
type: string
- name: rdkit_validate_smiles
version: "0.1.0"
category: validation
description: Validate SMILES strings using RDKit and annotate invalid rows.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: validated_molecule_table
type: table
parameters:
strict:
type: boolean
default: false
- name: rdkit_generate_3d
version: "0.1.0"
category: structure_generation
description: Generate initial 3D conformers from validated SMILES using RDKit.
inputs:
- name: validated_molecule_table
type: table
required: true
outputs:
- name: initial_xyz_files
type: file_list
format: xyz
- name: conformer_metadata_json
type: file
format: json
parameters:
num_conformers:
type: integer
default: 20
max_embed_attempts:
type: integer
default: 1000
optimize_initial_geometry:
type: boolean
default: true
random_seed:
type: integer
default: 20260516
- name: xtb_optimize
version: "0.1.0"
category: quantum_chemistry
description: Optimize molecular geometries using xtb.
inputs:
- name: initial_xyz_files
type: file_list
format: xyz
required: true
- name: conformer_metadata_json
type: file
format: json
required: false
outputs:
- name: optimized_xyz_files
type: file_list
format: xyz
- name: xtb_energy_files
type: file_list
format: text
- name: xtb_status_json
type: file
format: json
parameters:
method:
type: string
enum: [gfn2-xtb, gfn1-xtb, gfnff]
default: gfn2-xtb
charge_column:
type: string
spin_multiplicity_column:
type: string
max_steps:
type: integer
default: 500
fmax:
type: number
default: 0.05
solvent:
type:
- string
- "null"
default: null
executable:
type: string
default: xtb
- name: collect_results
version: "0.1.0"
category: postprocessing
description: Collect optimized structures, energies, status, and warnings into summary files.
inputs:
- name: optimized_xyz_files
type: file_list
format: xyz
required: true
- name: xtb_energy_files
type: file_list
format: text
required: false
- name: xtb_status_json
type: file
format: json
required: false
outputs:
- name: summary_csv
type: file
format: csv
- name: summary_json
type: file
format: json
- name: per_molecule_status_json
type: file
format: json
parameters:
summary_csv:
type: boolean
default: true
summary_json:
type: boolean
default: true
keep_intermediate_runs:
type: boolean
default: true
fail_fast:
type: boolean
default: false
- name: openbabel_convert
version: "0.1.0"
category: format_conversion
description: Convert molecule files between formats using Open Babel.
inputs:
- name: input_molecule_files
type: file_list
outputs:
- name: converted_molecule_files
type: file_list
parameters:
input_format:
type: string
output_format:
type: string
- name: rdkit_compute_descriptors
version: "0.1.0"
category: descriptor
description: Compute RDKit molecular descriptors from a molecule table.
inputs:
- name: validated_molecule_table
type: table
outputs:
- name: descriptor_table
type: table
parameters:
descriptor_set:
type: string
default: basic
- name: mordred_compute_descriptors
version: "0.1.0"
category: descriptor
description: Compute Mordred descriptors.
inputs:
- name: validated_molecule_table
type: table
outputs:
- name: mordred_descriptor_table
type: table
parameters:
ignore_3d:
type: boolean
default: false
- name: mace_single_point
version: "0.1.0"
category: mlip
description: Run a MACE single-point calculation.
inputs:
- name: xyz_files
type: file_list
outputs:
- name: mace_energy_json
type: file
parameters:
model_path:
type: string
device:
type: string
default: cuda
- name: fairchem_uma_optimize
version: "0.1.0"
category: mlip
description: Optimize structures using a fairchem UMA calculator.
inputs:
- name: initial_xyz_files
type: file_list
outputs:
- name: uma_optimized_xyz_files
type: file_list
- name: uma_status_json
type: file
parameters:
model_path:
type: string
device:
type: string
default: cuda
fmax:
type: number
- name: ase_neb_setup
version: "0.1.0"
category: reaction_path
description: Set up NEB images from initial and final structures.
inputs:
- name: initial_xyz
type: file
- name: final_xyz
type: file
outputs:
- name: neb_images
type: file_list
parameters:
n_images:
type: integer
default: 7
interpolation:
type: string
default: idpp
- name: ase_neb_optimize
version: "0.1.0"
category: reaction_path
description: Optimize NEB images.
inputs:
- name: neb_images
type: file_list
outputs:
- name: neb_result_json
type: file
parameters:
fmax:
type: number
default: 0.05
steps:
type: integer
default: 500
- name: gaussian_input_writer
version: "0.1.0"
category: dft
description: Write Gaussian input files.
inputs:
- name: xyz_files
type: file_list
outputs:
- name: gjf_files
type: file_list
parameters:
method:
type: string
basis:
type: string
charge:
type: integer
multiplicity:
type: integer
- name: orca_input_writer
version: "0.1.0"
category: dft
description: Write ORCA input files.
inputs:
- name: xyz_files
type: file_list
outputs:
- name: inp_files
type: file_list
parameters:
method:
type: string
basis:
type: string
- name: vasp_poscar_writer
version: "0.1.0"
category: periodic_dft
description: Write VASP POSCAR files.
inputs:
- name: ase_atoms_list
type: object_list
outputs:
- name: poscar_files
type: file_list
parameters:
sort_atoms:
type: boolean
default: true
- name: generate_dimer_distance_scan_xyz
version: "0.1.0"
category: structure_generation
description: Generate a series of XYZ structures by gradually changing the center-of-mass distance between two molecules.
inputs:
- name: monomer_a_xyz
type: file
format: xyz
required: true
- name: monomer_b_xyz
type: file
format: xyz
required: true
outputs:
- name: dimer_scan_xyz_files
type: file_list
format: xyz
- name: dimer_scan_metadata_json
type: file
format: json
parameters:
start_distance_angstrom:
type: number
default: 2.5
end_distance_angstrom:
type: number
default: 8.0
num_points:
type: integer
default: 20
axis:
type: string
enum: [x, y, z]
default: z
keep_monomer_orientation:
type: boolean
default: true
- name: random_dimer_pose_sampler
version: "0.1.0"
category: structure_generation
description: Generate random relative translations and rotations of two molecules for noncovalent complex sampling.
inputs:
- name: monomer_a_xyz
type: file
format: xyz
required: true
- name: monomer_b_xyz
type: file
format: xyz
required: true
outputs:
- name: random_dimer_xyz_files
type: file_list
format: xyz
- name: random_pose_metadata_json
type: file
format: json
parameters:
num_samples:
type: integer
default: 100
min_com_distance_angstrom:
type: number
default: 3.0
max_com_distance_angstrom:
type: number
default: 8.0
max_rotation_degrees:
type: number
default: 180.0
random_seed:
type: integer
default: 20260516
- name: substituent_replacement_generator
version: "0.1.0"
category: molecular_editing
description: Generate derivative molecules by replacing a specified substituent or attachment point with candidate substituents.
inputs:
- name: parent_smiles_table
type: table
required: true
- name: substituent_library_csv
type: file
format: csv
required: true
outputs:
- name: derivative_smiles_table
type: table
- name: substitution_log_json
type: file
format: json
parameters:
attachment_label:
type: string
default: "[*:1]"
max_derivatives:
type: integer
default: 1000
sanitize_products:
type: boolean
default: true
reject_invalid_valence:
type: boolean
default: true
- name: rank_by_aromatic_ring_count
version: "0.1.0"
category: descriptor
description: Count aromatic rings in a SMILES list and rank molecules by aromatic ring count.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: aromatic_ring_ranking_table
type: table
- name: aromatic_ring_count_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
descending:
type: boolean
default: true
include_ties:
type: boolean
default: true
- name: vdw_surface_area_estimator
version: "0.1.0"
category: geometry_descriptor
description: Estimate molecular van der Waals surface area from 3D structures using atomic van der Waals radii.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: vdw_surface_area_table
type: table
- name: vdw_surface_area_json
type: file
format: json
parameters:
probe_radius_angstrom:
type: number
default: 0.0
sampling_density:
type: integer
default: 960
radii_set:
type: string
enum: [bondi, uff, rdkit]
default: bondi
- name: solvent_accessible_surface_area
version: "0.1.0"
category: geometry_descriptor
description: Estimate solvent-accessible surface area by rolling a spherical probe over a molecular surface.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: sasa_table
type: table
- name: sasa_json
type: file
format: json
parameters:
probe_radius_angstrom:
type: number
default: 1.4
sampling_density:
type: integer
default: 960
radii_set:
type: string
enum: [bondi, uff, rdkit]
default: bondi
- name: conformer_rmsd_clusterer
version: "0.1.0"
category: conformer_analysis
description: Cluster conformers by heavy-atom RMSD and select representative conformers.
inputs:
- name: conformer_xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: clustered_conformer_xyz_files
type: file_list
format: xyz
- name: conformer_cluster_table
type: table
parameters:
rmsd_threshold_angstrom:
type: number
default: 0.5
heavy_atoms_only:
type: boolean
default: true
max_representatives:
type: integer
default: 20
- name: torsion_scan_input_generator
version: "0.1.0"
category: conformational_search
description: Generate constrained XYZ structures for a torsional angle scan around a specified four-atom dihedral.
inputs:
- name: input_xyz
type: file
format: xyz
required: true
outputs:
- name: torsion_scan_xyz_files
type: file_list
format: xyz
- name: torsion_scan_metadata_json
type: file
format: json
parameters:
atom_indices:
type: array
items: integer
minItems: 4
maxItems: 4
start_angle_degrees:
type: number
default: 0.0
end_angle_degrees:
type: number
default: 360.0
step_degrees:
type: number
default: 15.0
- name: distance_constraint_scan_generator
version: "0.1.0"
category: reaction_coordinate
description: Generate structures with a gradually changed interatomic distance constraint.
inputs:
- name: input_xyz
type: file
format: xyz
required: true
outputs:
- name: distance_scan_xyz_files
type: file_list
format: xyz
- name: distance_scan_metadata_json
type: file
format: json
parameters:
atom_i:
type: integer
atom_j:
type: integer
start_distance_angstrom:
type: number
end_distance_angstrom:
type: number
num_points:
type: integer
default: 20
- name: fragment_molecule_by_brics
version: "0.1.0"
category: molecular_editing
description: Fragment molecules using BRICS rules and output fragment SMILES.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: fragment_smiles_table
type: table
- name: fragmentation_report_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
keep_parent_id:
type: boolean
default: true
min_fragment_heavy_atoms:
type: integer
default: 3
- name: enumerate_tautomers
version: "0.1.0"
category: molecular_enumeration
description: Enumerate possible tautomeric forms for each input SMILES.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: tautomer_smiles_table
type: table
- name: tautomer_enumeration_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
max_tautomers:
type: integer
default: 50
canonicalize:
type: boolean
default: true
- name: enumerate_protonation_states
version: "0.1.0"
category: molecular_enumeration
description: Enumerate plausible protonation states for input molecules over a pH range.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: protonation_state_smiles_table
type: table
- name: protonation_state_report_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
ph_min:
type: number
default: 6.0
ph_max:
type: number
default: 8.0
max_states:
type: integer
default: 20
- name: generate_stereoisomers
version: "0.1.0"
category: molecular_enumeration
description: Enumerate unspecified stereocenters and generate stereoisomer SMILES.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: stereoisomer_smiles_table
type: table
- name: stereoisomer_report_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
max_isomers:
type: integer
default: 64
include_unassigned:
type: boolean
default: false
- name: pharmacophore_feature_counter
version: "0.1.0"
category: descriptor
description: Count simple pharmacophore features such as hydrogen bond donors, acceptors, aromatic centers, and charged groups.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: pharmacophore_feature_table
type: table
- name: pharmacophore_summary_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
include_formal_charges:
type: boolean
default: true
- name: molecular_shape_asphericity
version: "0.1.0"
category: geometry_descriptor
description: Compute shape descriptors such as asphericity, eccentricity, and radius of gyration from 3D coordinates.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: shape_descriptor_table
type: table
- name: shape_descriptor_json
type: file
format: json
parameters:
mass_weighted:
type: boolean
default: false
heavy_atoms_only:
type: boolean
default: true
- name: electrostatic_grid_sampler
version: "0.1.0"
category: electrostatics
description: Sample approximate electrostatic potential values on a 3D grid around a molecule.
inputs:
- name: charged_xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: electrostatic_grid_cube_files
type: file_list
format: cube
- name: electrostatic_grid_metadata_json
type: file
format: json
parameters:
grid_spacing_angstrom:
type: number
default: 0.3
padding_angstrom:
type: number
default: 4.0
charge_model:
type: string
enum: [gasteiger, mmff, user]
default: gasteiger
- name: molecular_dipole_estimator
version: "0.1.0"
category: electrostatics
description: Estimate molecular dipole moments from approximate partial charges and 3D coordinates.
inputs:
- name: charged_xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: dipole_moment_table
type: table
- name: dipole_moment_json
type: file
format: json
parameters:
charge_model:
type: string
enum: [gasteiger, mmff, user]
default: gasteiger
unit:
type: string
enum: [debye, atomic_unit]
default: debye
- name: pairwise_molecular_similarity
version: "0.1.0"
category: similarity
description: Compute pairwise molecular similarity from fingerprints for a list of SMILES.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: similarity_matrix_csv
type: file
format: csv
- name: nearest_neighbor_table
type: table
parameters:
smiles_column:
type: string
default: smiles
fingerprint:
type: string
enum: [morgan, rdkit, atom_pair]
default: morgan
radius:
type: integer
default: 2
n_bits:
type: integer
default: 2048
- name: substructure_filter
version: "0.1.0"
category: filtering
description: Filter molecules by SMARTS substructure queries.
inputs:
- name: molecule_table
type: table
required: true
- name: smarts_query_file
type: file
format: text
required: true
outputs:
- name: filtered_molecule_table
type: table
- name: substructure_match_report_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
match_mode:
type: string
enum: [include, exclude]
default: include
require_all_patterns:
type: boolean
default: false
- name: reactive_site_tagging
version: "0.1.0"
category: reaction_analysis
description: Tag likely reactive atoms or functional groups using SMARTS-based rules.
inputs:
- name: molecule_table
type: table
required: true
outputs:
- name: reactive_site_table
type: table
- name: reactive_site_annotation_json
type: file
format: json
parameters:
smiles_column:
type: string
default: smiles
rule_set:
type: string
enum: [organic_basic, carbonyl, radical, organometallic_warning]
default: organic_basic
- name: reaction_product_enumerator
version: "0.1.0"
category: reaction_enumeration
description: Enumerate products from reactant SMILES using a reaction SMARTS template.
inputs:
- name: reactant_smiles_table
type: table
required: true
- name: reaction_smarts_file
type: file
format: text
required: true
outputs:
- name: product_smiles_table
type: table
- name: reaction_enumeration_report_json
type: file
format: json
parameters:
reactant_columns:
type: array
items: string
max_products:
type: integer
default: 10000
sanitize_products:
type: boolean
default: true
- name: binding_pocket_grid_generator
version: "0.1.0"
category: docking_preparation
description: Generate a docking grid box around a protein binding pocket or ligand centroid.
inputs:
- name: protein_structure_file
type: file
format: pdb
required: true
- name: reference_ligand_file
type: file
required: false
outputs:
- name: docking_grid_json
type: file
format: json
- name: docking_box_pdb
type: file
format: pdb
parameters:
box_size_angstrom:
type: number
default: 20.0
center_mode:
type: string
enum: [ligand_centroid, residue_selection, user_defined]
default: ligand_centroid
- name: conformer_energy_ranker
version: "0.1.0"
category: conformer_analysis
description: Rank conformers by energy and select low-energy representatives.
inputs:
- name: conformer_xyz_files
type: file_list
format: xyz
required: true
- name: energy_table
type: table
required: true
outputs:
- name: ranked_conformer_table
type: table
- name: selected_low_energy_xyz_files
type: file_list
format: xyz
parameters:
energy_window_kcal_mol:
type: number
default: 5.0
max_selected:
type: integer
default: 20
sort_ascending:
type: boolean
default: true
- name: boltzmann_average_properties
version: "0.1.0"
category: statistical_thermodynamics
description: Compute Boltzmann-weighted averages of conformer properties from energies and property tables.
inputs:
- name: conformer_energy_table
type: table
required: true
- name: conformer_property_table
type: table
required: true
outputs:
- name: boltzmann_average_table
type: table
- name: boltzmann_weight_json
type: file
format: json
parameters:
temperature_kelvin:
type: number
default: 298.15
energy_unit:
type: string
enum: [kcal_mol, kj_mol, hartree, ev]
default: kcal_mol
- name: xyz_centroid_alignment
version: "0.1.0"
category: geometry_processing
description: Translate and rotate XYZ structures to align molecular centroids and principal axes.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: aligned_xyz_files
type: file_list
format: xyz
- name: alignment_transform_json
type: file
format: json
parameters:
align_principal_axes:
type: boolean
default: true
center_at_origin:
type: boolean
default: true
heavy_atoms_only:
type: boolean
default: true
- name: steric_clash_detector
version: "0.1.0"
category: geometry_validation
description: Detect steric clashes in XYZ structures using covalent and van der Waals radius thresholds.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: steric_clash_report_json
type: file
format: json
- name: clash_filtered_xyz_files
type: file_list
format: xyz
parameters:
vdw_scale:
type: number
default: 0.75
covalent_scale:
type: number
default: 0.60
remove_clashing_structures:
type: boolean
default: false
- name: molecular_volume_estimator
version: "0.1.0"
category: geometry_descriptor
description: Estimate molecular volume from van der Waals spheres or grid occupancy.
inputs:
- name: xyz_files
type: file_list
format: xyz
required: true
outputs:
- name: molecular_volume_table
type: table
- name: molecular_volume_json
type: file
format: json
parameters:
method:
type: string
enum: [vdw_sphere_union, grid_occupancy]
default: vdw_sphere_union
grid_spacing_angstrom:
type: number
default: 0.2
radii_set:
type: string
enum: [bondi, uff, rdkit]
default: bondi
import csv
import json
import math
import sys
from pathlib import Path
from typing import Any, Literal
import yaml
from ollama import chat
from pydantic import BaseModel, Field, ValidationError, model_validator
MODEL = "qwen2.5-coder:14b"
PROJECT_ROOT = Path(".")
REGISTRY_PATH = PROJECT_ROOT / "tool_registry_large.yaml"
PLANNED_PATH = PROJECT_ROOT / "planned_workflow_large.yaml"
CSV_PATH = PROJECT_ROOT / "examples" / "smiles_list.csv"
EXPECTED_TOOLS_PATH = PROJECT_ROOT / "expected_tools.json"
# USER_REQUEST_PATH = PROJECT_ROOT / "user_request.txt" # enable this line in Level 3
NUM_CTX = 16384
TEMPERATURE = 0
class WorkflowInput(BaseModel):
type: Literal["smiles_csv", "xyz_dir", "sdf"]
path: str
id_column: str
name_column: str | None = None
smiles_column: str
charge_column: str | None = None
spin_multiplicity_column: str | None = None
metadata_columns: list[str] = Field(default_factory=list)
class WorkflowStep(BaseModel):
step_id: str
tool: str
description: str
input_keys: list[str] = Field(default_factory=list)
output_keys: list[str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
warnings: list[str] = Field(default_factory=list)
class WorkflowSpec(BaseModel):
workflow_id: str
objective: str
input: WorkflowInput
steps: list[WorkflowStep]
expected_outputs: dict[str, Any] = Field(default_factory=dict)
global_warnings: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def basic_checks(self) -> "WorkflowSpec":
tools = [s.tool for s in self.steps]
if self.expected_outputs.get("summary_csv") or self.expected_outputs.get("summary_json"):
if "collect_results" not in tools:
raise ValueError("summary outputs requested but collect_results step is missing.")
return self
class ToolSpec(BaseModel):
name: str
version: str | None = None
category: str | None = None
description: str | None = None
inputs: list[dict[str, Any]] = Field(default_factory=list)
outputs: list[dict[str, Any]] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
class ToolRegistry(BaseModel):
tools: list[ToolSpec]
@property
def names(self) -> set[str]:
return {t.name for t in self.tools}
def get(self, name: str) -> ToolSpec | None:
for t in self.tools:
if t.name == name:
return t
return None
def tool_summary(self) -> list[dict[str, Any]]:
rows = []
for t in self.tools:
rows.append(
{
"name": t.name,
"category": t.category,
"description": t.description,
"inputs": [x.get("name") for x in t.inputs],
"outputs": [x.get("name") for x in t.outputs],
"parameters": list(t.parameters.keys()),
}
)
return rows
def read_yaml(path: Path) -> Any:
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
return yaml.safe_load(path.read_text(encoding="utf-8"))
def read_json(path: Path) -> Any:
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def read_csv_preview(path: Path, n: int = 5) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
with path.open(newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = []
for i, row in enumerate(reader):
if i >= n:
break
rows.append(row)
return {
"path": str(path.as_posix()),
"columns": reader.fieldnames or [],
"preview_rows": rows,
}
def load_registry() -> ToolRegistry:
return ToolRegistry.model_validate(read_yaml(REGISTRY_PATH))
def build_payload(registry: ToolRegistry) -> dict[str, Any]:
planned = read_yaml(PLANNED_PATH)
expected_tools = read_json(EXPECTED_TOOLS_PATH) # delete this line in Level 2, and 3
# user_request = USER_REQUEST_PATH.read_text(encoding="utf-8") # enable this line in Level 3
return {
"task": "Create a minimal workflow JSON for a SMILES CSV to xtb optimization workflow.",
"mode": "large_tool_registry_stress_test_with_meaningful_irrelevant_modules",
"important_rules": [
"Return only JSON matching the schema.",
"Use only tools listed in tool_registry.",
"Do not invent tool names.",
"Do not invent parameters. Each step.parameters must use only keys listed for that tool.",
"Do not use descriptor, enumeration, docking, DFT, NEB, MLIP, molecular editing, reaction enumeration, or geometry descriptor modules unless explicitly requested.",
"For this task, use the tools listed in expected_tools.required_tools.", # delete this line in Level 2, and 3
"Keep the workflow minimal. Avoid unnecessary steps.",
"Preserve CSV column mappings exactly.",
"Do not change default parameter values unless planned_workflow explicitly specifies them.",
"Do not infer solvent, method, convergence threshold, number of conformers, or random seed.",
"Parameter values must exactly match registry enum values when enum values are defined.",
],
# "user_request": user_request, # enable this line in Level 3
"planned_workflow": planned, # delete this line in Level 3
"expected_tools": expected_tools, # delete this line in Level 2, and 3
"tool_registry": registry.tool_summary(),
"smiles_csv": read_csv_preview(CSV_PATH),
"desired_output_schema": WorkflowSpec.model_json_schema(),
}
def call_model(payload: dict[str, Any]) -> str:
response = chat(
model=MODEL,
messages=[
{
"role": "system",
"content": (
"You are a strict workflow JSON generator. "
"Return only valid JSON. "
"Use only registered tools and registered parameters. "
"Prefer minimal workflows. "
"Do not add irrelevant chemistry modules. "
"Do not change parameter values from the provided expected policy."
),
},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False),
},
],
format=WorkflowSpec.model_json_schema(),
options={"temperature": TEMPERATURE, "num_ctx": NUM_CTX},
)
return response["message"]["content"]
def get_tool_io_names(tool: ToolSpec) -> tuple[set[str], set[str]]:
input_names = {x.get("name") for x in tool.inputs if x.get("name")}
output_names = {x.get("name") for x in tool.outputs if x.get("name")}
return input_names, output_names
def normalize_type_name(type_value: Any) -> set[str]:
if isinstance(type_value, list):
return {str(x) for x in type_value}
if type_value is None:
return set()
return {str(type_value)}
def is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def value_matches_type(value: Any, spec: dict[str, Any]) -> bool:
allowed_types = normalize_type_name(spec.get("type"))
if not allowed_types:
return True
if value is None:
return "null" in allowed_types
# YAML sometimes represents type arrays differently. This validator is intentionally simple.
if "string" in allowed_types and isinstance(value, str):
return True
if "integer" in allowed_types and isinstance(value, int) and not isinstance(value, bool):
return True
if "number" in allowed_types and is_number(value):
return True
if "boolean" in allowed_types and isinstance(value, bool):
return True
if "array" in allowed_types and isinstance(value, list):
return True
if "object" in allowed_types and isinstance(value, dict):
return True
return False
def values_equal(a: Any, b: Any) -> bool:
if is_number(a) and is_number(b):
return math.isclose(float(a), float(b), rel_tol=1e-12, abs_tol=1e-12)
return a == b
def validate_workflow(workflow: WorkflowSpec, registry: ToolRegistry) -> list[str]:
issues: list[str] = []
policy = read_json(EXPECTED_TOOLS_PATH)
known_tools = registry.names
used_tools = [s.tool for s in workflow.steps]
# Tool and parameter checks.
for step in workflow.steps:
if step.tool not in known_tools:
issues.append(f"Unknown tool: {step.tool}")
continue
if step.tool in policy.get("discouraged_tools", []):
issues.append(f"Discouraged irrelevant tool selected: {step.tool}")
tool_spec = registry.get(step.tool)
if tool_spec is None:
continue
allowed_params = set(tool_spec.parameters.keys())
for param, value in step.parameters.items():
if param not in allowed_params:
issues.append(f"Unknown parameter for {step.tool}: {param}")
continue
param_spec = tool_spec.parameters.get(param, {})
enum_values = param_spec.get("enum")
if enum_values is not None and value not in enum_values:
issues.append(
f"Invalid enum value for {step.tool}.{param}: {value!r}. "
f"Allowed values: {enum_values}"
)
if not value_matches_type(value, param_spec):
issues.append(
f"Invalid type for {step.tool}.{param}: {value!r}. "
f"Expected: {param_spec.get('type')}"
)
# Required tools and order.
required_tools = policy.get("required_tools", [])
for tool in required_tools:
if tool not in used_tools:
issues.append(f"Required tool missing: {tool}")
positions = {tool: used_tools.index(tool) for tool in required_tools if tool in used_tools}
for a, b in zip(required_tools, required_tools[1:]):
if a in positions and b in positions and positions[a] > positions[b]:
issues.append(f"Tool order is wrong: {a} appears after {b}")
# Dataflow check: input keys should be available from initial inputs or previous outputs.
produced = set(policy.get("initial_input_keys", ["smiles_csv"]))
for step in workflow.steps:
for key in step.input_keys:
if key not in produced:
issues.append(f"Input key not produced before use in {step.step_id}: {key}")
produced.update(step.output_keys)
# Redundancy check.
max_allowed_steps = policy.get("max_allowed_steps", len(required_tools) + 1)
if len(workflow.steps) > max_allowed_steps:
issues.append(
f"Potentially redundant workflow: {len(workflow.steps)} steps, "
f"max_allowed_steps={max_allowed_steps}."
)
# Expected parameter value checks.
expected_values = policy.get("expected_parameter_values", {})
sensitive = policy.get("sensitive_parameters", {})
for step in workflow.steps:
expected_for_tool = expected_values.get(step.tool, {})
sensitive_for_tool = set(sensitive.get(step.tool, []))
for key in sensitive_for_tool:
if key in expected_for_tool:
expected_value = expected_for_tool[key]
actual_value = step.parameters.get(key, "__MISSING__")
if actual_value == "__MISSING__":
issues.append(f"Sensitive parameter missing: {step.tool}.{key}")
elif not values_equal(actual_value, expected_value):
issues.append(
f"Sensitive parameter value drift: {step.tool}.{key} "
f"expected {expected_value!r}, got {actual_value!r}"
)
return issues
def main() -> None:
registry = load_registry()
if len(sys.argv) >= 2:
raw = Path(sys.argv[1]).read_text(encoding="utf-8")
else:
payload = build_payload(registry)
raw = call_model(payload)
print("=== raw model output ===")
print(raw)
print()
try:
workflow = WorkflowSpec.model_validate_json(raw)
except ValidationError as exc:
print("VALIDATION_FAILED")
print(exc)
raise SystemExit(1)
print("=== validated output ===")
print(workflow.model_dump_json(indent=2, ensure_ascii=False))
print()
issues = validate_workflow(workflow, registry)
print("=== large registry validation ===")
if issues:
print("NG / REVIEW")
for issue in issues:
print(f"- {issue}")
raise SystemExit(2)
else:
print("OK")
if __name__ == "__main__":
main()
Level 1の検証
Level 1 の出力結果を以下に示す。
検証5; Level 1 の出力結果
=== raw model output ===
{
"workflow_id": "smiles_to_xtb_relax_large_registry_test",
"objective": "Generate initial 3D structures from a SMILES CSV, optimize them with xtb, and collect summary CSV/JSON outputs.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read a CSV file containing molecule identifiers, SMILES, charge, and spin information.",
"input_keys": ["smiles_csv"],
"output_keys": ["molecule_table"],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
}
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit and annotate invalid rows.",
"input_keys": ["molecule_table"],
"output_keys": ["validated_molecule_table"],
"parameters": {
"strict": false
}
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": ["validated_molecule_table"],
"output_keys": ["initial_xyz_files", "conformer_metadata_json"],
"parameters": {
"num_conformers": 20,
"max_embed_attempts": 1000,
"optimize_initial_geometry": true,
"random_seed": 20260516
}
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": ["initial_xyz_files", "conformer_metadata_json"],
"output_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"parameters": {
"method": "gfn2-xtb",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 500,
"fmax": 0.05,
"solvent": null,
"executable": "xtb"
}
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"output_keys": ["summary_csv", "summary_json", "per_molecule_status_json"],
"parameters": {
"summary_csv": true,
"summary_json": true,
"keep_intermediate_runs": true,
"fail_fast": false
}
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"per_molecule_status_json": true
},
"global_warnings": []
}
=== validated output ===
{
"workflow_id": "smiles_to_xtb_relax_large_registry_test",
"objective": "Generate initial 3D structures from a SMILES CSV, optimize them with xtb, and collect summary CSV/JSON outputs.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"name_column": null,
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"metadata_columns": []
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read a CSV file containing molecule identifiers, SMILES, charge, and spin information.",
"input_keys": [
"smiles_csv"
],
"output_keys": [
"molecule_table"
],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"warnings": []
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit and annotate invalid rows.",
"input_keys": [
"molecule_table"
],
"output_keys": [
"validated_molecule_table"
],
"parameters": {
"strict": false
},
"warnings": []
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": [
"validated_molecule_table"
],
"output_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"parameters": {
"num_conformers": 20,
"max_embed_attempts": 1000,
"optimize_initial_geometry": true,
"random_seed": 20260516
},
"warnings": []
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"output_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"parameters": {
"method": "gfn2-xtb",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 500,
"fmax": 0.05,
"solvent": null,
"executable": "xtb"
},
"warnings": []
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"output_keys": [
"summary_csv",
"summary_json",
"per_molecule_status_json"
],
"parameters": {
"summary_csv": true,
"summary_json": true,
"keep_intermediate_runs": true,
"fail_fast": false
},
"warnings": []
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"per_molecule_status_json": true
},
"global_warnings": []
}
=== large registry validation ===
OK
結果として、level 1のregistry validationはOKとなった。workflowは read_smiles_csv → rdkit_validate_smiles → rdkit_generate_3d → xtb_optimize → collect_results の順で、各ツールはすべて tool_registry.yaml に登録済みであった。入出力キーも molecule_table、validated_molecule_table、initial_xyz_files、optimized_xyz_files となっており問題ない。
上述の通り、これは guided test になっていることは留意すべきである。
tool_registry.yamlで定義しているツールは42種類しかないことは留意しておくべきである。実用上はLLMの選択できる計算ツール・モジュール群が数百以上になると考えられる。そのような場合に、適切なものを選択できるかは自明ではない。
Level 2の検証
続いて、Level 2の検証を実施した。
Level 2では、expected_tools.json をLLMには渡さず、さらに planned_workflow_large.yaml から required_tools、preferred_step_order、parameter_policy を削除した。そのため、LLMは正解となるツールの情報を直接見ることなく、計算目的、CSV情報、large版tool registryのみから必要なモジュールを選択する必要がある。
検証5; Level 2 の出力結果
=== raw model output ===
{
"workflow_id": "smiles_to_xtb_relax_large_registry_test",
"objective": "Generate initial 3D structures from a SMILES CSV, optimize them with xtb, and collect summary CSV/JSON outputs.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read a CSV file containing molecule identifiers, SMILES, charge, and spin information.",
"input_keys": ["smiles_csv"],
"output_keys": ["molecule_table"],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
}
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit and annotate invalid rows.",
"input_keys": ["molecule_table"],
"output_keys": ["validated_molecule_table"],
"parameters": {
"strict": true
}
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": ["validated_molecule_table"],
"output_keys": ["initial_xyz_files", "conformer_metadata_json"],
"parameters": {
"num_conformers": 1,
"max_embed_attempts": 20,
"optimize_initial_geometry": true,
"random_seed": null
}
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": ["initial_xyz_files", "conformer_metadata_json"],
"output_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"parameters": {
"method": "GFN2-xTB",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 100,
"fmax": 0.004,
"solvent": null,
"executable": "xtb"
}
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"output_keys": ["summary_csv", "summary_json", "per_molecule_status_json"],
"parameters": {
"summary_csv": true,
"summary_json": true,
"keep_intermediate_runs": false,
"fail_fast": false
}
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"per_molecule_status_json": true
},
"global_warnings": []
}
=== validated output ===
{
"workflow_id": "smiles_to_xtb_relax_large_registry_test",
"objective": "Generate initial 3D structures from a SMILES CSV, optimize them with xtb, and collect summary CSV/JSON outputs.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"name_column": null,
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"metadata_columns": []
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read a CSV file containing molecule identifiers, SMILES, charge, and spin information.",
"input_keys": [
"smiles_csv"
],
"output_keys": [
"molecule_table"
],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"warnings": []
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit and annotate invalid rows.",
"input_keys": [
"molecule_table"
],
"output_keys": [
"validated_molecule_table"
],
"parameters": {
"strict": true
},
"warnings": []
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": [
"validated_molecule_table"
],
"output_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"parameters": {
"num_conformers": 1,
"max_embed_attempts": 20,
"optimize_initial_geometry": true,
"random_seed": null
},
"warnings": []
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"output_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"parameters": {
"method": "GFN2-xTB",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 100,
"fmax": 0.004,
"solvent": null,
"executable": "xtb"
},
"warnings": []
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"output_keys": [
"summary_csv",
"summary_json",
"per_molecule_status_json"
],
"parameters": {
"summary_csv": true,
"summary_json": true,
"keep_intermediate_runs": false,
"fail_fast": false
},
"warnings": []
}
],
"expected_outputs": {
"summary_csv": true,
"summary_json": true,
"per_molecule_status_json": true
},
"global_warnings": []
}
=== large registry validation ===
NG / REVIEW
- Invalid type for rdkit_generate_3d.random_seed: None. Expected: integer
- Invalid enum value for xtb_optimize.method: 'GFN2-xTB'. Allowed values: ['gfn2-xtb', 'gfn1-xtb', 'gfnff']
- Sensitive parameter value drift: rdkit_generate_3d.max_embed_attempts expected 1000, got 20
- Sensitive parameter value drift: rdkit_generate_3d.random_seed expected 20260516, got None
- Sensitive parameter value drift: rdkit_generate_3d.num_conformers expected 20, got 1
- Sensitive parameter value drift: xtb_optimize.method expected 'gfn2-xtb', got 'GFN2-xTB'
- Sensitive parameter value drift: xtb_optimize.max_steps expected 500, got 100
- Sensitive parameter value drift: xtb_optimize.fmax expected 0.05, got 0.004
- Sensitive parameter value drift: collect_results.keep_intermediate_runs expected True, got False
結果として、LLMは read_smiles_csv、rdkit_validate_smiles、rdkit_generate_3d、xtb_optimize、collect_results の5つを正しい順序で選択することに成功した。不要なdescriptor、DFT、NEB、MLIP、docking、分子編集系モジュールは混入しておらず、step間のinput/outputも自然につながっていた。この点では、large registryからの自律的ツール選択は成功したと評価できる。
一方で、validatorは NG / REVIEW を返した。原因はツール選択ではなく、パラメータ値の逸脱である。NG例は以下の通り:
-
xtb_optimize.methodがregistry enumのgfn2-xtbではなくGFN2-xTBと出力された(表記揺れ) -
rdkit_generate_3d.random_seedはinteger期待に対してnullとなった(型の不一致) -
num_conformers、max_embed_attempts、max_steps、fmax、keep_intermediate_runsなども期待される値からずれていた。
| パラメータ | Qwen2.5-Coder:14Bの出力 | 期待される値 | 差分の種類 | 影響・評価 |
|---|---|---|---|---|
rdkit_validate_smiles.strict |
true |
false |
validation方針の変更 | 安全寄りの設定だが、軽微なSMILES表現の差異や許容可能な入力まで弾く可能性がある。検証条件を勝手に変えている点はやや問題。 |
rdkit_generate_3d.num_conformers |
1 |
20 |
配座生成数の過小化 | 動作確認には使えるが、配座探索としては弱い。低エネルギー配座や多様な初期構造を見落とす可能性が高くなる。 |
rdkit_generate_3d.max_embed_attempts |
20 |
1000 |
embedding試行回数の過小 | やや重大。構造生成に失敗しやすくなる。複雑な分子、環状構造、立体的に混み合った分子で特に問題になり得る。 |
rdkit_generate_3d.optimize_initial_geometry |
true |
true |
一致 | 問題なし。 |
rdkit_generate_3d.random_seed |
null |
20260516 |
型不一致・再現性欠落 | 重大。integer型に対して null なので計算フロー中断の可能性あり。再現性も失われる。 |
xtb_optimize.method |
"GFN2-xTB" |
"gfn2-xtb" |
enum表記ゆれ | 厳密には不一致。関数によってはエラーになり獲る。 |
xtb_optimize.charge_column |
"charge" |
"charge" |
一致 | 問題なし。 |
xtb_optimize.spin_multiplicity_column |
"spin_multiplicity" |
"spin_multiplicity" |
一致 | 問題なし。 |
xtb_optimize.max_steps |
100 |
500 |
最適化step数が過小 | 分子によっては収束前に打ち切られる可能性がある。テスト計算なら許容される場合もあるが、やや危険。 |
xtb_optimize.fmax |
0.004 |
0.05 |
収束条件の過度な厳格化 | 計算時間増大や収束困難化につながる。LLM側であまり変えるべきではない。 |
xtb_optimize.solvent |
null |
null |
一致 | 問題なし。 |
xtb_optimize.executable |
"xtb" |
"xtb" |
一致 | 問題なし。 |
collect_results.summary_csv |
true |
true |
一致 | 問題なし。 |
collect_results.summary_json |
true |
true |
一致 | 問題なし。 |
collect_results.keep_intermediate_runs |
false |
true |
中間ファイル保持方針の変更 | 計算結果そのものには直接影響しないが、デバッグ、再現性確認、失敗解析には不利。ワークフロー実験では true の方が安全。 |
collect_results.fail_fast |
false |
false |
一致 | 問題なし。 |
この結果から、Qwen2.5-Coder:14Bは、多数の候補モジュールから必要なツールを選択する能力を有することが示された。一方で、計算条件に関わるsensitive parameterを勝手に補完・変更する傾向があり、実運用ではパラメータ値の型、enum、既定値、ユーザー指定値との整合性を必ずvalidatorで検査する必要があるようである。
Level 3の検証
続いて、Level 3の検証を実施した。Level 3では、planned_workflow_large.yaml を使わず、自然言語(日本語)によるプロンプト、CSVプレビュー、large版tool registryのみをLLMに渡した。これは、実運用に最も近い検証であり、人間の依頼文からLLMが必要モジュールを自律的に選択できるかを見るものである。
SMILES、電荷、スピン多重度を含むCSVファイルがあります。
各分子について初期3D構造を生成し、多数の小分子を対象に比較的軽量な方法で構造最適化を行い、
最終構造、エネルギー、成功/失敗ステータスをsummary CSVとsummary JSONにまとめるワークフローを作成してください。
不要な記述子計算、DFT計算、NEB、ドッキング、反応列挙、置換基生成は行わないでください。
検証5; Level 3 の出力結果
=== raw model output ===
{
"workflow_id": "smiles_to_xtb_optimization",
"objective": "Optimize molecular geometries from a SMILES CSV file using xtb and summarize the results.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read the SMILES CSV file and extract molecule information.",
"input_keys": ["smiles_csv"],
"output_keys": ["molecule_table"],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
}
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit.",
"input_keys": ["molecule_table"],
"output_keys": ["validated_molecule_table"],
"parameters": {
"strict": true
}
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": ["validated_molecule_table"],
"output_keys": ["initial_xyz_files", "conformer_metadata_json"],
"parameters": {
"num_conformers": 10,
"max_embed_attempts": 20,
"optimize_initial_geometry": true,
"random_seed": 42
}
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": ["initial_xyz_files", "conformer_metadata_json"],
"output_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"parameters": {
"method": "GFN2-xTB",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 100,
"fmax": 0.004,
"solvent": null,
"executable": "xtb"
}
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": ["optimized_xyz_files", "xtb_energy_files", "xtb_status_json"],
"output_keys": ["summary_csv", "summary_json", "per_molecule_status_json"],
"parameters": {
"summary_csv": "results/summary.csv",
"summary_json": "results/summary.json",
"keep_intermediate_runs": false,
"fail_fast": true
}
}
],
"expected_outputs": {
"summary_csv": "results/summary.csv",
"summary_json": "results/summary.json"
},
"global_warnings": []
}
=== validated output ===
{
"workflow_id": "smiles_to_xtb_optimization",
"objective": "Optimize molecular geometries from a SMILES CSV file using xtb and summarize the results.",
"input": {
"type": "smiles_csv",
"path": "examples/smiles_list.csv",
"id_column": "id",
"name_column": null,
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"metadata_columns": []
},
"steps": [
{
"step_id": "read_smiles_csv",
"tool": "read_smiles_csv",
"description": "Read the SMILES CSV file and extract molecule information.",
"input_keys": [
"smiles_csv"
],
"output_keys": [
"molecule_table"
],
"parameters": {
"id_column": "id",
"smiles_column": "smiles",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity"
},
"warnings": []
},
{
"step_id": "rdkit_validate_smiles",
"tool": "rdkit_validate_smiles",
"description": "Validate SMILES strings using RDKit.",
"input_keys": [
"molecule_table"
],
"output_keys": [
"validated_molecule_table"
],
"parameters": {
"strict": true
},
"warnings": []
},
{
"step_id": "rdkit_generate_3d",
"tool": "rdkit_generate_3d",
"description": "Generate initial 3D conformers from validated SMILES using RDKit.",
"input_keys": [
"validated_molecule_table"
],
"output_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"parameters": {
"num_conformers": 10,
"max_embed_attempts": 20,
"optimize_initial_geometry": true,
"random_seed": 42
},
"warnings": []
},
{
"step_id": "xtb_optimize",
"tool": "xtb_optimize",
"description": "Optimize molecular geometries using xtb.",
"input_keys": [
"initial_xyz_files",
"conformer_metadata_json"
],
"output_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"parameters": {
"method": "GFN2-xTB",
"charge_column": "charge",
"spin_multiplicity_column": "spin_multiplicity",
"max_steps": 100,
"fmax": 0.004,
"solvent": null,
"executable": "xtb"
},
"warnings": []
},
{
"step_id": "collect_results",
"tool": "collect_results",
"description": "Collect optimized structures, energies, status, and warnings into summary files.",
"input_keys": [
"optimized_xyz_files",
"xtb_energy_files",
"xtb_status_json"
],
"output_keys": [
"summary_csv",
"summary_json",
"per_molecule_status_json"
],
"parameters": {
"summary_csv": "results/summary.csv",
"summary_json": "results/summary.json",
"keep_intermediate_runs": false,
"fail_fast": true
},
"warnings": []
}
],
"expected_outputs": {
"summary_csv": "results/summary.csv",
"summary_json": "results/summary.json"
},
"global_warnings": []
}
=== large registry validation ===
NG / REVIEW
- Invalid enum value for xtb_optimize.method: 'GFN2-xTB'. Allowed values: ['gfn2-xtb', 'gfn1-xtb', 'gfnff']
- Invalid type for collect_results.summary_csv: 'results/summary.csv'. Expected: boolean
- Invalid type for collect_results.summary_json: 'results/summary.json'. Expected: boolean
- Sensitive parameter value drift: rdkit_generate_3d.num_conformers expected 20, got 10
- Sensitive parameter value drift: rdkit_generate_3d.random_seed expected 20260516, got 42
- Sensitive parameter value drift: rdkit_generate_3d.max_embed_attempts expected 1000, got 20
- Sensitive parameter value drift: xtb_optimize.max_steps expected 500, got 100
- Sensitive parameter value drift: xtb_optimize.fmax expected 0.05, got 0.004
- Sensitive parameter value drift: xtb_optimize.method expected 'gfn2-xtb', got 'GFN2-xTB'
- Sensitive parameter value drift: collect_results.keep_intermediate_runs expected True, got False
- Sensitive parameter value drift: collect_results.summary_csv expected True, got 'results/summary.csv'
- Sensitive parameter value drift: collect_results.fail_fast expected False, got True
- Sensitive parameter value drift: collect_results.summary_json expected True, got 'results/summary.json'
結果として、Qwen2.5-Coder:14Bは read_smiles_csv → rdkit_validate_smiles → rdkit_generate_3d → xtb_optimize → collect_results という妥当なワークフローを生成しており、不要な計算モジュールは混入しなかった。したがって、自然言語による入力から自力で必要な計算モジュールを選択する能力は良好と言える。
一方で、validatorは NG / REVIEW を返した。主な原因は、ツール選択ではなく、パラメータ値・型・enumの逸脱である。例えば、xtb_optimize.method は GFN2-xTB と出力されたが、registry enumでは gfn2-xtb が期待されていた(Level 2 と同じ症状)。また、collect_results.summary_csv と summary_json はbooleanを期待していたにもかかわらず、results/summary.csv や results/summary.json というパス文字列が出力された。
パス文字列が出力されたことに関して、これはよくある出力先の
results/ディレクトリを補完した挙動になっている。今回results/ディレクトリは入力にもSchemaにも明示していないため、広義のハルシネーションと言える。
以上の結果は、Qwen2.5-Coder:14Bが自然言語から計算科学ワークフローの大枠を組み立てる能力を持つ一方で、計算モジュールが要求する厳密な型、enum、既定値、再現性のあるパラメータを安定して守るにはvalidatorが不可欠であることを示している。
英語プロンプトにした場合でも、多少改善する可能性はあるが根本解決にはならないと考えられる。Level 3 のNGの原因は入力が日本語であることというより、以下の理由によると考えられるためである。
- 自然言語依頼だけでは出力パスや計算パラメータの既定値が未指定だった
- LLMが未指定部分を自然な推測によって補完した
- Schema上、summary_csvという名前が「出力するかどうか」と「出力ファイルパス」の両方に読める
- enum値やdefault値の厳密なコピーをモデル単体に任せていた
検証6:化学的推論の性能評価
ここでは、Qwen2.5-Coderが化学的にどの程度妥当な補助判断を返すかを、ごく簡単に確認する。ただし、これはあくまで補助的な評価であり、計算結果の最終的な妥当性の判定や結果の解釈は人間の研究者か、より高レイヤーのLLMにより行うべきである。
ここでは、出力されるJSONのKeyは厳密に定めていない。値が正しければ正しい出力と見なしている。
プロンプト例1:中性分子(トルエン)
Return only JSON.
For toluene, provide formula, total_charge, spin_multiplicity, atom_count, and warnings.
```json
{
"formula": "C8H10",
"total_charge": 0,
"spin_multiplicity": 1,
"atom_count": 9,
"warnings": []
}
```
トルエンの組成式は C7H8 で、原子数は 15 である。これは正しくない結果である。
出力されている組成式の原子数は18であるため、原子数の値が 9 になっている点も不自然である。
プロンプト例2:ラジカル(メチルラジカル)
Return only JSON.
For methyl radical, provide formula, total_charge, spin_multiplicity, atom_count, and warnings.
If spin cannot be safely inferred, add a warning.
```json
{
"formula": "CH3",
"total_charge": 0,
"spin_multiplicity": 2,
"atom_count": 4,
"warnings": []
}
```
正しい結果である。
プロンプト例3:遷移金属錯体(FeCl4)
Return only JSON.
For an iron complex represented only by the formula FeCl4, create calculation metadata.
Do not guess spin state silently. Add warnings when needed.
```json
{
"formula": "FeCl4",
"total_charge": 0,
"spin_multiplicity": null,
"atom_count": 5,
"warnings": ["Spin multiplicity cannot be safely inferred without additional information."]
}
```
スピン状態を断定せず、warningsに記載。期待される挙動になっている。
プロンプト例4:NEB計算の計算フロー生成
Return only JSON.
Create a workflow for NEB calculation from initial.xyz and final.xyz.
Use tools: read_xyz, interpolate_images, run_neb, collect_neb_results.
Do not invent other tools.
```json
{
"workflow": {
"steps": [
{
"tool": "read_xyz",
"input_files": ["initial.xyz"],
"output_data": "initial_structure"
},
{
"tool": "read_xyz",
"input_files": ["final.xyz"],
"output_data": "final_structure"
},
{
"tool": "interpolate_images",
"input_data": ["initial_structure", "final_structure"],
"parameters": {
"number_of_images": 10
},
"output_data": "images"
},
{
"tool": "run_neb",
"input_data": "images",
"parameters": {
"max_iterations": 200,
"convergence_criteria": 0.05
},
"output_data": "neb_results"
},
{
"tool": "collect_neb_results",
"input_data": "neb_results",
"output_files": ["neb_energy_profile.dat"]
}
]
}
}
```
tool registryを守って正しい順序で出力している。期待される挙動になっている。
プロンプト例4-1:NEB計算の計算フロー生成
計算対象が約500原子からなることに注意せよ、という文を追加した場合。
Return only JSON.
Create a workflow for NEB calculation from initial.xyz and final.xyz.
Note that this molecule consists of about 500 atoms.
Use tools: read_xyz, interpolate_images, run_neb, collect_neb_results.
Do not invent other tools.
```json
{
"workflow": {
"steps": [
{
"tool": "read_xyz",
"input_files": ["initial.xyz"],
"output_data": "initial_structure"
},
{
"tool": "read_xyz",
"input_files": ["final.xyz"],
"output_data": "final_structure"
},
{
"tool": "interpolate_images",
"input_data": ["initial_structure", "final_structure"],
"parameters": {
"number_of_images": 10
},
"output_data": "images"
},
{
"tool": "run_neb",
"input_data": "images",
"parameters": {
"max_iterations": 500,
"convergence_criteria": 0.01
},
"output_data": "neb_results"
},
{
"tool": "collect_neb_results",
"input_data": "neb_results",
"output_files": ["neb_energy_profile.dat"]
}
]
}
}
```
一般に原子数が多いほど最適化のイタレーション数が増えるため、max_iterations が増加したのは正常な挙動と思われる。
tool registryは破綻していないが、収束の閾値が厳しく設定された。この値はプロンプトで明示する必要がある。
プロンプト例5:複数化合物のJSON化
>>> Return only JSON.
...
... Classify the following molecular inputs for computational chemistry workflow planning.
...
... Inputs:
... 1. ethanol: CCO
... 2. acetate: CC(=O)[O-]
... 3. methyl radical: [CH3]
... 4. iron chloride: FeCl4
... 5. sodium acetate: [Na+].CC(=O)[O-]
...
... For each input, return:
... - total_charge
... - spin_multiplicity
... - is_open_shell
... - is_multicomponent
... - contains_transition_metal
... - requires_manual_review
... - recommended_action
... - warnings
```json
{
"inputs": [
{
"name": "ethanol",
"input_smiles": "CCO",
"total_charge": 0,
"spin_multiplicity": 1,
"is_open_shell": false,
"is_multicomponent": false,
"contains_transition_metal": false,
"requires_manual_review": false,
"recommended_action": "Geometry optimization and property calculation",
"warnings": []
},
{
"name": "acetate",
"input_smiles": "CC(=O)[O-]",
"total_charge": -1,
"spin_multiplicity": 2,
"is_open_shell": true,
"is_multicomponent": false,
"contains_transition_metal": false,
"requires_manual_review": false,
"recommended_action": "Geometry optimization with open-shell treatment",
"warnings": []
},
{
"name": "methyl radical",
"input_smiles": "[CH3]",
"total_charge": 0,
"spin_multiplicity": 2,
"is_open_shell": true,
"is_multicomponent": false,
"contains_transition_metal": false,
"requires_manual_review": false,
"recommended_action": "Geometry optimization with open-shell treatment",
"warnings": []
},
{
"name": "iron chloride",
"input_smiles": "FeCl4",
"total_charge": 0,
"spin_multiplicity": null,
"is_open_shell": false,
"is_multicomponent": false,
"contains_transition_metal": true,
"requires_manual_review": true,
"recommended_action": "Determine spin multiplicity and perform geometry optimization",
"warnings": ["Spin multiplicity cannot be safely inferred without additional information."]
},
{
"name": "sodium acetate",
"input_smiles": "[Na+].CC(=O)[O-]",
"total_charge": 0,
"spin_multiplicity": 1,
"is_open_shell": false,
"is_multicomponent": true,
"contains_transition_metal": false,
"requires_manual_review": false,
"recommended_action": "Geometry optimization with proper treatment of the ionic system",
"warnings": []
}
]
}
```
一括での処理は問題なく実行できている。
酢酸アニオンの電荷を-1としている点は正しいが、開殻二重項として扱っておりスピン多重度の指定は正しくない。
まとめ
本稿では、ローカルLLMである Qwen2.5-Coder:14B を用いて、計算化学ワークフローをJSONとして生成し、下流モジュールに渡せる形式へ整形できるかを検証した。
結果として、Qwen2.5-Coder:14Bは、計算化学ワークフローの骨格生成には一定の能力を示した。ファイルの読み込みおよび適切なモジュールを選択できた点は良かった。一方で、パラメータ値や出力型の厳密性には不安定さが見られた。表記ゆれや、booleanとすべきところに文字列を入れる型不一致などが確認された。また、厳密に規定されていないパラメータの値を、LLMが独自に補完・変更する挙動も見られた。本文では示していないが、指定していない溶媒 water を補完するなど、未指定の計算条件を自然に埋めてしまう傾向もあった。
ただし、これらはQwen2.5-Coder:14Bの能力不足を意味するものではない。必要なツールのリスト、パラメータのポリシー、期待されるenum値や既定値を明示した場合には、出力はかなり安定した。出力の品質はプロンプトの作りこみ、JSON Schemaの設計、tool registryの要約方法、validator設計に強く依存する。
化学的メタデータ生成のテストでも同様に、Qwen2.5-Coder:14Bは未指定の立体化学、柔軟な分子、不正なSMILES、遷移金属錯体のスピン状態などに対して一定の警告を返すことができた。一方で、検証6で見たように、トルエンの組成式を正しく言い当てられなかった点は、このモデルが化学的妥当性の評価に向かないことを示している。酢酸アニオンを開殻二重項として扱うなど、明確な化学的誤りも見られた。14B程度のサイズでは、化合物の種類の判別は可能だが、原子数を含む詳細な物性や特性までは理解できていないことが観察された。(ただし、プロンプトの改良によって結果が改善する余地は残されている)
以上より、Qwen2.5-Coder:14Bは、計算化学ワークフローの「骨格」を提案する補助には有用と言える一方で、計算条件の最終決定に用いるべきではない。特に、電荷・スピン、溶媒、乱数のseed値、収束条件、enum値、出力パスなどは振れ幅が大きく、化学的妥当性をLLM側で把握できていないように見受けられる。実用上は、LLMの出力をそのまま信頼せず、実行前に検証する必要がある。
実運用では、LLMには自然言語依頼の解釈、必要ツール列の提案、入出力キーの接続、workflow JSONの骨格生成を担当させることは可能と思われる。一方で、パラメータ既定値、enum正規化、型検査、tool registry照合、dry-run、RDKitによる化学的検証などはPython側で行う、という分業が現実的である。
ただし、本検証ではプロンプトのチューニングが十分とは言えない部分もある。より厳密なsystem prompt、few-shot例、英語プロンプト、JSON Schemaの詳細化、tool registryの渡し方を工夫すれば、LLMの出力は大きく改善する可能性がある。本稿の結果はQwen2.5-Coder:14Bの初期的な実装検証として捉えていただき、プロンプト最適化やSchema最適化による改善幅の確認は今後の課題としたい。
-
Ollamaにおいて「qwen2.5-coder:14b」は
qwen2.5-coder:14b-instructを指す。qwen2.5-coder:14b-baseの意味ではないので注意。 ↩ -
計算化学の研究に従事したことのある方であればよくご存じのところとは思うが、計算のインプットファイルや初期構造を用意したり、計算結果を収集してまとめたりすることは(ある程度自動化できるとはいえ)それなりに面倒なタスクである。それらの作業をまるっと機械化できれば、計算に割く人間側の手間が減り、24時間連続稼働できれば試行錯誤の総量を増やせる。人間の研究者はデータの解析や解釈により多くの時間をかけることができ、ひいては研究の生産性向上につながると考えられる。 ↩