fix: bound LLM inference memory for batch screening

This commit is contained in:
楚毓 2026-09-23 10:11:41 +08:00
parent a7c7d54c61
commit c0d2c008b0
24 changed files with 7618 additions and 25 deletions

View File

@ -36,8 +36,8 @@ COPY app/ ./app/
# 安装项目包
RUN pip install -e .
# 复制模型文件
COPY models/final/ ./models/final/
# 模型、Qwen 权重和 RAG 数据在运行时以只读 volume 挂载。
# 不把数 GB 权重烘进镜像,也避免误把 Git LFS pointer 当成模型。
# ============ API 服务 ============
FROM base AS api
@ -60,4 +60,3 @@ ENV STREAMLIT_SERVER_PORT=8501 \
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
CMD ["streamlit", "run", "app/app.py"]

59
SERVER_DEPLOY.md Normal file
View File

@ -0,0 +1,59 @@
# GPU 服务器测试与部署
这个代码包不包含大模型权重。解压后,请先在项目根目录放好以下运行时文件:
```text
models/final/model.pt
models/mpnn/all_amine_split_for_LiON/cv_0/fold_0/model_0/model.pt
models/mpnn/all_amine_split_for_LiON/cv_1/fold_0/model_0/model.pt
models/mpnn/all_amine_split_for_LiON/cv_2/fold_0/model_0/model.pt
models/mpnn/all_amine_split_for_LiON/cv_3/fold_0/model_0/model.pt
models/mpnn/all_amine_split_for_LiON/cv_4/fold_0/model_0/model.pt
models/qwen2.5-7b-instruct/config.json
models/qwen2.5-7b-instruct/ # 其余 tokenizer/权重文件
data/interim/internal.csv
```
`.pt` 必须是真实二进制权重,不能是 100 多字节的 Git LFS pointer。
## 1. 服务器预检
```bash
nvidia-smi
docker compose -f docker-compose-gpu.yml config
bash server_gpu_test.sh preflight
```
## 2. 构建并启动测试服务
```bash
LLM_INFERENCE_BATCH_SIZE=4 bash server_gpu_test.sh start
```
对 24GB 显卡可将微批设为 `2`;48GB/80GB 显卡通常可使用 `4`。这个参数只控制 Qwen 内部微批,不会限制 API 一次提交的条数。
## 3. 单条与 32 条 LLM 冒烟测试
```bash
bash server_gpu_test.sh smoke
```
另开一个终端观察峰值:
```bash
watch -n 0.5 nvidia-smi
```
预期:两个请求都返回 HTTP 200,日志中出现 `LLM inference micro-batching`,32 条请求不再把 32 条同时送入 Qwen。
## 4. 确认后上线
如果当前机器就是正式机,`start` 启动的已是 Compose 中的正式服务。检查:
```bash
docker compose -f docker-compose-gpu.yml ps
docker compose -f docker-compose-gpu.yml logs --tail=200 api
curl -fsS http://127.0.0.1:18000/
```
回滚时保留旧代码目录,然后在旧目录里重新执行 `docker compose up -d --build`。

View File

@ -229,7 +229,12 @@ class BatchPredictRequest(BaseModel):
items: List[PredictRequest] = Field(
..., min_length=1, max_length=500, description="配方列表,单次最多 500 条"
)
batch_size: int = Field(default=32, ge=1, le=256, description="模型前向批大小")
batch_size: int = Field(
default=32,
ge=1,
le=256,
description="外层模型前向批大小;7B LLM 会按显存安全值内部微批",
)
use_llm: bool = Field(
default=True,
description="是否启用 LLM 分支;关闭可大幅提速,但会损失 delivery 精度",
@ -668,4 +673,3 @@ if __name__ == "__main__":
port=8000,
reload=True,
)

View File

