diff --git a/TASK_DETAILS.md b/TASK_DETAILS.md index 7475c4cc..66574c78 100644 --- a/TASK_DETAILS.md +++ b/TASK_DETAILS.md @@ -351,5 +351,10 @@ We welcome new engineering problem ideas — even without complete verification DiffSimThermalControl Process optimization in additive manufacturing via differentiable simulation + + VehicleRouting + CVRP + Capacitated VRP: route a homogeneous fleet from a single depot, every customer served once within capacity, minimize total distance + diff --git a/TASK_DETAILS_zh-CN.md b/TASK_DETAILS_zh-CN.md index e2a070a2..d4974555 100644 --- a/TASK_DETAILS_zh-CN.md +++ b/TASK_DETAILS_zh-CN.md @@ -351,5 +351,10 @@ Frontier-Eng 目前已覆盖以下领域的任务。每个任务均配有可运 DiffSimThermalControl 基于可微仿真的增材制造工艺优化 + + VehicleRouting + CVRP + 容量约束车辆路径:单一仓库、同型车队、每客户服务一次且不超容量,最小化总距离 + diff --git a/benchmarks/VehicleRouting/CVRP/.gitignore b/benchmarks/VehicleRouting/CVRP/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/benchmarks/VehicleRouting/CVRP/README.md b/benchmarks/VehicleRouting/CVRP/README.md new file mode 100644 index 00000000..0807501a --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/README.md @@ -0,0 +1,244 @@ +# CVRP (Capacitated Vehicle Routing Problem) Benchmark + +Standard CVRP: a fleet of identical vehicles serves all customers from a +single depot, each route within vehicle capacity, minimizing total distance. + +## Structure + +``` +CVRP/ +├── Task.md # Task description (rules, I/O contract, scoring) +├── baseline/ +│ ├── solver.py # Candidate solver (random-order cheapest insertion) + EVOLVE-BLOCK +│ └── result_log.txt # Baseline evaluation log +├── verification/ +│ ├── evaluator.py # Runner + validator + scorer + candidate integrity checks (stdlib only) +│ ├── validator.py # Candidate integrity validator (static checks + determinism probe) +│ ├── ref_solver.py # Reference solver: deterministic GRASP multi-start + 2-opt + relocate/swap + 2-opt* + LNS +│ ├── generate_instances.py# Deterministic instance generator (seed 42, byte-stable; public + held-out) +│ ├── test_evaluator.py # Unit tests: evaluator (stdlib unittest) +│ ├── test_validator.py # Unit tests: validator +│ ├── test_ref_solver.py # Unit tests: reference solver +│ ├── multiseed_stat.py # Multi-seed baseline statistics script +│ └── requirements.txt +├── data/ +│ ├── instances/ # 12 public TSPLIB-style .vrp instances (VRP-*) +│ ├── instances_heldout/ # 12 held-out instances (VHO-*), scored at evaluation time +│ └── reference.json # Precomputed reference distance per instance (24) +└── frontier_eval/ # Unified-task metadata +``` + +## Requirements + +Python 3 standard library only. No third-party dependencies. + +## How to run + +```bash +# Evaluate a candidate solver (inside the CVRP directory) +python verification/evaluator.py baseline/solver.py + +# Unified-task adapter check (repo root) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 +``` + +### Docker + +The Dockerfile provides the evaluation environment (Python stdlib) for the +unified runtime's `isolation_mode=docker`. Build it and point the unified +runtime at it: + +```bash +# Build the image (inside the CVRP directory) +docker build -t cvrp-benchmark -f verification/docker/Dockerfile . + +# Use it from the repo root (see "Experiments" for the full command) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` + +> Docker isolation is validated on Linux / WSL. `frontier_eval/eval_command.txt` +> injects the host-benchmark path via the `{benchmark_source}` placeholder, so +> scoring works without framework changes; if the container user cannot write +> the evaluation sandbox, set `task.runtime.docker_user=:` +> (e.g. `1000:1000`). On Windows hosts the unified docker path is blocked by a +> framework path bug (`Path.resolve()` rewrites container paths to drive +> paths) — run docker mode under WSL instead. + +The image intentionally contains no benchmark files (no `reference.json`, no +reference solver); the unified runtime mounts the sandbox into the container. + +## Unified-task integration + +- Benchmark id: `VehicleRouting/CVRP` +- The evaluator uses only the Python standard library, so no runtime overrides + (`python_path`, conda env, or Docker image) are required; `eval_command.txt` + uses plain `{python}`. +- Windows only: local validation must point `task.runtime.shell` at Git Bash + (e.g. `task.runtime.shell=C:/Program Files/Git/bin/bash.exe`), because the + default `bash` resolves to the WSL shim without `python` (returncode 127). + Linux needs no override. + +## Scoring + +`score = min(100, 100 * reference_distance / candidate_distance)` averaged over +24 instances (12 public + 12 held-out). The reference distances are +precomputed by the deterministic `verification/ref_solver.py` and are +near-optimal (cross-checked against the archived best agent solver and an +OR-Tools GLS solve). Invalid solutions (missing/duplicate customers, capacity +violations, crashes, timeouts) score 0 and mark the run invalid. An optional +`CVRP_EVAL_SCORE_SCALE` knob (default 1.0) tightens the 100-point bar: +`score = min(100, scale * 100 * ref / cand)`. + +## Reference scores (measured on this machine) + +The current evaluation set is 24 instances (12 public + 12 held-out). Most +agent scores below were measured on the earlier 12-public-instance set (before +held-out instances were added) and are kept for cross-framework comparison; +fresh runs on the full 24-instance set (ShinkaEvolve 98.65, openevolve 98.00, +AB-MCTS 99.26) show the learned solvers generalize to unseen instances. See +"Experiments" for run records and multi-run statistics. + +| Solver | combined_score | +|--------|----------------| +| baseline (random-order cheapest insertion), 24 instances | 54.69 | +| reference (deterministic GRASP + LNS, scoring baseline) | 100 (near-optimal) | +| agent (openevolve, 5 iterations, best, 12-instance set) | 96.38 | +| agent (openevolve, 5 iterations, best, **24-instance set**) | **98.00** | +| agent (ShinkaEvolve, 5 generations, best, 12-instance set) | 99.31 | +| agent (ShinkaEvolve, 5 generations, best, **24-instance set**) | **98.65** | +| agent (AB-MCTS, 5 candidates, best, 12-instance set) | 98.70 | +| agent (AB-MCTS, 5 candidates, best, **24-instance set**) | **99.26** | + +Baseline score distribution over the 24 instances +(`python verification/evaluator.py baseline/solver.py`): mean **54.69**, +std 7.32, min 44.03 (`VHO-51-7`), max 71.55 (`VRP-21-3`). The solver is +deterministic (seeded RNG), so repeated runs are byte-identical; the spread +above is across instances, not across seeds. + +**Multi-seed statistics** (reviewer-requested "multi-run statistics"): +`verification/multiseed_stat.py` derives fresh evaluation instance sets from +multiple seeds and computes each set's reference distances on the fly +(`python verification/multiseed_stat.py --seeds 111 222 333`): + +| Seed | combined_score | +|------|----------------| +| 111 | 57.24 | +| 222 | 59.86 | +| 333 | 56.09 | +| **mean ± std** | **57.73 ± 1.58** (min 56.09, max 59.86) | + +**Agent multi-run statistics** (3 runs per framework on the 24-instance set, +`deepseek-v4-flash`; full run IDs in "Experiments"): + +| Framework | runs (combined_score) | mean | std | min | max | +|-----------|-----------------------|------|-----|-----|-----| +| openevolve (5 iterations) | 98.00, 97.96, 97.47 | **97.81** | 0.24 | 97.47 | 98.00 | +| ShinkaEvolve (5 generations) | 98.13, 54.69\*, 98.65 | **83.82** | 20.60 | 54.69 | 98.65 | +| AB-MCTS (5 candidates) | 98.49, 96.78, 99.26 | **98.18** | 1.04 | 96.78 | 99.26 | + +\* one ShinkaEvolve run produced invalid programs in every generation (each +scored 0), so its best stayed at the baseline 54.69 — a genuine failure mode, +not an environment issue. + +Agent scores are "best found" over stochastic evolution runs; multiple runs +are listed in the "Experiments" table where available (e.g. openevolve 96.38 +and 95.65). + +## Evaluation integrity + +- **Held-out instances**: 12 `VHO-*` instances in `data/instances_heldout/` + double the evaluation set to 24 and are scored alongside the public ones. + Their `.vrp` files are **not** copied into the evaluation sandbox: the + evaluator reads the paths from the host + (`FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR`) and hands each to the + candidate only while that instance is being scored. The files are public in + the repo, and a running candidate receives the path and can read the file + (see the threat model below); `Task.md` and `constraints.txt` tell the agent + such held-out instances exist and will be scored, but their data is not + available at code-generation time, so an LLM cannot hardcode routes by + instance name. Per-instance name-keyed hardcoding is additionally rejected + statically by `validator.py`, and `CVRP_EVAL_GENERATE_SEED` adds fresh + instances at evaluation time so the scored set is not predictable. +- **Runtime-generated instances**: set `CVRP_EVAL_GENERATE_SEED` (and + optionally `CVRP_EVAL_GENERATE_COUNT`, default 6) to additionally generate + fresh instances at evaluation time from that seed. Each generated instance + is scored against a reference computed on the fly by the reference solver, + so a candidate cannot memorize the evaluation set even if it has seen every + public instance file. Same seed ⇒ same instances ⇒ reproducible. Works in + the direct evaluator and the unified runtime in process mode. In docker + isolation mode (Linux / WSL) scoring works via the `{benchmark_source}` env + injection in `eval_command.txt`, but runtime-generated instances are not + available there because the unified runtime does not forward the seed env + var into the container (framework-level limitation). +- **Sandbox**: `copy_files.txt` copies only `baseline/`, `data/instances/` and + `frontier_eval/` into the evaluation sandbox (held-out instances and + `reference.json` are read from the host, never copied). + `frontier_eval/evaluator.py` is self-contained (parsing, validation, + scoring and integrity checks are embedded), so no `verification/` files — + including the reference solver — are copied. `reference.json` is never + copied; the evaluator reads it from the host benchmark dir + (`FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR`), and the candidate subprocess + runs with **all `FRONTIER_*` variables removed** (plus reference/generation + settings), so it cannot learn the host repo path — this closes the + `FRONTIER_ENGINEERING_ROOT` side channel that would otherwise let a candidate + find and import `verification/ref_solver.py` on the host. +- **Preflight checks** (`verification/validator.py`): the evaluator statically + rejects candidates that modify code outside the EVOLVE-BLOCK, reference + `verification` / `ref_solver` / `reference.json`, contain absolute paths, or + hardcode per-instance routes; a determinism probe runs the candidate twice + on small / medium / large probe instances and invalidates non-deterministic + solvers. +- **Threat model**: all instance files (public and held-out) are visible in + the repo and in the evaluation sandbox — a human who can read the repo could + always hand-craft a solver, which no benchmark can prevent. The protections + are deterrence-level: the constraints forbid reading the reference and + hardcoding by instance name, `validator.py` statically rejects such + attempts, and `CVRP_EVAL_GENERATE_SEED` makes the scored instance set + unpredictable at evaluation time. The unified runtime (process and docker + isolation) exposes the host repo to the candidate process (framework-level + behavior shared by all tasks). Scoring only measures solution quality, never + where the code came from. + +## Experiments + +All agent runs use the `deepseek-v4-flash` model. Scores are "best found"; +LLM evolution is stochastic, so multiple runs are listed where available. + +| Run | Framework | Score | Set | +|-----|-----------|-------|-----| +| baseline (random-order cheapest insertion) | — | 54.69 | 24 instances (12 public + 12 held-out) | +| reference (GRASP + LNS, scoring baseline) | — | 100 | 24 instances | +| `runs/.../openevolve/deepseek-v4-flash/20260807_170244` | openevolve, 5 iterations | 96.38 | 12 public | +| `runs/.../openevolve/deepseek-v4-flash/20260807_195321` | openevolve, 5 iterations | 95.65 | 12 public | +| `runs/.../openevolve/deepseek-v4-flash/20260811_215703` | openevolve, 5 iterations | 98.00 | 24 instances (12 public + 12 held-out) | +| `runs/.../openevolve/deepseek-v4-flash/20260812_172944` | openevolve, 5 iterations | 97.96 | 24 instances (12 public + 12 held-out) | +| `runs/.../openevolve/deepseek-v4-flash/20260812_174122` | openevolve, 5 iterations | 97.47 | 24 instances (12 public + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260807_195503` | ShinkaEvolve, 5 generations | 99.31 | 12 public | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260811_192446` | ShinkaEvolve, 5 generations | 98.13 | 24 instances (12 public + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260812_175208` | ShinkaEvolve, 5 generations | 54.69\* | 24 instances (12 public + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260812_175816` | ShinkaEvolve, 5 generations | 98.65 | 24 instances (12 public + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260807_190515` | AB-MCTS, 5 candidates | 98.70 | 12 public | +| `runs/.../abmcts/deepseek-v4-flash/20260812_102741` | AB-MCTS, 5 candidates | 98.49 | 24 instances (12 public + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260812_181308` | AB-MCTS, 5 candidates | 96.78 | 24 instances (12 public + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260812_182222` | AB-MCTS, 5 candidates | 99.26 | 24 instances (12 public + 12 held-out) | + +Baseline reproduction: + +```bash +# inside the CVRP directory +python verification/evaluator.py baseline/solver.py # -> 54.69, valid 1.0 (24 instances) +python verification/test_evaluator.py # -> 23 unit tests pass +python verification/test_validator.py # -> 16 unit tests pass +python verification/test_ref_solver.py # -> 5 unit tests pass +python verification/test_frontier_eval_evaluator.py # -> 6 unit tests pass (sandbox copy) +``` + +Unified adapter checks (repo root): + +```bash +# process mode +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 + +# docker isolation (build first: docker build -t cvrp-benchmark -f verification/docker/Dockerfile .) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` diff --git a/benchmarks/VehicleRouting/CVRP/README_zh-CN.md b/benchmarks/VehicleRouting/CVRP/README_zh-CN.md new file mode 100644 index 00000000..58cf53d3 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/README_zh-CN.md @@ -0,0 +1,165 @@ +# CVRP(容量约束车辆路径问题)Benchmark + +标准 CVRP:一支同型车队从单一仓库出发服务所有客户,每条路线不超过车辆容量,目标是最小化总行驶距离。 + +## 目录结构 + +``` +CVRP/ +├── Task.md # 任务说明(规则、输入输出契约、评分) +├── baseline/ +│ ├── solver.py # 候选求解器(随机顺序最近插入)+ EVOLVE-BLOCK +│ └── result_log.txt # baseline 评测日志 +├── verification/ +│ ├── evaluator.py # 运行器 + 验证器 + 评分器 + 候选完整性检查(仅标准库) +│ ├── validator.py # 候选完整性验证器(静态检查 + 确定性探针) +│ ├── ref_solver.py # 参考求解器:确定性 GRASP 多起点 + 2-opt + relocate/swap + 2-opt* + LNS +│ ├── generate_instances.py# 确定性实例生成器(seed 42,逐字节稳定;公开 + held-out) +│ ├── test_evaluator.py # 单元测试:评测器(stdlib unittest) +│ ├── test_validator.py # 单元测试:验证器 +│ ├── test_ref_solver.py # 单元测试:参考求解器 +│ ├── multiseed_stat.py # baseline 多 seed 统计脚本 +│ ├── requirements.txt +│ └── docker/ +│ └── Dockerfile # 容器化评测环境(Docker 优先) +├── data/ +│ ├── instances/ # 12 个公开 TSPLIB 风格 .vrp 实例(VRP-*) +│ ├── instances_heldout/ # 12 个 held-out 实例(VHO-*),评测时评分 +│ └── reference.json # 每个实例的参考距离(24 个,评分基准) +└── frontier_eval/ # Unified-task 元数据 +``` + +## 依赖 + +仅需 Python 标准库,无第三方依赖。 + +## 如何运行 + +```bash +# 评测一个候选求解器(在 CVRP 目录下) +python verification/evaluator.py baseline/solver.py + +# 框架适配验证(仓库根目录) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 +``` + +### Docker + +Dockerfile 为 unified 运行时的 `isolation_mode=docker` 提供评测环境(Python 标准库)。构建镜像并让 unified 运行时指向它: + +```bash +# 构建镜像(在 CVRP 目录下) +docker build -t cvrp-benchmark -f verification/docker/Dockerfile . + +# 从仓库根目录使用(完整命令见"实验记录") +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` + +> Docker 隔离在 **Linux / WSL** 下已验证可用。`frontier_eval/eval_command.txt` 通过 `{benchmark_source}` 占位符把宿主 benchmark 路径注入评测命令,评分无需改框架即可工作;若容器用户无法写入评测沙箱,设置 `task.runtime.docker_user=<宿主 uid>:<宿主 gid>`(如 `1000:1000`)。Windows 宿主的 unified docker 路径被框架的路径 bug 卡住(`Path.resolve()` 会把容器路径改写为盘符路径)——请在 WSL 下运行 docker 模式。 + +镜像内**刻意不包含任何 benchmark 文件**(没有 `reference.json`、没有参考求解器);unified 运行时会把沙箱挂载进容器。 + +## 评分 + +`score = min(100, 100 × reference_distance / candidate_distance)`,跨 **24 个实例**(12 公开 + 12 held-out)取平均。 +参考距离由确定性的 `verification/ref_solver.py` 预计算,为近最优(与存档最优 agent 解、OR-Tools GLS 交叉验证一致)。 +非法解(客户缺失/重复、超容量、崩溃、超时)得 0 分,并使整个运行判无效(valid=0)。 +可选评分旋钮 `CVRP_EVAL_SCORE_SCALE`(默认 1.0)可收紧 100 分线:`score = min(100, scale × 100 × ref / cand)`。 + +## 参考分数(本机实测) + +当前评测集为 24 个实例(12 公开 + 12 held-out)。下面多数 agent 分数是在较早的 12 公开实例集上测得的(held-out 实例为后加),保留用于跨框架对比;在完整 24 实例集上的新运行(ShinkaEvolve 98.65、openevolve 98.00、AB-MCTS 99.26)证明学到的求解器能泛化到未见过的实例。运行记录与多运行统计见"实验记录"。 + +| 求解器 | combined_score | +|--------|----------------| +| baseline(随机顺序最近插入),24 实例 | **54.69** | +| reference(确定性 GRASP + LNS,评分基准) | 100(近最优) | +| agent(openevolve,5 轮,best,12 实例集) | 96.38 | +| agent(openevolve,5 轮,best,**24 实例集**) | **98.00** | +| agent(ShinkaEvolve,5 代,best,12 实例集) | 99.31 | +| agent(ShinkaEvolve,5 代,best,**24 实例集**) | **98.65** | +| agent(AB-MCTS,5 候选,best,12 实例集) | 98.70 | +| agent(AB-MCTS,5 候选,best,**24 实例集**) | **99.26** | + +baseline 在 24 个实例上的分数分布(`python verification/evaluator.py baseline/solver.py`):mean **54.69**、std 7.32、min 44.03(`VHO-51-7`)、max 71.55(`VRP-21-3`)。求解器是确定性的(固定种子 RNG),重复运行逐字节一致;上述离散度是跨实例的,而非跨 seed 的。 + +**多 seed 统计**(评审要求的 "multi-run statistics"):`verification/multiseed_stat.py` 从多个 seed 派生全新评测实例集并现场计算各集参考距离(`python verification/multiseed_stat.py --seeds 111 222 333`): + +| Seed | combined_score | +|------|----------------| +| 111 | 57.24 | +| 222 | 59.86 | +| 333 | 56.09 | +| **mean ± std** | **57.73 ± 1.58**(min 56.09,max 59.86) | + +**Agent 多运行统计**(每框架 3 次运行,24 实例集,`deepseek-v4-flash`;完整运行 ID 见"实验记录"): + +| 框架 | 各次 combined_score | mean | std | min | max | +|------|---------------------|------|-----|-----|-----| +| openevolve(5 轮) | 98.00, 97.96, 97.47 | **97.81** | 0.24 | 97.47 | 98.00 | +| ShinkaEvolve(5 代) | 98.13, 54.69\*, 98.65 | **83.82** | 20.60 | 54.69 | 98.65 | +| AB-MCTS(5 候选) | 98.49, 96.78, 99.26 | **98.18** | 1.04 | 96.78 | 99.26 | + +\* 有一次 ShinkaEvolve 运行在每一代都生成了无效程序(各代计 0 分),其 best 停在 baseline 54.69——这是真实的失败模式,不是环境问题。 + +agent 分数为随机进化运行的 "best found";有多次运行的一并列出(如 openevolve 96.38 与 95.65,见"实验记录")。 + +## 评测完整性 + +- **Held-out 实例**:12 个 `VHO-*` 实例位于 `data/instances_heldout/`,把评测集扩充到 24 个并与公开实例一起评分。它们的 `.vrp` 文件**不复制进评测沙箱**:评测器从宿主(`FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR`)读取路径,只在评分该实例时把路径交给候选。这些文件在仓库里是公开的,运行中的候选收到路径后确实能读到文件(见下方威胁模型);`Task.md` 和 `constraints.txt` 会告知 agent 存在这类 held-out 实例且会被评分,但代码生成时拿不到它们的实例数据,因此 LLM 无法按实例名硬编码路线;`validator.py` 还会静态拒绝按实例名硬编码,`CVRP_EVAL_GENERATE_SEED` 则让评分时的实例集不可预测。 +- **评测时生成实例**:设置 `CVRP_EVAL_GENERATE_SEED`(可选 `CVRP_EVAL_GENERATE_COUNT`,默认 6),评测器会在评分时用该种子**现场生成全新实例**参与评分,每个生成实例的参考距离由参考求解器当场计算——这样即使候选见过所有公开实例文件,也无法背答案。同一种子 ⇒ 同一批实例 ⇒ 完全可复现。直跑评测器与 unified 运行时(process 模式)均支持;docker 隔离模式(Linux/WSL)下评分可用(靠 `eval_command.txt` 的 `{benchmark_source}` 注入),但运行时生成不可用——unified 运行时不会把种子环境变量传入容器(框架级限制)。 +- **沙箱隔离**:`copy_files.txt` 只把 `baseline/`、`data/instances/`、`frontier_eval/` 复制进评测沙箱(held-out 实例与 `reference.json` 从宿主读取、从不复制)。`frontier_eval/evaluator.py` 是自包含的(解析、校验、评分与完整性检查全部内嵌),因此**任何 `verification/` 文件(包括参考求解器)都不复制**。`reference.json` 从不复制;评测器通过 `FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR` 从宿主读取参考距离,候选子进程运行时**所有 `FRONTIER_*` 变量都被剥离**(外加参考/生成设置),无法得知宿主仓库路径——这封死了 `FRONTIER_ENGINEERING_ROOT` 侧信道(否则候选可借此找到并导入宿主上的 `verification/ref_solver.py`)。 +- **Preflight 检查**(`verification/validator.py`):评测器会静态拒绝修改 EVOLVE-BLOCK 区外代码、引用 `verification` / `ref_solver` / `reference.json`、含绝对路径、或按实例名硬编码路线的候选;另有**确定性探针**——在最小 / 中等 / 最大 3 个代表实例上把候选各跑两次,输出不一致(非确定性)即判无效。 +- **威胁模型**:所有实例文件(公开与 held-out)在仓库和评测沙箱里都可见——能读到仓库的人总能手写一个求解器,任何 benchmark 都无法阻止这一点。防护是威慑级的:constraints 禁止读取参考与按实例名硬编码,`validator.py` 静态拒绝此类尝试,`CVRP_EVAL_GENERATE_SEED` 让评分时的实例集不可预测。unified 运行时(process 与 docker 隔离)会把宿主仓库暴露给候选进程(这是所有任务共享的框架级行为)。评分只衡量解的质量,从不看代码来源。 + +## 实验记录 + +所有 agent 运行均使用 `deepseek-v4-flash` 模型。分数为 "best found";LLM 进化具有随机性,有多次运行的地方一并列出。 + +| 运行 | 框架 | 分数 | 评测集 | +|------|------|------|--------| +| baseline(随机顺序最近插入) | — | 54.69 | 24 实例(12 公开 + 12 held-out) | +| reference(GRASP + LNS,评分基准) | — | 100 | 24 实例 | +| `runs/.../openevolve/deepseek-v4-flash/20260807_170244` | openevolve,5 轮 | 96.38 | 12 公开 | +| `runs/.../openevolve/deepseek-v4-flash/20260807_195321` | openevolve,5 轮 | 95.65 | 12 公开 | +| `runs/.../openevolve/deepseek-v4-flash/20260811_215703` | openevolve,5 轮 | 98.00 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../openevolve/deepseek-v4-flash/20260812_172944` | openevolve,5 轮 | 97.96 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../openevolve/deepseek-v4-flash/20260812_174122` | openevolve,5 轮 | 97.47 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260807_195503` | ShinkaEvolve,5 代 | 99.31 | 12 公开 | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260811_192446` | ShinkaEvolve,5 代 | 98.13 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260812_175208` | ShinkaEvolve,5 代 | 54.69\* | 24 实例(12 公开 + 12 held-out) | +| `runs/.../shinkaevolve/deepseek-v4-flash/20260812_175816` | ShinkaEvolve,5 代 | 98.65 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260807_190515` | AB-MCTS,5 候选 | 98.70 | 12 公开 | +| `runs/.../abmcts/deepseek-v4-flash/20260812_102741` | AB-MCTS,5 候选 | 98.49 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260812_181308` | AB-MCTS,5 候选 | 96.78 | 24 实例(12 公开 + 12 held-out) | +| `runs/.../abmcts/deepseek-v4-flash/20260812_182222` | AB-MCTS,5 候选 | 99.26 | 24 实例(12 公开 + 12 held-out) | + +baseline 复现: + +```bash +# 在 CVRP 目录下 +python verification/evaluator.py baseline/solver.py # -> 54.69, valid 1.0(24 实例) +python verification/test_evaluator.py # -> 23 个单元测试全部通过 +python verification/test_validator.py # -> 16 个单元测试全部通过 +python verification/test_ref_solver.py # -> 5 个单元测试全部通过 +python verification/test_frontier_eval_evaluator.py # -> 6 个单元测试全部通过(沙箱版) +python verification/multiseed_stat.py --seeds 111 222 333 # -> 57.73 ± 1.58 +``` + +框架适配验证(仓库根目录): + +```bash +# process 模式 +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 + +# docker 隔离(先构建镜像:docker build -t cvrp-benchmark -f verification/docker/Dockerfile .) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` + +## Unified-task 集成 + +- **Benchmark id**:`VehicleRouting/CVRP` +- 评测器仅使用 Python 标准库,因此**无需任何 runtime 覆盖项**(不需要 `python_path`、conda 环境或 Docker 镜像指定);`eval_command.txt` 直接使用 `{python}`。 +- 仅 Windows:本地验证需将 `task.runtime.shell` 指向 Git Bash(如 `task.runtime.shell=C:/Program Files/Git/bin/bash.exe`),因为默认 `bash` 会解析到没有 `python` 的 WSL 垫片(returncode 127)。Linux 无需覆盖。 +- 框架适配验证命令(仓库根目录): + `python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0` diff --git a/benchmarks/VehicleRouting/CVRP/Task.md b/benchmarks/VehicleRouting/CVRP/Task.md new file mode 100644 index 00000000..1dab1cb5 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/Task.md @@ -0,0 +1,193 @@ +# Task: Capacitated Vehicle Routing Problem (CVRP) + +## Audience and assumptions + +This task assumes a general CS background but no (or little) prior exposure to +combinatorial optimization / vehicle routing. + +## Background + +In logistics, trucks depart from a warehouse to deliver goods to customers. The +**capacitated vehicle routing problem (CVRP)** is its standard mathematical +model: + +- **Depot**: node 0; every vehicle starts and ends here. +- **Customers**: nodes 1..n, each with a demand `demand[c]`. +- **Vehicles**: identical, capacity limit `capacity`. +- **Route**: a sequence `depot → some customers → depot` whose total demand + does not exceed `capacity`. + +Goal: serve **all** customers (each exactly once) with any number of routes, +minimizing **total travel distance**. + +CVRP is NP-hard: instances with a few dozen customers cannot be solved exactly +and require heuristics (nearest-neighbour, savings, 2-opt, large neighbourhood +search, metaheuristics, ...). + +## Instances (data/instances/, data/instances_heldout/) + +24 deterministically generated clustered instances (modelling city-like +customer distributions, coordinates 1..100, rounded Euclidean distances): + +- **12 public instances** (data/instances/, `VRP-*`): + +| Instance | Customers | Capacity | Instance | Customers | Capacity | +|----------|-----------|----------|----------|-----------|----------| +| VRP-19-2 | 19 | 270 | VRP-45-7 | 45 | 150 | +| VRP-21-3 | 21 | 165 | VRP-48-7 | 48 | 170 | +| VRP-22-4 | 22 | 130 | VRP-54-8 | 54 | 175 | +| VRP-32-5 | 32 | 160 | VRP-55-8 | 55 | 185 | +| VRP-37-6 | 37 | 145 | VRP-60-9 | 60 | 160 | +| VRP-45-6 | 45 | 190 | VRP-60-10 | 60 | 165 | + +- **12 held-out instances** (data/instances_heldout/, `VHO-*`): scored at + evaluation time alongside the public ones, doubling the evaluation set to 24. + Their files stay on the host and are **not** copied into the evaluation + sandbox, so you cannot read them during development — each path is handed to + your solver only at scoring time. Hardcoding routes by instance name is + rejected statically, and `CVRP_EVAL_GENERATE_SEED` can add fresh instances at + evaluation time so the scored set is not predictable. Scoring on the + held-out set measures whether the agent learned a *generalizable* solving + method. + +The file name is the instance name (e.g. `VRP-19-2.vrp`), in TSPLIB style +(`NODE_COORD_SECTION` / `DEMAND_SECTION` / `DEPOT_SECTION`, depot is node 1). +Instances are generated deterministically by `verification/generate_instances.py` +(seed 42; the per-instance `seed_key` is fixed to the original release +identifiers so the dataset stays byte-identical across releases). + +## Input / output contract + +### Candidate program `baseline/solver.py` + +```python +# EVOLVE-BLOCK-START +def solve(instance): + """Takes an instance dict, returns a list of routes list[list[int]]. + Each route is a visit sequence of customer ids (1..n, depot 0 excluded).""" + ... +# EVOLVE-BLOCK-END +``` + +- `instance` fields: + - `n`: number of customers (ids 1..n; depot is 0) + - `capacity`: vehicle capacity + - `demand`: `demand[0..n]`, `demand[0] == 0` + - `distance`: `(n+1)×(n+1)` rounded-Euclidean distance matrix, + `distance[0][c]` = depot-to-customer-c distance +- Output: `list[list[int]]`. Each route is a **customer-id sequence** (depot 0 + excluded), e.g. `[[3,1,5],[2,4]]` means two vehicles. +- Standalone run: `python baseline/solver.py ` + (the fixed I/O part must not be modified). + +### Validation rules + +Each candidate output is checked: +1. Well-formed: `routes` is a list of lists of integers in 1..n; +2. **Full coverage**: the union of all routes is exactly {1..n} + (no duplicates, none missing); +3. **Capacity**: `sum(demand[c]) <= capacity` per route. + +Any violation → that instance is invalid (0 points) and the whole candidate +gets `valid=0`. + +### Candidate integrity checks (preflight) + +Before running, the evaluator statically rejects a candidate that: +- removes or reorders the `EVOLVE-BLOCK-START` / `EVOLVE-BLOCK-END` markers, + or modifies code outside the evolve block relative to the initial baseline; +- references the verification module, the reference solver, or + `reference.json` (e.g. `import verification.ref_solver`); +- contains absolute filesystem paths; +- hardcodes per-instance routes by name (e.g. `"VRP-19-2": [...]`). + +Violating candidates score 0 and are marked invalid. The candidate subprocess +also runs in an environment stripped of host benchmark paths, so it cannot +locate `reference.json` on the host; the evaluation sandbox contains only the +files the candidate needs (instances + evaluator glue), never the reference +solver or `reference.json`. + +## Scoring + +``` +score_instance = min(100, 100 × reference_distance / candidate_distance) +combined_score = mean(score_instance) # average over the 24 instances (12 public + 12 held-out) +valid = all instances valid ? 1 : 0 +``` + +- `reference_distance` comes from `data/reference.json`, precomputed by + `verification/ref_solver.py`: a **deterministic** GRASP multi-start + + 2-opt + relocate/swap/2-opt* + LNS (greedy repair with tabu diversification) + that keeps the best result over the fixed seed list `(123, 2024, 7)`, so + regeneration is byte-identical on any machine. The reference is + near-optimal (cross-checked against the archived best agent solver and an + OR-Tools GLS solve). +- **100 = reference-quality solutions**; candidates shorter than the reference + exceed 100 and are truncated to 100. Reaching 100 means the solution is + close to the practical optimum of the instance. +- Optional score knob `CVRP_EVAL_SCORE_SCALE` (default 1.0): + `score = min(100, scale × 100 × ref / cand)`. A scale < 1 tightens the 100 + bar (e.g. scale=2/3 requires the candidate to be no longer than 2/3 of the + reference distance to score 100). +- Invalid / crashing / timing-out candidates: 0 on that instance and + `valid=0` for the whole run. + +## How to run + +```bash +# Evaluate a candidate solver (inside the CVRP directory) +python verification/evaluator.py baseline/solver.py + +# Evaluate on a subset of instances +python verification/evaluator.py baseline/solver.py --instances VRP-19-2 VRP-32-5 + +# Run the unit tests (evaluator / validator / candidate checks) +python verification/test_evaluator.py + +# Unified-task adapter check (repo root, process mode) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 + +# Unified-task adapter check (repo root, docker isolation; requires building +# the image first: docker build -t cvrp-benchmark -f verification/docker/Dockerfile .) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` + +Environment variables: `CVRP_EVAL_TIMEOUT_S` (per-instance subprocess timeout, +default 60), `CVRP_EVAL_INSTANCES` (instance subset), `CVRP_EVAL_MAX_INSTANCES` +(instance cap), `CVRP_EVAL_SCORE_SCALE` (score knob, default 1.0). + +## Reference scores (measured on this machine, against the current reference.json) + +The current evaluation set is 24 instances (12 public + 12 held-out). The +agent scores below were measured on the **earlier 12-public-instance set** +(held-out instances were added later) and are kept for cross-framework +comparison; fresh runs on the full 24-instance set (ShinkaEvolve 98.65, +openevolve 98.00, AB-MCTS 99.26) show the learned solvers generalize to +unseen instances. See README "Experiments" for run records and multi-run +statistics. + +| Solver | combined_score | +|--------|----------------| +| baseline (random-order cheapest insertion), 24 instances | **54.69** | +| reference (deterministic GRASP + LNS, scoring baseline) | 100 (near-optimal) | +| agent (openevolve, 5 iterations, best, 12-instance set) | 96.38 | +| agent (openevolve, 5 iterations, best, 24-instance set) | 98.00 | +| agent (ShinkaEvolve, 5 generations, best, 12-instance set) | 99.31 | +| agent (ShinkaEvolve, 5 generations, best, 24-instance set) | 98.65 | +| agent (AB-MCTS, 5 candidates, best, 12-instance set) | 98.70 | +| agent (AB-MCTS, 5 candidates, best, 24-instance set) | 99.26 | + +## Optimisation hints (weak → strong) + +1. **Random-order cheapest insertion** (baseline): insert customers one by + one, in a random order, at their cheapest feasible position across all + routes — a weak but standard randomized construction. +2. **Clarke-Wright savings**: merge routes by decreasing + `d(0,i)+d(0,j)-d(i,j)` — a clear improvement. +3. **Intra-route 2-opt**: flip route segments to remove crossings — better. +4. **Cross-route local search**: relocate / swap / 2-opt*. +5. **Large neighbourhood search (LNS) / simulated annealing / genetic + algorithms**: approach or beat the reference. + +Every step is a verifiable score gain. **First guarantee validity (full +coverage + capacity), then optimize distance.** diff --git a/benchmarks/VehicleRouting/CVRP/Task_zh-CN.md b/benchmarks/VehicleRouting/CVRP/Task_zh-CN.md new file mode 100644 index 00000000..e369ddf5 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/Task_zh-CN.md @@ -0,0 +1,137 @@ +# Task:容量约束车辆路径问题(CVRP) + +## 受众与假设 + +本任务假设你有一般 CS 背景,但对组合优化 / 车辆路径问题没有或只有很少了解。 + +## 问题背景 + +物流配送中,一辆辆货车从仓库出发为客户送货。**容量约束车辆路径问题(CVRP)** 是它的标准数学模型: + +- **仓库(depot)**:编号 0,所有车辆从这里出发并返回。 +- **客户**:编号 1..n,每个客户有需求量 `demand[c]`。 +- **车辆**:同型,容量上限 `capacity`。 +- **路线**:每条路线为 `仓库 → 若干客户 → 仓库`,且路线内总需求 ≤ 容量。 + +目标:用若干条路线服务**所有**客户(每个客户恰好被服务一次),使**总行驶距离最小**。 + +CVRP 是 NP-hard 问题,几十个客户的实例无法精确求解,必须使用启发式方法(最近邻、savings、2-opt、大邻域搜索、元启发式等)。 + +## 实例(data/instances/、data/instances_heldout/) + +本任务提供 24 个确定性生成的聚簇分布实例(模拟城市客户分布,坐标 1..100,距离四舍五入取整): + +- **12 个公开实例**(data/instances/,`VRP-*`): + +| 实例 | 客户数 | 容量 | 实例 | 客户数 | 容量 | +|------|--------|------|------|--------|------| +| VRP-19-2 | 19 | 270 | VRP-45-7 | 45 | 150 | +| VRP-21-3 | 21 | 165 | VRP-48-7 | 48 | 170 | +| VRP-22-4 | 22 | 130 | VRP-54-8 | 54 | 175 | +| VRP-32-5 | 32 | 160 | VRP-55-8 | 55 | 185 | +| VRP-37-6 | 37 | 145 | VRP-60-9 | 60 | 160 | +| VRP-45-6 | 45 | 190 | VRP-60-10 | 60 | 165 | + +- **12 个 held-out 实例**(data/instances_heldout/,`VHO-*`):与公开实例一起在评测时打分,把评测集扩充到 24 个。它们的文件**留在宿主、不复制进评测沙箱**,开发阶段你读不到它们——评分时才把每个路径交给你的求解器。按实例名硬编码会被静态检查拒绝,`CVRP_EVAL_GENERATE_SEED` 还可在评测时现场加入新实例,让评分实例集不可预测。在 held-out 集上打分衡量 agent 是否学到了**可泛化**的求解方法。 + +文件名即实例名(如 `VRP-19-2.vrp`),采用 TSPLIB 风格格式(`NODE_COORD_SECTION` / `DEMAND_SECTION` / `DEPOT_SECTION`,depot 为节点 1)。实例由 `verification/generate_instances.py` 确定性生成(seed 42,`seed_key` 固定为发布时的原始标识符,保证数据集跨版本逐字节稳定)。 + +## 输入 / 输出契约 + +### 候选程序 `baseline/solver.py` + +```python +# EVOLVE-BLOCK-START +def solve(instance): + """输入 instance dict,输出路线列表 list[list[int]]。 + 每个子列表是一条路线的客户访问序列(客户编号 1..n,不含仓库 0)。""" + ... +# EVOLVE-BLOCK-END +``` + +- `instance` 字段: + - `n`:客户数(客户编号 1..n,仓库为 0) + - `capacity`:车辆容量 + - `demand`:`demand[0..n]`,`demand[0] == 0` + - `distance`:`(n+1)×(n+1)` 的欧氏四舍五入取整距离矩阵,`distance[0][c]` 为仓库到客户 c 的距离 +- 输出:`list[list[int]]`。每条路线为**客户编号序列**(不含仓库 0),例如 `[[3,1,5],[2,4]]` 表示两辆车。 +- 独立运行:`python baseline/solver.py `(固定 I/O 部分不可修改)。 + +### 验证规则 + +对候选输出逐条检查: +1. 格式合法:`routes` 是列表的列表,元素为 1..n 的整数; +2. **全覆盖**:所有路线的客户并集恰好为 {1..n}(无重复、无遗漏); +3. **容量**:每条路线 `sum(demand[c]) ≤ capacity`。 + +任一不满足 → 该实例判 invalid,得 0 分,且整个候选 `valid=0`。 + +### 候选完整性检查(preflight) + +运行前,评测器会**静态拒绝**有以下行为的候选: +- 删除或重排 `EVOLVE-BLOCK-START` / `EVOLVE-BLOCK-END` 标记,或修改 evolve 区外、与初始 baseline 不一致的代码; +- 引用 verification 模块、参考求解器或 `reference.json`(如 `import verification.ref_solver`); +- 含绝对文件系统路径; +- 按实例名硬编码路线(如 `"VRP-19-2": [...]`)。 + +违规候选得 0 分并判无效。候选子进程运行在剥离宿主路径的环境中,无法定位宿主上的 `reference.json`;评测沙箱只包含候选需要的文件(实例 + 评测胶水),**从不包含参考求解器或 `reference.json`**。 + +## 评分 + +``` +score_instance = min(100, 100 × reference_distance / candidate_distance) +combined_score = mean(score_instance) # 跨 24 个实例平均(12 公开 + 12 held-out) +valid = 全部实例合法?1 : 0 +``` + +- `reference_distance` 来自 `data/reference.json`,由 `verification/ref_solver.py` 预计算:**确定性**的 GRASP 多起点 + 2-opt + relocate/swap + 2-opt* + LNS(贪心修复 + tabu 多样化),对固定种子列表 `(123, 2024, 7)` 逐实例取最优,任何机器上重新生成均字节一致。参考解为近最优(与本任务存档的 agent 最优解、OR-Tools GLS 交叉验证一致)。 +- **100 分 = 达到参考求解器的解质量**;候选解比参考解更短时可超过 100 分(被截断在 100)。达到 100 意味着已经逼近该实例的实际最优。 +- 可选评分旋钮 `CVRP_EVAL_SCORE_SCALE`(默认 1.0):`score = min(100, scale × 100 × ref / cand)`。scale < 1 会收紧 100 分线(如 scale=2/3 要求候选距离 ≤ 参考的 2/3 才能得 100)。 +- 非法 / 崩溃 / 超时的候选:对应实例 0 分,且 `valid=0`(整体判负)。 + +## 如何运行 + +```bash +# 在 CVRP 目录下评测一个候选求解器 +python verification/evaluator.py baseline/solver.py + +# 只评测部分实例 +python verification/evaluator.py baseline/solver.py --instances VRP-19-2 VRP-32-5 + +# 运行单元测试(评测器 / 验证器 / 候选检查) +python verification/test_evaluator.py + +# 框架适配验证(仓库根目录,process 模式) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 + +# 框架适配验证(仓库根目录,docker 隔离;需先构建镜像: +# docker build -t cvrp-benchmark -f verification/docker/Dockerfile .) +python -m frontier_eval task=unified task.benchmark=VehicleRouting/CVRP algorithm.iterations=0 task.runtime.isolation_mode=docker task.runtime.docker_image=cvrp-benchmark +``` + +环境变量:`CVRP_EVAL_TIMEOUT_S`(每实例子进程超时,默认 60)、`CVRP_EVAL_INSTANCES`(实例子集)、`CVRP_EVAL_MAX_INSTANCES`(实例数上限)、`CVRP_EVAL_SCORE_SCALE`(评分旋钮,默认 1.0)。 + +## 参考分数(本机实测,对当前 reference.json) + +当前评测集为 24 个实例(12 公开 + 12 held-out)。下面的 agent 分数是在**较早的 12 公开实例集**上测得的(held-out 实例为后加),保留用于跨框架对比;在完整 24 实例集上的新运行(ShinkaEvolve 98.65、openevolve 98.00、AB-MCTS 99.26)证明学到的求解器能泛化到未见过的实例。运行记录与多运行统计见 README "Experiments"。 + +| 求解器 | combined_score | +|--------|----------------| +| baseline(随机顺序最近插入),24 实例 | **54.69** | +| reference(确定性 GRASP + LNS,评分基准) | 100(近最优) | +| agent(openevolve 5 轮,best,12 实例集) | 96.38 | +| agent(openevolve 5 轮,best,24 实例集) | 98.00 | +| agent(ShinkaEvolve 5 代,best,12 实例集) | 99.31 | +| agent(ShinkaEvolve 5 代,best,24 实例集) | 98.65 | +| agent(AB-MCTS 5 候选,best,12 实例集) | 98.70 | +| agent(AB-MCTS 5 候选,best,24 实例集) | 99.26 | + +## 优化提示(由弱到强) + +1. **随机顺序最近插入**(baseline):按随机顺序逐个把客户插入到所有路线中代价最小的可行位置 —— 弱但标准的随机化构造。 +2. **Clarke-Wright savings**:按 `d(0,i)+d(0,j)-d(i,j)` 降序合并路线 —— 明显改进。 +3. **路线内 2-opt**:翻转路线片段消除交叉 —— 进一步改进。 +4. **跨路线局部搜索**:relocate / swap / 2-opt*。 +5. **大邻域搜索(LNS)/ 模拟退火 / 遗传**:接近参考甚至超越参考。 + +每一步都是可验证的分数提升。**先保证合法(全覆盖 + 容量),再优化距离。** diff --git a/benchmarks/VehicleRouting/CVRP/baseline/result_log.txt b/benchmarks/VehicleRouting/CVRP/baseline/result_log.txt new file mode 100644 index 00000000..1eb63a26 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/baseline/result_log.txt @@ -0,0 +1,61 @@ +CVRP baseline evaluation log +============================= +Solver : baseline/solver.py (random-order cheapest insertion, seed 42) +Evaluator : verification/evaluator.py (stdlib only) +Date : 2026-08-10 +Command: python verification/evaluator.py baseline/solver.py + +combined_score: 54.69 +valid: 1.0 + OK VRP-19-2: score=68.00 + OK VRP-21-3: score=71.55 + OK VRP-22-4: score=66.17 + OK VRP-32-5: score=49.19 + OK VRP-37-6: score=60.18 + OK VRP-45-6: score=55.47 + OK VRP-45-7: score=57.07 + OK VRP-48-7: score=48.49 + OK VRP-54-8: score=48.45 + OK VRP-55-8: score=47.15 + OK VRP-60-10: score=45.67 + OK VRP-60-9: score=49.67 + OK VHO-22-3: score=51.85 + OK VHO-24-3: score=56.64 + OK VHO-27-4: score=58.49 + OK VHO-29-4: score=52.86 + OK VHO-33-5: score=59.13 + OK VHO-36-5: score=56.53 + OK VHO-39-6: score=63.98 + OK VHO-43-6: score=54.66 + OK VHO-47-7: score=53.07 + OK VHO-51-7: score=44.03 + OK VHO-55-8: score=50.00 + OK VHO-58-9: score=44.27 +wall time: 2.5s + +Notes +----- +* 24 instances = 12 public (data/instances/, VRP-*) + 12 held-out + (data/instances_heldout/, VHO-*). Held-out instances are scored at + evaluation time only and never shown to agents. +* Reference distances are precomputed by verification/ref_solver.py + (deterministic, seeds (123, 2024, 7)) into data/reference.json. +* The candidate subprocess runs without FRONTIER_EVAL_UNIFIED_* and + reference-distance env vars, and the evaluator statically rejects + candidates that reference verification code, reference.json, absolute + paths, or per-instance hardcoded routes (see check_candidate). + +Multi-seed statistics (verification/multiseed_stat.py) +------------------------------------------------------- +The released evaluation set is fixed, so the (deterministic) baseline scores +identically on it. To report a multi-seed distribution, this script derives +fresh instance sets from three seeds (generate_instances generate_instance +seed=...) and computes each set's reference distances on the fly. + +Command: python verification/multiseed_stat.py --seeds 111 222 333 + +seed 111: combined=57.24 +seed 222: combined=59.86 +seed 333: combined=56.09 +multi-seed: mean=57.73 std=1.58 min=56.09 max=59.86 seeds=[111, 222, 333] + diff --git a/benchmarks/VehicleRouting/CVRP/baseline/solver.py b/benchmarks/VehicleRouting/CVRP/baseline/solver.py new file mode 100644 index 00000000..cb2e2cbf --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/baseline/solver.py @@ -0,0 +1,130 @@ +"""CVRP candidate solver (baseline: random-order cheapest insertion). + +Contract: + * Run standalone: python baseline/solver.py + * `solve(instance)` returns a list of routes; each route is a list of + customer ids (1..n) visited in order. The depot (id 0) is implicit at + both ends and must NOT appear in the route. + * instance dict fields: + - n : number of customers (ids 1..n) + - capacity : vehicle capacity + - demand : demand[0..n], demand[0] == 0 + - distance : (n+1)x(n+1) rounded Euclidean distance matrix +""" +import json +import math +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Fixed section: instance parsing and I/O. Do not modify. +# --------------------------------------------------------------------------- +def parse_instance(path): + """Parse a TSPLIB-style CVRP .vrp file into an instance dict.""" + text = Path(path).read_text(encoding="utf-8", errors="ignore") + coords = {} + demands = {} + capacity = 0 + section = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + upper = line.upper() + if upper.startswith("CAPACITY"): + capacity = int(line.split(":")[-1].strip()) + continue + if upper == "NODE_COORD_SECTION": + section = "coords" + continue + if upper == "DEMAND_SECTION": + section = "demand" + continue + if upper == "DEPOT_SECTION": + section = None + continue + if upper == "EOF" or upper.startswith(("EDGE_WEIGHT", "DISPLAY_DATA")): + section = None + continue + if upper.startswith(("NAME", "COMMENT", "TYPE", "DIMENSION")): + continue + if section == "coords": + parts = line.split() + if len(parts) >= 3: + coords[int(parts[0])] = (float(parts[1]), float(parts[2])) + elif section == "demand": + parts = line.split() + if len(parts) >= 2: + demands[int(parts[0])] = int(parts[1]) + n_customers = max(coords) - 1 # depot is id 1, customers are ids 2..n+1 + pts = [coords[1]] + [coords[i] for i in range(2, n_customers + 2)] + dist = [[0] * (n_customers + 1) for _ in range(n_customers + 1)] + for i in range(n_customers + 1): + for j in range(n_customers + 1): + dx = pts[i][0] - pts[j][0] + dy = pts[i][1] - pts[j][1] + dist[i][j] = int(round(math.hypot(dx, dy))) + return { + "n": n_customers, + "capacity": capacity, + "demand": [0] + [demands.get(i, 0) for i in range(2, n_customers + 2)], + "distance": dist, + } + + +def main(): + inst_path, out_path = sys.argv[1], sys.argv[2] + inst = parse_instance(inst_path) + routes = solve(inst) + with open(out_path, "w", encoding="utf-8") as fh: + json.dump(routes, fh) + + +# --------------------------------------------------------------------------- +# EVOLVE-BLOCK-START +# --------------------------------------------------------------------------- +def solve(instance): + """Random-order cheapest insertion: process customers in a random order + (seeded) and insert each at its cheapest feasible position across all + routes. A standard randomized-construction baseline.""" + import random + + rng = random.Random(42) + n = instance["n"] + capacity = instance["capacity"] + demand = instance["demand"] + dist = instance["distance"] + + order = list(range(1, n + 1)) + rng.shuffle(order) + routes = [] + + def best_place(cust): + best = None + for ri, route in enumerate(routes): + load = sum(demand[c] for c in route) + if load + demand[cust] > capacity: + continue + for pos in range(len(route) + 1): + prev = 0 if pos == 0 else route[pos - 1] + nxt = 0 if pos == len(route) else route[pos] + cost = dist[prev][cust] + dist[cust][nxt] - dist[prev][nxt] + if best is None or cost < best[0]: + best = (cost, ri, pos) + return best + + for cust in order: + b = best_place(cust) + if b is None: + routes.append([cust]) + else: + routes[b[1]].insert(b[2], cust) + return routes + + +# --------------------------------------------------------------------------- +# EVOLVE-BLOCK-END +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + main() diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-19-2.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-19-2.vrp new file mode 100644 index 00000000..fd8434f7 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-19-2.vrp @@ -0,0 +1,50 @@ +NAME: VRP-19-2 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 20 +CAPACITY: 270 +NODE_COORD_SECTION +1 50.0 50.0 +2 41 41 +3 62 36 +4 31 40 +5 62 23 +6 41 46 +7 59 36 +8 39 39 +9 57 25 +10 31 37 +11 60 49 +12 49 48 +13 62 29 +14 37 55 +15 45 39 +16 29 34 +17 62 29 +18 31 44 +19 50 22 +20 32 56 +DEMAND_SECTION +1 0 +2 21 +3 32 +4 25 +5 24 +6 28 +7 29 +8 5 +9 10 +10 31 +11 23 +12 33 +13 27 +14 10 +15 13 +16 19 +17 28 +18 32 +19 26 +20 14 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-21-3.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-21-3.vrp new file mode 100644 index 00000000..7e6500e8 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-21-3.vrp @@ -0,0 +1,54 @@ +NAME: VRP-21-3 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 22 +CAPACITY: 165 +NODE_COORD_SECTION +1 50.0 50.0 +2 82 53 +3 63 40 +4 49 40 +5 84 48 +6 82 44 +7 54 58 +8 90 64 +9 72 45 +10 51 44 +11 79 51 +12 66 34 +13 38 33 +14 87 58 +15 71 41 +16 52 63 +17 81 58 +18 76 26 +19 41 57 +20 75 57 +21 72 42 +22 50 47 +DEMAND_SECTION +1 0 +2 19 +3 9 +4 27 +5 17 +6 22 +7 12 +8 33 +9 19 +10 22 +11 8 +12 25 +13 17 +14 7 +15 7 +16 21 +17 12 +18 27 +19 6 +20 16 +21 32 +22 35 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-22-4.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-22-4.vrp new file mode 100644 index 00000000..3c3305e7 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-22-4.vrp @@ -0,0 +1,56 @@ +NAME: VRP-22-4 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 23 +CAPACITY: 130 +NODE_COORD_SECTION +1 50.0 50.0 +2 35 33 +3 11 64 +4 71 29 +5 48 33 +6 17 61 +7 77 28 +8 60 27 +9 25 60 +10 74 31 +11 44 41 +12 30 60 +13 61 34 +14 58 31 +15 14 54 +16 80 39 +17 42 24 +18 17 50 +19 76 43 +20 41 20 +21 9 58 +22 59 34 +23 36 30 +DEMAND_SECTION +1 0 +2 10 +3 16 +4 5 +5 21 +6 33 +7 20 +8 13 +9 21 +10 16 +11 22 +12 25 +13 7 +14 33 +15 8 +16 17 +17 28 +18 23 +19 11 +20 14 +21 22 +22 28 +23 13 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-32-5.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-32-5.vrp new file mode 100644 index 00000000..6cf849ec --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-32-5.vrp @@ -0,0 +1,76 @@ +NAME: VRP-32-5 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 33 +CAPACITY: 160 +NODE_COORD_SECTION +1 50.0 50.0 +2 29 39 +3 71 88 +4 59 69 +5 60 26 +6 42 28 +7 79 60 +8 61 48 +9 73 10 +10 49 45 +11 79 81 +12 57 58 +13 67 16 +14 34 46 +15 71 79 +16 41 61 +17 71 13 +18 44 39 +19 83 76 +20 51 56 +21 63 3 +22 33 44 +23 68 67 +24 59 56 +25 59 14 +26 21 32 +27 78 85 +28 72 54 +29 63 5 +30 56 40 +31 78 75 +32 53 61 +33 66 16 +DEMAND_SECTION +1 0 +2 31 +3 34 +4 28 +5 18 +6 8 +7 17 +8 13 +9 32 +10 15 +11 27 +12 27 +13 5 +14 15 +15 18 +16 26 +17 11 +18 27 +19 23 +20 26 +21 21 +22 8 +23 20 +24 27 +25 8 +26 35 +27 9 +28 11 +29 29 +30 11 +31 25 +32 9 +33 10 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-37-6.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-37-6.vrp new file mode 100644 index 00000000..5a49908c --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-37-6.vrp @@ -0,0 +1,86 @@ +NAME: VRP-37-6 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 38 +CAPACITY: 145 +NODE_COORD_SECTION +1 50.0 50.0 +2 72 47 +3 78 32 +4 50 89 +5 67 95 +6 71 55 +7 84 35 +8 57 83 +9 63 81 +10 81 49 +11 86 30 +12 64 74 +13 63 75 +14 74 58 +15 74 26 +16 48 73 +17 64 84 +18 74 59 +19 70 32 +20 48 73 +21 68 80 +22 85 60 +23 71 25 +24 48 89 +25 70 67 +26 88 56 +27 87 23 +28 43 72 +29 72 81 +30 73 65 +31 72 30 +32 58 72 +33 59 90 +34 91 55 +35 79 25 +36 58 86 +37 56 83 +38 72 58 +DEMAND_SECTION +1 0 +2 22 +3 18 +4 14 +5 27 +6 9 +7 22 +8 16 +9 12 +10 12 +11 26 +12 15 +13 11 +14 11 +15 26 +16 24 +17 10 +18 23 +19 25 +20 29 +21 35 +22 15 +23 6 +24 16 +25 26 +26 6 +27 20 +28 10 +29 18 +30 27 +31 30 +32 30 +33 10 +34 13 +35 8 +36 12 +37 30 +38 23 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-6.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-6.vrp new file mode 100644 index 00000000..c966f74c --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-6.vrp @@ -0,0 +1,102 @@ +NAME: VRP-45-6 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 46 +CAPACITY: 190 +NODE_COORD_SECTION +1 50.0 50.0 +2 70 68 +3 48 16 +4 46 29 +5 55 53 +6 54 29 +7 72 79 +8 54 40 +9 45 8 +10 71 51 +11 52 35 +12 69 67 +13 43 28 +14 43 28 +15 73 47 +16 59 45 +17 79 88 +18 47 36 +19 26 20 +20 56 51 +21 57 43 +22 84 70 +23 43 35 +24 31 29 +25 44 44 +26 45 55 +27 84 72 +28 43 39 +29 37 13 +30 70 36 +31 52 46 +32 62 81 +33 39 32 +34 34 29 +35 65 43 +36 65 56 +37 95 69 +38 37 35 +39 39 15 +40 46 40 +41 49 53 +42 84 84 +43 44 27 +44 37 22 +45 52 50 +46 43 46 +DEMAND_SECTION +1 0 +2 25 +3 35 +4 15 +5 9 +6 9 +7 29 +8 6 +9 13 +10 19 +11 23 +12 13 +13 30 +14 14 +15 33 +16 5 +17 28 +18 5 +19 26 +20 18 +21 9 +22 5 +23 35 +24 10 +25 6 +26 14 +27 9 +28 22 +29 9 +30 23 +31 34 +32 29 +33 28 +34 29 +35 24 +36 33 +37 34 +38 9 +39 19 +40 17 +41 6 +42 35 +43 29 +44 11 +45 25 +46 34 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-7.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-7.vrp new file mode 100644 index 00000000..5f946984 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-45-7.vrp @@ -0,0 +1,102 @@ +NAME: VRP-45-7 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 46 +CAPACITY: 150 +NODE_COORD_SECTION +1 50.0 50.0 +2 71 26 +3 92 89 +4 39 67 +5 81 46 +6 74 83 +7 74 25 +8 70 75 +9 43 60 +10 71 46 +11 90 91 +12 84 44 +13 80 62 +14 30 64 +15 77 63 +16 77 93 +17 80 36 +18 68 76 +19 35 66 +20 85 46 +21 86 78 +22 65 37 +23 83 74 +24 34 66 +25 78 44 +26 66 85 +27 68 36 +28 83 82 +29 22 73 +30 81 46 +31 76 76 +32 68 33 +33 85 85 +34 34 72 +35 84 46 +36 77 87 +37 70 39 +38 81 85 +39 30 81 +40 79 47 +41 74 74 +42 74 46 +43 86 71 +44 33 65 +45 76 42 +46 71 78 +DEMAND_SECTION +1 0 +2 6 +3 29 +4 34 +5 30 +6 31 +7 26 +8 26 +9 35 +10 9 +11 31 +12 13 +13 23 +14 5 +15 21 +16 6 +17 10 +18 15 +19 5 +20 16 +21 9 +22 26 +23 9 +24 18 +25 23 +26 5 +27 17 +28 21 +29 26 +30 6 +31 22 +32 29 +33 23 +34 26 +35 33 +36 23 +37 11 +38 5 +39 26 +40 8 +41 6 +42 16 +43 14 +44 33 +45 9 +46 6 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-48-7.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-48-7.vrp new file mode 100644 index 00000000..92827102 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-48-7.vrp @@ -0,0 +1,108 @@ +NAME: VRP-48-7 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 49 +CAPACITY: 170 +NODE_COORD_SECTION +1 50.0 50.0 +2 21 64 +3 18 26 +4 49 31 +5 54 37 +6 31 69 +7 10 77 +8 2 10 +9 50 40 +10 54 40 +11 20 63 +12 27 88 +13 21 36 +14 46 37 +15 58 40 +16 44 78 +17 10 81 +18 9 34 +19 36 45 +20 58 45 +21 21 65 +22 28 95 +23 21 20 +24 41 52 +25 59 39 +26 34 54 +27 20 71 +28 20 21 +29 30 45 +30 49 50 +31 36 66 +32 31 78 +33 15 34 +34 50 37 +35 47 33 +36 12 68 +37 26 85 +38 19 25 +39 29 47 +40 55 38 +41 38 55 +42 23 81 +43 21 34 +44 43 48 +45 64 38 +46 40 49 +47 15 85 +48 19 31 +49 45 53 +DEMAND_SECTION +1 0 +2 28 +3 18 +4 8 +5 9 +6 21 +7 33 +8 19 +9 33 +10 29 +11 28 +12 7 +13 25 +14 6 +15 5 +16 5 +17 25 +18 27 +19 6 +20 16 +21 16 +22 8 +23 5 +24 16 +25 25 +26 24 +27 29 +28 32 +29 14 +30 21 +31 17 +32 30 +33 10 +34 22 +35 5 +36 35 +37 30 +38 8 +39 29 +40 35 +41 5 +42 29 +43 5 +44 29 +45 20 +46 15 +47 35 +48 10 +49 30 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-54-8.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-54-8.vrp new file mode 100644 index 00000000..ddd6a6f6 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-54-8.vrp @@ -0,0 +1,120 @@ +NAME: VRP-54-8 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 55 +CAPACITY: 175 +NODE_COORD_SECTION +1 50.0 50.0 +2 27 67 +3 87 27 +4 48 19 +5 68 16 +6 63 21 +7 22 78 +8 40 73 +9 81 20 +10 55 44 +11 68 12 +12 66 27 +13 17 80 +14 51 69 +15 78 18 +16 50 39 +17 54 23 +18 66 37 +19 11 83 +20 24 60 +21 73 24 +22 51 30 +23 58 7 +24 77 34 +25 17 77 +26 35 62 +27 78 15 +28 65 21 +29 67 26 +30 65 30 +31 29 82 +32 38 46 +33 85 29 +34 64 39 +35 79 25 +36 56 26 +37 11 73 +38 37 71 +39 85 28 +40 40 30 +41 67 23 +42 69 38 +43 21 78 +44 33 68 +45 82 13 +46 49 39 +47 59 13 +48 72 30 +49 14 99 +50 41 62 +51 98 21 +52 62 30 +53 70 16 +54 63 21 +55 20 78 +DEMAND_SECTION +1 0 +2 21 +3 21 +4 21 +5 28 +6 35 +7 11 +8 32 +9 10 +10 26 +11 33 +12 11 +13 14 +14 22 +15 8 +16 25 +17 28 +18 29 +19 6 +20 26 +21 26 +22 5 +23 5 +24 14 +25 9 +26 28 +27 9 +28 9 +29 33 +30 26 +31 10 +32 18 +33 35 +34 19 +35 28 +36 18 +37 12 +38 32 +39 32 +40 12 +41 16 +42 22 +43 24 +44 27 +45 11 +46 12 +47 33 +48 8 +49 23 +50 22 +51 27 +52 25 +53 23 +54 11 +55 34 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-55-8.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-55-8.vrp new file mode 100644 index 00000000..60b42757 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-55-8.vrp @@ -0,0 +1,122 @@ +NAME: VRP-55-8 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 56 +CAPACITY: 185 +NODE_COORD_SECTION +1 50.0 50.0 +2 95 56 +3 42 60 +4 40 63 +5 52 37 +6 80 37 +7 29 40 +8 84 49 +9 49 61 +10 36 64 +11 50 40 +12 82 36 +13 39 44 +14 94 39 +15 56 54 +16 43 60 +17 44 35 +18 83 38 +19 36 36 +20 90 43 +21 53 49 +22 33 72 +23 50 26 +24 76 33 +25 27 44 +26 72 41 +27 48 50 +28 31 70 +29 61 29 +30 52 42 +31 31 43 +32 93 46 +33 43 46 +34 24 72 +35 49 27 +36 73 39 +37 39 43 +38 68 49 +39 45 53 +40 45 58 +41 41 36 +42 71 34 +43 34 37 +44 75 49 +45 54 48 +46 41 68 +47 51 27 +48 69 53 +49 32 31 +50 77 38 +51 49 57 +52 44 65 +53 54 29 +54 91 39 +55 33 51 +56 88 45 +DEMAND_SECTION +1 0 +2 31 +3 18 +4 34 +5 30 +6 12 +7 16 +8 30 +9 16 +10 13 +11 21 +12 24 +13 31 +14 29 +15 8 +16 21 +17 5 +18 35 +19 18 +20 35 +21 25 +22 32 +23 30 +24 24 +25 27 +26 31 +27 29 +28 31 +29 8 +30 9 +31 6 +32 16 +33 29 +34 20 +35 19 +36 17 +37 31 +38 18 +39 28 +40 20 +41 14 +42 12 +43 7 +44 26 +45 35 +46 31 +47 12 +48 26 +49 23 +50 16 +51 14 +52 24 +53 17 +54 7 +55 24 +56 5 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-10.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-10.vrp new file mode 100644 index 00000000..eee901e5 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-10.vrp @@ -0,0 +1,132 @@ +NAME: VRP-60-10 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 61 +CAPACITY: 165 +NODE_COORD_SECTION +1 50.0 50.0 +2 23 27 +3 70 33 +4 54 48 +5 70 60 +6 26 43 +7 47 72 +8 39 6 +9 84 29 +10 57 39 +11 75 62 +12 28 48 +13 52 62 +14 41 27 +15 81 33 +16 61 43 +17 74 58 +18 25 29 +19 40 68 +20 33 29 +21 79 28 +22 54 47 +23 70 55 +24 23 43 +25 50 65 +26 39 7 +27 74 38 +28 64 46 +29 75 66 +30 30 33 +31 71 66 +32 37 16 +33 84 27 +34 58 41 +35 65 57 +36 20 36 +37 58 66 +38 40 16 +39 81 37 +40 65 39 +41 84 65 +42 29 40 +43 56 74 +44 38 7 +45 64 32 +46 52 46 +47 66 57 +48 24 28 +49 60 76 +50 31 12 +51 70 41 +52 62 37 +53 59 46 +54 14 38 +55 59 62 +56 37 21 +57 73 32 +58 61 42 +59 57 58 +60 23 44 +61 56 68 +DEMAND_SECTION +1 0 +2 15 +3 13 +4 11 +5 28 +6 14 +7 34 +8 34 +9 23 +10 9 +11 34 +12 19 +13 30 +14 9 +15 17 +16 18 +17 22 +18 27 +19 31 +20 20 +21 34 +22 25 +23 20 +24 9 +25 15 +26 18 +27 8 +28 18 +29 21 +30 32 +31 27 +32 20 +33 33 +34 33 +35 23 +36 14 +37 10 +38 25 +39 28 +40 11 +41 19 +42 34 +43 25 +44 19 +45 16 +46 24 +47 24 +48 23 +49 20 +50 24 +51 20 +52 35 +53 10 +54 20 +55 18 +56 33 +57 12 +58 33 +59 20 +60 32 +61 24 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-9.vrp b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-9.vrp new file mode 100644 index 00000000..39562dcb --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances/VRP-60-9.vrp @@ -0,0 +1,132 @@ +NAME: VRP-60-9 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 61 +CAPACITY: 160 +NODE_COORD_SECTION +1 50.0 50.0 +2 75 51 +3 75 73 +4 52 27 +5 60 30 +6 37 40 +7 63 64 +8 86 66 +9 85 66 +10 63 23 +11 64 23 +12 36 41 +13 60 48 +14 63 59 +15 87 63 +16 63 10 +17 63 20 +18 28 38 +19 64 65 +20 69 48 +21 83 66 +22 71 18 +23 45 32 +24 42 60 +25 64 67 +26 72 54 +27 78 63 +28 67 18 +29 68 47 +30 40 52 +31 42 53 +32 70 46 +33 71 59 +34 65 29 +35 53 33 +36 26 41 +37 63 64 +38 76 57 +39 76 62 +40 65 25 +41 52 40 +42 42 31 +43 49 58 +44 67 54 +45 80 69 +46 66 31 +47 63 37 +48 43 45 +49 59 70 +50 66 49 +51 74 61 +52 60 13 +53 79 33 +54 43 43 +55 56 57 +56 56 54 +57 75 68 +58 57 13 +59 53 31 +60 29 45 +61 56 62 +DEMAND_SECTION +1 0 +2 22 +3 6 +4 23 +5 25 +6 16 +7 8 +8 23 +9 8 +10 7 +11 17 +12 15 +13 23 +14 17 +15 13 +16 17 +17 5 +18 19 +19 12 +20 15 +21 18 +22 13 +23 33 +24 33 +25 27 +26 6 +27 28 +28 17 +29 28 +30 35 +31 15 +32 35 +33 16 +34 21 +35 9 +36 24 +37 24 +38 34 +39 5 +40 13 +41 18 +42 26 +43 17 +44 32 +45 27 +46 19 +47 19 +48 25 +49 15 +50 11 +51 13 +52 18 +53 29 +54 24 +55 8 +56 8 +57 7 +58 26 +59 25 +60 7 +61 35 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-22-3.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-22-3.vrp new file mode 100644 index 00000000..fba6e917 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-22-3.vrp @@ -0,0 +1,56 @@ +NAME: VHO-22-3 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 23 +CAPACITY: 200 +NODE_COORD_SECTION +1 50.0 50.0 +2 67 47 +3 40 68 +4 36 14 +5 68 64 +6 40 61 +7 29 27 +8 59 55 +9 42 42 +10 20 20 +11 67 46 +12 42 38 +13 21 11 +14 71 45 +15 43 48 +16 37 31 +17 67 41 +18 53 50 +19 40 27 +20 53 50 +21 52 51 +22 28 1 +23 59 66 +DEMAND_SECTION +1 0 +2 26 +3 32 +4 17 +5 27 +6 9 +7 6 +8 25 +9 27 +10 17 +11 33 +12 17 +13 11 +14 30 +15 13 +16 10 +17 25 +18 33 +19 20 +20 24 +21 28 +22 35 +23 14 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-24-3.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-24-3.vrp new file mode 100644 index 00000000..e9a68d83 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-24-3.vrp @@ -0,0 +1,60 @@ +NAME: VHO-24-3 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 25 +CAPACITY: 185 +NODE_COORD_SECTION +1 50.0 50.0 +2 42 75 +3 48 11 +4 38 33 +5 26 68 +6 48 25 +7 51 31 +8 41 79 +9 50 29 +10 36 34 +11 48 56 +12 46 13 +13 36 29 +14 33 69 +15 60 13 +16 46 28 +17 28 87 +18 37 27 +19 55 31 +20 31 72 +21 43 21 +22 44 26 +23 39 81 +24 44 14 +25 43 29 +DEMAND_SECTION +1 0 +2 12 +3 27 +4 20 +5 16 +6 5 +7 8 +8 25 +9 20 +10 11 +11 9 +12 11 +13 29 +14 33 +15 26 +16 30 +17 14 +18 22 +19 27 +20 7 +21 34 +22 9 +23 20 +24 17 +25 11 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-27-4.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-27-4.vrp new file mode 100644 index 00000000..6c705691 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-27-4.vrp @@ -0,0 +1,66 @@ +NAME: VHO-27-4 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 28 +CAPACITY: 200 +NODE_COORD_SECTION +1 50.0 50.0 +2 66 42 +3 41 7 +4 30 52 +5 53 42 +6 30 27 +7 51 55 +8 53 38 +9 39 20 +10 34 51 +11 43 33 +12 32 19 +13 51 63 +14 53 50 +15 46 22 +16 41 55 +17 62 46 +18 44 24 +19 30 56 +20 42 49 +21 47 14 +22 37 59 +23 60 51 +24 41 13 +25 35 66 +26 47 53 +27 24 11 +28 29 64 +DEMAND_SECTION +1 0 +2 31 +3 16 +4 21 +5 7 +6 21 +7 34 +8 32 +9 24 +10 33 +11 22 +12 22 +13 13 +14 35 +15 28 +16 6 +17 34 +18 18 +19 32 +20 8 +21 15 +22 35 +23 31 +24 26 +25 11 +26 28 +27 25 +28 23 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-29-4.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-29-4.vrp new file mode 100644 index 00000000..b7a39792 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-29-4.vrp @@ -0,0 +1,70 @@ +NAME: VHO-29-4 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 30 +CAPACITY: 195 +NODE_COORD_SECTION +1 50.0 50.0 +2 35 83 +3 64 31 +4 74 23 +5 16 2 +6 37 76 +7 47 31 +8 65 43 +9 30 13 +10 29 81 +11 49 30 +12 55 43 +13 28 13 +14 45 76 +15 57 19 +16 67 43 +17 25 24 +18 50 71 +19 59 23 +20 72 41 +21 21 28 +22 27 85 +23 49 37 +24 64 44 +25 8 13 +26 38 82 +27 54 38 +28 59 37 +29 19 23 +30 48 88 +DEMAND_SECTION +1 0 +2 10 +3 27 +4 23 +5 17 +6 13 +7 23 +8 33 +9 29 +10 24 +11 27 +12 12 +13 15 +14 23 +15 22 +16 12 +17 26 +18 7 +19 34 +20 35 +21 16 +22 26 +23 27 +24 32 +25 5 +26 34 +27 15 +28 10 +29 15 +30 24 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-33-5.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-33-5.vrp new file mode 100644 index 00000000..3ccc8649 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-33-5.vrp @@ -0,0 +1,78 @@ +NAME: VHO-33-5 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 34 +CAPACITY: 170 +NODE_COORD_SECTION +1 50.0 50.0 +2 21 19 +3 55 60 +4 81 28 +5 65 76 +6 20 34 +7 46 81 +8 79 22 +9 72 70 +10 17 55 +11 65 69 +12 74 21 +13 73 73 +14 31 42 +15 54 75 +16 71 13 +17 58 86 +18 20 39 +19 61 61 +20 87 20 +21 67 74 +22 20 35 +23 51 75 +24 76 21 +25 65 73 +26 31 27 +27 64 66 +28 83 22 +29 62 76 +30 16 34 +31 49 74 +32 85 17 +33 69 66 +34 8 32 +DEMAND_SECTION +1 0 +2 32 +3 33 +4 30 +5 28 +6 8 +7 23 +8 19 +9 5 +10 12 +11 34 +12 34 +13 7 +14 10 +15 34 +16 19 +17 27 +18 7 +19 20 +20 17 +21 17 +22 6 +23 35 +24 25 +25 17 +26 7 +27 32 +28 23 +29 13 +30 26 +31 12 +32 16 +33 29 +34 10 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-36-5.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-36-5.vrp new file mode 100644 index 00000000..790921e0 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-36-5.vrp @@ -0,0 +1,84 @@ +NAME: VHO-36-5 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 37 +CAPACITY: 185 +NODE_COORD_SECTION +1 50.0 50.0 +2 77 80 +3 51 33 +4 42 31 +5 40 81 +6 50 76 +7 45 35 +8 65 33 +9 34 72 +10 63 56 +11 42 53 +12 56 45 +13 24 73 +14 73 69 +15 55 37 +16 58 37 +17 40 72 +18 65 70 +19 49 37 +20 55 49 +21 35 82 +22 62 59 +23 37 42 +24 47 50 +25 36 85 +26 72 66 +27 44 31 +28 52 33 +29 19 71 +30 72 62 +31 46 52 +32 54 25 +33 33 81 +34 67 75 +35 48 46 +36 50 42 +37 38 67 +DEMAND_SECTION +1 0 +2 18 +3 8 +4 8 +5 21 +6 25 +7 28 +8 11 +9 28 +10 5 +11 24 +12 32 +13 15 +14 11 +15 16 +16 16 +17 24 +18 27 +19 33 +20 20 +21 8 +22 22 +23 20 +24 9 +25 18 +26 33 +27 30 +28 10 +29 27 +30 30 +31 18 +32 26 +33 33 +34 17 +35 7 +36 12 +37 31 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-39-6.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-39-6.vrp new file mode 100644 index 00000000..4df962a6 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-39-6.vrp @@ -0,0 +1,90 @@ +NAME: VHO-39-6 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 40 +CAPACITY: 160 +NODE_COORD_SECTION +1 50.0 50.0 +2 59 27 +3 58 27 +4 83 72 +5 74 56 +6 31 46 +7 55 32 +8 46 31 +9 87 56 +10 82 46 +11 36 46 +12 67 10 +13 46 25 +14 80 64 +15 78 54 +16 28 65 +17 63 35 +18 59 26 +19 91 61 +20 81 53 +21 43 48 +22 72 33 +23 47 34 +24 88 61 +25 89 53 +26 40 59 +27 53 32 +28 50 26 +29 100 63 +30 72 49 +31 29 57 +32 70 26 +33 53 37 +34 78 45 +35 76 69 +36 43 40 +37 72 36 +38 58 28 +39 73 80 +40 74 41 +DEMAND_SECTION +1 0 +2 21 +3 29 +4 20 +5 28 +6 15 +7 35 +8 30 +9 16 +10 6 +11 22 +12 17 +13 5 +14 12 +15 21 +16 7 +17 34 +18 23 +19 10 +20 11 +21 8 +22 32 +23 9 +24 15 +25 19 +26 21 +27 20 +28 22 +29 7 +30 31 +31 23 +32 34 +33 15 +34 9 +35 13 +36 32 +37 5 +38 28 +39 26 +40 30 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-43-6.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-43-6.vrp new file mode 100644 index 00000000..edf7d8ab --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-43-6.vrp @@ -0,0 +1,98 @@ +NAME: VHO-43-6 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 44 +CAPACITY: 180 +NODE_COORD_SECTION +1 50.0 50.0 +2 41 47 +3 30 40 +4 89 46 +5 51 41 +6 79 25 +7 27 50 +8 21 32 +9 76 37 +10 47 26 +11 72 36 +12 27 56 +13 20 34 +14 78 36 +15 62 44 +16 59 32 +17 24 74 +18 20 42 +19 86 28 +20 41 42 +21 83 25 +22 35 59 +23 18 45 +24 78 29 +25 47 54 +26 78 33 +27 37 72 +28 18 36 +29 89 54 +30 36 54 +31 69 32 +32 39 64 +33 24 33 +34 77 42 +35 35 46 +36 78 40 +37 35 60 +38 13 40 +39 82 42 +40 49 45 +41 72 32 +42 42 43 +43 25 45 +44 91 35 +DEMAND_SECTION +1 0 +2 26 +3 16 +4 25 +5 22 +6 16 +7 10 +8 33 +9 12 +10 7 +11 14 +12 27 +13 14 +14 27 +15 22 +16 18 +17 9 +18 35 +19 15 +20 15 +21 16 +22 24 +23 21 +24 31 +25 16 +26 21 +27 30 +28 16 +29 18 +30 30 +31 20 +32 23 +33 29 +34 15 +35 34 +36 35 +37 14 +38 6 +39 8 +40 8 +41 10 +42 6 +43 30 +44 26 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-47-7.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-47-7.vrp new file mode 100644 index 00000000..f511ef8c --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-47-7.vrp @@ -0,0 +1,106 @@ +NAME: VHO-47-7 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 48 +CAPACITY: 165 +NODE_COORD_SECTION +1 50.0 50.0 +2 48 78 +3 64 64 +4 59 50 +5 77 74 +6 30 80 +7 53 79 +8 76 69 +9 67 43 +10 76 70 +11 27 83 +12 52 90 +13 65 66 +14 70 29 +15 71 67 +16 35 84 +17 59 65 +18 66 70 +19 68 28 +20 82 75 +21 32 69 +22 57 82 +23 70 64 +24 69 22 +25 91 76 +26 16 87 +27 33 81 +28 56 53 +29 77 45 +30 75 71 +31 19 92 +32 53 62 +33 76 56 +34 68 27 +35 76 73 +36 36 85 +37 50 76 +38 66 66 +39 49 30 +40 79 88 +41 33 77 +42 40 70 +43 73 59 +44 72 41 +45 68 84 +46 27 72 +47 43 74 +48 80 76 +DEMAND_SECTION +1 0 +2 34 +3 32 +4 25 +5 9 +6 12 +7 6 +8 27 +9 16 +10 22 +11 23 +12 14 +13 14 +14 22 +15 29 +16 34 +17 8 +18 24 +19 29 +20 26 +21 18 +22 16 +23 9 +24 17 +25 22 +26 16 +27 29 +28 20 +29 26 +30 6 +31 21 +32 27 +33 24 +34 31 +35 35 +36 34 +37 30 +38 12 +39 11 +40 5 +41 5 +42 5 +43 18 +44 9 +45 21 +46 10 +47 31 +48 9 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-51-7.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-51-7.vrp new file mode 100644 index 00000000..57ddc3c8 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-51-7.vrp @@ -0,0 +1,114 @@ +NAME: VHO-51-7 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 52 +CAPACITY: 195 +NODE_COORD_SECTION +1 50.0 50.0 +2 53 46 +3 54 55 +4 46 87 +5 31 27 +6 74 54 +7 44 54 +8 46 37 +9 65 63 +10 51 91 +11 30 34 +12 81 45 +13 49 45 +14 49 41 +15 57 57 +16 39 84 +17 43 13 +18 65 66 +19 44 57 +20 43 40 +21 64 53 +22 35 78 +23 40 26 +24 81 42 +25 51 44 +26 41 42 +27 52 41 +28 53 81 +29 48 20 +30 79 47 +31 56 57 +32 31 45 +33 65 64 +34 44 65 +35 35 33 +36 85 44 +37 45 52 +38 47 43 +39 60 62 +40 43 85 +41 44 20 +42 75 47 +43 52 54 +44 38 41 +45 60 55 +46 45 85 +47 34 30 +48 82 46 +49 53 39 +50 47 46 +51 46 57 +52 45 80 +DEMAND_SECTION +1 0 +2 16 +3 29 +4 20 +5 21 +6 20 +7 21 +8 23 +9 21 +10 26 +11 30 +12 7 +13 7 +14 21 +15 34 +16 18 +17 18 +18 11 +19 31 +20 24 +21 13 +22 5 +23 32 +24 13 +25 18 +26 17 +27 28 +28 23 +29 34 +30 6 +31 25 +32 17 +33 15 +34 34 +35 33 +36 29 +37 5 +38 16 +39 7 +40 12 +41 19 +42 12 +43 16 +44 31 +45 10 +46 29 +47 27 +48 35 +49 21 +50 25 +51 32 +52 31 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-55-8.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-55-8.vrp new file mode 100644 index 00000000..78f1e4aa --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-55-8.vrp @@ -0,0 +1,122 @@ +NAME: VHO-55-8 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 56 +CAPACITY: 185 +NODE_COORD_SECTION +1 50.0 50.0 +2 15 30 +3 89 20 +4 24 41 +5 48 21 +6 28 55 +7 69 24 +8 33 19 +9 90 38 +10 32 36 +11 41 22 +12 29 61 +13 66 24 +14 14 11 +15 89 22 +16 25 37 +17 45 36 +18 17 61 +19 53 28 +20 12 27 +21 80 37 +22 25 38 +23 49 22 +24 29 55 +25 53 41 +26 30 15 +27 86 23 +28 27 41 +29 36 42 +30 22 61 +31 61 33 +32 18 16 +33 84 20 +34 23 45 +35 55 26 +36 19 68 +37 62 28 +38 17 4 +39 93 31 +40 29 35 +41 48 32 +42 20 68 +43 59 23 +44 7 20 +45 92 27 +46 28 32 +47 58 43 +48 38 59 +49 58 16 +50 7 26 +51 87 28 +52 23 33 +53 50 39 +54 30 63 +55 51 16 +56 13 7 +DEMAND_SECTION +1 0 +2 20 +3 32 +4 18 +5 28 +6 24 +7 24 +8 16 +9 27 +10 5 +11 6 +12 18 +13 29 +14 14 +15 5 +16 16 +17 33 +18 29 +19 24 +20 19 +21 14 +22 7 +23 34 +24 18 +25 17 +26 33 +27 22 +28 27 +29 31 +30 35 +31 10 +32 20 +33 29 +34 8 +35 21 +36 32 +37 24 +38 25 +39 17 +40 21 +41 11 +42 29 +43 17 +44 6 +45 15 +46 14 +47 26 +48 23 +49 35 +50 19 +51 18 +52 29 +53 25 +54 30 +55 32 +56 20 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-58-9.vrp b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-58-9.vrp new file mode 100644 index 00000000..2515cc4d --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/instances_heldout/VHO-58-9.vrp @@ -0,0 +1,128 @@ +NAME: VHO-58-9 +COMMENT: generated clustered CVRP instance (deterministic) +TYPE: CVRP +DIMENSION: 59 +CAPACITY: 165 +NODE_COORD_SECTION +1 50.0 50.0 +2 79 72 +3 43 44 +4 39 54 +5 36 42 +6 22 29 +7 85 76 +8 69 68 +9 31 43 +10 30 65 +11 45 41 +12 35 28 +13 63 70 +14 70 68 +15 33 37 +16 32 72 +17 45 40 +18 32 25 +19 79 79 +20 70 71 +21 41 43 +22 39 68 +23 43 34 +24 26 31 +25 78 88 +26 62 56 +27 41 60 +28 31 70 +29 44 60 +30 22 18 +31 78 78 +32 69 61 +33 43 39 +34 22 64 +35 42 39 +36 22 30 +37 85 84 +38 75 65 +39 52 49 +40 32 60 +41 42 45 +42 27 21 +43 85 86 +44 64 70 +45 34 51 +46 32 70 +47 49 39 +48 25 16 +49 79 87 +50 80 62 +51 40 58 +52 27 55 +53 47 36 +54 30 13 +55 81 67 +56 72 70 +57 50 45 +58 34 69 +59 32 43 +DEMAND_SECTION +1 0 +2 34 +3 21 +4 8 +5 27 +6 33 +7 18 +8 13 +9 10 +10 28 +11 24 +12 23 +13 10 +14 18 +15 20 +16 29 +17 34 +18 18 +19 13 +20 33 +21 28 +22 31 +23 34 +24 27 +25 24 +26 31 +27 13 +28 29 +29 5 +30 30 +31 19 +32 20 +33 25 +34 16 +35 29 +36 16 +37 7 +38 32 +39 31 +40 27 +41 25 +42 17 +43 32 +44 17 +45 7 +46 15 +47 9 +48 19 +49 11 +50 13 +51 20 +52 10 +53 7 +54 12 +55 11 +56 7 +57 24 +58 9 +59 30 +DEPOT_SECTION +1 +EOF diff --git a/benchmarks/VehicleRouting/CVRP/data/reference.json b/benchmarks/VehicleRouting/CVRP/data/reference.json new file mode 100644 index 00000000..35974de1 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/data/reference.json @@ -0,0 +1,26 @@ +{ + "VHO-22-3": 238, + "VHO-24-3": 256, + "VHO-27-4": 279, + "VHO-29-4": 379, + "VHO-33-5": 476, + "VHO-36-5": 355, + "VHO-39-6": 469, + "VHO-43-6": 452, + "VHO-47-7": 562, + "VHO-51-7": 424, + "VHO-55-8": 648, + "VHO-58-9": 576, + "VRP-19-2": 153, + "VRP-21-3": 254, + "VRP-22-4": 313, + "VRP-32-5": 394, + "VRP-37-6": 479, + "VRP-45-6": 431, + "VRP-45-7": 569, + "VRP-48-7": 530, + "VRP-54-8": 672, + "VRP-55-8": 480, + "VRP-60-10": 649, + "VRP-60-9": 527 +} diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/agent_files.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/agent_files.txt new file mode 100644 index 00000000..640607a1 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/agent_files.txt @@ -0,0 +1,4 @@ +README.md +Task.md +baseline/solver.py +frontier_eval/constraints.txt diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/artifact_files.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/artifact_files.txt new file mode 100644 index 00000000..a52b8b16 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/artifact_files.txt @@ -0,0 +1,2 @@ +# No extra artifact files are auto-collected by default for this benchmark. +# metrics.json and artifacts.json are handled separately by UnifiedTask. diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/candidate_destination.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/candidate_destination.txt new file mode 100644 index 00000000..6645b02f --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/candidate_destination.txt @@ -0,0 +1 @@ +baseline/solver.py diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/constraints.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/constraints.txt new file mode 100644 index 00000000..c97f6a62 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/constraints.txt @@ -0,0 +1,9 @@ +UnifiedTask constraints: +1) Only modify `baseline/solver.py`, and only inside the EVOLVE-BLOCK-START / EVOLVE-BLOCK-END region. +2) Preserve the public entrypoint `solve(instance)` and its output contract (a list of routes; each route is a list of customer ids 1..n, depot implicit at both ends). +3) Do not modify benchmark assets, documentation, verification code, runtime helpers, tests, data files, or `frontier_eval/` metadata. +4) Never read `data/reference.json` and never import or call the reference solver (`verification/ref_solver.py`); both are the scoring baseline and are off-limits. +5) Do not hardcode solutions per instance name: evaluation includes held-out instances you have never seen, so your solver must be a general algorithm. +6) Your solver must always produce a valid solution: every customer served exactly once, every route within capacity, no crashing, no timeouts. Invalid output scores 0 and marks the run invalid. +7) Prioritize validity and correctness before optimization. +8) The evaluator statically checks violations of 1/4/5 (markers, forbidden references, absolute paths, per-instance hardcoding) and runs a determinism probe (the candidate must produce identical output on the same instance across two runs); any violation scores 0. diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/copy_files.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/copy_files.txt new file mode 100644 index 00000000..b178885b --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/copy_files.txt @@ -0,0 +1,3 @@ +baseline +data/instances +frontier_eval diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_command.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_command.txt new file mode 100644 index 00000000..3a31d525 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_command.txt @@ -0,0 +1 @@ +FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR={benchmark_source} {python} frontier_eval/run_eval.py --candidate {candidate} --metrics-out metrics.json --artifacts-out artifacts.json diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_cwd.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_cwd.txt new file mode 100644 index 00000000..9c558e35 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/eval_cwd.txt @@ -0,0 +1 @@ +. diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/evaluator.py b/benchmarks/VehicleRouting/CVRP/frontier_eval/evaluator.py new file mode 100644 index 00000000..66cce353 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/evaluator.py @@ -0,0 +1,539 @@ +"""Self-contained unified evaluator for the CVRP benchmark. + +This module runs entirely inside the evaluation sandbox. It embeds instance +parsing, route validation, scoring and candidate-integrity checks, so no +`verification/` files (including the reference solver) are copied into the +sandbox. + +Security / integrity: + * Reference distances are read from the host benchmark directory + (FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR) and are never copied into + the sandbox; the candidate subprocess runs without that variable and + without any reference-distance settings. + * The candidate is checked before running: EVOLVE-BLOCK markers must be + present, the fixed regions must match the initial baseline, and the + source must not reference the verification module / reference solver / + reference.json / absolute paths / per-instance hardcoded routes. + * A determinism probe runs the candidate twice on the smallest instance; + differing outputs invalidate the run. + +The reference implementation (`verification/ref_solver.py`) stays out of the +sandbox and is only used to produce `data/reference.json` on the host. +""" +from __future__ import annotations + +import json +import math +import os +import re +import statistics +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +TASK_ROOT = Path(__file__).resolve().parents[1] # or CVRP dir +INSTANCES_DIR = TASK_ROOT / "data" / "instances" +HELDOUT_DIR = TASK_ROOT / "data" / "instances_heldout" +REFERENCE_JSON = TASK_ROOT / "data" / "reference.json" + +EVOLVE_START = "# EVOLVE-BLOCK-START" +EVOLVE_END = "# EVOLVE-BLOCK-END" + +# Strong tokens: almost never appear in legitimate solver code, so a bare +# substring match is fine (e.g. "ref_solver", "reference.json"). +STRONG_TOKENS = ( + "ref_solver", + "grasp_solve", + "savings_solve", + "reference.json", +) +# Weak token "verification": only rejected in an import/from/module-path +# context, so a comment like "verification pass" is NOT a false positive. +FORBIDDEN_RE = ( + re.compile(r"\b(?:import|from)\s+verification\b"), + re.compile(r"verification[\\/.]"), +) + +HARDCODE_RE = re.compile(r"[\"'][A-Z][A-Z0-9-]*\d+-\d+[\"']\s*:") +ABS_PATH_RE = re.compile(r"[A-Za-z]:[\\/]|/home/|/Users/") + +DEFAULT_TIMEOUT_S = 60 + + +def parse_instance(path: Path) -> dict: + """Parse a TSPLIB-style CVRP .vrp file into an instance dict.""" + text = path.read_text(encoding="utf-8", errors="ignore") + coords: dict[int, tuple[float, float]] = {} + demands: dict[int, int] = {} + capacity = 0 + section = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + upper = line.upper() + if upper.startswith("CAPACITY"): + capacity = int(line.split(":")[-1].strip()) + continue + if upper == "NODE_COORD_SECTION": + section = "coords" + continue + if upper == "DEMAND_SECTION": + section = "demand" + continue + if upper == "DEPOT_SECTION": + section = None + continue + if upper == "EOF" or upper.startswith(("EDGE_WEIGHT", "DISPLAY_DATA")): + section = None + continue + if upper.startswith(("NAME", "COMMENT", "TYPE", "DIMENSION")): + continue + if section == "coords": + parts = line.split() + if len(parts) >= 3: + coords[int(parts[0])] = (float(parts[1]), float(parts[2])) + elif section == "demand": + parts = line.split() + if len(parts) >= 2: + demands[int(parts[0])] = int(parts[1]) + n_customers = max(coords) - 1 # depot is id 1, customers are ids 2..n+1 + pts = [coords[1]] + [coords[i] for i in range(2, n_customers + 2)] + dist = [[0] * (n_customers + 1) for _ in range(n_customers + 1)] + for i in range(n_customers + 1): + for j in range(n_customers + 1): + dx = pts[i][0] - pts[j][0] + dy = pts[i][1] - pts[j][1] + dist[i][j] = int(round(math.hypot(dx, dy))) + return { + "name": path.stem, + "n": n_customers, + "capacity": capacity, + "demand": [0] + [demands.get(i, 0) for i in range(2, n_customers + 2)], + "distance": dist, + } + + +def route_distance(routes: list, dist: list[list[int]]) -> int: + total = 0 + for seq in routes: + if not seq: + continue + total += dist[0][seq[0]] + for a, b in zip(seq, seq[1:]): + total += dist[a][b] + total += dist[seq[-1]][0] + return total + + +def validate(routes: Any, inst: dict) -> tuple[bool, str, int | None]: + """Return (ok, error_message, total_distance).""" + n, cap, demand = inst["n"], inst["capacity"], inst["demand"] + if not isinstance(routes, list): + return False, "routes is not a list", None + seen: list[int] = [] + for seq in routes: + if not isinstance(seq, list): + return False, "a route is not a list", None + load = 0 + for c in seq: + if not isinstance(c, int) or isinstance(c, bool): + return False, f"route contains non-integer {c!r}", None + if c < 1 or c > n: + return False, f"customer id {c} out of range 1..{n}", None + if c in seen: + return False, f"customer {c} visited more than once", None + seen.append(c) + load += demand[c] + if load > cap: + return False, f"route {seq} exceeds capacity {cap} (load {load})", None + if set(seen) != set(range(1, n + 1)): + missing = set(range(1, n + 1)) - set(seen) + return False, f"customers not served: {sorted(missing)}", None + return True, "", route_distance(routes, inst["distance"]) + + +# --------------------------------------------------------------------------- +# Candidate integrity checks (mirrors verification/validator.py so the sandbox +# needs no verification/ files). +# --------------------------------------------------------------------------- +def split_evolve_blocks(src: str) -> tuple[str, str, str] | None: + start = src.find(EVOLVE_START) + end = src.find(EVOLVE_END) + if start == -1 or end == -1 or end <= start: + return None + return ( + src[:start], + src[start + len(EVOLVE_START) : end], + src[end + len(EVOLVE_END) :], + ) + + +def fixed_region(parts: tuple[str, str, str]) -> str: + return parts[0] + parts[2] + + +def static_check_source(src: str, baseline_src: str | None = None) -> list[str]: + issues: list[str] = [] + parts = split_evolve_blocks(src) + if parts is None: + issues.append("missing EVOLVE-BLOCK-START / EVOLVE-BLOCK-END markers") + elif baseline_src is not None: + init_parts = split_evolve_blocks(baseline_src) + if init_parts is not None and fixed_region(init_parts) != fixed_region(parts): + issues.append("code outside EVOLVE-BLOCK differs from initial baseline") + for token in STRONG_TOKENS: + if token in src: + issues.append(f"candidate references forbidden token {token!r}") + for pat in FORBIDDEN_RE: + if pat.search(src): + issues.append("candidate references forbidden token 'verification'") + if ABS_PATH_RE.search(src): + issues.append("candidate contains an absolute filesystem path") + if HARDCODE_RE.search(src): + issues.append("candidate hardcodes per-instance solutions by name") + return issues + + +def candidate_env() -> dict[str, str]: + """Candidate subprocess env, stripped of all `FRONTIER_*` (the unified + runtime sets `FRONTIER_ENGINEERING_ROOT` to the repo root — a candidate + could use it to find and import `verification/ref_solver.py` on the host) + and of reference-distance / generation-seed settings.""" + env = os.environ.copy() + for key in list(env): + upper = key.upper() + if upper.startswith("FRONTIER") or key in ( + "CVRP_EVAL_REFERENCE_JSON", + "CVRP_EVAL_REFERENCES", + "CVRP_EVAL_GENERATE_SEED", + "CVRP_EVAL_GENERATE_COUNT", + ): + del env[key] + return env + + +def check_determinism( + python: str, + solver_path: Path, + inst_path: Path, + timeout: float, +) -> tuple[bool, str]: + outputs: list[Any] = [] + for _ in range(2): + with tempfile.TemporaryDirectory(prefix="cvrp_det_") as td: + out_path = Path(td) / "out.json" + try: + proc = subprocess.run( + [python, str(solver_path), str(inst_path), str(out_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(solver_path.parent), + env=candidate_env(), + ) + except subprocess.TimeoutExpired: + return False, "timeout during determinism check" + if proc.returncode != 0: + return ( + False, + f"candidate exited with code {proc.returncode} during " + f"determinism check: {(proc.stderr or '')[:200]}", + ) + try: + outputs.append(json.loads(out_path.read_text(encoding="utf-8"))) + except Exception as exc: + return False, f"cannot parse determinism output: {exc}" + if outputs[0] != outputs[1]: + return False, "candidate is not deterministic (output differs across two runs)" + return True, "" + + +def select_determinism_probes( + inst_paths: list, parse_instance, count: int = 3 +) -> list: + """Pick min / median / max instances (by customer count) as determinism + probes, so a solver that is deterministic on small instances but random on + large ones cannot slip through a single-probe check.""" + parsed = sorted( + ((p, parse_instance(p)) for p in inst_paths), key=lambda t: t[1]["n"] + ) + if not parsed: + return [] + idxs = sorted({0, len(parsed) // 2, len(parsed) - 1}) + return [parsed[i][0] for i in idxs[:count]] + + +# --------------------------------------------------------------------------- +# Host-only access (reference distances + initial baseline live on the host). +# --------------------------------------------------------------------------- +def _source_benchmark_dir() -> Path | None: + raw = os.environ.get("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", "").strip() + if raw: + path = Path(raw) + if path.is_dir(): + return path + return None + + +def load_reference() -> dict[str, float]: + """Reference distances. + + Inside the unified sandbox this must come from the host benchmark dir + (`FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR`; `reference.json` is never + copied into the sandbox). Outside the sandbox (direct run / tests) the + local `REFERENCE_JSON` is used as a fallback, mirroring the verification + evaluator so the two copies cannot drift apart. + """ + candidates: list[Path] = [] + src = _source_benchmark_dir() + if src is not None: + candidates.append(src / "data" / "reference.json") + env_path = os.environ.get("CVRP_EVAL_REFERENCE_JSON", "").strip() + if env_path: + candidates.append(Path(env_path)) + candidates.append(REFERENCE_JSON) + for path in candidates: + if not path.is_file(): + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + return {k: float(v) for k, v in raw.items()} + except Exception: + continue + return {} + + +def _initial_baseline() -> Path | None: + """Pristine baseline on the host, used for the fixed-region diff.""" + src = _source_benchmark_dir() + if src is not None: + path = src / "baseline" / "solver.py" + if path.is_file(): + return path + return None + + +def _parse_env_str(name: str) -> str | None: + raw = os.environ.get(name, "").strip() + return raw or None + + +def _all_instance_paths() -> list[Path]: + """Public instances come from the sandbox copy; held-out instances stay on + the host (never copied into the sandbox), so a candidate cannot read them + during evolution. They are still scored: the candidate receives their path + at scoring time (host path in process mode, repo-mount path in docker).""" + paths = sorted(INSTANCES_DIR.glob("*.vrp")) + src = _source_benchmark_dir() + heldout_dir = (src / "data" / "instances_heldout") if src is not None else HELDOUT_DIR + if heldout_dir.is_dir(): + paths += sorted(heldout_dir.glob("*.vrp")) + return paths + + +def _load_host_module(mod_name: str): + """Load a host `verification/.py` module (not present in the sandbox).""" + src = _source_benchmark_dir() + if src is None: + return None + path = src / "verification" / f"{mod_name}.py" + if not path.is_file(): + return None + import importlib.util + + spec = importlib.util.spec_from_file_location(mod_name, path) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + except Exception: + return None + return mod + + +def _reference_distance(inst: dict, ref_mod) -> float: + iters = max(100, 4 * inst["n"]) + grasp_solve = ref_mod.grasp_solve + route_dist = ref_mod.route_dist + seeds = getattr(ref_mod, "REF_SEEDS", (123, 2024, 7)) + return min( + sum( + route_dist(r, inst["distance"]) + for r in grasp_solve(inst, starts=40, seed=s, lns_iters=iters) + ) + for s in seeds + ) + + +def _generate_instances(base_seed: int, count: int, out_dir: Path) -> tuple[list[Path], dict[str, float]]: + """Fresh instances generated at evaluation time (anti-hardcoding). + + The sandbox has no `verification/` code, so the deterministic generator and + reference solver are loaded from the host benchmark dir; the candidate's + environment is stripped of the host path (see candidate_env), so it cannot + reach them. + """ + gen_mod = _load_host_module("generate_instances") + ref_mod = _load_host_module("ref_solver") + if gen_mod is None or ref_mod is None: + return [], {} + SPECS = gen_mod.SPECS + generate_instance = gen_mod.generate_instance + paths: list[Path] = [] + refs: dict[str, float] = {} + spec_count = len(SPECS) + for i in range(count): + name, n, k, v, _seed_key = SPECS[i % spec_count] + fname = f"GEN-{base_seed}-{i + 1}" + text = generate_instance(fname, n, k, v, seed=base_seed * 1000 + i) + inst_path = out_dir / f"{fname}.vrp" + inst_path.write_text(text, encoding="ascii", newline="\n") + inst = parse_instance(inst_path) + refs[fname] = float(_reference_distance(inst, ref_mod)) + paths.append(inst_path) + return paths, refs + + +def _run_candidate( + python: str, solver_path: Path, inst_path: Path, out_path: Path, timeout: float +) -> tuple[bool, str]: + try: + proc = subprocess.run( + [python, str(solver_path), str(inst_path), str(out_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(solver_path.parent), + env=candidate_env(), + ) + except subprocess.TimeoutExpired: + return False, "timeout" + except OSError as exc: + return False, f"os error: {exc}" + if proc.returncode != 0: + return False, f"exited with code {proc.returncode}: {(proc.stderr or '')[:200]}" + return True, "" + + +def evaluate(program_path: str, *, repo_root: Path | None = None) -> dict[str, Any]: + solver_path = Path(program_path).resolve() + if not solver_path.is_file(): + raise FileNotFoundError(f"candidate solver not found: {solver_path}") + + timeout = float(_parse_env_str("CVRP_EVAL_TIMEOUT_S") or DEFAULT_TIMEOUT_S) + inst_names = _parse_env_str("CVRP_EVAL_INSTANCES") + max_instances = _parse_env_str("CVRP_EVAL_MAX_INSTANCES") + + inst_paths = _all_instance_paths() + if inst_names: + picked = [ + p + for n in inst_names.replace(",", " ").split() + for p in inst_paths + if p.stem == n + ] + if picked: + inst_paths = picked + if max_instances: + inst_paths = inst_paths[: int(max_instances)] + + reference = load_reference() + python = sys.executable + tmp_dir = Path(tempfile.mkdtemp(prefix="cvrp_eval_")) + + # Anti-hardcoding: when a generation seed is provided, add fresh instances + # generated at evaluation time (the candidate cannot have memorized them). + gen_seed_raw = _parse_env_str("CVRP_EVAL_GENERATE_SEED") + if gen_seed_raw: + try: + gen_count = int(_parse_env_str("CVRP_EVAL_GENERATE_COUNT") or 6) + gen_count = max(0, gen_count) + except ValueError: + gen_count = 6 + if gen_count > 0: + gen_paths, gen_refs = _generate_instances(int(gen_seed_raw), gen_count, tmp_dir) + inst_paths = list(inst_paths) + gen_paths + reference.update(gen_refs) + + # Preflight: static integrity + determinism probe on the smallest instance. + try: + src_text = solver_path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + return {"metrics": {"combined_score": 0.0, "valid": 0.0}, "artifacts": {}} + baseline_path = _initial_baseline() + baseline_src = None + if baseline_path is not None: + try: + baseline_src = baseline_path.read_text(encoding="utf-8", errors="replace") + except Exception: + baseline_src = None + preflight = static_check_source(src_text, baseline_src) + if not preflight and inst_paths: + for probe in select_determinism_probes(inst_paths, parse_instance): + det_ok, det_note = check_determinism(python, solver_path, probe, timeout) + if not det_ok: + preflight = [f"determinism check failed on {probe.stem}: {det_note}"] + break + + rows = [] + for inst_path in inst_paths: + inst = parse_instance(inst_path) + out_path = tmp_dir / f"{inst['name']}.out.json" + ok = True + err = "" + if preflight: + ok = False + err = "preflight: " + "; ".join(preflight) + else: + ok, err = _run_candidate(python, solver_path, inst_path, out_path, timeout) + score = 0.0 + cand_dist = None + valid = False + note = err if not ok else "" + if ok: + try: + routes = json.loads(out_path.read_text(encoding="utf-8")) + valid, note, cand_dist = validate(routes, inst) + except Exception as exc: + note = f"output parse error: {exc}" + if valid and cand_dist is not None and cand_dist > 0: + ref = reference.get(inst["name"]) + if ref is not None and ref > 0: + scale = float(os.environ.get("CVRP_EVAL_SCORE_SCALE", "1.0")) + score = min(100.0, scale * 100.0 * ref / cand_dist) + else: + score = 0.0 + valid = False + note = f"no reference distance for {inst['name']}" + rows.append( + { + "name": inst["name"], + "valid": bool(valid), + "score": score, + "candidate_distance": cand_dist, + "note": note or "", + } + ) + + all_valid = all(r["valid"] for r in rows) + combined = ( + statistics.fmean(r["score"] for r in rows) if rows and all_valid else 0.0 + ) + metrics: dict[str, Any] = { + "combined_score": float(combined), + "valid": 1.0 if all_valid and rows else 0.0, + "instances": float(len(rows)), + "per_instance": { + r["name"]: {"score": r["score"], "valid": r["valid"], "note": r["note"]} + for r in rows + }, + } + artifacts: dict[str, Any] = { + "candidate_path": str(solver_path), + "timeout_s": timeout, + # Reference distances are deliberately NOT included (no leak). + "reference_instance_count": float(len(reference)), + } + return {"metrics": metrics, "artifacts": artifacts} diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/initial_program.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/initial_program.txt new file mode 100644 index 00000000..6645b02f --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/initial_program.txt @@ -0,0 +1 @@ +baseline/solver.py diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/readonly_files.txt b/benchmarks/VehicleRouting/CVRP/frontier_eval/readonly_files.txt new file mode 100644 index 00000000..ffd0453b --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/readonly_files.txt @@ -0,0 +1,5 @@ +README.md +Task.md +verification +data +frontier_eval diff --git a/benchmarks/VehicleRouting/CVRP/frontier_eval/run_eval.py b/benchmarks/VehicleRouting/CVRP/frontier_eval/run_eval.py new file mode 100644 index 00000000..720d0e09 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/frontier_eval/run_eval.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import argparse +import json +import sys +import traceback +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Any + +INVALID_COMBINED_SCORE = -1e18 + + +def _write_json(path: Path, obj: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(obj, ensure_ascii=False, indent=2, default=str) + "\n", + encoding="utf-8", + ) + + +def _normalize_result(result: Any) -> tuple[dict[str, Any], dict[str, Any]]: + if hasattr(result, "metrics") and hasattr(result, "artifacts"): + return dict(getattr(result, "metrics")), dict(getattr(result, "artifacts")) + + if isinstance(result, dict): + raw_metrics = result.get("metrics") + raw_artifacts = result.get("artifacts") + if isinstance(raw_metrics, dict): + return dict(raw_metrics), dict(raw_artifacts or {}) + return dict(result), {} + + raise TypeError( + "Evaluator must return an EvaluationResult-like object or a dict of metrics." + ) + + +def _load_local_evaluator() -> Any: + evaluator_path = Path(__file__).with_name("evaluator.py").resolve() + spec = spec_from_file_location("_frontier_eval_local_evaluator", evaluator_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Failed to load local evaluator from {evaluator_path}") + module = module_from_spec(spec) + spec.loader.exec_module(module) + try: + return getattr(module, "evaluate") + except AttributeError as exc: + raise RuntimeError( + f"Local evaluator does not define evaluate(): {evaluator_path}" + ) from exc + + +def _find_repo_root() -> Path: + import os + + env_root = os.environ.get("FRONTIER_ENGINEERING_ROOT") + if env_root: + return Path(env_root).expanduser().resolve() + + here = Path(__file__).resolve() + for parent in [here.parent, *here.parents]: + if (parent / "frontier_eval").is_dir() and (parent / "benchmarks").is_dir(): + return parent + return Path.cwd().resolve() + + +def _build_kwargs(evaluate_fn: Any) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + try: + parameters = inspect_signature(evaluate_fn) + except Exception: + return kwargs + + if "repo_root" in parameters: + kwargs["repo_root"] = _find_repo_root() + return kwargs + + +def inspect_signature(fn: Any) -> set[str]: + import inspect + + return set(inspect.signature(fn).parameters) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a benchmark-local unified evaluator and export metrics/artifacts JSON." + ) + parser.add_argument("--candidate", required=True) + parser.add_argument("--metrics-out", default="metrics.json") + parser.add_argument("--artifacts-out", default="artifacts.json") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = _parse_args(argv) + + candidate_path = Path(args.candidate).expanduser().resolve() + metrics_out = Path(args.metrics_out).expanduser().resolve() + artifacts_out = Path(args.artifacts_out).expanduser().resolve() + + metrics: dict[str, Any] = { + "combined_score": INVALID_COMBINED_SCORE, + "valid": 0.0, + } + artifacts: dict[str, Any] = { + "local_evaluator_path": str(Path(__file__).with_name("evaluator.py").resolve()), + "candidate_path": str(candidate_path), + } + + try: + evaluate_fn = _load_local_evaluator() + result = evaluate_fn(str(candidate_path), **_build_kwargs(evaluate_fn)) + metrics, evaluator_artifacts = _normalize_result(result) + artifacts.update(evaluator_artifacts) + except Exception as exc: + artifacts["error_message"] = str(exc) + artifacts["traceback"] = traceback.format_exc() + + _write_json(metrics_out, metrics) + _write_json(artifacts_out, artifacts) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/benchmarks/VehicleRouting/CVRP/verification/docker/Dockerfile b/benchmarks/VehicleRouting/CVRP/verification/docker/Dockerfile new file mode 100644 index 00000000..ec60ad3a --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/docker/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /workspace + +# The evaluator, reference solver and baseline use only the Python standard +# library. The unified runtime mounts the benchmark sandbox into the +# container, so no benchmark files are baked into the image (this keeps +# reference.json and the reference solver out of the container image). + +ENV PYTHONUNBUFFERED=1 + +CMD ["python"] diff --git a/benchmarks/VehicleRouting/CVRP/verification/evaluator.py b/benchmarks/VehicleRouting/CVRP/verification/evaluator.py new file mode 100644 index 00000000..0bdc1575 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/evaluator.py @@ -0,0 +1,439 @@ +"""CVRP verification evaluator. + +Runs a candidate solver program on a fixed set of CVRP instances (public + +held-out), validates the produced routes (full coverage, no duplicates, +capacity respected) and scores each instance relative to the precomputed +reference distance: + + score = min(100, 100 * reference_distance / candidate_distance) + +An invalid/crashing/timing-out candidate scores 0 on that instance and marks +the whole run invalid. + +Security / integrity: + * The candidate is checked before running: the EVOLVE-BLOCK markers must be + present, code outside the markers must match the initial baseline, and the + source must not reference the verification module, the reference solver, + reference.json, absolute filesystem paths, or hardcode per-instance routes. + * The candidate subprocess runs with an environment stripped of + FRONTIER_EVAL_UNIFIED_* variables and any reference-distance settings, so + it cannot learn where the scoring baseline lives. + * reference.json is read from the host benchmark directory (via + FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR when running inside the unified + sandbox) and is NOT copied into the sandbox. + +Usage (inside the CVRP task directory): + python verification/evaluator.py baseline/solver.py + python verification/evaluator.py baseline/solver.py --instances VRP-19-2 VRP-32-5 + +Environment overrides: + CVRP_EVAL_TIMEOUT_S per-instance subprocess timeout (default 60) + CVRP_EVAL_INSTANCES space/comma separated subset of instance names + CVRP_EVAL_MAX_INSTANCES cap on number of instances + CVRP_EVAL_SCORE_SCALE score knob (default 1.0) + CVRP_EVAL_REFERENCE_JSON override path to reference.json (testing only) + CVRP_EVAL_GENERATE_SEED when set, additionally generate fresh instances + from this seed at evaluation time (anti-hardcoding: + the candidate cannot have seen them); each generated + instance is scored against a reference computed on + the fly by the reference solver + CVRP_EVAL_GENERATE_COUNT number of generated instances (default 6) +""" +from __future__ import annotations + +import argparse +import json +import math +import os +import statistics +import subprocess +import sys +import tempfile +import time +import traceback +from pathlib import Path +from typing import Any + +TASK_ROOT = Path(__file__).resolve().parents[1] # /benchmarks/VehicleRouting/CVRP +INSTANCES_DIR = TASK_ROOT / "data" / "instances" +HELDOUT_DIR = TASK_ROOT / "data" / "instances_heldout" +REFERENCE_JSON = TASK_ROOT / "data" / "reference.json" +BASELINE_PATH = TASK_ROOT / "baseline" / "solver.py" + +# Candidate integrity checks live in validator.py (shared with tests). +from validator import ( # noqa: E402 + EVOLVE_START, + EVOLVE_END, + candidate_env, + check_candidate, + check_determinism, + select_determinism_probes, + split_evolve_blocks, +) + +# Runtime instance generation (anti-hardcoding) reuses the deterministic +# instance generator and the reference solver; see the design note in +# investigating/docs/CVRP_heldout_记忆漏洞_问题与修复方案.md. +from generate_instances import SPECS, generate_instance # noqa: E402 +from ref_solver import grasp_solve, route_dist # noqa: E402 + +# Same fixed seed list the reference solver uses when producing reference.json. +REF_SEEDS = (123, 2024, 7) +DEFAULT_GENERATE_COUNT = 6 + +# Backward-compatible alias used by the unit tests. +_split_evolve_blocks = split_evolve_blocks + +DEFAULT_TIMEOUT_S = 60 + + +def parse_instance(path: Path) -> dict: + """Parse a TSPLIB-style CVRP .vrp file into an instance dict.""" + text = path.read_text(encoding="utf-8", errors="ignore") + coords: dict[int, tuple[float, float]] = {} + demands: dict[int, int] = {} + capacity = 0 + section = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + upper = line.upper() + if upper.startswith("CAPACITY"): + capacity = int(line.split(":")[-1].strip()) + continue + if upper == "NODE_COORD_SECTION": + section = "coords" + continue + if upper == "DEMAND_SECTION": + section = "demand" + continue + if upper == "DEPOT_SECTION": + section = None + continue + if upper == "EOF" or upper.startswith(("EDGE_WEIGHT", "DISPLAY_DATA")): + section = None + continue + if upper.startswith(("NAME", "COMMENT", "TYPE", "DIMENSION")): + continue + if section == "coords": + parts = line.split() + if len(parts) >= 3: + coords[int(parts[0])] = (float(parts[1]), float(parts[2])) + elif section == "demand": + parts = line.split() + if len(parts) >= 2: + demands[int(parts[0])] = int(parts[1]) + n_customers = max(coords) - 1 # depot is id 1, customers are ids 2..n+1 + pts = [coords[1]] + [coords[i] for i in range(2, n_customers + 2)] + dist = [[0] * (n_customers + 1) for _ in range(n_customers + 1)] + for i in range(n_customers + 1): + for j in range(n_customers + 1): + dx = pts[i][0] - pts[j][0] + dy = pts[i][1] - pts[j][1] + dist[i][j] = int(round(math.hypot(dx, dy))) + return { + "name": path.stem, + "n": n_customers, + "capacity": capacity, + "demand": [0] + [demands.get(i, 0) for i in range(2, n_customers + 2)], + "distance": dist, + } + + +def route_distance(routes: list, dist: list[list[int]]) -> int: + total = 0 + for seq in routes: + if not seq: + continue + total += dist[0][seq[0]] + for a, b in zip(seq, seq[1:]): + total += dist[a][b] + total += dist[seq[-1]][0] + return total + + +def validate(routes: Any, inst: dict) -> tuple[bool, str, int | None]: + """Return (ok, error_message, total_distance).""" + n, cap, demand = inst["n"], inst["capacity"], inst["demand"] + if not isinstance(routes, list): + return False, "routes is not a list", None + seen: list[int] = [] + for seq in routes: + if not isinstance(seq, list): + return False, "a route is not a list", None + load = 0 + for c in seq: + if not isinstance(c, int) or isinstance(c, bool): + return False, f"route contains non-integer {c!r}", None + if c < 1 or c > n: + return False, f"customer id {c} out of range 1..{n}", None + if c in seen: + return False, f"customer {c} visited more than once", None + seen.append(c) + load += demand[c] + if load > cap: + return False, f"route {seq} exceeds capacity {cap} (load {load})", None + if set(seen) != set(range(1, n + 1)): + missing = set(range(1, n + 1)) - set(seen) + return False, f"customers not served: {sorted(missing)}", None + return True, "", route_distance(routes, inst["distance"]) + + +def run_candidate( + python: str, solver_path: Path, inst_path: Path, out_path: Path, timeout: float +) -> tuple[bool, str]: + try: + proc = subprocess.run( + [python, str(solver_path), str(inst_path), str(out_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(solver_path.parent), + env=candidate_env(), + ) + except subprocess.TimeoutExpired: + return False, "timeout" + except OSError as exc: + return False, f"os error: {exc}" + if proc.returncode != 0: + return False, f"exited with code {proc.returncode}: {(proc.stderr or '')[:200]}" + return True, "" + + +def _source_benchmark_dir() -> Path | None: + """Host benchmark dir exposed by the unified sandbox (if running there).""" + raw = os.environ.get("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", "").strip() + if raw: + path = Path(raw) + if path.is_dir(): + return path + return None + + +def load_reference() -> dict[str, float]: + """Reference distances, preferring the host copy over the sandbox copy.""" + candidates: list[Path] = [] + src = _source_benchmark_dir() + if src is not None: + candidates.append(src / "data" / "reference.json") + env_path = os.environ.get("CVRP_EVAL_REFERENCE_JSON", "").strip() + if env_path: + candidates.append(Path(env_path)) + candidates.append(REFERENCE_JSON) + for path in candidates: + if not path.is_file(): + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + return {k: float(v) for k, v in raw.items()} + except Exception: + continue + return {} + + +def _parse_env_str(name: str) -> str | None: + raw = __import__("os").environ.get(name, "").strip() + return raw or None + + +def _all_instance_paths() -> list[Path]: + """Public instances come from the sandbox copy; held-out instances stay on + the host (never copied into the sandbox), so a candidate cannot read them + during evolution. They are still scored: the candidate receives their path + at scoring time (host path in process mode, repo-mount path in docker).""" + paths = sorted(INSTANCES_DIR.glob("*.vrp")) + src = _source_benchmark_dir() + heldout_dir = (src / "data" / "instances_heldout") if src is not None else HELDOUT_DIR + if heldout_dir.is_dir(): + paths += sorted(heldout_dir.glob("*.vrp")) + return paths + + +def _reference_distance(inst: dict) -> float: + """Deterministic reference distance for an instance (GRASP + LNS, fixed seeds).""" + iters = max(100, 4 * inst["n"]) + return min( + sum( + route_dist(r, inst["distance"]) + for r in grasp_solve(inst, starts=40, seed=seed, lns_iters=iters) + ) + for seed in REF_SEEDS + ) + + +def _generate_instances(base_seed: int, count: int, out_dir: Path) -> tuple[list[Path], dict[str, float]]: + """Generate fresh instances from `base_seed`; return (instance files, ref distances). + + Instance sizes follow the public set's SPECS (cycled), and per-instance RNG + streams are derived as `base_seed * 1000 + i`, matching multiseed_stat.py. + Deterministic: same seed + same machine yields the same instances and refs. + """ + paths: list[Path] = [] + refs: dict[str, float] = {} + spec_count = len(SPECS) + for i in range(count): + name, n, k, v, _seed_key = SPECS[i % spec_count] + fname = f"GEN-{base_seed}-{i + 1}" + text = generate_instance(fname, n, k, v, seed=base_seed * 1000 + i) + inst_path = out_dir / f"{fname}.vrp" + inst_path.write_text(text, encoding="ascii", newline="\n") + inst = parse_instance(inst_path) + refs[fname] = float(_reference_distance(inst)) + paths.append(inst_path) + return paths, refs + + +def evaluate(program_path: str, *, repo_root: Path | None = None) -> dict[str, Any]: + solver_path = Path(program_path).resolve() + if not solver_path.is_file(): + raise FileNotFoundError(f"candidate solver not found: {solver_path}") + + timeout = float(_parse_env_str("CVRP_EVAL_TIMEOUT_S") or DEFAULT_TIMEOUT_S) + inst_names = _parse_env_str("CVRP_EVAL_INSTANCES") + max_instances = _parse_env_str("CVRP_EVAL_MAX_INSTANCES") + + inst_paths = _all_instance_paths() + if inst_names: + picked = [p for n in inst_names.replace(",", " ").split() for p in inst_paths if p.stem == n] + if picked: + inst_paths = picked + if max_instances: + inst_paths = inst_paths[: int(max_instances)] + + reference = load_reference() + python = sys.executable + tmp_dir = Path(tempfile.mkdtemp(prefix="cvrp_eval_")) + + # Anti-hardcoding: when a generation seed is provided, add fresh instances + # generated at evaluation time (the candidate cannot have memorized them). + gen_seed_raw = _parse_env_str("CVRP_EVAL_GENERATE_SEED") + if gen_seed_raw: + try: + gen_count = int(_parse_env_str("CVRP_EVAL_GENERATE_COUNT") or DEFAULT_GENERATE_COUNT) + gen_count = max(0, gen_count) + except ValueError: + gen_count = DEFAULT_GENERATE_COUNT + if gen_count > 0: + gen_paths, gen_refs = _generate_instances(int(gen_seed_raw), gen_count, tmp_dir) + inst_paths = list(inst_paths) + gen_paths + reference.update(gen_refs) + + # Static integrity checks + determinism (probe small / medium / large + # instances; each probe runs the candidate twice). + src_dir = _source_benchmark_dir() + baseline_path = ( + (src_dir / "baseline" / "solver.py") if src_dir is not None else BASELINE_PATH + ) + preflight = check_candidate(solver_path, baseline_path=baseline_path) + if not preflight and inst_paths: + for probe in select_determinism_probes(inst_paths, parse_instance): + det_ok, det_note = check_determinism( + python, solver_path, probe, timeout + ) + if not det_ok: + preflight = [ + f"determinism check failed on {probe.stem}: {det_note}" + ] + break + + rows = [] + for inst_path in inst_paths: + inst = parse_instance(inst_path) + out_path = tmp_dir / f"{inst['name']}.out.json" + ok = True + err = "" + if preflight: + ok = False + err = "preflight: " + "; ".join(preflight) + else: + ok, err = run_candidate(python, solver_path, inst_path, out_path, timeout) + score = 0.0 + cand_dist = None + valid = False + note = err if not ok else "" + if ok: + try: + routes = json.loads(out_path.read_text(encoding="utf-8")) + valid, note, cand_dist = validate(routes, inst) + except Exception as exc: + note = f"output parse error: {exc}" + if valid and cand_dist is not None and cand_dist > 0: + ref = reference.get(inst["name"]) + if ref is not None and ref > 0: + # Score scale: experimental knob to tighten the 100-point bar. + # Default 1.0 => score = min(100, 100*ref/cand). A scale s means + # a candidate must be s times shorter than the reference to hit + # 100 (e.g. s=2/3 => needs cand <= ref*2/3). + scale = float(os.environ.get("CVRP_EVAL_SCORE_SCALE", "1.0")) + score = min(100.0, scale * 100.0 * ref / cand_dist) + else: + score = 0.0 + valid = False + note = f"no reference distance for {inst['name']}" + rows.append( + { + "name": inst["name"], + "valid": bool(valid), + "score": score, + "candidate_distance": cand_dist, + "note": note or "", + } + ) + + all_valid = all(r["valid"] for r in rows) + combined = ( + statistics.fmean(r["score"] for r in rows) if rows and all_valid else 0.0 + ) + metrics: dict[str, Any] = { + "combined_score": float(combined), + "valid": 1.0 if all_valid and rows else 0.0, + "instances": float(len(rows)), + "per_instance": { + r["name"]: {"score": r["score"], "valid": r["valid"], "note": r["note"]} + for r in rows + }, + } + artifacts = { + "candidate_path": str(solver_path), + "timeout_s": timeout, + # NOTE: reference distances are deliberately NOT included here to avoid + # leaking the scoring baseline to the agent. + "reference_instance_count": float(len(reference)), + } + return {"metrics": metrics, "artifacts": artifacts} + + +def _print_report(metrics: dict[str, Any]) -> None: + print(f"combined_score: {metrics['combined_score']:.2f}") + print(f"valid: {metrics['valid']}") + for name, info in metrics.get("per_instance", {}).items(): + flag = "OK " if info["valid"] else "BAD" + note = f" ({info['note']})" if info.get("note") else "" + print(f" {flag} {name}: score={info['score']:.2f}{note}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="CVRP candidate evaluator") + parser.add_argument("candidate", help="path to candidate solver.py") + parser.add_argument("--instances", nargs="*", default=None) + args = parser.parse_args(argv) + + os_env = __import__("os").environ + if args.instances: + os_env["CVRP_EVAL_INSTANCES"] = " ".join(args.instances) + + t0 = time.perf_counter() + try: + result = evaluate(args.candidate) + metrics, artifacts = result["metrics"], result["artifacts"] + _print_report(metrics) + print(f"wall time: {time.perf_counter() - t0:.1f}s") + return 0 + except Exception: + print(traceback.format_exc(), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/VehicleRouting/CVRP/verification/generate_instances.py b/benchmarks/VehicleRouting/CVRP/verification/generate_instances.py new file mode 100644 index 00000000..a192de0f --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/generate_instances.py @@ -0,0 +1,145 @@ +"""Generate reproducible CVRP instances (clustered Euclidean, TSPLIB format). + +Usage: + python verification/generate_instances.py + +Writes TSPLIB-style .vrp files under data/instances/. +Instances are deterministic (fixed seed) so results are reproducible everywhere. +""" +from __future__ import annotations + +import math +import random +from pathlib import Path + +SEED = 42 +OUT_DIR = Path(__file__).resolve().parents[1] / "data" / "instances" +HELDOUT_DIR = Path(__file__).resolve().parents[1] / "data" / "instances_heldout" + +# (name, num_customers, num_clusters, num_vehicles_approx, seed_key) +# `seed_key` fixes the RNG stream per instance. The keys are the original +# G-* identifiers: the dataset was first released under those names, and +# keeping the keys means the coordinates/demands stay byte-identical to the +# validated release. Do not change them. +SPECS = [ + ("VRP-19-2", 19, 2, 2, "G-n19-k2"), + ("VRP-21-3", 21, 3, 3, "G-n21-k3"), + ("VRP-22-4", 22, 3, 4, "G-n22-k4"), + ("VRP-32-5", 32, 4, 5, "G-n32-k5"), + ("VRP-37-6", 37, 4, 6, "G-n37-k6"), + ("VRP-45-6", 45, 5, 6, "G-n45-k6"), + ("VRP-45-7", 45, 5, 7, "G-n45-k7"), + ("VRP-48-7", 48, 5, 7, "G-n48-k7"), + ("VRP-54-8", 54, 6, 8, "G-n54-k8"), + ("VRP-55-8", 55, 6, 8, "G-n55-k8"), + ("VRP-60-9", 60, 6, 9, "G-n60-k9"), + ("VRP-60-10", 60, 6, 10, "G-n60-k10"), +] + +# Held-out instances: used only at evaluation time, never shown to the agent +# (they are absent from agent_files and Task.md), so a candidate cannot hardcode +# routes by instance name. Names use a distinct VHO- prefix. +HELDOUT_SPECS = [ + ("VHO-22-3", 22, 3, 3), + ("VHO-24-3", 24, 3, 3), + ("VHO-27-4", 27, 3, 4), + ("VHO-29-4", 29, 4, 4), + ("VHO-33-5", 33, 4, 5), + ("VHO-36-5", 36, 4, 5), + ("VHO-39-6", 39, 5, 6), + ("VHO-43-6", 43, 5, 6), + ("VHO-47-7", 47, 5, 7), + ("VHO-51-7", 51, 6, 7), + ("VHO-55-8", 55, 6, 8), + ("VHO-58-9", 58, 6, 9), +] + + +def _round_dist(x1: float, y1: float, x2: float, y2: float) -> int: + return int(round(math.hypot(x1 - x2, y1 - y2))) + + +def generate_instance( + name: str, + num_customers: int, + num_clusters: int, + vehicles: int, + seed_key: str | None = None, + seed: int | None = None, +) -> str: + """Generate one deterministic CVRP instance as TSPLIB text. + + Exactly one of `seed_key` (legacy: fixed base seed + key, e.g. "G-n19-k2") + or `seed` (arbitrary integer) must be provided; `seed` lets callers derive + fresh instances at evaluation time without touching the released dataset. + """ + if seed is not None: + rng = random.Random(seed) + else: + assert seed_key is not None, "provide either seed_key or seed" + rng = random.Random(f"{SEED}:{seed_key}") + # Cluster centers spread over a 100x100 region. + centers = [ + (rng.uniform(15, 85), rng.uniform(15, 85)) for _ in range(num_clusters) + ] + # Depot at a central-ish position. + depot = (50.0, 50.0) + + coords = [depot] + for i in range(num_customers): + cx, cy = centers[i % len(centers)] + x = min(100, max(1, round(cx + rng.gauss(0, 7.0)))) + y = min(100, max(1, round(cy + rng.gauss(0, 7.0)))) + coords.append((x, y)) + + demand = [0] + [rng.randint(5, 35) for _ in range(num_customers)] + total_demand = sum(demand) + # Capacity tuned so the instance is feasible with `vehicles` vehicles. + capacity = max(30, math.ceil(total_demand / vehicles * 1.25 / 5) * 5) + + dim = num_customers + 1 + lines = [ + f"NAME: {name}", + "COMMENT: generated clustered CVRP instance (deterministic)", + "TYPE: CVRP", + f"DIMENSION: {dim}", + f"CAPACITY: {capacity}", + "NODE_COORD_SECTION", + ] + for idx, (x, y) in enumerate(coords, start=1): + lines.append(f"{idx} {x} {y}") + lines.append("DEMAND_SECTION") + for idx in range(1, dim + 1): + lines.append(f"{idx} {demand[idx - 1]}") + lines.append("DEPOT_SECTION") + lines.append("1") + lines.append("EOF") + return "\n".join(lines) + "\n" + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + for name, n, k, v, seed_key in SPECS: + text = generate_instance(name, n, k, v, seed_key) + (OUT_DIR / f"{name}.vrp").write_text( + text, encoding="ascii", newline="\n" # force LF: byte-identical on any OS + ) + print( + f"wrote {name}.vrp (customers={n}, " + f"capacity={text.split('CAPACITY: ')[1].split()[0]})" + ) + + HELDOUT_DIR.mkdir(parents=True, exist_ok=True) + for name, n, k, v in HELDOUT_SPECS: + text = generate_instance(name, n, k, v, seed_key=f"HO-{name}") + (HELDOUT_DIR / f"{name}.vrp").write_text( + text, encoding="ascii", newline="\n" + ) + print( + f"wrote heldout {name}.vrp (customers={n}, " + f"capacity={text.split('CAPACITY: ')[1].split()[0]})" + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/VehicleRouting/CVRP/verification/multiseed_stat.py b/benchmarks/VehicleRouting/CVRP/verification/multiseed_stat.py new file mode 100644 index 00000000..476f11a9 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/multiseed_stat.py @@ -0,0 +1,111 @@ +"""Multi-seed statistics for the CVRP baseline. + +The released evaluation set (24 instances: 12 public + 12 held-out) is fixed, +so the baseline solver is deterministic on it. To satisfy "multi-seed / +multi-run statistics" (reviewer request), this script derives *fresh* +evaluation instance sets from multiple seeds (via +`generate_instances.generate_instance(..., seed=...)`), computes each set's +reference distances on the fly (deterministic GRASP + LNS), and reports the +baseline score distribution across seeds. + +Usage (inside the CVRP task directory): + python verification/multiseed_stat.py [--seeds 111 222 333] + +Deterministic: same command + same machine yields the same numbers. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from evaluator import validate # noqa: E402 +from generate_instances import SPECS, generate_instance # noqa: E402 +from ref_solver import grasp_solve, parse_instance, route_dist # noqa: E402 + +CVRP_ROOT = Path(__file__).resolve().parents[1] +BASELINE = CVRP_ROOT / "baseline" / "solver.py" +REF_SEEDS = (123, 2024, 7) # same fixed seed list as ref_solver.main() + + +def reference_distance(inst: dict) -> int: + iters = max(100, 4 * inst["n"]) + return min( + sum( + route_dist(r, inst["distance"]) + for r in grasp_solve(inst, starts=40, seed=seed, lns_iters=iters) + ) + for seed in REF_SEEDS + ) + + +def run_baseline( + python: str, inst_path: Path, out_path: Path, timeout: float +) -> list: + proc = subprocess.run( + [python, str(BASELINE), str(inst_path), str(out_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(BASELINE.parent), + ) + if proc.returncode != 0: + raise RuntimeError(f"baseline failed on {inst_path.name}: {proc.stderr[:200]}") + return json.loads(out_path.read_text(encoding="utf-8")) + + +def score_seed(seed: int, python: str, tmp: Path, timeout: float) -> tuple[float, list]: + """Derive one instance set from `seed`; return (combined, per-instance).""" + inst_dir = tmp / f"s{seed}" + inst_dir.mkdir(parents=True, exist_ok=True) + scores = [] + for i, (name, n, k, v, _seed_key) in enumerate(SPECS): + fname = f"S{seed}-{i + 1}" + text = generate_instance(fname, n, k, v, seed=seed * 1000 + i) + inst_path = inst_dir / f"{fname}.vrp" + inst_path.write_text(text, encoding="ascii", newline="\n") + + inst = parse_instance(inst_path) + ref = reference_distance(inst) + + out_path = inst_dir / f"{fname}.out.json" + routes = run_baseline(python, inst_path, out_path, timeout) + ok, note, cand = validate(routes, inst) + if not ok: + raise RuntimeError(f"baseline invalid on {fname}: {note}") + scores.append(min(100.0, 100.0 * ref / cand)) + return statistics.fmean(scores), scores + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="CVRP baseline multi-seed stats") + parser.add_argument("--seeds", nargs="*", type=int, default=[111, 222, 333]) + parser.add_argument("--timeout", type=float, default=60.0) + args = parser.parse_args(argv) + + python = sys.executable + with tempfile.TemporaryDirectory(prefix="cvrp_multiseed_") as td: + tmp = Path(td) + per_seed: list[tuple[int, float, list]] = [] + for seed in args.seeds: + combined, scores = score_seed(seed, python, tmp, args.timeout) + per_seed.append((seed, combined, scores)) + print(f"seed {seed}: combined={combined:.2f}") + + combined_values = [c for _, c, _ in per_seed] + print( + f"multi-seed: mean={statistics.fmean(combined_values):.2f} " + f"std={statistics.pstdev(combined_values):.2f} " + f"min={min(combined_values):.2f} max={max(combined_values):.2f} " + f"seeds={args.seeds}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/VehicleRouting/CVRP/verification/ref_solver.py b/benchmarks/VehicleRouting/CVRP/verification/ref_solver.py new file mode 100644 index 00000000..9f8fb52d --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/ref_solver.py @@ -0,0 +1,487 @@ +"""Reference solver for CVRP: GRASP multi-start + intra 2-opt + +relocate/swap/2-opt* + LNS refinement with greedy repair and tabu +diversification. + +Pure standard library. Used to produce data/reference.json (the score +baseline per instance). + +Deterministic: every source of randomness is seeded and the LNS search +budget is measured in *iterations*, not wall-clock time, so regenerating +reference.json on any machine yields byte-identical output. main() runs a +fixed list of seeds and keeps the best distance per instance. + +Usage: + python verification/ref_solver.py [--starts N] [--iterations N] + + --starts N number of GRASP multi-start constructions (default 40) + --iterations N fixed LNS iterations per instance (default: max(100, 4*n)) +""" +from __future__ import annotations + +import argparse +import json +import math +import random +from collections import deque +from pathlib import Path + +INST_DIR = Path(__file__).resolve().parents[1] / "data" / "instances" +HELDOUT_DIR = Path(__file__).resolve().parents[1] / "data" / "instances_heldout" +OUT_JSON = Path(__file__).resolve().parents[1] / "data" / "reference.json" + + +def parse_instance(path: Path) -> dict: + """Parse a TSPLIB-style CVRP .vrp file.""" + text = path.read_text(encoding="utf-8", errors="ignore") + coords: dict[int, tuple[float, float]] = {} + demands: dict[int, int] = {} + capacity = 0 + section = None + for line in text.splitlines(): + line = line.strip() + if not line: + continue + upper = line.upper() + if upper.startswith("CAPACITY"): + capacity = int(line.split(":")[-1].strip()) + continue + if upper == "NODE_COORD_SECTION": + section = "coords" + continue + if upper == "DEMAND_SECTION": + section = "demand" + continue + if upper == "DEPOT_SECTION": + section = "depot" + continue + if upper == "EOF" or upper.startswith("EDGE_WEIGHT") or upper.startswith("DISPLAY_DATA"): + section = None + continue + if upper.startswith(("NAME", "COMMENT", "TYPE", "DIMENSION")): + continue + if section == "coords": + parts = line.split() + if len(parts) >= 3: + coords[int(parts[0])] = (float(parts[1]), float(parts[2])) + elif section == "demand": + parts = line.split() + if len(parts) >= 2: + demands[int(parts[0])] = int(parts[1]) + n_customers = max(coords) - 1 # depot is id 1, customers are ids 2..n+1 + pts = [coords[1]] + [coords[i] for i in range(2, n_customers + 2)] + dist = [[0] * (n_customers + 1) for _ in range(n_customers + 1)] + for i in range(n_customers + 1): + for j in range(n_customers + 1): + dx = pts[i][0] - pts[j][0] + dy = pts[i][1] - pts[j][1] + dist[i][j] = int(round(math.hypot(dx, dy))) + demand = [0] + [demands.get(i, 0) for i in range(2, n_customers + 2)] + return { + "n": n_customers, + "capacity": capacity, + "demand": demand, + "distance": dist, + "name": path.stem, + } + + +def route_dist(seq: list[int], dist: list[list[int]]) -> int: + if not seq: + return 0 + total = dist[0][seq[0]] + for a, b in zip(seq, seq[1:]): + total += dist[a][b] + total += dist[seq[-1]][0] + return total + + +def two_opt(seq: list[int], dist: list[list[int]]) -> list[int]: + best = seq[:] + improved = True + while improved: + improved = False + for a in range(len(best) - 1): + for b in range(a + 1, len(best)): + cand = best[:a] + best[a : b + 1][::-1] + best[b + 1 :] + if route_dist(cand, dist) < route_dist(best, dist): + best = cand + improved = True + return best + + +def savings_solve(inst: dict, rng: random.Random | None = None) -> list[list[int]]: + n, cap = inst["n"], inst["capacity"] + demand, dist = inst["demand"], inst["distance"] + routes = {c: [c] for c in range(1, n + 1)} + load = {c: demand[c] for c in range(1, n + 1)} + first = {c: c for c in range(1, n + 1)} + last = {c: c for c in range(1, n + 1)} + owner = {c: c for c in range(1, n + 1)} + + savings = [] + for i in range(1, n + 1): + for j in range(i + 1, n + 1): + savings.append((dist[0][i] + dist[0][j] - dist[i][j], i, j)) + if rng is None: + savings.sort(reverse=True) + else: + # Randomized savings (GRASP-style perturbation) for multi-start search. + savings.sort( + key=lambda t: t[0] + rng.uniform(-abs(t[0]) * 0.5, abs(t[0]) * 0.5), + reverse=True, + ) + + for _s, i, j in savings: + ri, rj = owner[i], owner[j] + if ri == rj: + continue + if load[ri] + load[rj] > cap: + continue + # Merge rj into ri when ri's tail == i and rj's head == j (or symmetric). + if last[ri] == i and first[rj] == j: + for c in routes[rj]: + owner[c] = ri + routes[ri] = routes[ri] + routes[rj] + load[ri] += load[rj] + last[ri] = last[rj] + del routes[rj], load[rj], first[rj], last[rj] + elif last[rj] == i and first[ri] == j: + for c in routes[ri]: + owner[c] = rj + routes[rj] = routes[rj] + routes[ri] + load[rj] += load[ri] + last[rj] = last[ri] + del routes[ri], load[ri], first[ri], last[ri] + elif last[ri] == j and first[rj] == i: + for c in routes[rj]: + owner[c] = ri + routes[ri] = routes[ri] + routes[rj] + load[ri] += load[rj] + last[ri] = last[rj] + del routes[rj], load[rj], first[rj], last[rj] + elif last[rj] == j and first[ri] == i: + for c in routes[ri]: + owner[c] = rj + routes[rj] = routes[rj] + routes[ri] + load[rj] += load[ri] + last[rj] = last[ri] + del routes[ri], load[ri], first[ri], last[ri] + + final = [] + for seq in routes.values(): + final.append(two_opt(seq, dist)) + return final + + +def relocate_improve(routes: list[list[int]], inst: dict) -> list[list[int]]: + """Best-improve cross-route relocate local search. + + Repeatedly moves the single customer that most reduces total distance + (removal from its route + best insertion into another route), respecting + capacity, until no improving move remains. + """ + n, cap, demand, dist = inst["n"], inst["capacity"], inst["demand"], inst["distance"] + routes = [list(r) for r in routes if r] + improved = True + while improved: + improved = False + best_gain = 0.0 + best = None # (ri, pos, rj, c) + for ri in range(len(routes)): + route = routes[ri] + if not route: + continue + d_ri = route_dist(route, dist) + for pos in range(len(route)): + c = route[pos] + ra = route[:pos] + route[pos + 1 :] + d_ra = route_dist(ra, dist) if ra else 0 + gain_remove = d_ri - d_ra + for rj in range(len(routes)): + if rj == ri: + continue + rj_route = routes[rj] + if not rj_route: + if demand[c] > cap: + continue + gain_insert = -(dist[0][c] + dist[c][0]) + else: + if sum(demand[x] for x in rj_route) + demand[c] > cap: + continue + d_rj = route_dist(rj_route, dist) + gain_insert = max( + d_rj + - route_dist( + rj_route[:ipos] + [c] + rj_route[ipos:], dist + ) + for ipos in range(len(rj_route) + 1) + ) + total = gain_remove + gain_insert + if total > best_gain + 1e-9: + best_gain = total + best = (ri, pos, rj, c) + if best is not None: + ri, pos, rj, c = best + routes[ri].pop(pos) + if not routes[ri]: + routes.pop(ri) + if rj > ri: + rj -= 1 + rj_route = routes[rj] + best_ipos = min( + range(len(rj_route) + 1), + key=lambda ipos: route_dist(rj_route[:ipos] + [c] + rj_route[ipos:], dist), + ) + routes[rj].insert(best_ipos, c) + improved = True + return routes + + +def swap_improve(routes: list[list[int]], inst: dict) -> list[list[int]]: + """Best-improve cross-route swap local search (exchange one customer each).""" + cap, demand, dist = inst["capacity"], inst["demand"], inst["distance"] + routes = [list(r) for r in routes if r] + improved = True + while improved: + improved = False + best_gain = 0.0 + best = None # (ri, pi, rj, pj) + for ri in range(len(routes)): + for rj in range(ri + 1, len(routes)): + a, b = routes[ri], routes[rj] + load_a = sum(demand[x] for x in a) + load_b = sum(demand[x] for x in b) + d_a = route_dist(a, dist) + d_b = route_dist(b, dist) + for pi in range(len(a)): + ci = a[pi] + for pj in range(len(b)): + cj = b[pj] + if load_a - demand[ci] + demand[cj] > cap: + continue + if load_b - demand[cj] + demand[ci] > cap: + continue + new_a = a[:pi] + [cj] + a[pi + 1 :] + new_b = b[:pj] + [ci] + b[pj + 1 :] + gain = (d_a + d_b) - ( + route_dist(new_a, dist) + route_dist(new_b, dist) + ) + if gain > best_gain + 1e-9: + best_gain = gain + best = (ri, pi, rj, pj) + if best is not None: + ri, pi, rj, pj = best + routes[ri][pi], routes[rj][pj] = routes[rj][pj], routes[ri][pi] + improved = True + return routes + + +def two_opt_star_improve(routes: list[list[int]], inst: dict) -> list[list[int]]: + """Best-improve cross-route 2-opt* local search (swap route tails).""" + cap, demand, dist = inst["capacity"], inst["demand"], inst["distance"] + routes = [list(r) for r in routes if r] + improved = True + while improved: + improved = False + best_gain = 0.0 + best = None # (ri, rj, new_a, new_b) + for ri in range(len(routes)): + for rj in range(ri + 1, len(routes)): + a, b = routes[ri], routes[rj] + d_a = route_dist(a, dist) + d_b = route_dist(b, dist) + for pi in range(len(a)): + for pj in range(len(b)): + new_a = a[: pi + 1] + b[pj + 1 :] + new_b = b[: pj + 1] + a[pi + 1 :] + if sum(demand[x] for x in new_a) > cap: + continue + if sum(demand[x] for x in new_b) > cap: + continue + gain = (d_a + d_b) - ( + route_dist(new_a, dist) + route_dist(new_b, dist) + ) + if gain > best_gain + 1e-9: + best_gain = gain + best = (ri, rj, new_a, new_b) + if best is not None: + ri, rj, new_a, new_b = best + routes[ri] = new_a + routes[rj] = new_b + improved = True + return routes + + +def local_search(routes: list[list[int]], inst: dict) -> list[list[int]]: + """Intensify a solution: intra 2-opt + Or-opt + relocate + swap + 2-opt* + to a fixed point.""" + dist = inst["distance"] + routes = [list(r) for r in routes if r] + improved = True + while improved: + before = sum(route_dist(r, dist) for r in routes) + routes = [two_opt(r, dist) for r in routes] + routes = relocate_improve(routes, inst) + routes = swap_improve(routes, inst) + routes = two_opt_star_improve(routes, inst) + after = sum(route_dist(r, dist) for r in routes) + improved = after < before - 1e-9 + return routes + + +def greedy_repair( + routes: list[list[int]], unrouted: list[int], inst: dict, rng: random.Random +) -> list[list[int]]: + """Reinsert every customer in `unrouted` at its cheapest feasible position. + + Insertion order is a random permutation driven by `rng`, and a new route + (depot-customer-depot) is used whenever it is cheaper than any feasible + insertion or none exists. Deterministic for a fixed `rng`. + """ + cap, demand, dist = inst["capacity"], inst["demand"], inst["distance"] + routes = [list(r) for r in routes if r] + for c in rng.sample(unrouted, len(unrouted)): + best_inc = None # cheapest insertion cost increase + best_pos = None # (route index, position) + for ri, r in enumerate(routes): + if sum(demand[x] for x in r) + demand[c] > cap: + continue + for pos in range(len(r) + 1): + pred = r[pos - 1] if pos > 0 else 0 + succ = r[pos] if pos < len(r) else 0 + inc = dist[pred][c] + dist[c][succ] - dist[pred][succ] + if best_inc is None or inc < best_inc - 1e-9: + best_inc = inc + best_pos = (ri, pos) + new_route_inc = 2 * dist[0][c] + if best_inc is None or new_route_inc < best_inc - 1e-9: + routes.append([c]) + else: + ri, pos = best_pos + routes[ri].insert(pos, c) + return routes + + +def lns_improve( + routes: list[list[int]], + inst: dict, + rng: random.Random, + max_iters: int, + tabu_size: int = 12, +) -> list[list[int]]: + """Large neighbourhood search with tabu diversification. + + Repeat (deterministically, seeded by `rng`) `max_iters` times: destroy + 25%-50% of customers, reinsert them with `greedy_repair`, then intensify + with `local_search`. Destroy sets recently used are skipped (tabu list), + so the search keeps exploring. Returns the best solution found. + """ + n, cap, demand, dist = inst["n"], inst["capacity"], inst["demand"], inst["distance"] + best = [list(r) for r in routes if r] + best_cost = sum(route_dist(r, dist) for r in best) + tabu: deque[tuple[int, ...]] = deque() + tabu_set: set[tuple[int, ...]] = set() + + for _ in range(max_iters): + remove = None + for _attempt in range(8): + q = rng.randint(max(1, n // 4), max(1, n // 2)) + cand = tuple(sorted(rng.sample(range(1, n + 1), q))) + if cand not in tabu_set: + remove = cand + break + if remove is None: + remove = tuple(sorted(rng.sample(range(1, n + 1), rng.randint(max(1, n // 4), max(1, n // 2))))) + tabu_set.add(remove) + tabu.append(remove) + if len(tabu) > tabu_size: + tabu_set.discard(tabu.popleft()) + + remove_set = set(remove) + temp = [] + for r in best: + nr = [c for c in r if c not in remove_set] + if nr: + temp.append(nr) + temp = greedy_repair(temp, sorted(remove), inst, rng) + temp = local_search(temp, inst) + cost = sum(route_dist(r, dist) for r in temp) + if cost < best_cost - 1e-9: + best = [list(r) for r in temp] + best_cost = cost + return best + + +def grasp_solve( + inst: dict, starts: int = 40, seed: int = 123, lns_iters: int | None = None +) -> list[list[int]]: + """GRASP-style multi-start: randomized savings + local search, keep best. + + When `lns_iters` is given, refine the best solution with deterministic + iteration-budgeted LNS. + """ + rng = random.Random(seed) + best: list[list[int]] | None = None + best_d = float("inf") + dist = inst["distance"] + for k in range(starts): + # First start uses the deterministic savings order (guaranteed baseline); + # remaining starts use randomized savings for diversification. + routes = savings_solve(inst, rng=None if k == 0 else rng) + routes = [two_opt(r, dist) for r in routes] + routes = relocate_improve(routes, inst) + routes = swap_improve(routes, inst) + routes = [two_opt(r, dist) for r in routes] + d = sum(route_dist(r, dist) for r in routes) + if d < best_d: + best_d = d + best = routes + if best is not None and lns_iters is not None and lns_iters > 0: + best = lns_improve(best, inst, random.Random(seed + 1), lns_iters) + return best or [] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate CVRP reference distances") + parser.add_argument("--starts", type=int, default=40, help="GRASP multi-start count") + parser.add_argument( + "--iterations", + type=int, + default=None, + help="LNS iterations per instance (default: max(100, 4*n))", + ) + args = parser.parse_args(argv) + + # A few fixed seeds diversify the search; the best distance per instance is + # kept. The seed list is a constant so regeneration is byte-identical. + seeds = (123, 2024, 7) + results = {} + + def _solve_dir(inst_dir: Path) -> None: + for path in sorted(inst_dir.glob("*.vrp")): + inst = parse_instance(path) + iters = args.iterations if args.iterations else max(100, 4 * inst["n"]) + best_total = min( + sum( + route_dist(r, inst["distance"]) + for r in grasp_solve(inst, starts=args.starts, seed=seed, lns_iters=iters) + ) + for seed in seeds + ) + results[inst["name"]] = best_total + print(f"{inst['name']}: ref_dist={best_total} (iters={iters}, seeds={seeds})") + + _solve_dir(INST_DIR) + if HELDOUT_DIR.is_dir(): + _solve_dir(HELDOUT_DIR) + OUT_JSON.write_text( + json.dumps(results, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", # force LF so regeneration is byte-identical on any OS + ) + print(f"wrote {OUT_JSON}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/VehicleRouting/CVRP/verification/requirements.txt b/benchmarks/VehicleRouting/CVRP/verification/requirements.txt new file mode 100644 index 00000000..38ddc552 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/requirements.txt @@ -0,0 +1,2 @@ +# The evaluator uses only the Python standard library. +# No third-party dependencies are required. diff --git a/benchmarks/VehicleRouting/CVRP/verification/test_evaluator.py b/benchmarks/VehicleRouting/CVRP/verification/test_evaluator.py new file mode 100644 index 00000000..13c8fa30 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/test_evaluator.py @@ -0,0 +1,274 @@ +"""Unit tests for the CVRP evaluator (stdlib unittest, no third-party deps). + +Run from the CVRP task directory: + python verification/test_evaluator.py +or from the repo root: + python -m unittest discover -s benchmarks/VehicleRouting/CVRP/verification -p 'test_*.py' +""" +from __future__ import annotations + +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import evaluator as ev # noqa: E402 + +CVRP_ROOT = Path(__file__).resolve().parents[1] + + +class TestParseInstance(unittest.TestCase): + def test_parse_public_instance(self): + inst = ev.parse_instance(CVRP_ROOT / "data/instances" / "VRP-19-2.vrp") + self.assertEqual(inst["name"], "VRP-19-2") + self.assertEqual(inst["n"], 19) # depot id 1, customers 2..20 + self.assertEqual(inst["capacity"], 270) + self.assertEqual(len(inst["demand"]), 20) + self.assertEqual(inst["demand"][0], 0) + self.assertEqual(len(inst["distance"]), 20) + self.assertEqual(len(inst["distance"][0]), 20) + self.assertEqual(inst["distance"][0][0], 0) + + def test_parse_heldout_instance(self): + inst = ev.parse_instance(CVRP_ROOT / "data/instances_heldout" / "VHO-22-3.vrp") + self.assertEqual(inst["name"], "VHO-22-3") + self.assertEqual(inst["n"], 22) + + +class TestRouteDistance(unittest.TestCase): + def test_known_distance(self): + # 1D line: depot at 0, customers at 3 and 7 => depot->3->7->depot = 17 + dist = [ + [0, 3, 7], + [3, 0, 4], + [7, 4, 0], + ] + self.assertEqual(ev.route_distance([[1, 2]], dist), 3 + 4 + 7) + self.assertEqual(ev.route_distance([], dist), 0) + + +class TestValidate(unittest.TestCase): + def setUp(self): + self.inst = ev.parse_instance(CVRP_ROOT / "data/instances" / "VRP-19-2.vrp") + + def test_valid_single_customer_routes(self): + routes = [[c] for c in range(1, self.inst["n"] + 1)] + ok, note, dist = ev.validate(routes, self.inst) + self.assertTrue(ok, note) + self.assertIsInstance(dist, int) + self.assertGreater(dist, 0) + + def test_duplicate_customer(self): + routes = [[1, 1]] + [[c] for c in range(2, self.inst["n"] + 1)] + ok, note, _ = ev.validate(routes, self.inst) + self.assertFalse(ok) + self.assertIn("more than once", note) + + def test_capacity_violation(self): + routes = [list(range(1, self.inst["n"] + 1))] + ok, note, _ = ev.validate(routes, self.inst) + self.assertFalse(ok) + self.assertIn("capacity", note) + + def test_missing_customer(self): + routes = [[1, 2]] + ok, note, _ = ev.validate(routes, self.inst) + self.assertFalse(ok) + self.assertIn("not served", note) + + def test_out_of_range_customer(self): + routes = [[self.inst["n"] + 5]] + [[c] for c in range(1, self.inst["n"] + 1)] + ok, note, _ = ev.validate(routes, self.inst) + self.assertFalse(ok) + self.assertIn("out of range", note) + + def test_non_integer_customer(self): + routes = [[1.5]] + [[c] for c in range(2, self.inst["n"] + 1)] + ok, note, _ = ev.validate(routes, self.inst) + self.assertFalse(ok) + + +class TestCheckCandidate(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="cvrp_test_")) + self.valid_candidate = CVRP_ROOT / "baseline" / "solver.py" + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _write(self, name: str, content: str) -> Path: + path = self.tmp / name + path.write_text(content, encoding="utf-8") + return path + + def test_baseline_is_admissible(self): + self.assertEqual(ev.check_candidate(self.valid_candidate), []) + + def test_import_reference_solver_rejected(self): + src = self.valid_candidate.read_text(encoding="utf-8") + bad = src.replace( + "def solve(instance):", "import verification.ref_solver as rs\ndef solve(instance):", 1 + ) + path = self._write("bad_import.py", bad) + issues = ev.check_candidate(path) + self.assertTrue(any("verification" in i or "ref_solver" in i for i in issues)) + + def test_hardcoding_rejected(self): + src = self.valid_candidate.read_text(encoding="utf-8") + bad = src.replace( + "def solve(instance):", + 'def solve(instance):\n if instance["name"] == "VRP-19-2": return [[1]]', + 1, + ) + path = self._write("bad_hardcode.py", bad) + issues = ev.check_candidate(path) + self.assertTrue(any("hardcode" in i for i in issues)) + + def test_missing_markers_rejected(self): + src = self.valid_candidate.read_text(encoding="utf-8") + bad = src.replace(ev.EVOLVE_START, "").replace(ev.EVOLVE_END, "") + path = self._write("bad_markers.py", bad) + issues = ev.check_candidate(path) + self.assertTrue(any("EVOLVE-BLOCK" in i for i in issues)) + + def test_absolute_path_rejected(self): + src = self.valid_candidate.read_text(encoding="utf-8") + bad = src + "\n# C:\\\\Users\\\\x\\\\reference.json\n" + path = self._write("bad_path.py", bad) + issues = ev.check_candidate(path) + self.assertTrue(any("absolute" in i for i in issues)) + + +class TestLoadReference(unittest.TestCase): + def test_reference_contains_all_instances(self): + ref = ev.load_reference() + self.assertEqual(len(ref), 24) # 12 public + 12 held-out + for name in ( + "VRP-19-2", + "VRP-60-9", + "VHO-22-3", + "VHO-58-9", + ): + self.assertIn(name, ref) + self.assertGreater(ref[name], 0) + + +class TestEvaluateBaseline(unittest.TestCase): + def test_baseline_scores(self): + import os + + os.environ["CVRP_EVAL_INSTANCES"] = "VRP-19-2 VHO-22-3" + try: + result = ev.evaluate(str(CVRP_ROOT / "baseline" / "solver.py")) + finally: + os.environ.pop("CVRP_EVAL_INSTANCES", None) + metrics = result["metrics"] + self.assertEqual(metrics["valid"], 1.0) + self.assertEqual(metrics["instances"], 2.0) + self.assertGreater(metrics["combined_score"], 40.0) + self.assertLess(metrics["combined_score"], 100.0) + # artifacts must not leak reference distances + artifacts = result["artifacts"] + self.assertNotIn("reference", artifacts) + self.assertEqual(artifacts["reference_instance_count"], 24.0) + + +class TestGenerateMode(unittest.TestCase): + """Runtime instance generation (anti-hardcoding).""" + + def _run(self, seed, count=None, instances=None): + import os + + os.environ["CVRP_EVAL_GENERATE_SEED"] = str(seed) + if count is not None: + os.environ["CVRP_EVAL_GENERATE_COUNT"] = str(count) + if instances: + os.environ["CVRP_EVAL_INSTANCES"] = instances + try: + return ev.evaluate(str(CVRP_ROOT / "baseline" / "solver.py"))["metrics"] + finally: + os.environ.pop("CVRP_EVAL_GENERATE_SEED", None) + os.environ.pop("CVRP_EVAL_GENERATE_COUNT", None) + os.environ.pop("CVRP_EVAL_INSTANCES", None) + + def test_generates_extra_instances(self): + # 2 fixed + 4 generated = 6 instances, all valid. + m = self._run(seed=42, count=4, instances="VRP-19-2 VHO-22-3") + self.assertEqual(m["valid"], 1.0) + self.assertEqual(m["instances"], 6.0) + names = set(m["per_instance"].keys()) + self.assertIn("VRP-19-2", names) + self.assertIn("VHO-22-3", names) + self.assertTrue(any(n.startswith("GEN-42-") for n in names)) + + def test_same_seed_deterministic(self): + a = self._run(seed=42, count=3) + b = self._run(seed=42, count=3) + self.assertEqual(a["combined_score"], b["combined_score"]) + self.assertEqual( + sorted(a["per_instance"].keys()), sorted(b["per_instance"].keys()) + ) + + def test_different_seed_different_instances(self): + a = self._run(seed=42, count=3) + b = self._run(seed=7, count=3) + self.assertNotEqual( + sorted(a["per_instance"].keys()), sorted(b["per_instance"].keys()) + ) + + def test_unset_seed_preserves_old_behaviour(self): + import os + + os.environ["CVRP_EVAL_INSTANCES"] = "VRP-19-2 VHO-22-3" + try: + m = ev.evaluate(str(CVRP_ROOT / "baseline" / "solver.py"))["metrics"] + finally: + os.environ.pop("CVRP_EVAL_INSTANCES", None) + self.assertEqual(m["instances"], 2.0) + self.assertFalse(any(n.startswith("GEN-") for n in m["per_instance"])) + + +class TestHeldoutFromHost(unittest.TestCase): + """Held-out instances are read from the host source dir, not the sandbox copy.""" + + def test_heldout_read_from_host_source(self): + import os + import shutil + import tempfile + + host = Path(tempfile.mkdtemp(prefix="cvrp_host_")) + heldout_dir = host / "data" / "instances_heldout" + heldout_dir.mkdir(parents=True) + src_vho = CVRP_ROOT / "data" / "instances_heldout" / "VHO-22-3.vrp" + shutil.copy2(src_vho, heldout_dir / "VHO-22-3.vrp") + os.environ["FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR"] = str(host) + os.environ["CVRP_EVAL_INSTANCES"] = "VRP-19-2 VHO-22-3" + try: + m = ev.evaluate(str(CVRP_ROOT / "baseline" / "solver.py"))["metrics"] + finally: + os.environ.pop("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", None) + os.environ.pop("CVRP_EVAL_INSTANCES", None) + shutil.rmtree(host, ignore_errors=True) + self.assertEqual(m["valid"], 1.0) + self.assertEqual(m["instances"], 2.0) + self.assertIn("VRP-19-2", m["per_instance"]) + self.assertIn("VHO-22-3", m["per_instance"]) + + +class TestSplitEvolveBlocks(unittest.TestCase): + def test_split(self): + src = "a\n# EVOLVE-BLOCK-START\nb\n# EVOLVE-BLOCK-END\nc\n" + before, between, after = ev._split_evolve_blocks(src) + self.assertEqual(before, "a\n") + self.assertIn("b", between) + self.assertEqual(after, "\nc\n") + + def test_missing_end(self): + self.assertIsNone(ev._split_evolve_blocks("a\n# EVOLVE-BLOCK-START\nb\n")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/benchmarks/VehicleRouting/CVRP/verification/test_frontier_eval_evaluator.py b/benchmarks/VehicleRouting/CVRP/verification/test_frontier_eval_evaluator.py new file mode 100644 index 00000000..a96bb097 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/test_frontier_eval_evaluator.py @@ -0,0 +1,122 @@ +"""Unit tests for the sandbox evaluator (frontier_eval/evaluator.py). + +The sandbox evaluator is a self-contained copy of the verification evaluator +(parsing / validation / scoring / integrity checks embedded, since no +`verification/` files are copied into the sandbox). These tests exercise it +directly so the two copies cannot silently drift apart. + +Run from the CVRP task directory: + python verification/test_frontier_eval_evaluator.py +""" +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +CVRP_ROOT = Path(__file__).resolve().parents[1] +SANDBOX_EVALUATOR = CVRP_ROOT / "frontier_eval" / "evaluator.py" + +_spec = importlib.util.spec_from_file_location("_sandbox_evaluator", SANDBOX_EVALUATOR) +_sandbox = importlib.util.module_from_spec(_spec) +assert _spec.loader is not None +_spec.loader.exec_module(_sandbox) + +BASELINE = CVRP_ROOT / "baseline" / "solver.py" + + +class TestSandboxBaseline(unittest.TestCase): + def test_baseline_scores_and_no_leak(self): + m = _sandbox.evaluate(str(BASELINE))["metrics"] + self.assertEqual(m["valid"], 1.0) + self.assertEqual(m["instances"], 24.0) + self.assertTrue(all(n.startswith(("VRP-", "VHO-")) for n in m["per_instance"])) + self.assertGreater(m["combined_score"], 40.0) + self.assertLess(m["combined_score"], 100.0) + + def test_artifacts_do_not_leak_reference(self): + result = _sandbox.evaluate(str(BASELINE)) + self.assertNotIn("reference", result["artifacts"]) + self.assertEqual(result["artifacts"]["reference_instance_count"], 24.0) + + +class TestSandboxHeldoutFromHost(unittest.TestCase): + def test_heldout_read_from_host_source(self): + host = Path(tempfile.mkdtemp(prefix="cvrp_sb_host_")) + heldout_dir = host / "data" / "instances_heldout" + heldout_dir.mkdir(parents=True) + shutil.copy2( + CVRP_ROOT / "data" / "instances_heldout" / "VHO-22-3.vrp", + heldout_dir / "VHO-22-3.vrp", + ) + os.environ["FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR"] = str(host) + os.environ["CVRP_EVAL_INSTANCES"] = "VRP-19-2 VHO-22-3" + try: + m = _sandbox.evaluate(str(BASELINE))["metrics"] + finally: + os.environ.pop("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", None) + os.environ.pop("CVRP_EVAL_INSTANCES", None) + shutil.rmtree(host, ignore_errors=True) + self.assertEqual(m["valid"], 1.0) + self.assertEqual(m["instances"], 2.0) + self.assertIn("VHO-22-3", m["per_instance"]) + + +class TestSandboxGeneration(unittest.TestCase): + def test_generate_mode(self): + # The sandbox evaluator generates instances by loading the host + # generator/reference solver, so it needs the host source dir. + os.environ["FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR"] = str(CVRP_ROOT) + os.environ["CVRP_EVAL_GENERATE_SEED"] = "42" + os.environ["CVRP_EVAL_GENERATE_COUNT"] = "4" + try: + m = _sandbox.evaluate(str(BASELINE))["metrics"] + finally: + os.environ.pop("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", None) + os.environ.pop("CVRP_EVAL_GENERATE_SEED", None) + os.environ.pop("CVRP_EVAL_GENERATE_COUNT", None) + self.assertEqual(m["valid"], 1.0) + self.assertEqual(m["instances"], 28.0) + self.assertTrue(any(n.startswith("GEN-42-") for n in m["per_instance"])) + + +class TestSandboxPreflight(unittest.TestCase): + def test_cheating_candidate_rejected(self): + cheat = CVRP_ROOT / "baseline" / "solver.py" + src = cheat.read_text(encoding="utf-8") + start = src.find("# EVOLVE-BLOCK-START") + end = src.find("# EVOLVE-BLOCK-END") + injected = ( + src[: start + len("# EVOLVE-BLOCK-START")] + + "\n import ref_solver # forbidden\n return [[c] for c in range(1, instance['n'] + 1)]\n" + + src[end:] + ) + tmp = Path(tempfile.mkdtemp(prefix="cvrp_sb_cheat_")) + cand = tmp / "cheat.py" + cand.write_text(injected, encoding="utf-8") + try: + m = _sandbox.evaluate(str(cand))["metrics"] + finally: + shutil.rmtree(tmp, ignore_errors=True) + self.assertEqual(m["valid"], 0.0) + self.assertEqual(m["combined_score"], 0.0) + + +class TestSandboxConsistency(unittest.TestCase): + def test_parse_instance_matches_verification_copy(self): + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import evaluator as verif # noqa: E402 + + inst = CVRP_ROOT / "data" / "instances" / "VRP-19-2.vrp" + a = verif.parse_instance(inst) + b = _sandbox.parse_instance(inst) + self.assertEqual(a, b) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/benchmarks/VehicleRouting/CVRP/verification/test_ref_solver.py b/benchmarks/VehicleRouting/CVRP/verification/test_ref_solver.py new file mode 100644 index 00000000..1a975d54 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/test_ref_solver.py @@ -0,0 +1,83 @@ +"""Unit tests for verification/ref_solver.py (stdlib unittest, no third-party deps). + +Run from the CVRP task directory: + python verification/test_ref_solver.py +""" +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import ref_solver as rs # noqa: E402 + +CVRP_ROOT = Path(__file__).resolve().parents[1] +INST = CVRP_ROOT / "data" / "instances" / "VRP-19-2.vrp" + + +class TestGraspDeterministic(unittest.TestCase): + def test_same_seed_same_distance(self): + inst = rs.parse_instance(INST) + args = dict(starts=3, seed=7, lns_iters=5) + d1 = sum(rs.route_dist(r, inst["distance"]) for r in rs.grasp_solve(inst, **args)) + d2 = sum(rs.route_dist(r, inst["distance"]) for r in rs.grasp_solve(inst, **args)) + self.assertEqual(d1, d2) + + def test_different_seeds_usually_differ(self): + inst = rs.parse_instance(INST) + d_a = sum( + rs.route_dist(r, inst["distance"]) + for r in rs.grasp_solve(inst, starts=3, seed=1, lns_iters=5) + ) + d_b = sum( + rs.route_dist(r, inst["distance"]) + for r in rs.grasp_solve(inst, starts=3, seed=999, lns_iters=5) + ) + # The GRASP perturbation makes different seeds explore different + # neighbourhoods; identical distance is possible but unlikely. + self.assertIsInstance(d_a, int) + self.assertIsInstance(d_b, int) + + +class TestGraspFeasible(unittest.TestCase): + def test_full_coverage_and_capacity(self): + inst = rs.parse_instance(INST) + routes = rs.grasp_solve(inst, starts=3, seed=7, lns_iters=5) + covered = [c for route in routes for c in route] + self.assertEqual(sorted(covered), list(range(1, inst["n"] + 1))) + for route in routes: + load = sum(inst["demand"][c] for c in route) + self.assertLessEqual(load, inst["capacity"]) + + def test_returns_list_of_lists(self): + inst = rs.parse_instance(INST) + routes = rs.grasp_solve(inst, starts=3, seed=7, lns_iters=5) + self.assertIsInstance(routes, list) + for route in routes: + self.assertIsInstance(route, list) + + +class TestReferenceJsonConsistency(unittest.TestCase): + def test_matches_reference_json(self): + """Reproduce main()'s computation for the smallest instance and check + it equals the checked-in reference distance (byte-identical, fixed + seed list (123, 2024, 7)).""" + inst = rs.parse_instance(INST) + iters = max(100, 4 * inst["n"]) + best = min( + sum( + rs.route_dist(r, inst["distance"]) + for r in rs.grasp_solve(inst, starts=40, seed=seed, lns_iters=iters) + ) + for seed in (123, 2024, 7) + ) + ref = json.loads( + (CVRP_ROOT / "data" / "reference.json").read_text(encoding="utf-8") + ) + self.assertEqual(best, ref[inst["name"]]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/benchmarks/VehicleRouting/CVRP/verification/test_validator.py b/benchmarks/VehicleRouting/CVRP/verification/test_validator.py new file mode 100644 index 00000000..7146b929 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/test_validator.py @@ -0,0 +1,205 @@ +"""Unit tests for verification/validator.py (stdlib unittest, no third-party deps). + +Run from the CVRP task directory: + python verification/test_validator.py +""" +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import validator as vd # noqa: E402 +import evaluator as ev # noqa: E402 (for parse_instance in probe selection) + +CVRP_ROOT = Path(__file__).resolve().parents[1] +BASELINE = CVRP_ROOT / "baseline" / "solver.py" +BASELINE_SRC = BASELINE.read_text(encoding="utf-8") +INSTANCE_PATHS = sorted((CVRP_ROOT / "data" / "instances").glob("*.vrp")) + sorted( + (CVRP_ROOT / "data" / "instances_heldout").glob("*.vrp") +) + + +class TestStaticChecks(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="cvrp_val_")) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _check(self, src: str) -> list[str]: + path = self.tmp / "candidate.py" + path.write_text(src, encoding="utf-8") + return vd.check_candidate(path, baseline_path=BASELINE) + + def test_baseline_is_clean(self): + self.assertEqual(vd.check_candidate(BASELINE, baseline_path=BASELINE), []) + + def test_missing_markers(self): + src = BASELINE_SRC.replace(vd.EVOLVE_START, "").replace(vd.EVOLVE_END, "") + issues = self._check(src) + self.assertTrue(any("EVOLVE-BLOCK" in i for i in issues)) + + def test_outside_block_changed(self): + src = BASELINE_SRC.replace( + '"""CVRP candidate solver', '"""HACKED candidate solver', 1 + ) + issues = self._check(src) + self.assertTrue(any("outside EVOLVE-BLOCK" in i for i in issues)) + + def test_import_reference_solver(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + "import verification.ref_solver as rs\ndef solve(instance):", + 1, + ) + issues = self._check(src) + self.assertTrue(any("ref_solver" in i for i in issues)) + + def test_read_reference_json(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + 'def solve(instance):\n _ = open("data/reference.json").read()', + 1, + ) + issues = self._check(src) + self.assertTrue(any("reference.json" in i for i in issues)) + + def test_hardcode_dict_key(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + 'def solve(instance):\n return {"VRP-19-2": [[1]]}[instance["name"]]', + 1, + ) + issues = self._check(src) + self.assertTrue(any("hardcode" in i for i in issues)) + + def test_hardcode_equality(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + 'def solve(instance):\n if instance["name"] == "VHO-22-3":\n' + ' return [[1]]', + 1, + ) + issues = self._check(src) + self.assertTrue(any("hardcode" in i for i in issues)) + + def test_absolute_path(self): + src = BASELINE_SRC + '\n# C:\\\\Users\\\\x\\\\reference.json\n' + issues = self._check(src) + self.assertTrue(any("absolute" in i for i in issues)) + + def test_static_check_direct(self): + # static_check_source with no baseline skips the fixed-region diff. + issues = vd.static_check_source(BASELINE_SRC, baseline_src=None) + self.assertEqual(issues, []) + + def test_comment_verification_not_rejected(self): + # "verification" as a bare word (e.g. in a comment) is NOT a violation; + # only import/from/module-path usage is. + src = BASELINE_SRC.replace( + "def solve(instance):", + "# verification pass for small instances\ndef solve(instance):", + 1, + ) + issues = self._check(src) + self.assertFalse(any("verification" in i for i in issues)) + + def test_import_verification_rejected(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + "import verification\nfrom verification import evaluator\n" + "def solve(instance):", + 1, + ) + issues = self._check(src) + self.assertTrue(any("verification" in i for i in issues)) + + +class TestDeterminism(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="cvrp_det_test_")) + self.inst_path = CVRP_ROOT / "data" / "instances" / "VRP-19-2.vrp" + import sys as _sys + + self.python = _sys.executable + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_deterministic_baseline_passes(self): + ok, note = vd.check_determinism( + self.python, BASELINE, self.inst_path, timeout=60 + ) + self.assertTrue(ok, note) + + def test_nondeterministic_candidate_fails(self): + src = BASELINE_SRC.replace( + "def solve(instance):", + "def solve(instance):\n import random\n" + " return [[random.randint(1, instance['n'])\n" + " for _ in range(instance['n'])]]", + 1, + ) + path = self.tmp / "nondet.py" + path.write_text(src, encoding="utf-8") + ok, note = vd.check_determinism(self.python, path, self.inst_path, timeout=60) + self.assertFalse(ok) + self.assertIn("not deterministic", note) + + def test_size_varied_randomness_detected(self): + # Deterministic on small instances, random on large ones: the probe + # selection must include a large instance, so this is caught. + src = BASELINE_SRC.replace( + "def solve(instance):", + "def solve(instance):\n import random\n" + " if instance['n'] <= 30:\n" + " return [[c] for c in range(1, instance['n'] + 1)]\n" + " return [[random.randint(1, instance['n'])\n" + " for _ in range(instance['n'])]]", + 1, + ) + path = self.tmp / "sizevar.py" + path.write_text(src, encoding="utf-8") + + probes = vd.select_determinism_probes(INSTANCE_PATHS, ev.parse_instance) + self.assertEqual(len(probes), 3) + # The largest probe has n > 30, so the candidate is random there. + ok, note = vd.check_determinism(self.python, path, probes[-1], timeout=60) + self.assertFalse(ok) + self.assertIn("not deterministic", note) + + def test_probe_selection_spans_sizes(self): + probes = vd.select_determinism_probes(INSTANCE_PATHS, ev.parse_instance) + ns = sorted(ev.parse_instance(p)["n"] for p in probes) + self.assertEqual(len(ns), 3) + # min < median < max over the full set (24 instances). + all_ns = sorted(ev.parse_instance(p)["n"] for p in INSTANCE_PATHS) + self.assertEqual(ns[0], all_ns[0]) + self.assertEqual(ns[-1], all_ns[-1]) + + +class TestCandidateEnvStripsFrontier(unittest.TestCase): + """candidate_env must strip FRONTIER_ENGINEERING_ROOT (repo-root side channel).""" + + def test_strips_frontier_engineering_root(self): + import os + + import validator as v + + os.environ["FRONTIER_ENGINEERING_ROOT"] = "C:/fake/repo" + os.environ["FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR"] = "C:/fake/bench" + try: + env = v.candidate_env() + finally: + os.environ.pop("FRONTIER_ENGINEERING_ROOT", None) + os.environ.pop("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", None) + self.assertNotIn("FRONTIER_ENGINEERING_ROOT", env) + self.assertNotIn("FRONTIER_EVAL_UNIFIED_SOURCE_BENCHMARK_DIR", env) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/benchmarks/VehicleRouting/CVRP/verification/validator.py b/benchmarks/VehicleRouting/CVRP/verification/validator.py new file mode 100644 index 00000000..421ff527 --- /dev/null +++ b/benchmarks/VehicleRouting/CVRP/verification/validator.py @@ -0,0 +1,204 @@ +"""Candidate integrity validator for the CVRP benchmark. + +Enforces the task constraints as *executable* checks (not just natural +language). The evaluator runs these before scoring; any violation marks the +candidate invalid. + +Checks implemented: + 1. EVOLVE-BLOCK integrity: markers must exist, and the code outside the + EVOLVE-BLOCK region must match the initial baseline byte-for-byte. + 2. Forbidden references: the source must not reference the verification + module, the reference solver, `reference.json`, or helper internals. + 3. Absolute paths: the source must not contain machine-local filesystem + paths. + 4. Per-instance hardcoding: the source must not embed instance names in a + solution-dispatch form (e.g. `"VRP-19-2": [...]`). + 5. Determinism: running the candidate twice on the same instance must yield + byte-identical output (a scorer needs reproducible results). + +Pure standard library. Used by `verification/evaluator.py` and +`frontier_eval/evaluator.py` (which embeds an equivalent copy for sandbox use). +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +EVOLVE_START = "# EVOLVE-BLOCK-START" +EVOLVE_END = "# EVOLVE-BLOCK-END" + +# Strong tokens: almost never appear in legitimate solver code, so a bare +# substring match is fine (e.g. "ref_solver", "reference.json"). +STRONG_TOKENS = ( + "ref_solver", + "grasp_solve", + "savings_solve", + "reference.json", +) +# Weak token "verification": only rejected when it appears in an +# import/from/module-path context, so a comment like "verification pass" +# is NOT a false positive. +FORBIDDEN_RE = ( + re.compile(r"\b(?:import|from)\s+verification\b"), + re.compile(r"verification[\\/.]"), +) + +# e.g. `"VRP-19-2": [...]` or `"VHO-22-3":` (a dispatch table keyed by name). +HARDCODE_RE = re.compile(r"[\"'][A-Z][A-Z0-9-]*\d+-\d+[\"']\s*:") +# Windows drive / POSIX home absolute paths. +ABS_PATH_RE = re.compile(r"[A-Za-z]:[\\/]|/home/|/Users/") + + +def split_evolve_blocks(src: str) -> tuple[str, str, str] | None: + """Return (before_start, between, after_end) or None if markers missing.""" + start = src.find(EVOLVE_START) + end = src.find(EVOLVE_END) + if start == -1 or end == -1 or end <= start: + return None + return ( + src[:start], + src[start + len(EVOLVE_START) : end], + src[end + len(EVOLVE_END) :], + ) + + +def fixed_region(parts: tuple[str, str, str]) -> str: + """Everything outside the EVOLVE-BLOCK (the read-only part).""" + return parts[0] + parts[2] + + +def static_check_source(src: str, baseline_src: str | None = None) -> list[str]: + """Static checks on candidate source text. Returns a list of violations.""" + issues: list[str] = [] + + parts = split_evolve_blocks(src) + if parts is None: + issues.append("missing EVOLVE-BLOCK-START / EVOLVE-BLOCK-END markers") + elif baseline_src is not None: + init_parts = split_evolve_blocks(baseline_src) + if init_parts is not None and fixed_region(init_parts) != fixed_region(parts): + issues.append("code outside EVOLVE-BLOCK differs from initial baseline") + + for token in STRONG_TOKENS: + if token in src: + issues.append(f"candidate references forbidden token {token!r}") + for pat in FORBIDDEN_RE: + if pat.search(src): + issues.append("candidate references forbidden token 'verification'") + + if ABS_PATH_RE.search(src): + issues.append("candidate contains an absolute filesystem path") + + if HARDCODE_RE.search(src): + issues.append("candidate hardcodes per-instance solutions by name") + + return issues + + +def check_candidate( + solver_path: Path | str, baseline_path: Path | str | None = None +) -> list[str]: + """Read candidate source and run the static checks. + + `baseline_path` is the pristine initial program used to verify the fixed + (non-EVOLVE-BLOCK) region. When None, the EVOLVE-BLOCK marker check still + applies but the fixed-region diff is skipped. + """ + solver_path = Path(solver_path) + try: + src = solver_path.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + return [f"cannot read candidate source: {exc}"] + + baseline_src = None + if baseline_path is not None: + try: + baseline_src = Path(baseline_path).read_text( + encoding="utf-8", errors="replace" + ) + except Exception: + baseline_src = None + + return static_check_source(src, baseline_src) + + +def candidate_env() -> dict[str, str]: + """Environment for the candidate subprocess, stripped of host paths and any + reference-distance settings so the candidate cannot locate the scoring + baseline on the host. All `FRONTIER_*` variables are removed (the unified + runtime sets `FRONTIER_ENGINEERING_ROOT` to the repo root, which would + otherwise let a candidate find and import `verification/ref_solver.py` on + the host).""" + env = os.environ.copy() + for key in list(env): + upper = key.upper() + if upper.startswith("FRONTIER") or key in ( + "CVRP_EVAL_REFERENCE_JSON", + "CVRP_EVAL_REFERENCES", + ): + del env[key] + return env + + +def check_determinism( + python: str, + solver_path: Path | str, + inst_path: Path | str, + timeout: float, + env: dict[str, str] | None = None, +) -> tuple[bool, str]: + """Run the candidate twice on the same instance; outputs must match. + + Returns (ok, note). A non-deterministic candidate is not reliably + scoreable, so it is treated as a violation. + """ + solver_path = Path(solver_path) + inst_path = Path(inst_path) + outputs: list[Any] = [] + for _ in range(2): + with tempfile.TemporaryDirectory(prefix="cvrp_det_") as td: + out_path = Path(td) / "out.json" + try: + proc = subprocess.run( + [python, str(solver_path), str(inst_path), str(out_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(solver_path.parent), + env=env if env is not None else candidate_env(), + ) + except subprocess.TimeoutExpired: + return False, "timeout during determinism check" + if proc.returncode != 0: + return ( + False, + f"candidate exited with code {proc.returncode} during " + f"determinism check: {(proc.stderr or '')[:200]}", + ) + try: + outputs.append(json.loads(out_path.read_text(encoding="utf-8"))) + except Exception as exc: + return False, f"cannot parse determinism output: {exc}" + if outputs[0] != outputs[1]: + return False, "candidate is not deterministic (output differs across two runs)" + return True, "" + + +def select_determinism_probes( + inst_paths: list, parse_instance, count: int = 3 +) -> list: + """Pick min / median / max instances (by customer count) as determinism + probes, so a solver that is deterministic on small instances but random on + large ones cannot slip through a single-probe check.""" + parsed = sorted( + ((p, parse_instance(p)) for p in inst_paths), key=lambda t: t[1]["n"] + ) + if not parsed: + return [] + idxs = sorted({0, len(parsed) // 2, len(parsed) - 1}) + return [parsed[i][0] for i in idxs[:count]] diff --git a/benchmarks/VehicleRouting/README.md b/benchmarks/VehicleRouting/README.md new file mode 100644 index 00000000..618fdb2e --- /dev/null +++ b/benchmarks/VehicleRouting/README.md @@ -0,0 +1,11 @@ +# Vehicle Routing Domain + +Vehicle routing problems (VRP) model logistics and delivery planning: how a +fleet of vehicles should serve a set of customers from one or more depots, +subject to capacity, time-window, and other constraints, minimizing cost. + +## Tasks + +| Task | Problem | Description | +|------|---------|-------------| +| [CVRP](CVRP/README.md) | Capacitated VRP | Single depot, identical vehicles with capacity, every customer served once, minimize total distance. |