@ -571,7 +571,9 @@ def predict_all(
all_ee_preds = []
all_toxic_preds = []
with torch.no_grad():
# inference_mode 比 no_grad 还会关闭 version counter/view tracking,
# 对大批量 LLM 推理可再减少一部分临时内存。
with torch.inference_mode():
for batch in dataloader:
smiles = batch["smiles"]
tabular = {k: v.to(device) for k, v in batch["tabular"].items()}
@ -1145,4 +1147,3 @@ def main(
if __name__ == "__main__":
app()

View File

@ -6,12 +6,20 @@ services:
dockerfile: Dockerfile
target: api
container_name: lnp-api
ports:
# 仅绑定本机,供服务器上的冒烟测试使用,不直接暴露到公网。
- "127.0.0.1:${API_PORT:-18000}:8000"
environment:
- MODEL_PATH=/app/models/final/model.pt
- RAG_POOL_CSV=/app/data/interim/internal.csv
# 7B LLM 内部微批;外层批量筛选仍可一次提交更多条。
- LLM_INFERENCE_BATCH_SIZE=${LLM_INFERENCE_BATCH_SIZE:-4}
volumes:
# 挂载模型目录以便更新模型
# 权重和 RAG 数据不进镜像,服务器上需预先放到这些路径。
- ./models/final:/app/models/final:ro
- ./models/mpnn:/app/models/mpnn:ro
- ./models/qwen2.5-7b-instruct:/app/models/qwen2.5-7b-instruct:ro
- ./data/interim/internal.csv:/app/data/interim/internal.csv:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]

View File

@ -9,6 +9,16 @@ import torch.nn as nn
DEFAULT_MOLT5_PATH = os.environ.get("MOLT5_PATH", "models/molt5-base")
def _positive_int_env(name: str, default: int) -> int:
"""Read a positive integer environment variable with a safe fallback."""
try:
value = int(os.environ.get(name, str(default)))
except (TypeError, ValueError):
return default
return value if value > 0 else default
# 检索池支持的额外多任务(除 delivery 外)
_EXTRA_TASKS = ["size", "pdi", "ee", "toxic", "biodist"]
@ -58,6 +68,9 @@ class LLMPromptEncoder(nn.Module):
self.use_rag = use_rag
self.rag_top_k = rag_top_k
self.use_soft_prompt = use_soft_prompt
# 7B 模型即使在 no_grad 下,大批量长 prompt 的临时激活也很大。
# 外层 DataLoader 仍可用大 batch;这里只把 LLM 前向切成小块。
self.inference_batch_size = _positive_int_env("LLM_INFERENCE_BATCH_SIZE", 4)
_name_l = model_name_or_path.lower()
_is_t5 = "t5" in _name_l
@ -145,16 +158,18 @@ class LLMPromptEncoder(nn.Module):
def _apply_lora(self, r, alpha, dropout, prepare_kbit=False):
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
if prepare_kbit:
# 4-bit/8-bit 基座只 prepare 一次。PEFT 会处理输入梯度并开启
# gradient checkpointing;后面再显式开一次,兼容不同 PEFT 版本。
is_kbit = (
prepare_kbit
or getattr(self.encoder, "is_loaded_in_4bit", False)
or getattr(self.encoder, "is_loaded_in_8bit", False)
)
if is_kbit:
self.encoder = prepare_model_for_kbit_training(self.encoder)
else:
for p in self.encoder.parameters():
p.requires_grad = False
# 4-bit 量化模型(QLoRA)需先 prepare,才能正确接收梯度
if getattr(self.encoder, "is_loaded_in_4bit", False) or getattr(self.encoder, "is_loaded_in_8bit", False):
from peft import prepare_model_for_kbit_training
self.encoder = prepare_model_for_kbit_training(self.encoder)
for p in self.encoder.parameters():
p.requires_grad = False
# 不同架构注意力层命名不同:
@ -170,6 +185,24 @@ class LLMPromptEncoder(nn.Module):
target_modules=_targets, bias="none")
self.encoder = get_peft_model(self.encoder, cfg)
# Decoder-only LLM 做表征编码不需要 KV cache。训练时保留 cache
# 只会额外占显存;梯度检查点则用计算换显存。
if hasattr(self.encoder, "config"):
self.encoder.config.use_cache = False
if hasattr(self.encoder, "gradient_checkpointing_enable"):
try:
self.encoder.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False}
)
except TypeError:
self.encoder.gradient_checkpointing_enable()
def _encoder_forward(self, **kwargs):
"""Forward the backbone without allocating an unused decoder KV cache."""
if self._is_qwen:
kwargs["use_cache"] = False
return self.encoder(**kwargs)
# ---------- 检索池 ----------
def set_retrieval_pool(self, smiles_list, labels, pool_id="train", extra_labels=None):
"""设置 RAG 检索池(防泄漏:只传训练集分子与标签)。
@ -326,14 +359,15 @@ class LLMPromptEncoder(nn.Module):
def _rag_encode_batch(self, prompts, device):
"""编码一批 RAG prompt,取最后有效 token。grad 由调用方上下文决定。"""
outs = []
for i in range(0, len(prompts), 4):
bt = prompts[i:i+4]
chunk_size = self.inference_batch_size
for i in range(0, len(prompts), chunk_size):
bt = prompts[i:i + chunk_size]
enc = self.tokenizer(
bt, padding=True, truncation=True,
max_length=self.max_length, return_tensors="pt",
).to(device)
self._warn_if_truncated(enc)
out = self.encoder(**enc).last_hidden_state # [B,L,H]
out = self._encoder_forward(**enc).last_hidden_state # [B,L,H]
lengths = enc["attention_mask"].sum(1) - 1
b = torch.arange(out.size(0), device=device)
outs.append(out[b, lengths.long(), :].float()) # [B,H]
@ -359,7 +393,8 @@ class LLMPromptEncoder(nn.Module):
return self._rag_encode_batch(prompts, device)
# ---------- soft-prompt 编码(带梯度,不缓存特征)----------
def _encode_softrag(self, smiles, chem, tab, device) -> torch.Tensor:
def _encode_softrag_chunk(self, smiles, chem, tab, device) -> torch.Tensor:
"""单个 LLM micro-batch 的 soft-RAG 前向。"""
prompts = [self._get_prompt(s) for s in smiles]
enc = self.tokenizer(
prompts, padding=True, truncation=True,
@ -393,7 +428,7 @@ class LLMPromptEncoder(nn.Module):
position_ids = attn_mask.long().cumsum(-1) - 1
position_ids = position_ids.masked_fill(attn_mask == 0, 1)
out = self.encoder(
out = self._encoder_forward(
inputs_embeds=inputs_embeds,
attention_mask=attn_mask,
position_ids=position_ids,
@ -407,6 +442,43 @@ class LLMPromptEncoder(nn.Module):
feat = out[b, lengths.long(), :]
return self.proj_down(feat.float())
def _encode_softrag(self, smiles, chem, tab, device) -> torch.Tensor:
"""
soft-RAG 编码。推理时自动微批,避免 batch API 把 7B LLM 的
临时激活按请求 batch_size 线性放大。训练仍保持原 batch,
由 gradient checkpointing 降低激活显存。
"""
batch_size = len(smiles)
micro_batch = self.inference_batch_size
should_chunk = (
not self.training
and not torch.is_grad_enabled()
and batch_size > micro_batch
)
if not should_chunk:
return self._encode_softrag_chunk(smiles, chem, tab, device)
if not getattr(self, "_microbatch_logged", False):
from loguru import logger
logger.info(
f"LLM inference micro-batching: batch={batch_size}, "
f"micro_batch={micro_batch} (override with LLM_INFERENCE_BATCH_SIZE)"
)
self._microbatch_logged = True
outputs = []
for start in range(0, batch_size, micro_batch):
end = min(start + micro_batch, batch_size)
chem_chunk = chem[start:end] if chem is not None else None
tab_chunk = tab[start:end] if tab is not None else None
outputs.append(
self._encode_softrag_chunk(
smiles[start:end], chem_chunk, tab_chunk, device
)
)
return torch.cat(outputs, dim=0)
# ---------- 旧路径(保留,向后兼容)----------
def _mean_pool(self, last_hidden, mask):
m = mask.unsqueeze(-1).float()
@ -417,12 +489,13 @@ class LLMPromptEncoder(nn.Module):
missing = [s for s in smiles if s not in self._cache]
if missing:
uniq = list(dict.fromkeys(missing))
for i in range(0, len(uniq), 256):
chunk = uniq[i:i + 256]
chunk_size = self.inference_batch_size if self._is_qwen else 256
for i in range(0, len(uniq), chunk_size):
chunk = uniq[i:i + chunk_size]
mols = [self._fmt_mol(s) for s in chunk]
enc = self.tokenizer(mols, padding=True, truncation=True,
max_length=self.max_length, return_tensors="pt").to(device)
out = self.encoder(**enc).last_hidden_state
out = self._encoder_forward(**enc).last_hidden_state
pooled = self._mean_pool(out, enc["attention_mask"])
for s, v in zip(chunk, pooled):
self._cache[s] = v.cpu()
@ -432,7 +505,7 @@ class LLMPromptEncoder(nn.Module):
mols = [self._fmt_mol(s) for s in smiles]
enc = self.tokenizer(mols, padding=True, truncation=True,
max_length=self.max_length, return_tensors="pt").to(device)
out = self.encoder(**enc).last_hidden_state
out = self._encoder_forward(**enc).last_hidden_state
return self._mean_pool(out, enc["attention_mask"])
def forward(self, smiles: List[str], chem: Optional[torch.Tensor] = None,
@ -448,4 +521,4 @@ class LLMPromptEncoder(nn.Module):
def clear_cache(self) -> None:
self._cache.clear()
self._prompt_cache.clear()
self._prompt_cache.clear()

View File

@ -118,7 +118,7 @@ def load_model(
return model
@torch.no_grad()
@torch.inference_mode()
def predict_batch(
model: Union[LNPModel, LNPModelWithoutMPNN],
loader: DataLoader,

View File

@ -31,5 +31,6 @@ uvicorn = ">=0.33.0, <0.34"
optuna = ">=4.5.0, <5"
captum = ">=0.7.0, <0.8"
transformers = ">=4.30, <4.46"
peft = "==0.13.2"
sentencepiece = "*"
protobuf = "*"

View File

@ -21,6 +21,7 @@ uvicorn>=0.33.0,<0.34
optuna>=4.5.0,<5
captum>=0.7.0
transformers>=4.30,<4.46
peft==0.13.2
sentencepiece
protobuf
bitsandbytes

108
server_gpu_test.sh Executable file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cd "${ROOT_DIR}"
COMPOSE_FILE=${COMPOSE_FILE:-docker-compose-gpu.yml}
API_PORT=${API_PORT:-18000}
API_URL=${API_URL:-http://127.0.0.1:${API_PORT}}
LLM_INFERENCE_BATCH_SIZE=${LLM_INFERENCE_BATCH_SIZE:-4}
export API_PORT
export LLM_INFERENCE_BATCH_SIZE
fail() {
echo "ERROR: $*" >&2
exit 1
}
assert_real_file() {
local path=$1
[ -f "${path}" ] || fail "missing file: ${path}"
if head -n 1 "${path}" 2>/dev/null | grep -q "git-lfs.github.com/spec"; then
fail "${path} is a Git LFS pointer, not the real artifact"
fi
}
preflight() {
command -v docker >/dev/null || fail "docker is not installed"
command -v nvidia-smi >/dev/null || fail "nvidia-smi is not installed"
nvidia-smi >/dev/null || fail "NVIDIA driver/GPU is unavailable"
docker compose version >/dev/null || fail "docker compose plugin is unavailable"
assert_real_file models/final/model.pt
assert_real_file data/interim/internal.csv
assert_real_file models/qwen2.5-7b-instruct/config.json
local fold
for fold in 0 1 2 3 4; do
assert_real_file "models/mpnn/all_amine_split_for_LiON/cv_${fold}/fold_0/model_0/model.pt"
done
find models/qwen2.5-7b-instruct -maxdepth 1 -type f \
\( -name '*.safetensors' -o -name '*.bin' \) -print -quit | grep -q . \
|| fail "Qwen weight shards (*.safetensors or *.bin) are missing"
docker compose -f "${COMPOSE_FILE}" config >/dev/null
echo "preflight: OK"
}
wait_for_api() {
local attempt
for attempt in $(seq 1 120); do
if curl -fsS "${API_URL}/" >/dev/null 2>&1; then
echo "API ready after ${attempt} checks"
return 0
fi
sleep 2
done
docker compose -f "${COMPOSE_FILE}" logs --tail=200 api || true
fail "API did not become healthy within 240 seconds"
}
start() {
preflight
docker compose -f "${COMPOSE_FILE}" build
docker compose -f "${COMPOSE_FILE}" up -d
wait_for_api
docker compose -f "${COMPOSE_FILE}" ps
curl -fsS "${API_URL}/"
echo
}
request_item() {
printf '%s' '{"smiles":"CC(C)NCCNC(C)C","cationic_lipid_to_mrna_ratio":10.0,"cationic_lipid_mol_ratio":35.0,"phospholipid_mol_ratio":16.0,"cholesterol_mol_ratio":46.5,"peg_lipid_mol_ratio":2.5,"helper_lipid":"DOPE","route":"intravenous"}'
}
smoke() {
wait_for_api
local item payload i
item=$(request_item)
curl -fsS -H 'Content-Type: application/json' \
-d "${item}" "${API_URL}/predict" >/tmp/lnp-single-response.json
echo "single prediction: OK"
payload='{"items":['
for i in $(seq 1 32); do
[ "${i}" -eq 1 ] || payload+=','
payload+="${item}"
done
payload+='],"batch_size":32,"use_llm":true}'
curl -fsS -H 'Content-Type: application/json' \
-d "${payload}" "${API_URL}/predict/batch" >/tmp/lnp-batch-response.json
grep -q '"n_succeeded":32' /tmp/lnp-batch-response.json \
|| fail "batch response did not report 32 successes"
echo "32-item LLM prediction: OK"
docker compose -f "${COMPOSE_FILE}" logs --tail=200 api \
| grep -E 'LLM inference micro-batching|ERROR|CUDA out of memory' || true
}
case "${1:-}" in
preflight) preflight ;;
start) start ;;
smoke) smoke ;;
*) echo "Usage: $0 {preflight|start|smoke}" >&2; exit 2 ;;
esac

View File

@ -0,0 +1,88 @@
# LNP API 与 GPU 显存测试
这里有两套测试:
- `run_lnp_batch_stress.sh`:专门复现 LNP 批量调用的显存问题。
- `run_cases.sh`:检查健康状态、器官列表、单条预测、批量预测、异常输入和小规模配方优化。
## 1. 复现 LNP 批量显存场景
推荐先运行与问题最接近的场景:
```bash
bash server_test_cases/run_lnp_batch_stress.sh repro
```
`repro` 会依次提交:
1. 32 条不同 LNP 配方,`use_llm=true`、外层 `batch_size=32`。这是原先最容易从约 10GB 涨到 63GB 的场景。
2. 500 条不同 LNP 配方,仍使用外层 `batch_size=32`,检查多轮处理后是否持续涨显存。
逐级观察批量大小与显存的关系:
```bash
bash server_test_cases/run_lnp_batch_stress.sh ladder
```
完整测试(关闭 LLM 对照 + 阶梯 + 500 条持续压力):
```bash
bash server_test_cases/run_lnp_batch_stress.sh full
```
每个场景都会打印并写入 CSV:运行前显存、采样峰值、显存增量、耗时、HTTP 状态。结果文件名形如 `vram-results-20260922-160000.csv`。
可以设置显存上限,让超过阈值时测试直接失败。例如期望不超过 20GB:
```bash
MAX_GPU_MIB=20480 bash server_test_cases/run_lnp_batch_stress.sh repro
```
这里采样的是整张卡的显存;测试时请尽量不要让其他任务共用该卡。
## 2. Mock 数据
已附带三组可直接提交的数据:
- `mock_batch_32_llm.json`:核心显存回归场景。
- `mock_batch_500_llm.json`:最大请求量与持续处理场景。
- `mock_batch_128_no_llm.json`:关闭 LLM 的对照组。
这些数据从项目 `data/interim/internal.csv` 抽取不同 SMILES,再随机生成满足总和 100% 的组分比例。也可以自行生成:
```bash
python3 server_test_cases/generate_mock_batch.py \
--count 200 \
--batch-size 32 \
--use-llm \
--source-csv data/interim/internal.csv \
--output /tmp/lnp-batch-200.json
```
生成过程使用固定随机种子,便于前后版本用完全相同的数据对比。
## 3. 其他功能测试
服务启动后执行:
```bash
bash server_test_cases/run_cases.sh
```
如果 API 端口或 GPU 编号不同:
```bash
API_PORT=18000 GPU_INDEX=0 bash server_test_cases/run_cases.sh
```
用例包含:
1. 健康检查与模型状态。
2. 可用器官列表。
3. 启用 LLM 的单条预测,检查输出完整性、有限数和 biodistribution 归一化。
4. 64 条关闭 LLM 的吞吐对照。
5. 32 条启用 LLM 的显存回归测试。
6. 2 条合法 + 3 条非法输入,检查批量接口的局部容错。
7. 小范围 `/optimize` 配方搜索,检查优化功能和返回结果。
GPU 显存采样来自整张卡;如果还有其他进程使用同一张卡,请以 `nvidia-smi` 中 API 进程的变化为准。

View File

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Generate reproducible /predict/batch payloads for GPU memory tests."""
from __future__ import annotations
import argparse
import csv
import json
import random
from pathlib import Path
# Used only when data/interim/internal.csv is not available. These are valid,
# deliberately varied SMILES; the project dataset is preferred for realistic RAG prompts.
FALLBACK_SMILES = [
"CC(C)NCCNC(C)C",
"CCCCN(CC)CC",
"CCCCCCCCN(C)C",
"CCN(CC)CCOC(=O)CCCC",
"CCCCCCCCCCN(CC)CC",
"CC(C)(C)OC(=O)NCCN(C)C",
"CCCCCCCCCCCCN(C)C",
"CCOC(=O)CN(C)C",
"CCCCCCN(CC)CC",
"CN(C)CCOC(=O)CCCCCCCC",
]
def load_smiles(source: Path | None) -> list[str]:
if source and source.is_file():
with source.open(encoding="utf-8-sig", newline="") as handle:
rows = csv.DictReader(handle)
values = []
seen = set()
for row in rows:
value = (row.get("smiles") or "").strip()
if value and value.lower() != "nan" and value not in seen:
seen.add(value)
values.append(value)
if values:
return values
return FALLBACK_SMILES
def make_composition(rng: random.Random) -> tuple[float, float, float, float]:
# Match the ranges used by the optimizer and guarantee an exact 100% sum.
for _ in range(1000):
peg = round(rng.uniform(1.0, 5.0), 2)
cationic = round(rng.uniform(25.0, 53.0), 2)
phospholipid = round(rng.uniform(8.0, 30.0), 2)
cholesterol = round(100.0 - peg - cationic - phospholipid, 2)
if 15.0 <= cholesterol <= 46.0:
return cationic, phospholipid, cholesterol, peg
raise RuntimeError("unable to generate a valid composition")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--count", type=int, required=True, choices=range(1, 501))
parser.add_argument("--batch-size", type=int, default=32, choices=range(1, 257))
parser.add_argument("--use-llm", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--seed", type=int, default=20260922)
parser.add_argument("--source-csv", type=Path)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
rng = random.Random(args.seed)
smiles_pool = load_smiles(args.source_csv)
rng.shuffle(smiles_pool)
items = []
for index in range(args.count):
cationic, phospholipid, cholesterol, peg = make_composition(rng)
items.append(
{
"smiles": smiles_pool[index % len(smiles_pool)],
"cationic_lipid_to_mrna_ratio": round(rng.uniform(7.0, 20.0), 2),
"cationic_lipid_mol_ratio": cationic,
"phospholipid_mol_ratio": phospholipid,
"cholesterol_mol_ratio": cholesterol,
"peg_lipid_mol_ratio": peg,
"helper_lipid": ("DOPE", "DSPC")[index % 2],
"route": ("intravenous", "intramuscular")[index % 2],
}
)
payload = {
"items": items,
"batch_size": args.batch_size,
"use_llm": args.use_llm,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"generated {args.output}: items={args.count}, unique_smiles={len(set(x['smiles'] for x in items))}, "
f"batch_size={args.batch_size}, use_llm={args.use_llm}"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,56 @@
{
"items": [
{
"smiles": "CC(C)NCCNC(C)C",
"cationic_lipid_to_mrna_ratio": 10.0,
"cationic_lipid_mol_ratio": 35.0,
"phospholipid_mol_ratio": 16.0,
"cholesterol_mol_ratio": 46.5,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "CCCCCCCCCCCCCCCCN",
"cationic_lipid_to_mrna_ratio": 12.0,
"cationic_lipid_mol_ratio": 40.0,
"phospholipid_mol_ratio": 15.0,
"cholesterol_mol_ratio": 42.5,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "this-is-not-smiles",
"cationic_lipid_to_mrna_ratio": 10.0,
"cationic_lipid_mol_ratio": 35.0,
"phospholipid_mol_ratio": 16.0,
"cholesterol_mol_ratio": 46.5,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "CCN(CC)CC",
"cationic_lipid_to_mrna_ratio": 10.0,
"cationic_lipid_mol_ratio": 35.0,
"phospholipid_mol_ratio": 16.0,
"cholesterol_mol_ratio": 30.0,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "C1CCN(CC1)CCO",
"cationic_lipid_to_mrna_ratio": 10.0,
"cationic_lipid_mol_ratio": 35.0,
"phospholipid_mol_ratio": 16.0,
"cholesterol_mol_ratio": 46.5,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "INVALID_HELPER",
"route": "intravenous"
}
],
"batch_size": 5,
"use_llm": false
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,326 @@
{
"items": [
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O",
"cationic_lipid_to_mrna_ratio": 7.46,
"cationic_lipid_mol_ratio": 37.58,
"phospholipid_mol_ratio": 23.5,
"cholesterol_mol_ratio": 36.53,
"peg_lipid_mol_ratio": 2.39,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(CCC(NCC1=CC=C(B(O)O)C=C1)=O)=O",
"cationic_lipid_to_mrna_ratio": 16.82,
"cationic_lipid_mol_ratio": 50.09,
"phospholipid_mol_ratio": 13.01,
"cholesterol_mol_ratio": 33.14,
"peg_lipid_mol_ratio": 3.76,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCN([C@H](C(NCCCN(CCCCCCOC(CCCCCCC)=O)CCCCCCOC(CCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCC)=O)CCCCCCC",
"cationic_lipid_to_mrna_ratio": 9.92,
"cationic_lipid_mol_ratio": 46.82,
"phospholipid_mol_ratio": 16.37,
"cholesterol_mol_ratio": 33.48,
"peg_lipid_mol_ratio": 3.33,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCC)=O)CCCCCCOC(CCCCC)=O)=O)CO)CCCCCCOC(CCCCC)=O)CCCCC",
"cationic_lipid_to_mrna_ratio": 10.73,
"cationic_lipid_mol_ratio": 36.31,
"phospholipid_mol_ratio": 23.42,
"cholesterol_mol_ratio": 35.49,
"peg_lipid_mol_ratio": 4.78,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=NC=CS1)CC2=NC=CS2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O",
"cationic_lipid_to_mrna_ratio": 19.35,
"cationic_lipid_mol_ratio": 51.39,
"phospholipid_mol_ratio": 10.87,
"cholesterol_mol_ratio": 35.21,
"peg_lipid_mol_ratio": 2.53,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCCN1CCOCC1",
"cationic_lipid_to_mrna_ratio": 17.21,
"cationic_lipid_mol_ratio": 46.17,
"phospholipid_mol_ratio": 19.64,
"cholesterol_mol_ratio": 31.96,
"peg_lipid_mol_ratio": 2.23,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "CCCCCCCCCCCC(OCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCOC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)=O",
"cationic_lipid_to_mrna_ratio": 10.34,
"cationic_lipid_mol_ratio": 44.28,
"phospholipid_mol_ratio": 17.74,
"cholesterol_mol_ratio": 34.79,
"peg_lipid_mol_ratio": 3.19,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCN([C@H](C(NCCN(C)CCN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)=O)CO)CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCCCC=C",
"cationic_lipid_to_mrna_ratio": 9.15,
"cationic_lipid_mol_ratio": 42.88,
"phospholipid_mol_ratio": 10.3,
"cholesterol_mol_ratio": 43.76,
"peg_lipid_mol_ratio": 3.06,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "CC(CNC([C@@H](N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)CO)=O)(CN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)C",
"cationic_lipid_to_mrna_ratio": 10.12,
"cationic_lipid_mol_ratio": 49.74,
"phospholipid_mol_ratio": 12.42,
"cholesterol_mol_ratio": 35.63,
"peg_lipid_mol_ratio": 2.21,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(NCCN(CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCN",
"cationic_lipid_to_mrna_ratio": 19.72,
"cationic_lipid_mol_ratio": 32.8,
"phospholipid_mol_ratio": 25.26,
"cholesterol_mol_ratio": 38.21,
"peg_lipid_mol_ratio": 3.73,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN1CC(C(OC)=O)CC1=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O",
"cationic_lipid_to_mrna_ratio": 13.7,
"cationic_lipid_mol_ratio": 43.91,
"phospholipid_mol_ratio": 21.35,
"cholesterol_mol_ratio": 31.92,
"peg_lipid_mol_ratio": 2.82,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "CCCCCCCCCCCCOC(CCN([C@H](C(NCCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)=O)CO)CCC(OCCCCCCCCCCCC)=O)=O",
"cationic_lipid_to_mrna_ratio": 10.47,
"cationic_lipid_mol_ratio": 32.49,
"phospholipid_mol_ratio": 24.48,
"cholesterol_mol_ratio": 40.96,
"peg_lipid_mol_ratio": 2.07,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)N(CC1)CCN1C2=NC=CC=N2",
"cationic_lipid_to_mrna_ratio": 12.86,
"cationic_lipid_mol_ratio": 51.8,
"phospholipid_mol_ratio": 19.97,
"cholesterol_mol_ratio": 25.07,
"peg_lipid_mol_ratio": 3.16,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(CN(CC(O)CCCCCCCC)CC(O)CCCCCCCC)NCCCN(CC(O)CCCCCCCC)CC(O)CCCCCCCC",
"cationic_lipid_to_mrna_ratio": 18.73,
"cationic_lipid_mol_ratio": 45.45,
"phospholipid_mol_ratio": 13.35,
"cholesterol_mol_ratio": 38.13,
"peg_lipid_mol_ratio": 3.07,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "CCCCCCCCCCCC(OCCCCCCN(CCN(C)CCNC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O",
"cationic_lipid_to_mrna_ratio": 10.09,
"cationic_lipid_mol_ratio": 37.43,
"phospholipid_mol_ratio": 11.94,
"cholesterol_mol_ratio": 45.78,
"peg_lipid_mol_ratio": 4.85,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "OC[C@@H](C(NCCCCCCN(CC(O)CCCCCCCCCC)CC(O)CCCCCCCCCC)=O)N(CC(CCCCCCCCCC)O)CC(CCCCCCCCCC)O",
"cationic_lipid_to_mrna_ratio": 13.05,
"cationic_lipid_mol_ratio": 52.84,
"phospholipid_mol_ratio": 8.6,
"cholesterol_mol_ratio": 37.06,
"peg_lipid_mol_ratio": 1.5,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O",
"cationic_lipid_to_mrna_ratio": 11.09,
"cationic_lipid_mol_ratio": 48.25,
"phospholipid_mol_ratio": 14.52,
"cholesterol_mol_ratio": 32.66,
"peg_lipid_mol_ratio": 4.57,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCC/C=C\\C/C=C\\CCCCC)CCN(CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O",
"cationic_lipid_to_mrna_ratio": 19.63,
"cationic_lipid_mol_ratio": 31.98,
"phospholipid_mol_ratio": 25.5,
"cholesterol_mol_ratio": 40.57,
"peg_lipid_mol_ratio": 1.95,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(NCCCCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC)[C@H](C)N(CCCCCCCCCCCC)CCCCCCCCCCCC",
"cationic_lipid_to_mrna_ratio": 16.96,
"cationic_lipid_mol_ratio": 28.21,
"phospholipid_mol_ratio": 28.57,
"cholesterol_mol_ratio": 39.51,
"peg_lipid_mol_ratio": 3.71,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CCC(F)(F)F)CCC(F)(F)F)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O",
"cationic_lipid_to_mrna_ratio": 19.73,
"cationic_lipid_mol_ratio": 37.62,
"phospholipid_mol_ratio": 22.08,
"cholesterol_mol_ratio": 36.42,
"peg_lipid_mol_ratio": 3.88,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "CCCCCCCCC(CN(CCCNC([C@@H](N(CC(CCCCCCCC)O)CC(CCCCCCCC)O)CO)=O)CC(CCCCCCCC)O)O",
"cationic_lipid_to_mrna_ratio": 15.62,
"cationic_lipid_mol_ratio": 51.29,
"phospholipid_mol_ratio": 27.02,
"cholesterol_mol_ratio": 18.83,
"peg_lipid_mol_ratio": 2.86,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)N(CC1)CCN1C2=NC=CC=N2",
"cationic_lipid_to_mrna_ratio": 14.57,
"cationic_lipid_mol_ratio": 38.85,
"phospholipid_mol_ratio": 18.16,
"cholesterol_mol_ratio": 40.17,
"peg_lipid_mol_ratio": 2.82,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "CCCCCCCCCCCC(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCCCCCC)=O)=O",
"cationic_lipid_to_mrna_ratio": 17.0,
"cationic_lipid_mol_ratio": 41.61,
"phospholipid_mol_ratio": 21.12,
"cholesterol_mol_ratio": 35.9,
"peg_lipid_mol_ratio": 1.37,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)CCN1CCOCC1",
"cationic_lipid_to_mrna_ratio": 12.75,
"cationic_lipid_mol_ratio": 31.73,
"phospholipid_mol_ratio": 29.19,
"cholesterol_mol_ratio": 37.55,
"peg_lipid_mol_ratio": 1.53,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(CN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC)NCCCN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC",
"cationic_lipid_to_mrna_ratio": 19.09,
"cationic_lipid_mol_ratio": 41.63,
"phospholipid_mol_ratio": 16.23,
"cholesterol_mol_ratio": 39.43,
"peg_lipid_mol_ratio": 2.71,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O",
"cationic_lipid_to_mrna_ratio": 16.96,
"cationic_lipid_mol_ratio": 34.46,
"phospholipid_mol_ratio": 28.65,
"cholesterol_mol_ratio": 32.06,
"peg_lipid_mol_ratio": 4.83,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC([C@@H](N)CCCCN)=O",
"cationic_lipid_to_mrna_ratio": 14.76,
"cationic_lipid_mol_ratio": 34.58,
"phospholipid_mol_ratio": 25.73,
"cholesterol_mol_ratio": 35.03,
"peg_lipid_mol_ratio": 4.66,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(CN(CCCCCCCCCCCC)CCCCCCCCCCCC)NCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC",
"cationic_lipid_to_mrna_ratio": 7.01,
"cationic_lipid_mol_ratio": 42.86,
"phospholipid_mol_ratio": 23.71,
"cholesterol_mol_ratio": 30.73,
"peg_lipid_mol_ratio": 2.7,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=COC=N1)CC2=COC=N2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O",
"cationic_lipid_to_mrna_ratio": 7.11,
"cationic_lipid_mol_ratio": 43.3,
"phospholipid_mol_ratio": 22.32,
"cholesterol_mol_ratio": 31.55,
"peg_lipid_mol_ratio": 2.83,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCNC(CCCN)=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O",
"cationic_lipid_to_mrna_ratio": 18.85,
"cationic_lipid_mol_ratio": 45.22,
"phospholipid_mol_ratio": 17.28,
"cholesterol_mol_ratio": 33.01,
"peg_lipid_mol_ratio": 4.49,
"helper_lipid": "DSPC",
"route": "intramuscular"
},
{
"smiles": "O=C(CN(CCCCCCCCCC)CCCCCCCCCC)NCCCN(CCCCCCCCCC)CCCCCCCCCC",
"cationic_lipid_to_mrna_ratio": 10.74,
"cationic_lipid_mol_ratio": 38.47,
"phospholipid_mol_ratio": 25.72,
"cholesterol_mol_ratio": 32.81,
"peg_lipid_mol_ratio": 3.0,
"helper_lipid": "DOPE",
"route": "intravenous"
},
{
"smiles": "O=C(OCCCCCCCC/C=C\\C/C=C\\CCCCC)CCN(CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCC(OCCCCCCCC/C=C\\C/C=C\\CCCCC)=O)CCNC(CCCN)=O",
"cationic_lipid_to_mrna_ratio": 17.44,
"cationic_lipid_mol_ratio": 42.07,
"phospholipid_mol_ratio": 17.36,
"cholesterol_mol_ratio": 36.89,
"peg_lipid_mol_ratio": 3.68,
"helper_lipid": "DSPC",
"route": "intramuscular"
}
],
"batch_size": 32,
"use_llm": true
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
{
"smiles": "CC(C)NCCNC(C)C",
"organ": "liver",
"top_k": 3,
"num_seeds": 5,
"top_per_seed": 1,
"rerank_top_n": 8,
"step_sizes": [5],
"wr_step_sizes": [5],
"routes": ["intravenous"],
"comp_ranges": {
"weight_ratio_min": 10,
"weight_ratio_max": 15,
"cationic_mol_min": 40,
"cationic_mol_max": 50,
"phospholipid_mol_min": 10,
"phospholipid_mol_max": 20,
"cholesterol_mol_min": 28,
"cholesterol_mol_max": 45,
"peg_mol_min": 1,
"peg_mol_max": 5
},
"scoring_weights": {
"biodist_weight": 1,
"delivery_weight": 0,
"size_weight": 0,
"ee_class_weights": [0, 0, 0],
"pdi_class_weights": [0, 0],
"toxic_class_weights": [0, 0]
}
}

187
server_test_cases/run_cases.sh Executable file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
CASE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
API_PORT=${API_PORT:-18000}
API_URL=${API_URL:-http://127.0.0.1:${API_PORT}}
GPU_INDEX=${GPU_INDEX:-0}
CURL_TIMEOUT=${CURL_TIMEOUT:-1800}
PASS=0
FAIL=0
run_case() {
local name=$1
shift
echo
echo "===== ${name} ====="
if "$@"; then
PASS=$((PASS + 1))
echo "PASS: ${name}"
else
FAIL=$((FAIL + 1))
echo "FAIL: ${name}" >&2
fi
}
validate_health() {
local response=/tmp/lnp-test-health.json
curl -fsS --max-time 30 "${API_URL}/" >"${response}"
python3 - "${response}" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert data["status"] == "healthy", data
assert data["model_loaded"] is True, data
assert data["use_llm"] is True, data
print(json.dumps(data, ensure_ascii=False, indent=2))
PY
}
validate_organs() {
local response=/tmp/lnp-test-organs.json
curl -fsS --max-time 30 "${API_URL}/organs" >"${response}"
python3 - "${response}" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
expected = {"lymph_nodes", "heart", "liver", "spleen", "lung", "kidney", "muscle"}
assert set(data) == expected, data
print(json.dumps(data, ensure_ascii=False))
PY
}
validate_single() {
local response=/tmp/lnp-test-single.json
curl -fsS --max-time "${CURL_TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary "@${CASE_DIR}/single_valid.json" \
"${API_URL}/predict" >"${response}"
python3 - "${response}" <<'PY'
import json, math, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
required = ["biodist", "quantified_delivery", "pdi_class", "ee_class", "toxic_class"]
assert all(k in data for k in required), data
values = list(data["biodist"].values())
assert len(values) == 7, values
assert all(math.isfinite(float(x)) for x in values), values
assert abs(sum(values) - 1.0) < 1e-4, sum(values)
assert math.isfinite(float(data["quantified_delivery"]))
print(json.dumps(data, ensure_ascii=False, indent=2))
PY
}
make_batch_payload() {
local count=$1
local use_llm=$2
local batch_size=$3
python3 - "${CASE_DIR}/single_valid.json" "${count}" "${use_llm}" "${batch_size}" <<'PY'
import json, sys
item = json.load(open(sys.argv[1], encoding="utf-8"))
print(json.dumps({
"items": [item for _ in range(int(sys.argv[2]))],
"batch_size": int(sys.argv[4]),
"use_llm": sys.argv[3].lower() == "true",
}))
PY
}
validate_batch_response() {
local response=$1
local expected=$2
python3 - "${response}" "${expected}" <<'PY'
import json, math, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
expected = int(sys.argv[2])
assert data["n_requested"] == expected, data
assert data["n_succeeded"] == expected, data
assert data["errors"] == [], data["errors"]
assert len(data["predictions"]) == expected
for pred in data["predictions"]:
values = list(pred["biodist"].values())
assert len(values) == 7
assert all(math.isfinite(float(x)) for x in values)
assert abs(sum(values) - 1.0) < 1e-4
print(f"n_succeeded={data['n_succeeded']}, errors={len(data['errors'])}")
PY
}
run_batch() {
local count=$1
local use_llm=$2
local batch_size=$3
local response="/tmp/lnp-test-batch-${count}-${use_llm}.json"
local payload before peak current curl_pid start_ms end_ms
payload=$(make_batch_payload "${count}" "${use_llm}" "${batch_size}")
before=$(nvidia-smi --id="${GPU_INDEX}" --query-gpu=memory.used \
--format=csv,noheader,nounits | head -n 1 | tr -d ' ')
peak=${before}
start_ms=$(date +%s%3N)
curl -fsS --max-time "${CURL_TIMEOUT}" \
-H 'Content-Type: application/json' \
-d "${payload}" "${API_URL}/predict/batch" >"${response}" &
curl_pid=$!
while kill -0 "${curl_pid}" 2>/dev/null; do
current=$(nvidia-smi --id="${GPU_INDEX}" --query-gpu=memory.used \
--format=csv,noheader,nounits | head -n 1 | tr -d ' ')
if [ "${current}" -gt "${peak}" ]; then peak=${current}; fi
sleep 0.2
done
wait "${curl_pid}"
end_ms=$(date +%s%3N)
validate_batch_response "${response}" "${count}"
echo "use_llm=${use_llm}, count=${count}, outer_batch=${batch_size}"
echo "GPU used before=${before} MiB, observed peak=${peak} MiB, delta=$((peak - before)) MiB"
echo "elapsed=$((end_ms - start_ms)) ms"
}
validate_mixed_errors() {
local response=/tmp/lnp-test-mixed.json
curl -fsS --max-time "${CURL_TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary "@${CASE_DIR}/mixed_valid_invalid.json" \
"${API_URL}/predict/batch" >"${response}"
python3 - "${response}" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert data["n_requested"] == 5, data
assert data["n_succeeded"] == 2, data
assert len(data["errors"]) == 3, data
assert {x["index"] for x in data["errors"]} == {2, 3, 4}, data["errors"]
print(json.dumps(data["errors"], ensure_ascii=False, indent=2))
PY
}
validate_optimize() {
local response=/tmp/lnp-test-optimize.json
curl -fsS --max-time "${CURL_TIMEOUT}" \
-H 'Content-Type: application/json' \
--data-binary "@${CASE_DIR}/optimize_smoke.json" \
"${API_URL}/optimize" >"${response}"
python3 - "${response}" <<'PY'
import json, math, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert data["target_organ"] == "liver", data
assert 1 <= len(data["formulations"]) <= 3, data
for row in data["formulations"]:
assert math.isfinite(float(row["target_biodist"])), row
assert abs(sum(float(x) for x in row["all_biodist"].values()) - 1.0) < 1e-4, row
print(f"optimize returned {len(data['formulations'])} formulations")
PY
}
run_case "health" validate_health
run_case "available organs" validate_organs
run_case "single prediction with LLM" validate_single
run_case "64 items without LLM (throughput baseline)" run_batch 64 false 64
run_case "32 items with LLM (VRAM regression)" run_batch 32 true 32
run_case "mixed valid/invalid items" validate_mixed_errors
run_case "small optimize search" validate_optimize
echo
echo "===== SUMMARY ====="
echo "passed=${PASS}, failed=${FAIL}"
if [ "${FAIL}" -ne 0 ]; then
exit 1
fi

View File

@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -uo pipefail
CASE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
API_PORT=${API_PORT:-18000}
API_URL=${API_URL:-http://127.0.0.1:${API_PORT}}
GPU_INDEX=${GPU_INDEX:-0}
CURL_TIMEOUT=${CURL_TIMEOUT:-3600}
MAX_GPU_MIB=${MAX_GPU_MIB:-0}
PROFILE=${1:-repro}
RESULT_CSV=${RESULT_CSV:-"${CASE_DIR}/vram-results-$(date +%Y%m%d-%H%M%S).csv"}
if ! command -v curl >/dev/null || ! command -v python3 >/dev/null; then
echo "curl and python3 are required" >&2
exit 2
fi
if ! command -v nvidia-smi >/dev/null; then
echo "nvidia-smi is required for the GPU memory test" >&2
exit 2
fi
if ! curl -fsS --max-time 30 "${API_URL}/" >/tmp/lnp-stress-health.json; then
echo "API is not reachable: ${API_URL}" >&2
exit 2
fi
gpu_used_mib() {
nvidia-smi --id="${GPU_INDEX}" --query-gpu=memory.used --format=csv,noheader,nounits \
| head -n 1 | tr -d ' '
}
now_ms() {
python3 -c 'import time; print(int(time.time() * 1000))'
}
validate_response() {
local response=$1 expected=$2
python3 - "${response}" "${expected}" <<'PY'
import json, math, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
expected = int(sys.argv[2])
assert data["n_requested"] == expected, data
assert data["n_succeeded"] == expected, data
assert data["errors"] == [], data["errors"]
assert len(data["predictions"]) == expected, len(data["predictions"])
for pred in data["predictions"]:
values = list(pred["biodist"].values())
assert len(values) == 7
assert all(math.isfinite(float(value)) for value in values)
assert abs(sum(values) - 1.0) < 1e-4, sum(values)
print(f"validated {expected} predictions")
PY
}
run_payload() {
local label=$1 payload=$2 expected=$3
local response="/tmp/lnp-${label}-response.json"
local http_file="/tmp/lnp-${label}-http.txt"
local before peak current curl_pid curl_status http_code start_ms end_ms elapsed delta status
echo
echo "===== ${label} ====="
before=$(gpu_used_mib)
peak=${before}
start_ms=$(now_ms)
curl -sS --max-time "${CURL_TIMEOUT}" -o "${response}" -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "@${payload}" \
"${API_URL}/predict/batch" >"${http_file}" &
curl_pid=$!
while kill -0 "${curl_pid}" 2>/dev/null; do
current=$(gpu_used_mib 2>/dev/null || echo 0)
if [[ "${current}" =~ ^[0-9]+$ ]] && [ "${current}" -gt "${peak}" ]; then
peak=${current}
fi
sleep 0.1
done
wait "${curl_pid}"
curl_status=$?
end_ms=$(now_ms)
http_code=$(cat "${http_file}" 2>/dev/null || echo 000)
elapsed=$((end_ms - start_ms))
delta=$((peak - before))
status=PASS
if [ "${curl_status}" -ne 0 ] || [ "${http_code}" != "200" ]; then
status=FAIL
echo "request failed: curl_status=${curl_status}, HTTP=${http_code}" >&2
sed -n '1,30p' "${response}" 2>/dev/null || true
elif ! validate_response "${response}" "${expected}"; then
status=FAIL
elif [ "${MAX_GPU_MIB}" -gt 0 ] && [ "${peak}" -gt "${MAX_GPU_MIB}" ]; then
status=FAIL
echo "peak ${peak} MiB exceeds MAX_GPU_MIB=${MAX_GPU_MIB}" >&2
fi
echo "${label}: status=${status}, before=${before} MiB, peak=${peak} MiB, delta=${delta} MiB, elapsed=${elapsed} ms"
printf '%s,%s,%s,%s,%s,%s,%s\n' \
"${label}" "${status}" "${before}" "${peak}" "${delta}" "${elapsed}" "${http_code}" >>"${RESULT_CSV}"
[ "${status}" = PASS ]
}
make_payload() {
local count=$1 outer_batch=$2 use_llm=$3 output=$4
local llm_flag=--use-llm
[ "${use_llm}" = false ] && llm_flag=--no-use-llm
python3 "${CASE_DIR}/generate_mock_batch.py" \
--count "${count}" --batch-size "${outer_batch}" "${llm_flag}" \
--source-csv "${CASE_DIR}/../data/interim/internal.csv" --output "${output}"
}
printf 'case,status,before_mib,peak_mib,delta_mib,elapsed_ms,http_code\n' >"${RESULT_CSV}"
FAILED=0
case "${PROFILE}" in
repro)
run_payload "llm-32-outer32" "${CASE_DIR}/mock_batch_32_llm.json" 32 || FAILED=1
run_payload "llm-500-outer32" "${CASE_DIR}/mock_batch_500_llm.json" 500 || FAILED=1
;;
ladder)
for count in 1 8 16 32 64 128; do
payload="/tmp/lnp-ladder-${count}.json"
make_payload "${count}" "${count}" true "${payload}"
run_payload "llm-${count}-outer${count}" "${payload}" "${count}" || FAILED=1
curl -fsS --max-time 30 "${API_URL}/" >/dev/null || break
done
;;
full)
run_payload "baseline-no-llm-128" "${CASE_DIR}/mock_batch_128_no_llm.json" 128 || FAILED=1
for count in 1 8 16 32 64 128; do
payload="/tmp/lnp-ladder-${count}.json"
make_payload "${count}" "${count}" true "${payload}"
run_payload "llm-${count}-outer${count}" "${payload}" "${count}" || FAILED=1
curl -fsS --max-time 30 "${API_URL}/" >/dev/null || break
done
curl -fsS --max-time 30 "${API_URL}/" >/dev/null \
&& run_payload "llm-500-outer32" "${CASE_DIR}/mock_batch_500_llm.json" 500 || FAILED=1
;;
*)
echo "Usage: $0 [repro|ladder|full]" >&2
exit 2
;;
esac
echo
echo "Results: ${RESULT_CSV}"
exit "${FAILED}"

View File

@ -0,0 +1,10 @@
{
"smiles": "CC(C)NCCNC(C)C",
"cationic_lipid_to_mrna_ratio": 10.0,
"cationic_lipid_mol_ratio": 35.0,
"phospholipid_mol_ratio": 16.0,
"cholesterol_mol_ratio": 46.5,
"peg_lipid_mol_ratio": 2.5,
"helper_lipid": "DOPE",
"route": "intravenous"
}

View File

@ -0,0 +1,33 @@
name,SMILES
LNP-001,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O
LNP-002,O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(CCC(NCC1=CC=C(B(O)O)C=C1)=O)=O
LNP-003,O=C(OCCCCCCN([C@H](C(NCCCN(CCCCCCOC(CCCCCCC)=O)CCCCCCOC(CCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCC)=O)CCCCCCC
LNP-004,O=C(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCC)=O)CCCCCCOC(CCCCC)=O)=O)CO)CCCCCCOC(CCCCC)=O)CCCCC
LNP-005,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=NC=CS1)CC2=NC=CS2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
LNP-006,O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCCN1CCOCC1
LNP-007,CCCCCCCCCCCC(OCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCOC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)=O
LNP-008,O=C(OCCCCCCN([C@H](C(NCCN(C)CCN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)=O)CO)CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCCCC=C
LNP-009,CC(CNC([C@@H](N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)CO)=O)(CN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)C
LNP-010,O=C(NCCN(CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCN
LNP-011,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN1CC(C(OC)=O)CC1=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
LNP-012,CCCCCCCCCCCCOC(CCN([C@H](C(NCCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)=O)CO)CCC(OCCCCCCCCCCCC)=O)=O
LNP-013,O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)N(CC1)CCN1C2=NC=CC=N2
LNP-014,O=C(CN(CC(O)CCCCCCCC)CC(O)CCCCCCCC)NCCCN(CC(O)CCCCCCCC)CC(O)CCCCCCCC
LNP-015,CCCCCCCCCCCC(OCCCCCCN(CCN(C)CCNC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O
LNP-016,OC[C@@H](C(NCCCCCCN(CC(O)CCCCCCCCCC)CC(O)CCCCCCCCCC)=O)N(CC(CCCCCCCCCC)O)CC(CCCCCCCCCC)O
LNP-017,O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O
LNP-018,O=C(OCCCCCCCC/C=C\C/C=C\CCCCC)CCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O
LNP-019,O=C(NCCCCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC)[C@H](C)N(CCCCCCCCCCCC)CCCCCCCCCCCC
LNP-020,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CCC(F)(F)F)CCC(F)(F)F)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
LNP-021,CCCCCCCCC(CN(CCCNC([C@@H](N(CC(CCCCCCCC)O)CC(CCCCCCCC)O)CO)=O)CC(CCCCCCCC)O)O
LNP-022,O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)N(CC1)CCN1C2=NC=CC=N2
LNP-023,CCCCCCCCCCCC(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCCCCCC)=O)=O
LNP-024,O=C(OCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)CCN1CCOCC1
LNP-025,O=C(CN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC)NCCCN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC
LNP-026,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O
LNP-027,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC([C@@H](N)CCCCN)=O
LNP-028,O=C(CN(CCCCCCCCCCCC)CCCCCCCCCCCC)NCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC
LNP-029,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=COC=N1)CC2=COC=N2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
LNP-030,O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCNC(CCCN)=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
LNP-031,O=C(CN(CCCCCCCCCC)CCCCCCCCCC)NCCCN(CCCCCCCCCC)CCCCCCCCCC
LNP-032,O=C(OCCCCCCCC/C=C\C/C=C\CCCCC)CCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCNC(CCCN)=O
1 name SMILES
2 LNP-001 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O
3 LNP-002 O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(CCC(NCC1=CC=C(B(O)O)C=C1)=O)=O
4 LNP-003 O=C(OCCCCCCN([C@H](C(NCCCN(CCCCCCOC(CCCCCCC)=O)CCCCCCOC(CCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCC)=O)CCCCCCC
5 LNP-004 O=C(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCC)=O)CCCCCCOC(CCCCC)=O)=O)CO)CCCCCCOC(CCCCC)=O)CCCCC
6 LNP-005 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=NC=CS1)CC2=NC=CS2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
7 LNP-006 O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCCN1CCOCC1
8 LNP-007 CCCCCCCCCCCC(OCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCOC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)=O
9 LNP-008 O=C(OCCCCCCN([C@H](C(NCCN(C)CCN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)=O)CO)CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCCCC=C
10 LNP-009 CC(CNC([C@@H](N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)CO)=O)(CN(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)C
11 LNP-010 O=C(NCCN(CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CCCN
12 LNP-011 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN1CC(C(OC)=O)CC1=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
13 LNP-012 CCCCCCCCCCCCOC(CCN([C@H](C(NCCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)=O)CO)CCC(OCCCCCCCCCCCC)=O)=O
14 LNP-013 O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)N(CC1)CCN1C2=NC=CC=N2
15 LNP-014 O=C(CN(CC(O)CCCCCCCC)CC(O)CCCCCCCC)NCCCN(CC(O)CCCCCCCC)CC(O)CCCCCCCC
16 LNP-015 CCCCCCCCCCCC(OCCCCCCN(CCN(C)CCNC([C@@H](N(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)CO)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O
17 LNP-016 OC[C@@H](C(NCCCCCCN(CC(O)CCCCCCCCCC)CC(O)CCCCCCCCCC)=O)N(CC(CCCCCCCCCC)O)CC(CCCCCCCCCC)O
18 LNP-017 O=C(OCCCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCCCC)=O)CCNC(C[C@@H](CN)CC(C)C)=O
19 LNP-018 O=C(OCCCCCCCC/C=C\C/C=C\CCCCC)CCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O
20 LNP-019 O=C(NCCCCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC)[C@H](C)N(CCCCCCCCCCCC)CCCCCCCCCCCC
21 LNP-020 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CCC(F)(F)F)CCC(F)(F)F)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
22 LNP-021 CCCCCCCCC(CN(CCCNC([C@@H](N(CC(CCCCCCCC)O)CC(CCCCCCCC)O)CO)=O)CC(CCCCCCCC)O)O
23 LNP-022 O=C([C@H](CO)N(CCCCCCOC(CCCCCCCCC=C)=O)CCCCCCOC(CCCCCCCCC=C)=O)N(CC1)CCN1C2=NC=CC=N2
24 LNP-023 CCCCCCCCCCCC(OCCCCCCN([C@H](C(NCCCCCCN(CCCCCCOC(CCCCCCCCCCC)=O)CCCCCCOC(CCCCCCCCCCC)=O)=O)CO)CCCCCCOC(CCCCCCCCCCC)=O)=O
25 LNP-024 O=C(OCCCCCCCCCCCC)CCN(CCC(OCCCCCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCCCCCC)=O)CCC(OCCCCCCCCCCCC)=O)CCN1CCOCC1
26 LNP-025 O=C(CN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC)NCCCN(CCCCCCCCCCCCCC)CCCCCCCCCCCCCC
27 LNP-026 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCCCN(CCCCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC(CCC(NCCC1=CC=C(O)C(O)=C1)=O)=O
28 LNP-027 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O)CCNC([C@@H](N)CCCCN)=O
29 LNP-028 O=C(CN(CCCCCCCCCCCC)CCCCCCCCCCCC)NCCCN(CCCCCCCCCCCC)CCCCCCCCCCCC
30 LNP-029 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCN(CC1=COC=N1)CC2=COC=N2)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
31 LNP-030 O=C(OCCCCCCCC)CCN(CCC(OCCCCCCCC)=O)CCCCN(CCNC(CCCN)=O)CCCCN(CCC(OCCCCCCCC)=O)CCC(OCCCCCCCC)=O
32 LNP-031 O=C(CN(CCCCCCCCCC)CCCCCCCCCC)NCCCN(CCCCCCCCCC)CCCCCCCCCC
33 LNP-032 O=C(OCCCCCCCC/C=C\C/C=C\CCCCC)CCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCCCN(CCCCN(CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCC(OCCCCCCCC/C=C\C/C=C\CCCCC)=O)CCNC(CCCN)=O

View File

@ -0,0 +1,3 @@
head,Tail-A,Tail-B
H-01,CC(C)NCCNC(C)C,CCCCCCCCCCN(C)C
H-02,CCN(CC)CCOC(=O)CCCC,CCCCCCCCCCCCN(C)C
1 head Tail-A Tail-B
2 H-01 CC(C)NCCNC(C)C CCCCCCCCCCN(C)C
3 H-02 CCN(CC)CCOC(=O)CCCC CCCCCCCCCCCCN(C)C

View File

@ -0,0 +1,7 @@
name,SMILES
valid-1,CC(C)NCCNC(C)C
valid-2,CCCCCCCCCCN(C)C
invalid-backend-1,C1INVALIDCCCC
filtered-short,abc
duplicate-valid-1,CC(C)NCCNC(C)C
invalid-backend-2,NOT_A_VALID_CCCC
1 name SMILES
2 valid-1 CC(C)NCCNC(C)C
3 valid-2 CCCCCCCCCCN(C)C
4 invalid-backend-1 C1INVALIDCCCC
5 filtered-short abc
6 duplicate-valid-1 CC(C)NCCNC(C)C
7 invalid-backend-2 NOT_A_VALID_CCCC

58
tests/test_llm_memory.py Normal file
View File

@ -0,0 +1,58 @@
"""LLM 显存保护逻辑的轻量测试(不需要下载模型)。"""
from types import MethodType, SimpleNamespace
from unittest.mock import patch
import torch
from lnp_ml.modeling.layers.llm_prompt import LLMPromptEncoder, _positive_int_env
def _empty_encoder(micro_batch: int = 2) -> LLMPromptEncoder:
encoder = LLMPromptEncoder.__new__(LLMPromptEncoder)
torch.nn.Module.__init__(encoder)
encoder.inference_batch_size = micro_batch
return encoder
def test_invalid_inference_batch_env_falls_back() -> None:
with patch.dict("os.environ", {"LLM_INFERENCE_BATCH_SIZE": "invalid"}):
assert _positive_int_env("LLM_INFERENCE_BATCH_SIZE", 4) == 4
with patch.dict("os.environ", {"LLM_INFERENCE_BATCH_SIZE": "0"}):
assert _positive_int_env("LLM_INFERENCE_BATCH_SIZE", 4) == 4
def test_softrag_inference_is_microbatched() -> None:
encoder = _empty_encoder(micro_batch=2).eval()
calls = []
def fake_chunk(self, smiles, chem, tab, device):
calls.append((list(smiles), chem.clone(), tab.clone()))
return chem[:, 0, :] + tab[:, 0, :]
encoder._encode_softrag_chunk = MethodType(fake_chunk, encoder)
chem = torch.arange(5 * 2 * 3, dtype=torch.float32).reshape(5, 2, 3)
tab = torch.ones(5, 1, 3)
with torch.no_grad():
output = encoder._encode_softrag(
[f"SMILES-{i}" for i in range(5)], chem, tab, torch.device("cpu")
)
assert [len(smiles) for smiles, _, _ in calls] == [2, 2, 1]
assert torch.equal(output, chem[:, 0, :] + tab[:, 0, :])
def test_qwen_forward_disables_kv_cache() -> None:
encoder = _empty_encoder()
encoder._is_qwen = True
received = {}
class FakeBackbone(torch.nn.Module):
def forward(self, **kwargs):
received.update(kwargs)
return SimpleNamespace(last_hidden_state=torch.zeros(1, 1, 1))
encoder.encoder = FakeBackbone()
encoder._encoder_forward(input_ids=torch.ones(1, 1, dtype=torch.long))
assert received["use_cache"] is False