diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 000000000..e0f550d49 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,80 @@ +name: Benchmark + +on: + workflow_dispatch: + inputs: + max_instances: + description: 'Instances per family for small benchmark (1–9; default 2)' + default: '2' + required: true + type: string + run_large: + description: 'Also run large benchmark (C2/R2/RC2)' + type: boolean + default: false + max_large_instances: + description: 'Instances per family for large benchmark (1–8; R2 up to 11)' + default: '2' + required: false + type: string + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +defaults: + run: + shell: bash + +jobs: + benchmark: + name: Benchmark / ubuntu + # NOTE: Gurobi must be installed on the runner and GUROBI_HOME must be set. + # Use a self-hosted runner with Gurobi, or adjust the "Install Gurobi" step. + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + lfs: true + + - name: Install build dependencies + run: sudo apt-get update -q && sudo apt-get install -y build-essential cmake + + - name: Configure (Release, no Python, no tests) + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DUSE_PYTHON=OFF \ + -DUSE_TESTS=OFF + + - name: Build small benchmark + run: cmake --build build --target rcspp-vrp-benchmark --parallel + + - name: Build large benchmark + if: inputs.run_large + run: cmake --build build --target rcspp-vrp-benchmark-large --parallel + + - name: Run small benchmark (C1/R1/RC1) + run: | + ./build/bin/rcspp-vrp-benchmark ${{ inputs.max_instances }} \ + 2>&1 | tee benchmark_small.txt + + - name: Run large benchmark (C2/R2/RC2) + if: inputs.run_large + run: | + ./build/bin/rcspp-vrp-benchmark-large ${{ inputs.max_large_instances }} \ + 2>&1 | tee benchmark_large.txt + + - name: Upload small benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-small-${{ github.sha }} + path: benchmark_small.txt + + - name: Upload large benchmark results + if: inputs.run_large + uses: actions/upload-artifact@v4 + with: + name: benchmark-large-${{ github.sha }} + path: benchmark_large.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa4efcb1d..5c825493e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,20 @@ defaults: jobs: test: - name: Test / ${{ matrix.os }} / ${{ matrix.build-type }} + name: Test / ${{ matrix.os }} / ${{ matrix.build-type }} / py${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] build-type: [Debug, Release] + python-version: ['3.11', '3.12', '3.13'] + exclude: + # Debug builds don't run Python tests — only one Python version needed + - build-type: Debug + python-version: '3.11' + - build-type: Debug + python-version: '3.13' steps: - uses: actions/checkout@v4 @@ -42,7 +49,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ matrix.python-version }} - name: Configure run: cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} -DUSE_PYTHON=ON -DUSE_TESTS=ON @@ -60,7 +67,7 @@ jobs: - name: Python tests if: matrix.build-type == 'Release' working-directory: src/python - run: pytest -v + run: pytest -v --tb=short build_wheels: name: Wheels / ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index cf331435d..2edfaa5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ .vs/* +.cache/* out/* -build/* +build/ +build_*/ +cmake-build-*/ +**/__pycache__/** *.dll *.lib *.exp @@ -9,8 +13,23 @@ build/* *.o *.a *.exe +*.so +*.dylib instances/duals/ +# Python +.venv/ +.venv-*/ +venv/ +.pytest_cache/ +__pycache__/ +*.py[cod] +*.pyd +*.egg-info/ +dist/ +wheelhouse/ +build-*/ + # CMake / Ninja generated files CMakeCache.txt CMakeFiles/ @@ -37,3 +56,13 @@ INSTALL* # Per-target/build subdirectories generated by CMake/VS *.dir/ Testing/ + +# Claude Code project files +.claude/ + +# Clion +.idea/ + +# SLURM benchmark artifacts +scripts/ng_jobs.txt +logs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ff96f276..17f6a3e2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -92,6 +92,7 @@ repos: hooks: - id: markdownlint files: .*\.md$ + exclude: ^(build|build_[^/]+)/ args: ["--fix"] # ------------------------- diff --git a/instances/RC201_12.txt b/instances/RC201_12.txt new file mode 100644 index 000000000..486ffac01 --- /dev/null +++ b/instances/RC201_12.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4861e4ab38a6309dc27c0a1e494e06e6431543774c23ebe28b92085819b03bf2 +size 1633 diff --git a/scripts/gen_ng_jobs.py b/scripts/gen_ng_jobs.py new file mode 100644 index 000000000..bf96b7fda --- /dev/null +++ b/scripts/gen_ng_jobs.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Generate a job manifest for the ng-route benchmark SLURM array. + +Each manifest line holds the CLI arguments for one ``rcspp-vrp-benchmark-ng`` +run. By default there is one job per (instance, ng-size) pair so the SLURM +array runs every instance in parallel; ``--group-by family`` instead emits one +job per (family, ng-size). ``scripts/run_ng_benchmark.slurm`` reads the line +indexed by ``$SLURM_ARRAY_TASK_ID`` and runs it. + +Example: + python scripts/gen_ng_jobs.py --ng 3 5 8 --max-index 9 + sbatch --array=0-80 scripts/run_ng_benchmark.slurm +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def instance_names(families: list[str], max_index: int) -> list[str]: + """Return Solomon instance names (e.g. C101) for the given families/indices. + + Args: + families: Instance families to include (e.g. ["C", "R", "RC"]). + max_index: Highest instance index; expands to 10<1..max_index>. + + Returns: + The list of instance names in (index, family) order. + """ + return [f"{family}10{i}" for i in range(1, max_index + 1) for family in families] + + +def build_jobs(args: argparse.Namespace) -> list[str]: + """Build the manifest lines (one benchmark argument string per job). + + Args: + args: Parsed command-line arguments. + + Returns: + One argument string per job. + """ + common: list[str] = ["--max-labels", str(args.max_labels)] + if args.cols > 0: + common += ["--cols", str(args.cols)] + + jobs: list[str] = [] + if args.group_by == "instance": + for name in instance_names(args.families, args.max_index): + for ng in args.ng: + jobs.append(" ".join(["--instance", name, "--ng", str(ng), *common])) + else: # group_by == "family" + for family in args.families: + for ng in args.ng: + jobs.append( + " ".join([str(args.max_index), "--family", family, "--ng", str(ng), *common]) + ) + return jobs + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments. + + Returns: + The parsed arguments. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--ng", type=int, nargs="+", default=[3, 5, 8], help="ng-neighbourhood sizes" + ) + parser.add_argument( + "--families", + nargs="+", + default=["C", "R", "RC"], + help="Solomon instance families", + ) + parser.add_argument("--max-index", type=int, default=9, help="highest instance index (1..9)") + parser.add_argument("--max-labels", type=int, default=100, help="per-node label-expansion cap") + parser.add_argument( + "--cols", type=int, default=0, help="columns per CG iteration (0 => #customers)" + ) + parser.add_argument( + "--group-by", + choices=["instance", "family"], + default="instance", + help="one job per instance (default) or per family", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("scripts/ng_jobs.txt"), + help="manifest output path", + ) + return parser.parse_args() + + +def main() -> None: + """Generate the manifest and print the matching sbatch command.""" + args = parse_args() + jobs = build_jobs(args) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text("\n".join(jobs) + "\n") + + print(f"Wrote {len(jobs)} jobs to {args.output}") + print("Submit with:") + print( + f" MANIFEST={args.output} sbatch --array=0-{len(jobs) - 1} " + f"scripts/run_ng_benchmark.slurm" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_ng_benchmark.slurm b/scripts/run_ng_benchmark.slurm new file mode 100755 index 000000000..0af4f6f6e --- /dev/null +++ b/scripts/run_ng_benchmark.slurm @@ -0,0 +1,54 @@ +#!/bin/bash +# SLURM array runner for the ng-route augmentation benchmark. +# +# It executes one job from a manifest generated by scripts/gen_ng_jobs.py: +# array task N runs the (N+1)-th manifest line through rcspp-vrp-benchmark-ng. +# By default the manifest has one line per (instance, ng-size), so every +# instance runs in parallel. +# +# Usage (submit from the repository root; the binary locates instances/ relative +# to the source tree baked in at compile time): +# python scripts/gen_ng_jobs.py --ng 3 5 8 --max-index 9 +# MANIFEST=scripts/ng_jobs.txt sbatch --array=0-80 scripts/run_ng_benchmark.slurm +# +# The --array range must equal the number of manifest lines (printed by the +# generator). Override MANIFEST / BIN via --export, e.g.: +# sbatch --array=0-26 --export=ALL,MANIFEST=scripts/ng_jobs.txt scripts/run_ng_benchmark.slurm + +#SBATCH --job-name=ng-bench +#SBATCH --array=0-80 +#SBATCH --cpus-per-task=1 +#SBATCH --mem=8G +#SBATCH --time=12:00:00 +#SBATCH --output=logs/ng_%A_%a.out +#SBATCH --error=logs/ng_%A_%a.err + +set -euo pipefail + +MANIFEST="${MANIFEST:-scripts/ng_jobs.txt}" +BIN="${BIN:-./build/bin/rcspp-vrp-benchmark-ng}" +task="${SLURM_ARRAY_TASK_ID:-0}" + +if [[ ! -f "${MANIFEST}" ]]; then + echo "Manifest not found: ${MANIFEST} (run scripts/gen_ng_jobs.py first)" >&2 + exit 1 +fi +if [[ ! -x "${BIN}" ]]; then + echo "Benchmark binary not found/executable: ${BIN}" >&2 + echo "Build it first, e.g.: cmake --build build --target rcspp-vrp-benchmark-ng" >&2 + exit 1 +fi + +# Manifest is 0-indexed by array task; sed is 1-indexed. +line="$(sed -n "$(( task + 1 ))p" "${MANIFEST}")" +if [[ -z "${line}" ]]; then + echo "No manifest line for array task ${task} in ${MANIFEST}" >&2 + echo "Set --array to match the number of lines ($(wc -l < "${MANIFEST}"))." >&2 + exit 1 +fi + +mkdir -p logs +read -r -a job_args <<< "${line}" +echo "[$(date '+%F %T')] task=${task} args: ${line}" + +srun "${BIN}" "${job_args[@]}" diff --git a/src/python/conftest.py b/src/python/conftest.py index 959aa7547..7f21760d1 100644 --- a/src/python/conftest.py +++ b/src/python/conftest.py @@ -12,3 +12,10 @@ def pytest_configure(config): message=".*SolverGurobi.*", category=pytest.PytestUnraisableExceptionWarning, ) + # pytest's internal cache plugin leaves sqlite3 connections open; suppress the + # resulting ResourceWarning so it doesn't pollute test output. + warnings.filterwarnings( + "ignore", + message=".*unclosed.* ResourceGraph: # ── add_arc with rows variants ────────────────────────────────────────── -class TestAddArcDualRows: +class TestAddArcRows: """Test rows normalisation in add_arc.""" def test_single_tuple(self): @@ -261,7 +264,7 @@ def test_multiple_rows_per_arc(self): class TestUpdateReducedCosts: - """Test that update_reduced_costs correctly applies dual rows.""" + """Test that update_reduced_costs correctly applies rows.""" def test_rows_shift_optimal_path(self): """After update_reduced_costs, the path with negative reduced cost wins. @@ -327,7 +330,7 @@ class TestClone: """Test ResourceGraph.clone().""" def test_clone_preserves_rows(self): - """Clone() carries dual rows into the copy.""" + """Clone() carries rows into the copy.""" rg = _make_graph() arc0 = rg.add_arc((10.0,), 0, 1, cost=10.0, rows=(0, 2.0)) rg.add_arc((15.0,), 1, 3, cost=15.0) @@ -426,7 +429,7 @@ def test_clone_independent_reduced_costs(self): class TestCloneTopology: - """Test ResourceGraph.clone_topology() — clone with no dual rows.""" + """Test ResourceGraph.clone_topology() — clone with no rows.""" def test_rows_stripped(self): """clone_topology() produces arcs with empty rows.""" diff --git a/src/python/test_graph.py b/src/python/test_graph.py index d3d7a7899..59cf13c9b 100644 --- a/src/python/test_graph.py +++ b/src/python/test_graph.py @@ -5,10 +5,13 @@ import os import sys -import numpy as np +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "python_interface") +) + +import pytest # noqa: E402 -relative_path = "../python_interface/" -sys.path.insert(0, os.path.abspath(relative_path)) +np = pytest.importorskip("numpy") from rcspp.graph import ResourceGraph # noqa: E402 from rcspp.resource import ( # noqa: E402 @@ -179,7 +182,7 @@ def test_force_arc_solve_uses_forced_path(): def _make_rg_with_rows(): - """2-arc graph where arc costs are set via dual rows. + """2-arc graph where arc costs are set via rows. Arc 0 (0→1): base cost=10, row index=0 coef=1 → reduced = 10 - duals[0] Arc 1 (1→2): base cost=20, row index=1 coef=2 → reduced = 20 - 2*duals[1] diff --git a/src/python/test_rcspp.py b/src/python/test_rcspp.py index 882a01e2a..f5ce71acc 100644 --- a/src/python/test_rcspp.py +++ b/src/python/test_rcspp.py @@ -10,8 +10,9 @@ import threading import time -relative_path = "../python_interface/" -sys.path.insert(0, os.path.abspath(relative_path)) +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "python_interface") +) from rcspp import LogLevel, set_log_level from rcspp.graph import Algorithm, AlgorithmParams, BucketAlgorithmParams, ResourceGraph @@ -221,21 +222,27 @@ def example_algorithm_params(): rg.add_arc((3.0,), 1, 2, cost=3.0) rg.add_arc((10.0,), 0, 2, cost=10.0) - # Enum values: Algorithm.Simple, Algorithm.Pushing, Algorithm.Pulling, Algorithm.Greedy + # Enum values: Algorithm.Simple, Algorithm.Pushing, Algorithm.Pulling, Algorithm.Greedy, Algorithm.AStar sols_simple = rg.solve(Algorithm.Simple) sols_pushing = rg.solve(Algorithm.Pushing) sols_pulling = rg.solve(Algorithm.Pulling) sols_greedy = rg.solve(Algorithm.Greedy) + sols_astar = rg.solve(Algorithm.AStar) # String aliases are also accepted for convenience sols_str = rg.solve("simple") + sols_astar_str = rg.solve("astar") print_solutions("Algorithm.Simple", sols_simple) print_solutions("Algorithm.Pushing", sols_pushing) print_solutions("Algorithm.Pulling", sols_pulling) print_solutions("Algorithm.Greedy", sols_greedy) + print_solutions("Algorithm.AStar", sols_astar) assert sols_simple[0].cost == sols_str[0].cost, "string alias must match enum" - assert all(s.cost == 8.0 for s in [sols_simple[0], sols_pulling[0], sols_greedy[0]]) + assert sols_astar[0].cost == sols_astar_str[0].cost, "astar string alias must match enum" + assert all( + s.cost == 8.0 for s in [sols_simple[0], sols_pulling[0], sols_greedy[0], sols_astar[0]] + ) # AlgorithmParams: stop after the first solution params = AlgorithmParams() @@ -782,6 +789,41 @@ def example_bucket_labels(): sols_one = rg.solve(params=bp_inh) assert len(sols_one) == 1, f"Expected 1 solution, got {len(sols_one)}" + # ── Position-based API: bucket by int (pos 1), sort by real (pos 0) ─────── + # rg2 has real at registration pos 0 and int at pos 1. + # bucket_resource_pos=1 → int resource; sort_resource_pos=0 → real (cost). + bp_pos = BucketAlgorithmParams( + range_buckets=3, + bucket_resource_pos=1, + sort_resource_pos=0, + ) + sols_pos = rg2.solve(params=bp_pos) + print_solutions("BucketAlgorithmParams pos-based (bucket=int, sort=real)", sols_pos) + assert sols_pos, "bucket solve (pos-based) returned no solutions" + assert math.isclose( + sols_pos[0].cost, ref2_cost, abs_tol=1e-6 + ), f"bucket pos-based cost {sols_pos[0].cost} != reference {ref2_cost}" + + # Bucket by real (pos 0), sort by real (pos 0) — same type, first instance. + bp_pos_real = BucketAlgorithmParams( + range_buckets=5, + bucket_resource_pos=0, + sort_resource_pos=0, + ) + sols_pos_real = rg2.solve(params=bp_pos_real) + assert sols_pos_real, "bucket solve (pos 0,0) returned no solutions" + assert math.isclose( + sols_pos_real[0].cost, ref2_cost, abs_tol=1e-6 + ), f"bucket pos(0,0) cost {sols_pos_real[0].cost} != reference {ref2_cost}" + + # Out-of-range position raises ValueError. + bp_oob = BucketAlgorithmParams(bucket_resource_pos=99) + try: + rg2.solve(params=bp_oob) + assert False, "Expected ValueError for out-of-range bucket_resource_pos" + except ValueError: + pass # expected + # ── Run all examples ────────────────────────────────────────────────────────── diff --git a/src/python/test_rcspp_networkx.py b/src/python/test_rcspp_networkx.py index 26aa49065..d6c7db6a5 100644 --- a/src/python/test_rcspp_networkx.py +++ b/src/python/test_rcspp_networkx.py @@ -9,8 +9,9 @@ import networkx as nx -relative_path = "../python_interface/" -sys.path.insert(0, os.path.abspath(relative_path)) +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "python_interface") +) from rcspp.graph import Algorithm, AlgorithmParams, ResourceGraph from rcspp.resource import ( diff --git a/src/python/test_vrp.py b/src/python/test_vrp.py index ba3af5691..df6ec2a10 100644 --- a/src/python/test_vrp.py +++ b/src/python/test_vrp.py @@ -8,8 +8,10 @@ import random import sys -sys.path.insert(0, os.path.abspath("../python_interface/")) -sys.path.insert(0, os.path.abspath(".")) +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "python_interface") +) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from vrp.instance import Customer, Instance diff --git a/src/python_interface/CMakeLists.txt b/src/python_interface/CMakeLists.txt index efd1102fe..bf130bdef 100644 --- a/src/python_interface/CMakeLists.txt +++ b/src/python_interface/CMakeLists.txt @@ -43,9 +43,12 @@ target_include_directories(${PYTHON_EXTENSION_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/ # Link the core C++ library target_link_libraries(${PYTHON_EXTENSION_NAME} PRIVATE rcspp_objects) -# Collect .py files from the C++/pybind11 interface directory (incl. pricing_pool.py, -# which lives alongside _core so a single `rcspp` package exposes both). -file(GLOB_RECURSE PYTHON_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/rcspp/*.py") +# Find all .py, .pyi, and py.typed files in rcspp (recursively) +file(GLOB_RECURSE PYTHON_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/rcspp/*.py" + "${CMAKE_CURRENT_SOURCE_DIR}/rcspp/*.pyi" + "${CMAKE_CURRENT_SOURCE_DIR}/rcspp/py.typed" +) # Build per-file copy rules with explicit OUTPUT so CMake re-copies whenever a # .py file changes, without requiring the C++ extension to rebuild. @@ -84,4 +87,6 @@ install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/rcspp/" DESTINATION ${PYTHON_PACKAGE_NAME} FILES_MATCHING PATTERN "*.py" + PATTERN "*.pyi" + PATTERN "py.typed" ) diff --git a/src/python_interface/rcspp/__init__.py b/src/python_interface/rcspp/__init__.py index c65801770..368b988c9 100644 --- a/src/python_interface/rcspp/__init__.py +++ b/src/python_interface/rcspp/__init__.py @@ -48,14 +48,23 @@ del _imputil, _os, _sys, _sysconfig, _pkg_dir, _ext_suffix from . import graph, logger, resource # noqa: E402 -from ._core.graph import check_interrupted # noqa: E402 +from ._core import available_memory_bytes, process_memory_bytes # noqa: E402 +from ._core.graph import ( # noqa: E402 + AlgorithmStatus, + SolveResult, + check_interrupted, +) from .graph import ResourceGraph # noqa: E402 from .logger import LogLevel, get_log_level, init_logger, set_log_level # noqa: E402 __all__ = [ + "AlgorithmStatus", "ResourceGraph", + "available_memory_bytes", + "SolveResult", "check_interrupted", "graph", + "process_memory_bytes", "resource", "logger", "LogLevel", diff --git a/src/python_interface/rcspp/_core/__init__.pyi b/src/python_interface/rcspp/_core/__init__.pyi new file mode 100644 index 000000000..9741a48b0 --- /dev/null +++ b/src/python_interface/rcspp/_core/__init__.pyi @@ -0,0 +1,3 @@ +from . import graph as graph +from . import logger as logger +from . import resource as resource diff --git a/src/python_interface/rcspp/_core/graph.pyi b/src/python_interface/rcspp/_core/graph.pyi new file mode 100644 index 000000000..909048cec --- /dev/null +++ b/src/python_interface/rcspp/_core/graph.pyi @@ -0,0 +1,142 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Sequence + +import numpy as np + +class Algorithm(Enum): + Simple: Algorithm + Pushing: Algorithm + Pulling: Algorithm + Greedy: Algorithm + Tabu: Algorithm + +class AlgorithmStatus(Enum): + Complete: AlgorithmStatus + Timeout: AlgorithmStatus + MaxSolutions: AlgorithmStatus + MaxPhases: AlgorithmStatus + Interrupted: AlgorithmStatus + MemoryLimit: AlgorithmStatus + +class Row: + index: int + coefficient: float + def __init__(self, index: int = 0, coefficient: float = 0.0) -> None: ... + +class Column: + cost: float + rows: list[Row] + def __init__(self) -> None: ... + +class Solution: + cost: float + path_node_ids: list[int] + path_arc_ids: list[int] + column: Column + def __init__(self) -> None: ... + def to_arrays( + self, + ) -> tuple[ + float, + np.ndarray[Any, np.dtype[np.int64]], + np.ndarray[Any, np.dtype[np.int64]], + np.ndarray[Any, np.dtype[np.float64]], + ]: ... + +class SolveResult: + """Result of a solve() call. + + Behaves like a ``list[Solution]`` for backward compatibility + (``len()``, indexing, iteration) while also exposing ``.status``. + """ + + solutions: list[Solution] + status: AlgorithmStatus + def __init__(self) -> None: ... + def status_string(self) -> str: ... + def __len__(self) -> int: ... + def __iter__(self) -> Any: ... + def __getitem__(self, index: int) -> Solution: ... + def __bool__(self) -> bool: ... + def __repr__(self) -> str: ... + +class AlgorithmParams: + stop_after_X_solutions: int + return_dominated_solutions: bool + use_pool: bool + num_labels_to_extend_by_node: int + num_max_phases: int + max_iterations: int + timeout_s: float + tolerance: float + release_after_solve: bool + tabu_tenure: int + forbidden_tabu: set[int] + tabu_random_noise: bool + seed: int + max_memory_gb: float + limit_to_available_ram: bool + limit_to_total_ram: bool + memory_limit_fraction: float + memory_check_interval: int + memory_pressure_fraction: float + memory_pressure_max_labels_per_node: int + def __init__(self) -> None: ... + def check(self) -> None: ... + def could_be_non_optimal(self) -> bool: ... + +class BucketAlgorithmParams(AlgorithmParams): + """Low-level C++ bucket params. Prefer the Python wrapper in rcspp.graph.""" + + range_buckets: int + bucket_resource_index: int + sort_resource_index: int + bucket_resource_type: str + def __init__(self) -> None: ... + +class Node: + id: int + source: bool + sink: bool + in_arcs: list[Arc] + out_arcs: list[Arc] + resource: Any + def pos(self) -> int: ... + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + +class Arc: + id: int + cost: float + rows: list[Row] + extender: Any + def origin(self) -> Node: ... + def destination(self) -> Node: ... + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + +class Graph: + def add_node(self, id: int, source: bool = False, sink: bool = False) -> Node: ... + def add_arc( + self, + origin_id: int, + destination_id: int, + cost: float = 0.0, + rows: Sequence[Row] = (), + ) -> Arc: ... + def get_node(self, id: int) -> Node | None: ... + def get_arc(self, id: int) -> Arc | None: ... + def remove_arc(self, arc_id: int) -> bool: ... + def restore_arc(self, arc_id: int) -> bool: ... + def remove_arcs(self, arc_ids: Sequence[int]) -> list[int]: ... + def restore_arcs(self, arc_ids: Sequence[int]) -> list[int]: ... + def force_arc(self, arc_id: int) -> list[int]: ... + def number_of_arcs(self) -> int: ... + def number_of_nodes(self) -> int: ... + def removed_arc_ids(self) -> list[int]: ... + def next_arc_id(self) -> int: ... + def update(self) -> None: ... + +def check_interrupted() -> None: ... diff --git a/src/python_interface/rcspp/_core/logger.pyi b/src/python_interface/rcspp/_core/logger.pyi new file mode 100644 index 000000000..d88648aa8 --- /dev/null +++ b/src/python_interface/rcspp/_core/logger.pyi @@ -0,0 +1,14 @@ +from enum import IntEnum + +class LogLevel(IntEnum): + Trace = 0 + Debug = 1 + Info = 2 + Warn = 3 + Error = 4 + Fatal = 5 + Off = 6 + +def init(level: LogLevel) -> None: ... +def set_level(level: LogLevel) -> None: ... +def get_level() -> LogLevel: ... diff --git a/src/python_interface/rcspp/_core/resource.pyi b/src/python_interface/rcspp/_core/resource.pyi new file mode 100644 index 000000000..4c309245b --- /dev/null +++ b/src/python_interface/rcspp/_core/resource.pyi @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any + +# ── Generic base (not directly instantiated by users) ──────────────────────── + +class _ExtensionFunction: ... +class _FeasibilityFunction: ... +class _CostFunction: ... +class _DominanceFunction: ... + +# ── Numerical resource functions (suffix: _real, _int, _uint) ──────────────── + +class AdditionExtensionFunction_real(_ExtensionFunction): + def __init__(self) -> None: ... + +class AdditionExtensionFunction_int(_ExtensionFunction): + def __init__(self) -> None: ... + +class AdditionExtensionFunction_uint(_ExtensionFunction): + def __init__(self) -> None: ... + +class ValueCostFunction_real(_CostFunction): + def __init__(self) -> None: ... + +class ValueCostFunction_int(_CostFunction): + def __init__(self) -> None: ... + +class ValueDominanceFunction_real(_DominanceFunction): + def __init__(self) -> None: ... + +class ValueDominanceFunction_int(_DominanceFunction): + def __init__(self) -> None: ... + +class TrivialFeasibilityFunction_real(_FeasibilityFunction): + def __init__(self) -> None: ... + +class TrivialFeasibilityFunction_int(_FeasibilityFunction): + def __init__(self) -> None: ... + +class TrivialCostFunction_real(_CostFunction): + def __init__(self) -> None: ... + +class TrivialCostFunction_int(_CostFunction): + def __init__(self) -> None: ... + +class MinMaxFeasibilityFunction_real(_FeasibilityFunction): + def __init__(self, min_value: float, max_value: float) -> None: ... + +class MinMaxFeasibilityFunction_int(_FeasibilityFunction): + def __init__(self, min_value: int, max_value: int) -> None: ... + +class TimeWindowExtensionFunction_real(_ExtensionFunction): + def __init__( + self, tw_by_node: dict[int, tuple[float, float]], default_max_value: float = ... + ) -> None: ... + +class TimeWindowFeasibilityFunction_real(_FeasibilityFunction): + def __init__( + self, tw_by_node: dict[int, tuple[float, float]], default_max_value: float = ... + ) -> None: ... + +# ── Container resource functions (suffix: _bitset, _set) ───────────────────── + +class UnionExtensionFunction_bitset(_ExtensionFunction): + def __init__(self) -> None: ... + +class UnionExtensionFunction_set(_ExtensionFunction): + def __init__(self) -> None: ... + +class IntersectionExtensionFunction_bitset(_ExtensionFunction): + def __init__(self) -> None: ... + +class InclusionDominanceFunction_bitset(_DominanceFunction): + def __init__(self) -> None: ... + +class ContainDominanceFunction_bitset(_DominanceFunction): + def __init__(self) -> None: ... + +class TrivialFeasibilityFunction_bitset(_FeasibilityFunction): + def __init__(self) -> None: ... + +class SizeFeasibilityFunction_bitset(_FeasibilityFunction): + def __init__(self, min_size: int, max_size: int) -> None: ... + +class TrivialCostFunction_bitset(_CostFunction): + def __init__(self) -> None: ... diff --git a/src/python_interface/rcspp/graph.cpp b/src/python_interface/rcspp/graph.cpp index fac94acc2..2af877149 100644 --- a/src/python_interface/rcspp/graph.cpp +++ b/src/python_interface/rcspp/graph.cpp @@ -79,7 +79,52 @@ void init_graph(py::module_& m) { .value("Simple", SolverAlgorithm::Simple) .value("Pushing", SolverAlgorithm::Pushing) .value("Pulling", SolverAlgorithm::Pulling) - .value("Greedy", SolverAlgorithm::Greedy); + .value("Greedy", SolverAlgorithm::Greedy) + .value("Tabu", SolverAlgorithm::Tabu) + .value("AStar", SolverAlgorithm::AStar); + + // ── AlgorithmStatus enum ────────────────────────────────────────────────── + + py::enum_(m, "AlgorithmStatus") + .value("Complete", AlgorithmStatus::COMPLETE) + .value("Timeout", AlgorithmStatus::TIMEOUT) + .value("MaxSolutions", AlgorithmStatus::MAX_SOLUTIONS) + .value("MaxPhases", AlgorithmStatus::MAX_PHASES) + .value("Interrupted", AlgorithmStatus::INTERRUPTED) + .value("MemoryLimit", AlgorithmStatus::MEMORY_LIMIT); + + // ── SolveResult ─────────────────────────────────────────────────────────── + + py::class_(m, "SolveResult") + .def(py::init<>()) + .def_readwrite("solutions", &SolveResult::solutions) + .def_readwrite("status", &SolveResult::status) + .def("status_string", &SolveResult::status_string) + // Sequence protocol — lets existing code treat SolveResult like list[Solution]. + .def("__len__", [](const SolveResult& r) { return r.solutions.size(); }) + .def( + "__iter__", + [](const SolveResult& r) { + return py::make_iterator(r.solutions.begin(), r.solutions.end()); + }, + py::keep_alive<0, 1>()) + .def( + "__getitem__", + [](const SolveResult& r, py::ssize_t i) -> const Solution& { + if (i < 0) { + i += static_cast(r.solutions.size()); + } + if (i < 0 || static_cast(i) >= r.solutions.size()) { + throw py::index_error("index out of range"); + } + return r.solutions[static_cast(i)]; + }, + py::return_value_policy::reference_internal) + .def("__bool__", [](const SolveResult& r) { return !r.solutions.empty(); }) + .def("__repr__", [](const SolveResult& r) { + return "SolveResult(status=" + r.status_string() + + ", solutions=" + std::to_string(r.solutions.size()) + ")"; + }); // ── Shared scalar types ─────────────────────────────────────────────────── @@ -104,10 +149,46 @@ void init_graph(py::module_& m) { &PyAlgorithmParams::num_labels_to_extend_by_node) .def_readwrite("num_max_phases", &PyAlgorithmParams::num_max_phases) .def_readwrite("max_iterations", &PyAlgorithmParams::max_iterations) + .def_readwrite("timeout_s", + &PyAlgorithmParams::timeout_s, + "Wall-clock timeout in seconds; solve() returns early when elapsed >= " + "timeout_s (default: inf).") + .def_readwrite("tolerance", + &PyAlgorithmParams::tolerance, + "Numerical tolerance for cost comparisons (default 1e-9).") + .def_readwrite( + "release_after_solve", + &PyAlgorithmParams::release_after_solve, + "If true (default), release label memory after solve(). Set to false when the " + "same algorithm is called repeatedly in a tight loop to avoid shrink_to_fit() " + "overhead.") .def_readwrite("tabu_tenure", &PyAlgorithmParams::tabu_tenure) .def_readwrite("forbidden_tabu", &PyAlgorithmParams::forbidden_tabu) .def_readwrite("tabu_random_noise", &PyAlgorithmParams::tabu_random_noise) - .def_readwrite("seed", &PyAlgorithmParams::seed); + .def_readwrite("seed", &PyAlgorithmParams::seed) + // ── Memory-limit parameters ────────────────────────────────────── + .def_readwrite("max_memory_gb", + &PyAlgorithmParams::max_memory_gb, + "Hard cap on process RSS in GiB (0 = unlimited). " + "The solver stops early and returns whatever solutions have been found.") + .def_readwrite("limit_to_available_ram", + &PyAlgorithmParams::limit_to_available_ram, + "Derive limit from currently-available system RAM.") + .def_readwrite("limit_to_total_ram", + &PyAlgorithmParams::limit_to_total_ram, + "Derive limit from total physical RAM.") + .def_readwrite("memory_limit_fraction", + &PyAlgorithmParams::memory_limit_fraction, + "Fraction of RAM to use as limit (default 0.9).") + .def_readwrite("memory_check_interval", + &PyAlgorithmParams::memory_check_interval, + "Main-loop iterations between RSS checks (default 50 000).") + .def_readwrite("memory_pressure_fraction", + &PyAlgorithmParams::memory_pressure_fraction, + "RSS/limit fraction that triggers queue pruning (default 0.8).") + .def_readwrite("memory_pressure_max_labels_per_node", + &PyAlgorithmParams::memory_pressure_max_labels_per_node, + "Max labels per node when under memory pressure (default 200)."); py::class_(m, "BucketAlgorithmParams") .def(py::init<>()) diff --git a/src/python_interface/rcspp/graph.py b/src/python_interface/rcspp/graph.py index 147b52c80..1bc175af9 100644 --- a/src/python_interface/rcspp/graph.py +++ b/src/python_interface/rcspp/graph.py @@ -16,6 +16,8 @@ "pushing": lambda: _ext.graph.Algorithm.Pushing, "pulling": lambda: _ext.graph.Algorithm.Pulling, "greedy": lambda: _ext.graph.Algorithm.Greedy, + "tabu": lambda: _ext.graph.Algorithm.Tabu, + "astar": lambda: _ext.graph.Algorithm.AStar, } # Kept for backward compatibility @@ -96,7 +98,10 @@ def __init__(self, nx_graph: Optional[nx.DiGraph] = None, **kwargs): self._node_buffer: list = [] # list of (id, source, sink) self._arc_buffer: list = [] # list of (raw_consumption, origin, dest, cost, rows) self._rows_buffer: list = [] # list of (arc_id, row_index, coeff) triples - self._reserve_hint: tuple[int, int] = (0, 0) # (n_nodes, n_arcs) hint from reserve() + self._reserve_hint: tuple[int, int] = ( + 0, + 0, + ) # (n_nodes, n_arcs) hint from reserve() self._next_arc_id: int = 0 # mirrors C++ next_arc_id_; returned by add_arc if nx_graph is not None: self.from_networkx(nx_graph) @@ -179,7 +184,10 @@ def _flush(self): self._ensure_graph() if not self._node_buffer and not self._arc_buffer and not self._rows_buffer: return - n_nodes = max(self._reserve_hint[0], self._graph.number_of_nodes() + len(self._node_buffer)) + n_nodes = max( + self._reserve_hint[0], + self._graph.number_of_nodes() + len(self._node_buffer), + ) n_arcs = max(self._reserve_hint[1], self._graph.number_of_arcs() + len(self._arc_buffer)) self._graph.reserve(n_nodes, n_arcs) self._reserve_hint = (0, 0) @@ -457,7 +465,7 @@ def clone_topology(self) -> "ResourceGraph": (demand, time) slice after cloning. Returns: - A new :class:`ResourceGraph` with no dual rows on arcs. + A new :class:`ResourceGraph` with no rows on arcs. """ return self.clone(include_rows=False) @@ -489,14 +497,19 @@ def solve( Args: algorithm: ``Algorithm.Simple`` (default), ``Algorithm.Pushing``, - ``Algorithm.Pulling``, ``Algorithm.Greedy``, or the equivalent strings - ``'simple'``, ``'pushing'``, `'pulling'``, ``'greedy'``. + ``Algorithm.Pulling``, ``Algorithm.Greedy``, ``Algorithm.AStar``, + or the equivalent strings ``'simple'``, ``'pushing'``, ``'pulling'``, + ``'greedy'``, ``'astar'``. upper_bound: Prune paths with cost ≥ this value. params: :class:`AlgorithmParams` (defaults to ``AlgorithmParams()``). preprocess: Run preprocessing before solving. cost_index: Index within the cost resource type (the first ``real`` or ``int`` slot in canonical order that the user registered). Defaults to 0. + + Returns: + :class:`SolveResult` with a ``solutions`` list and an + ``AlgorithmStatus`` indicating why the solver stopped. """ if cost_index < 0: raise ValueError(f"cost_index must be non-negative, got {cost_index}") @@ -504,6 +517,8 @@ def solve( if params is None: params = _ext.graph.AlgorithmParams() self._flush() + if isinstance(params, BucketAlgorithmParams): + params = params._to_cpp(self._full_registration_order) if isinstance(algorithm, str): factory = _ALGORITHM_MAP.get(algorithm) if factory is None: @@ -651,7 +666,11 @@ def from_networkx(self, nx_graph: nx.DiGraph): def _make_add_resource_method(canonical_type: str): def add_resource_method( - self, extension_function, feasibility_function, cost_function, dominance_function + self, + extension_function, + feasibility_function, + cost_function, + dominance_function, ): if self._graph is not None: raise RuntimeError( @@ -685,3 +704,96 @@ def add_resource_method( # Patch the C++ submodule so `from rcspp._core.graph import ResourceGraph` resolves correctly _ext.graph.ResourceGraph = ResourceGraph + +# ── BucketAlgorithmParams Python wrapper ───────────────────────────────────── +# Keep a reference to the raw C++ class so the wrapper can instantiate it. +_CppBucketAlgorithmParams = _ext.graph.BucketAlgorithmParams + + +class BucketAlgorithmParams: + """AlgorithmParams variant using a bucket-partitioned label container. + + Pass ``bucket_resource_pos`` and/or ``sort_resource_pos`` to identify + resources by their registration order (0 = first ``add__resource()`` + call, 1 = second, etc.). Python resolves the position to the correct C++ + resource type and within-type index at solve time. + + When a position is not given (``None``), the corresponding C++ field + (``bucket_resource_type`` / ``sort_resource_index``) is used as-is, + preserving backward compatibility with code that sets those fields directly. + + Args: + range_buckets: Width of each bucket along the bucket resource axis. + bucket_resource_pos: Registration-order position of the resource used + to partition labels into buckets. ``None`` (default) keeps the + C++ default (empty ``bucket_resource_type`` → cost resource). + sort_resource_pos: Registration-order position of the resource used to + sort labels within each bucket. Must be a numerical resource + (``real`` or ``int``). ``None`` (default) keeps the C++ default + (``sort_resource_index=0``). + **kwargs: Additional :class:`AlgorithmParams` fields + (e.g. ``stop_after_X_solutions``, ``timeout_s``, ``max_memory_gb``). + """ + + def __init__( + self, + range_buckets: int = 100, + bucket_resource_pos=None, + sort_resource_pos=None, + **kwargs, + ): + self._bucket_resource_pos = ( + None if bucket_resource_pos is None else int(bucket_resource_pos) + ) + self._sort_resource_pos = None if sort_resource_pos is None else int(sort_resource_pos) + self._cpp = _CppBucketAlgorithmParams() + self._cpp.range_buckets = int(range_buckets) + for k, v in kwargs.items(): + setattr(self._cpp, k, v) + + def __getattr__(self, name: str): + if name.startswith("_"): + raise AttributeError(name) + return getattr(self._cpp, name) + + def __setattr__(self, name: str, value): + if name.startswith("_"): + super().__setattr__(name, value) + else: + setattr(self._cpp, name, value) + + def _to_cpp(self, full_registration_order: list) -> "_CppBucketAlgorithmParams": + """Resolve resource positions and return the underlying C++ params object. + + Args: + full_registration_order: Per-instance list of canonical resource type + names in the order ``add__resource()`` was called + (from :attr:`ResourceGraph._full_registration_order`). + + Returns: + The configured C++ ``BucketAlgorithmParams`` ready for ``solve()``. + """ + reg = full_registration_order + n = len(reg) + + def resolve(pos: int, label: str) -> tuple: + if pos < 0 or pos >= n: + raise ValueError( + f"{label}={pos} out of range; graph has {n} registered " + f"resource(s) (valid: 0–{n - 1})." + ) + t = reg[pos] + idx = sum(1 for r in reg[:pos] if r == t) + return t, idx + + if self._bucket_resource_pos is not None: + bucket_type, bucket_idx = resolve(self._bucket_resource_pos, "bucket_resource_pos") + # Translate Python canonical name to the C++ prefix expected by run_bucket_solve. + self._cpp.bucket_resource_type = CPP_NAME.get(bucket_type, bucket_type) + self._cpp.bucket_resource_index = bucket_idx + + if self._sort_resource_pos is not None: + _, sort_idx = resolve(self._sort_resource_pos, "sort_resource_pos") + self._cpp.sort_resource_index = sort_idx + + return self._cpp diff --git a/src/python_interface/rcspp/graph_impl.hpp b/src/python_interface/rcspp/graph_impl.hpp index bc336f458..66d286f6b 100644 --- a/src/python_interface/rcspp/graph_impl.hpp +++ b/src/python_interface/rcspp/graph_impl.hpp @@ -97,28 +97,43 @@ struct PyBucketAlgorithmParams : PyAlgorithmParams { // ─── Algorithm dispatch table ───────────────────────────────────────────────── -enum class SolverAlgorithm { Simple, Pushing, Pulling, Greedy }; +enum class SolverAlgorithm { Simple, Pushing, Pulling, Greedy, Tabu, AStar }; template class Algo> struct AlgoEntry { static constexpr SolverAlgorithm value = E; template - static std::vector run(RG& rg, double ub, AlgorithmParams p, bool pre, - size_t ci) { + static SolveResult run(RG& rg, double ub, AlgorithmParams p, bool pre, size_t ci) { return rg.template solve(ub, std::move(p), pre, ci); } }; +// Dispatch entry for A*: injects cost_index into params and binds CostRC. +template +struct AStarAlgoEntry { + static constexpr SolverAlgorithm value = E; + template + static SolveResult run(RG& rg, double ub, AlgorithmParams p, bool pre, size_t ci) { + p.heuristic_cost_index = ci; + return rg.template solve::template Algo, CostRC, LC>( + ub, + std::move(p), + pre, + ci); + } +}; + using AlgorithmTable = std::tuple, AlgoEntry, AlgoEntry, - AlgoEntry>; + AlgoEntry, + AlgoEntry, + AStarAlgoEntry>; template -std::vector dispatch_algorithm_impl(SolverAlgorithm alg, RG& rg, double ub, - AlgorithmParams p, bool pre, size_t ci, - std::tuple* /*tag*/) { - std::vector result; +SolveResult dispatch_algorithm_impl(SolverAlgorithm alg, RG& rg, double ub, AlgorithmParams p, + bool pre, size_t ci, std::tuple* /*tag*/) { + SolveResult result; [[maybe_unused]] bool matched = ((Entries::value == alg ? (result = Entries::template run(rg, ub, p, pre, ci), true) @@ -128,8 +143,8 @@ std::vector dispatch_algorithm_impl(SolverAlgorithm alg, RG& rg, doubl } template -std::vector dispatch_algorithm(SolverAlgorithm alg, RG& rg, double ub, - AlgorithmParams p, bool pre, size_t ci) { +SolveResult dispatch_algorithm(SolverAlgorithm alg, RG& rg, double ub, AlgorithmParams p, + bool pre, size_t ci) { p.should_stop = &ActiveCall::is_interrupted; return dispatch_algorithm_impl(alg, rg, @@ -198,8 +213,8 @@ void with_resource_type(const std::string& type_name, const char* param_name, Ca // Empty string → use CostRC (default). Non-numerical types are skipped. template -std::vector run_bucket_solve(SolverAlgorithm alg, RG& rg, double ub, - const PyBucketAlgorithmParams& py_p, bool pre, size_t ci) { +SolveResult run_bucket_solve(SolverAlgorithm alg, RG& rg, double ub, + const PyBucketAlgorithmParams& py_p, bool pre, size_t ci) { auto check_index = [&](const char* param, size_t idx, size_t count) { if (idx >= count) { throw py::value_error(std::string(param) + " " + std::to_string(idx) + @@ -209,7 +224,7 @@ std::vector run_bucket_solve(SolverAlgorithm alg, RG& rg, double ub, } }; - std::vector result; + SolveResult result; auto run_func = [&]() { const auto& factory = rg.get_resource_factory(); check_index("bucket_resource_index", @@ -353,16 +368,42 @@ py::class_& bind_graph_methods(py::class_& c) { .def( "_add_rows_bulk", [](G& g, py::array_t rows) { + // rows must be sorted by arc_id (column 0) — the Python side + // guarantees this via np.argsort in _build_base_graph. + // + // Process contiguous runs of the same arc_id: look up the arc + // once per run, reserve capacity once, then push_back every row + // in the run directly into the arc's rows vector. This avoids: + // • one std::vector heap allocation per row (old code), + // • repeated bounds checks and pointer dereferences per row. auto r = rows.unchecked<2>(); - for (py::ssize_t i = 0; i < r.shape(0); ++i) { - g.add_rows_to_arc(static_cast(r(i, 0)), - {Row{.index = static_cast(r(i, 1)), - .coefficient = static_cast(r(i, 2))}}); + const auto n = r.shape(0); + py::ssize_t i = 0; + while (i < n) { + const auto arc_id = static_cast(r(i, 0)); + auto* arc = g.get_arc(arc_id); + // Find the end of this arc's run. + py::ssize_t j = i + 1; + while (j < n && static_cast(r(j, 0)) == arc_id) { + ++j; + } + if (arc != nullptr) { + auto& dr = arc->rows; + dr.reserve(dr.size() + static_cast(j - i)); + for (py::ssize_t k = i; k < j; ++k) { + dr.push_back(Row{ + .index = static_cast(r(k, 1)), + .coefficient = static_cast(r(k, 2)), + }); + } + } + i = j; } }, py::arg("rows"), py::call_guard(), - "Bulk-append rows from a (N, 3) float64 array [arc_id, row_index, coeff]."); + "Bulk-append rows from a (N, 3) float64 array [arc_id, row_index, coeff]. " + "The array must be sorted by arc_id (column 0)."); } // ─── Helper: bind common ResourceGraph methods ──────────────────────────────── @@ -388,7 +429,7 @@ py::class_>& bind_rg_methods(py::class_>& c) { double ub, const PyBucketAlgorithmParams& py_p, bool pre, - size_t ci) -> std::vector { + size_t ci) -> SolveResult { return ActiveCall::run_interruptible([&] { return run_bucket_solve(alg, rg, @@ -410,7 +451,7 @@ py::class_>& bind_rg_methods(py::class_>& c) { double ub, const PyAlgorithmParams& py_p, bool pre, - size_t ci) -> std::vector { + size_t ci) -> SolveResult { using LC = LabelList; auto p = py_p.template to_params(); return ActiveCall::run_interruptible( diff --git a/src/python_interface/rcspp/pricing_pool.py b/src/python_interface/rcspp/pricing_pool.py index b24eff728..86a4c288e 100644 --- a/src/python_interface/rcspp/pricing_pool.py +++ b/src/python_interface/rcspp/pricing_pool.py @@ -199,10 +199,19 @@ def attach(cls, handle: dict) -> "SharedPricingPool": """Attach to an existing pool from a worker process (zero-copy).""" obj = object.__new__(cls) obj._lock = handle["lock"] - obj._shm = SharedMemory(name=handle["shm_name"], create=False) - # This process does not own the segment; prevent its resource_tracker - # from unlinking it on exit (spawn-safety — see _untrack_shared_memory). - _untrack_shared_memory(obj._shm) + # This process does not own the segment, so it must not let its + # resource_tracker unlink it on exit (spawn-safety). On Python 3.13+ + # ``track=False`` keeps the segment out of the tracker entirely — the + # clean way, with no shutdown noise. On older versions, register and + # then immediately unregister (see _untrack_shared_memory); that is + # still spawn-safe but the shared tracker logs a benign KeyError at + # exit because the owner's unlink() unregisters the same name again. + name = handle["shm_name"] + try: + obj._shm = SharedMemory(name=name, create=False, track=False) + except TypeError: # Python < 3.13: no ``track`` parameter + obj._shm = SharedMemory(name=name, create=False) + _untrack_shared_memory(obj._shm) hdr = np.ndarray((1,), dtype=_HEADER_DTYPE, buffer=obj._shm.buf) obj._n_constraints = int(hdr["n_constraints"][0]) obj._max_cols = int(hdr["max_cols"][0]) diff --git a/src/python_interface/rcspp/py.typed b/src/python_interface/rcspp/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/python_interface/rcspp/rcspp.cpp b/src/python_interface/rcspp/rcspp.cpp index ca3e42acf..05ca77ae5 100644 --- a/src/python_interface/rcspp/rcspp.cpp +++ b/src/python_interface/rcspp/rcspp.cpp @@ -7,6 +7,8 @@ #include #include +#include "rcspp/utils/memory.hpp" + namespace py = pybind11; void init_graph(py::module_&); @@ -61,4 +63,14 @@ PYBIND11_MODULE(_core, m) { py::arg("to_console") = true, py::arg("file_path") = std::string{}, "Initialize the logger (level, console output, optional log file)."); + + // ── Memory helpers ──────────────────────────────────────────────────────── + m.def( + "process_memory_bytes", + []() { return rcspp::MemoryInfo::process_bytes(); }, + "Current process Resident Set Size (RSS) in bytes."); + m.def( + "available_memory_bytes", + []() { return rcspp::MemoryInfo::available_system_bytes(); }, + "Available system RAM in bytes."); } diff --git a/src/python_interface/rcspp/solution_pool.cpp b/src/python_interface/rcspp/solution_pool.cpp index 4cd92c6af..36e840d38 100644 --- a/src/python_interface/rcspp/solution_pool.cpp +++ b/src/python_interface/rcspp/solution_pool.cpp @@ -106,12 +106,16 @@ void init_solution_pool(py::module_& m) { // NOLINT(readability-function-cognit fp->remove_if([=](SolutionPool::ColumnId, const Solution&, const ColumnActivity& act) -> bool { - if (max_age.has_value() && act.age > *max_age) return true; + if (max_age.has_value() && act.age > *max_age) { + return true; + } if (min_usage_rate.has_value() && act.priced_count > 0 && - act.usage_rate() < *min_usage_rate) + act.usage_rate() < *min_usage_rate) { return true; - if (max_last_rc.has_value() && act.last_reduced_cost >= *max_last_rc) + } + if (max_last_rc.has_value() && act.last_reduced_cost >= *max_last_rc) { return true; + } return false; }); } diff --git a/src/rcspp/algorithm/algorithm.hpp b/src/rcspp/algorithm/algorithm.hpp index 885f50a2e..47afc35de 100644 --- a/src/rcspp/algorithm/algorithm.hpp +++ b/src/rcspp/algorithm/algorithm.hpp @@ -26,10 +26,52 @@ #include "rcspp/graph/graph.hpp" #include "rcspp/label/label_pool.hpp" #include "rcspp/resource/concrete/numerical_resource.hpp" +#include "rcspp/utils/memory.hpp" #include "rcspp/utils/timer.hpp" namespace rcspp { +/// @brief Exit status returned by Algorithm::solve(). +enum class AlgorithmStatus { + COMPLETE, ///< All labels processed; result is optimal (given other params). + TIMEOUT, ///< Wall-clock timeout reached before completion. + MAX_SOLUTIONS, ///< stop_after_X_solutions reached. + MAX_PHASES, ///< num_max_phases exhausted with labels still remaining. + INTERRUPTED, ///< External should_stop callback returned true. + MEMORY_LIMIT, ///< RSS memory limit reached. +}; + +/// @brief Return value of Algorithm::solve(). +struct SolveResult { + std::vector solutions; + AlgorithmStatus status = AlgorithmStatus::COMPLETE; + /// @brief Number of labels extended during this solve (labeling effort). + size_t num_extended_labels = 0; + + /// @brief Human-readable name of the exit status. + [[nodiscard]] std::string status_string() const { + switch (status) { + case AlgorithmStatus::COMPLETE: + return "complete"; + case AlgorithmStatus::TIMEOUT: + return "timeout"; + case AlgorithmStatus::MAX_SOLUTIONS: + return "max_solutions"; + case AlgorithmStatus::MAX_PHASES: + return "max_phases"; + case AlgorithmStatus::INTERRUPTED: + return "interrupted"; + case AlgorithmStatus::MEMORY_LIMIT: + return "memory_limit"; + } + return "unknown"; + } +}; + +// Forward declaration so AlgorithmBaseParams::with_container can name the return type. +template +struct AlgorithmParams; + template using LabelIterator = std::list*>::iterator; @@ -39,6 +81,14 @@ using LabelIteratorPair = constexpr size_t MAX_INT = std::numeric_limits::max() / 2; // to avoid overflow +// ── Memory-limit algorithm defaults ────────────────────────────────────────── +/// Default fraction of RAM to use as the memory limit. +constexpr double kDefaultMemoryLimitFraction = 0.9; +/// Default number of main-loop iterations between RSS measurements. +constexpr size_t kDefaultMemoryCheckInterval = 50'000; +/// Default per-node queue size cap when memory pressure is triggered. +constexpr size_t kDefaultMemoryPressureMaxLabelsPerNode = 200; + struct AlgorithmBaseParams { void check() const { // NOLINT(readability-make-member-function-const) if (num_max_phases > 1 && num_labels_to_extend_by_node >= MAX_INT) { @@ -62,7 +112,8 @@ struct AlgorithmBaseParams { } [[nodiscard]] bool could_be_non_optimal() const { - return ((stop_after_X_solutions < MAX_INT) || (num_labels_to_extend_by_node < MAX_INT)); + return ((stop_after_X_solutions < MAX_INT) || + (num_labels_to_extend_by_node < MAX_INT) || std::isfinite(timeout_s)); } // stop after finding X solutions (not going to optimality) @@ -87,15 +138,119 @@ struct AlgorithmBaseParams { // maximum number of iterations/loops (for algorithms that use it) size_t max_iterations = MAX_INT; + // wall-clock timeout in seconds; solve() returns early when elapsed >= timeout_s + double timeout_s = std::numeric_limits::infinity(); + // callable returning true if the algorithm should stop early (e.g. SIGINT) std::function should_stop; + // numerical tolerance used for cost comparisons + double tolerance = 1e-9; // NOLINT(readability-magic-numbers) + + /// @brief If true (default), release all label memory at the end of solve(). + /// + /// Set to false when the same algorithm instance is called repeatedly in a + /// tight loop (e.g., inside DiversificationSearch) so the pool capacity is + /// retained across calls and shrink_to_fit() overhead is avoided. + bool release_after_solve = true; + // for tabu search algorithms - size_t tabu_tenure = 5; // NOLINT + size_t tabu_tenure = 5; // NOLINT(readability-magic-numbers) std::set forbidden_tabu; bool tabu_random_noise = true; + /// @brief Consecutive non-improving dives before a diversification step. + /// + /// Used by @ref ImprovingTabuSearch: when this many dives in a row fail + /// to strictly improve the best known cost, @c grow_extra() is called on + /// the tabu list to force exploration of unexplored regions. + size_t diversification_tenure = 10; // NOLINT(readability-magic-numbers) + int seed = 0; + + /// @brief Index of the cost resource component used to compute the A* heuristic. + /// + /// Injected by the dispatch layer (see AStarAlgoEntry in graph_impl.hpp) so that + /// AStarDominanceAlgorithm::initialize() runs Bellman–Ford on the same cost slot + /// as the labeling algorithm itself. Ignored by all other algorithm types. + size_t heuristic_cost_index = 0; + + // ── Memory-limit parameters ───────────────────────────────────────── + + /// @brief Hard upper bound on process RSS in gibibytes (GiB); 0 means unlimited. + /// + /// When non-zero, the algorithm stops (returning whatever solutions have + /// been found so far) as soon as the measured RSS reaches this value. + /// Fractional values are accepted (e.g. 0.5 for 512 MiB, 1.5 for 1.5 GiB). + /// + /// This takes priority over @ref limit_to_available_ram and + /// @ref limit_to_total_ram; both of those are ignored when this is set. + /// + /// @code + /// params.max_memory_gb = 8.0; // 8 GiB hard limit + /// params.max_memory_gb = 0.5; // 512 MiB hard limit + /// @endcode + double max_memory_gb = 0.0; + + /// @brief Automatically derive the memory limit from currently *available* RAM. + /// + /// If true, the limit is set to + /// @ref memory_limit_fraction × available-system-RAM at the start of + /// solve(). "Available" means memory the OS can give out without + /// swapping — it fluctuates as other processes allocate and free memory. + /// + /// Ignored when @ref max_memory_gb is non-zero. + /// Takes priority over @ref limit_to_total_ram. + bool limit_to_available_ram = false; + + /// @brief Automatically derive the memory limit from the machine's *total* RAM. + /// + /// If true, the limit is set to + /// @ref memory_limit_fraction × total-physical-RAM at the start of + /// solve(). Unlike @ref limit_to_available_ram, total RAM is a fixed + /// hardware constant (e.g. 16 GiB) and does not change between solves. + /// This gives a stable, reproducible limit regardless of what other + /// processes are doing. + /// + /// Ignored when @ref max_memory_gb is non-zero or when + /// @ref limit_to_available_ram is true. + bool limit_to_total_ram = false; + + /// @brief Fraction of RAM to use when @ref limit_to_available_ram or + /// @ref limit_to_total_ram is true. + /// + /// Must be in (0, 1]. Default 0.9 leaves a 10 % safety margin. + double memory_limit_fraction = kDefaultMemoryLimitFraction; + + /// @brief Number of main-loop iterations between consecutive RSS checks. + /// + /// Larger values reduce measurement overhead; smaller values react + /// faster to memory spikes. Must be > 0. + size_t memory_check_interval = kDefaultMemoryCheckInterval; + + /// @brief Pressure threshold as a fraction of the effective memory limit. + /// + /// When `current_rss >= memory_pressure_fraction × effective_limit`, + /// @ref on_memory_pressure() is called to prune label queues before + /// growth reaches the hard limit. Default 0.8 triggers at 80 %. + double memory_pressure_fraction = kDefaultMemoryPressureFraction; + + /// @brief Max unprocessed labels to retain per node under memory pressure. + /// + /// When @ref on_memory_pressure() fires, each per-node unprocessed + /// queue is trimmed to this many entries (cheapest labels kept). + /// Dominated excess labels are recycled; non-dominated excess labels + /// are stored for a future phase, consistent with truncated labeling. + size_t memory_pressure_max_labels_per_node = kDefaultMemoryPressureMaxLabelsPerNode; + + /// @brief Wrap these base params in an AlgorithmParams with the given container. + /// + /// @param container Label container instance (e.g. LabelList or LabelBuckets). + /// Defaults to a default-constructed LC{} when LC is + /// default-constructible (e.g. LabelList). + /// @return AlgorithmParams inheriting all settings from *this. + template + AlgorithmParams with_container(LC container = LC{}) const; }; template @@ -111,6 +266,12 @@ struct AlgorithmParams : AlgorithmBaseParams { const LabelContainerType labels; }; +// Out-of-line definition: AlgorithmParams is now complete. +template +AlgorithmParams AlgorithmBaseParams::with_container(LC container) const { + return AlgorithmParams(*this, std::move(container)); +} + template > requires ResourceTypeConcept class Algorithm { @@ -154,22 +315,27 @@ class Algorithm { best_cost_upper_bound_ = cost_upper_bound; label_pool_.clear(); solutions_.clear(); + effective_max_labels_per_node_ = params_.num_labels_to_extend_by_node; + memory_pressure_triggered_ = false; } - virtual std::vector solve(const Graph* graph, - double cost_upper_bound) { + virtual SolveResult solve(const Graph* graph, double cost_upper_bound) { // initialization Timer timer(true); + timed_out_ = false; + solve_timer_ = &timer; + memory_limit_.resolve(params_.max_memory_gb, + params_.limit_to_available_ram, + params_.limit_to_total_ram, + params_.memory_limit_fraction, + params_.memory_pressure_fraction); initialize(graph, cost_upper_bound); // initialize labels this->initialize_labels(); size_t num_phases = 0; - while (solutions_.size() < params_.stop_after_X_solutions && number_of_labels() > 0) { - if (is_interrupted()) { - break; - } + while (!should_stop() && number_of_labels() > 0) { // main labeling loop main_loop(); @@ -184,6 +350,8 @@ class Algorithm { } } + solve_timer_ = nullptr; + if (LOG_DEBUG_ACTIVE()) { LOG_DEBUG("Total number of extended labels: ", num_extended_labels_, "\n"); print_labels(); @@ -197,8 +365,10 @@ class Algorithm { } // prepare next phase to ensure that all_labels_processed() returns the right value - // Also, next solve() is ready to start if needed - prepareNextPhase(); + // (if not releasing all labels). Also, next solve() is ready to start if needed + if (!params_.release_after_solve) { + prepareNextPhase(); + } // sort solutions std::ranges::sort(solutions, @@ -215,7 +385,30 @@ class Algorithm { solutions.resize(params_.stop_after_X_solutions); } - return solutions; + // determine exit status + AlgorithmStatus status; + if (timed_out_) { + status = AlgorithmStatus::TIMEOUT; + } else if (is_interrupted()) { + status = AlgorithmStatus::INTERRUPTED; + } else if (memory_limit_.is_exceeded()) { + status = AlgorithmStatus::MEMORY_LIMIT; + } else if (number_of_labels() == 0) { + status = AlgorithmStatus::COMPLETE; + } else if (solutions.size() >= params_.stop_after_X_solutions) { + status = AlgorithmStatus::MAX_SOLUTIONS; + } else { + status = AlgorithmStatus::MAX_PHASES; + } + + // Optionally release label memory so RAM is reclaimed when the caller returns. + if (params_.release_after_solve) { + release_label_memory(); + } + + return {.solutions = std::move(solutions), + .status = status, + .num_extended_labels = num_extended_labels_}; } [[nodiscard]] bool all_labels_processed() const { return number_of_labels() == 0; } @@ -224,9 +417,30 @@ class Algorithm { return params_.should_stop && params_.should_stop(); } + /// @brief Read-only access to the label pool, for diagnostics and tests. + [[nodiscard]] const LabelPool& get_label_pool() const { return label_pool_; } + protected: bool print_{false}; + /// @brief Hook called when @ref memory_limit_.is_under_pressure() becomes true. + /// + /// The default implementation does nothing. Subclasses that own + /// unprocessed label queues (e.g. PushingDominanceAlgorithm) override + /// this to trim those queues, slowing further RSS growth before the + /// hard limit is hit. + virtual void on_memory_pressure() {} + + /// @brief Release all label memory held by the pool and label containers. + /// + /// Called at the end of solve() so that RSS is reclaimed as soon as the + /// caller returns. The default implementation frees the pool (including + /// shrink_to_fit). Subclasses override this to also clear their own + /// label pointer containers (non-dominated sets, unprocessed queues). + virtual void release_label_memory() { label_pool_.release(); } + + // ── Core virtuals ─────────────────────────────────────────────────── + virtual void initialize_labels() = 0; [[nodiscard]] virtual size_t number_of_labels() const = 0; @@ -271,6 +485,7 @@ class Algorithm { Column column; std::unordered_map row_map; std::vector path_node_ids; + path_node_ids.reserve(path_arc_ids.size() + 1); for (size_t arc_id : path_arc_ids) { const auto* arc = this->graph_->get_arc(arc_id); path_node_ids.push_back(arc->origin->id); @@ -301,9 +516,49 @@ class Algorithm { solutions_.insert(std::move(sol)); } + /// @brief Returns true when the wall-clock timeout has been exceeded. + /// + /// Sets timed_out_ on the first call that exceeds the limit so subsequent + /// calls are O(1) (no timer read). Safe to call only during solve(). + bool is_time_out() { + if (!timed_out_ && solve_timer_ != nullptr && + solve_timer_->elapsed_seconds() >= params_.timeout_s) { + timed_out_ = true; + } + return timed_out_; + } + + /// @brief Returns true when solve() should terminate the outer loop. + /// + /// Checks (in order): iteration budget, solution budget, timeout, and the + /// external stop callback. Pass iteration=0 to skip the iteration check. + /// + /// @param iteration Current iteration index (default 0 = no iteration limit check). + bool should_stop(size_t iteration = 0) { + return iteration >= params_.max_iterations || + solutions_.size() >= params_.stop_after_X_solutions || is_time_out() || + is_interrupted(); + } + LabelPool label_pool_; const Graph* graph_; const AlgorithmParams params_; + MemoryLimitHelper memory_limit_; ///< Resolved at the start of each solve(). + + /// @brief Effective per-node extension cap. + /// + /// Starts at @ref AlgorithmBaseParams::num_labels_to_extend_by_node at the + /// beginning of each solve and is tightened to + /// @ref AlgorithmBaseParams::memory_pressure_max_labels_per_node when + /// @ref on_memory_pressure() fires. Subclasses use this instead of + /// @c params_.num_labels_to_extend_by_node wherever the per-node limit is enforced. + size_t effective_max_labels_per_node_ = MAX_INT; + + /// @brief True after on_memory_pressure() has been called at least once this solve. + /// + /// Used by subclasses to distinguish a first pressure event (prune & store aside) + /// from subsequent ones (prune & discard stored labels to free more memory). + bool memory_pressure_triggered_ = false; double cost_upper_bound_ = std::numeric_limits::infinity(); double best_cost_upper_bound_ = std::numeric_limits::infinity(); @@ -312,5 +567,8 @@ class Algorithm { size_t nb_dominated_labels_{0}; size_t num_extended_labels_ = 0; Timer total_full_extend_time_; + + bool timed_out_ = false; + const Timer* solve_timer_ = nullptr; ///< Points to solve()'s timer; null outside solve(). }; } // namespace rcspp diff --git a/src/rcspp/algorithm/astar_dominance_algorithm.hpp b/src/rcspp/algorithm/astar_dominance_algorithm.hpp new file mode 100644 index 000000000..bcbf9b28e --- /dev/null +++ b/src/rcspp/algorithm/astar_dominance_algorithm.hpp @@ -0,0 +1,275 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include +#include + +#include "rcspp/algorithm/dominance_algorithm.hpp" +#include "rcspp/preprocessor/bellman_ford_algorithm.hpp" +#include "rcspp/resource/resource_traits.hpp" + +namespace rcspp { + +/// @brief Label-correcting dominance algorithm with A*-style priority ordering. +/// +/// Identical to @ref SimpleDominanceAlgorithm except the frontier is managed +/// by a min-heap ordered by @f$ f = g + h @f$, where @f$ g @f$ is the label's +/// current cost and @f$ h @f$ is a per-node admissible lower bound on the +/// remaining cost to any sink, computed once via a backward Bellman–Ford pass. +/// +/// Because @f$ h @f$ is admissible, optimality is preserved. Labels with +/// the smallest estimated total cost are expanded first, which typically +/// reduces the total number of labels extended compared to FIFO ordering +/// when arc costs are heterogeneous. +/// +/// @tparam ResourceType Composed resource type (must satisfy ResourceTypeConcept). +/// @tparam LabelContainerType Non-dominated label container (default: LabelList). +/// @tparam CostResourceType Numerical resource whose component value gives arc cost for +/// the heuristic Bellman–Ford. When it is not present in +/// @p ResourceType, the algorithm falls back to @p arc.cost. +/// Injected by AStarAlgoEntry so it matches the labeling cost. +template , + typename CostResourceType = RealResource> + requires ResourceTypeConcept +class AStarDominanceAlgorithm : public DominanceAlgorithm { + public: + /// @brief Construct with a resource factory and algorithm parameters. + /// + /// @param resource_factory Factory that creates initial resources for source labels. + /// @param params Algorithm configuration (truncation, memory limits, etc.). + AStarDominanceAlgorithm(ResourceFactory* resource_factory, + AlgorithmParams params) + : DominanceAlgorithm(resource_factory, + std::move(params)), + unprocessed_labels_{LabelFValueComparator{&h_to_sink_}} {} + + ~AStarDominanceAlgorithm() override = default; + + private: + // ─── Comparator ────────────────────────────────────────────────────────── + + /// @brief Min-heap comparator: lower f-value = higher priority. + /// + /// Stores a pointer to the outer object's @ref h_to_sink_ vector so that + /// the comparator always reflects the current heuristic values without + /// copying the vector. + struct LabelFValueComparator { + const std::vector* h; + + bool operator()(const LabelIteratorPair& a, + const LabelIteratorPair& b) const { + double fa = a.first->get_cost() + h->at(a.first->get_end_node()->pos()); + double fb = b.first->get_cost() + h->at(b.first->get_end_node()->pos()); + // std::priority_queue is a max-heap; invert comparison for min-heap. + return fa > fb; + } + }; + + using PriorityQueue = std::priority_queue, + std::vector>, + LabelFValueComparator>; + + // ─── Initialization ─────────────────────────────────────────────────── + + /// @brief Initialize the heuristic vector and per-node counters. + /// + /// Runs a backward Bellman–Ford from all sinks to fill @ref h_to_sink_. + /// When @p CostResourceType is present in @p ResourceType, the arc weight + /// is read from the resource extender at @p params_.heuristic_cost_index + /// (the reduced cost), giving a tight admissible lower bound aligned with + /// the labeling cost. Otherwise falls back to @p arc.cost. + void initialize(const Graph* graph, double cost_upper_bound) override { + Algorithm::initialize(graph, cost_upper_bound); + + number_of_extended_labels_per_node_.assign(graph->get_number_of_nodes(), 0); + + // Backward Bellman-Ford from sinks using the same cost slot as the labeling algorithm. + // Reduced costs can create negative-weight cycles (impossible with arc.cost alone), + // so we fall back to the arc-cost overload when that happens. + Distance dist; + try { + if constexpr (is_cost_in_composition_v) { + dist = BellmanFordAlgorithm::solve( + *graph, + graph->get_sink_node_ids(), + this->params_.heuristic_cost_index, + /*forward=*/false); + } else { + dist = BellmanFordAlgorithm::solve(*graph, + graph->get_sink_node_ids(), + /*forward=*/false); + } + } catch (const std::runtime_error&) { + // Negative-weight cycle in reduced costs: fall back to arc.cost (always positive). + dist = BellmanFordAlgorithm::solve(*graph, + graph->get_sink_node_ids(), + /*forward=*/false); + } + h_to_sink_.resize(graph->get_number_of_nodes()); + for (size_t node_id : graph->get_node_ids()) { + const auto* node = graph->get_node(node_id); + auto it = dist.find(node_id); + h_to_sink_[node->pos()] = + (it != dist.end()) ? it->second : std::numeric_limits::infinity(); + } + + // Rebuild priority queues with the fresh comparator. + LabelFValueComparator cmp{&h_to_sink_}; + unprocessed_labels_ = PriorityQueue(cmp); + unprocessed_truncated_labels_.clear(); + } + + // ─── Frontier management ────────────────────────────────────────────── + + LabelIteratorPair next_label_iterator() override { + LabelIteratorPair label_iterator_pair; + while (!unprocessed_labels_.empty()) { + label_iterator_pair = unprocessed_labels_.top(); + unprocessed_labels_.pop(); + + if (label_iterator_pair.first->dominated) { + // release_with_ref_count (not release_label): a dequeued label still pins + // the predecessor it was extended from; decrement its ref_count to avoid leak. + this->label_pool_.release_with_ref_count(label_iterator_pair.first); + } else { + size_t& num_extended = number_of_extended_labels_per_node_.at( + label_iterator_pair.first->get_end_node()->pos()); + if (num_extended < this->effective_max_labels_per_node_) { + ++num_extended; + break; + } + unprocessed_truncated_labels_.push_back(label_iterator_pair); + } + } + return label_iterator_pair; + } + + [[nodiscard]] size_t number_of_labels() const override { + return unprocessed_labels_.size(); + } + + void add_new_unprocessed_label( + const LabelIteratorPair& label_iterator_pair) override { + unprocessed_labels_.push(label_iterator_pair); + } + + // ─── Multi-phase support ────────────────────────────────────────────── + + /// @brief Restore truncated labels into the main heap for the next phase. + void prepareNextPhase() override { + std::ranges::fill(number_of_extended_labels_per_node_, 0); + for (const auto& pair : unprocessed_truncated_labels_) { + unprocessed_labels_.push(pair); + } + unprocessed_truncated_labels_.clear(); + } + + // ─── Memory pressure ────────────────────────────────────────────────── + + /// @brief Trim the heap when memory pressure is detected. + /// + /// Drains the heap into a temporary vector, sorts by dominance status + /// then ascending f-value, keeps the cheapest + /// @ref AlgorithmBaseParams::memory_pressure_max_labels_per_node × num_nodes + /// entries, recycles dominated excess labels, and stores non-dominated + /// excess in @ref unprocessed_truncated_labels_ for the next phase. + void on_memory_pressure() override { + const size_t limit = this->params_.memory_pressure_max_labels_per_node; + this->effective_max_labels_per_node_ = limit; + + if (this->memory_pressure_triggered_) { + for (auto& [label_ptr, label_iter] : unprocessed_truncated_labels_) { + this->remove_label(label_iter); + this->label_pool_.release_with_ref_count(label_ptr); + } + unprocessed_truncated_labels_.clear(); + } + this->memory_pressure_triggered_ = true; + + const size_t max_total = limit * this->graph_->get_number_of_nodes(); + if (unprocessed_labels_.size() <= max_total) { + return; + } + + // Drain heap into a flat vector for sorting. + std::vector> flat; + flat.reserve(unprocessed_labels_.size()); + while (!unprocessed_labels_.empty()) { + flat.push_back(unprocessed_labels_.top()); + unprocessed_labels_.pop(); + } + + // Non-dominated labels first; among equal dominance, lowest f-value first. + const auto& h = h_to_sink_; + std::ranges::sort(flat, [&h](const auto& a, const auto& b) { + if (a.first->dominated != b.first->dominated) { + return !a.first->dominated; + } + double fa = a.first->get_cost() + h[a.first->get_end_node()->pos()]; + double fb = b.first->get_cost() + h[b.first->get_end_node()->pos()]; + return fa < fb; + }); + + // Recycle or defer excess labels. + for (size_t i = max_total; i < flat.size(); ++i) { + auto& p = flat[i]; + if (p.first->dominated) { + this->label_pool_.release_with_ref_count(p.first); + } else { + unprocessed_truncated_labels_.push_back(p); + } + } + flat.resize(max_total); + + // Rebuild heap from retained labels. + unprocessed_labels_ = + PriorityQueue(flat.begin(), flat.end(), LabelFValueComparator{&h_to_sink_}); + } + + // ─── Cleanup ────────────────────────────────────────────────────────── + + /// @brief Release label memory and clear all unprocessed label containers. + void release_label_memory() override { + DominanceAlgorithm::release_label_memory(); + // Rebuild empty queues (pool has already freed the label objects). + unprocessed_labels_ = PriorityQueue(LabelFValueComparator{&h_to_sink_}); + unprocessed_truncated_labels_.clear(); + std::ranges::fill(number_of_extended_labels_per_node_, 0); + } + + // ─── Members ────────────────────────────────────────────────────────── + + /// @brief Admissible per-node lower bounds on the cost to the nearest sink. + /// + /// Indexed by node position (@ref Node::pos()). Computed once per + /// @ref initialize() call via a backward Bellman–Ford pass. + std::vector h_to_sink_; + + /// @brief Min-heap of active (non-truncated) labels ordered by f = g + h. + PriorityQueue unprocessed_labels_; + + /// @brief Labels deferred due to per-node extension cap; restored each phase. + std::vector> unprocessed_truncated_labels_; + + /// @brief Count of labels extended per node in the current phase. + std::vector number_of_extended_labels_per_node_; +}; + +/// @brief Presents AStarDominanceAlgorithm as a 2-param template. +/// +/// Required because C++ template template parameters must match exactly in arity +/// (P0522 matching is not reliably supported). Use AStarAlgoBound::Algo +/// wherever a @c template class argument is expected. +template +struct AStarAlgoBound { + template + class Algo : public AStarDominanceAlgorithm { + using AStarDominanceAlgorithm::AStarDominanceAlgorithm; + }; +}; + +} // namespace rcspp diff --git a/src/rcspp/algorithm/backtracking_dive_algorithm.hpp b/src/rcspp/algorithm/backtracking_dive_algorithm.hpp new file mode 100644 index 000000000..e126ce616 --- /dev/null +++ b/src/rcspp/algorithm/backtracking_dive_algorithm.hpp @@ -0,0 +1,200 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "rcspp/algorithm/algorithm.hpp" +#include "rcspp/graph/graph.hpp" +#include "rcspp/label/label.hpp" + +namespace rcspp { + +/** + * @brief Common DFS-with-backtracking machinery shared by the constructive + * heuristics in this package (e.g. @ref GreedyAlgorithm, @ref TabuSearchAlgorithm). + * + * The class owns a @c path_ that represents the current depth-first path under + * exploration: each entry holds the label currently selected at that depth and + * an ordered list of remaining sibling labels to try if a dead-end forces a + * backtrack. Subclasses drive the search through @ref extend_label and + * @ref backtrack, and they customise selection of the next children via the + * @ref select_children hook (e.g. cost-sorting only, or tabu filtering). + * + * This class does not implement @ref main_loop: each subclass picks how to + * sequence dives, sink-extractions, and resets. + */ +template > +class BacktrackingDiveAlgorithm : public Algorithm { + public: + BacktrackingDiveAlgorithm(ResourceFactory* resource_factory, + AlgorithmParams params) + : Algorithm(resource_factory, std::move(params)) {} + + protected: + using PathEntry = std::pair*, std::list*>>; + + void initialize_labels() override { seed_path_from_sources(); } + + [[nodiscard]] size_t number_of_labels() const override { return path_.empty() ? 0 : 1; } + + [[nodiscard]] std::list*> get_labels_at_sinks() const override { + return {}; + } + + std::vector get_path_arc_ids(const Label& label) override { + std::vector path_arc_ids; + for (const auto& p : path_) { + const auto* in_arc = p.first->get_in_arc(); + if (in_arc != nullptr) { + path_arc_ids.push_back(in_arc->id); + } + if (p.first == &label) { + break; + } + } + return path_arc_ids; + } + + // ------------------------------------------------------------------ + // path_ management + // ------------------------------------------------------------------ + + /// Seed @c path_ with one entry per source node. + /// Subsequent siblings at depth 0 are the source labels other than the first. + void seed_path_from_sources() { + std::list*> sources; + for (auto src_id : this->graph_->get_source_node_ids()) { + auto* src = this->graph_->get_node(src_id); + sources.push_back(&this->label_pool_.get_next_label(src)); + } + if (sources.empty()) { + return; + } + path_.clear(); + add_labels_to_path(std::move(sources)); + } + + /// Release every label held in @c path_ (current and siblings) and clear it. + void clear_path() { + for (auto& entry : path_) { + this->label_pool_.release_label(entry.first); + for (auto* sib : entry.second) { + this->label_pool_.release_label(sib); + } + } + path_.clear(); + } + + /// Push a new depth onto @c path_: the first label becomes the current, the + /// rest become its siblings. + void add_labels_to_path(std::list*> labels) { + auto* first = labels.front(); + labels.pop_front(); + path_.emplace_back(first, std::move(labels)); + } + + // ------------------------------------------------------------------ + // dive primitives + // ------------------------------------------------------------------ + + /// Try to extend @p label by one level. Builds all feasible extensions, + /// asks @ref select_children for the chosen ordered subset (with rejects), + /// and pushes the chosen list as a new depth in @c path_. Returns true + /// iff a new depth was pushed. + bool extend_label(Label* label) { + std::list*> feasible; + for (auto* arc : label->get_end_node()->out_arcs) { + if (!label->is_reachable(arc->destination->id)) { + continue; + } + auto& candidate = this->label_pool_.get_next_label(arc->destination); + label->extend(*arc, &candidate); + if (candidate.is_feasible()) { + feasible.push_back(&candidate); + } else { + this->label_pool_.release_label(&candidate); + } + } + + std::list*> rejects; + select_children(label, feasible, rejects); + for (auto* r : rejects) { + this->label_pool_.release_label(r); + } + + if (feasible.empty()) { + return false; + } + add_labels_to_path(std::move(feasible)); + return true; + } + + /// Pop dead-end depths and switch to the next sibling at the deepest depth + /// that still has one. Returns false iff @c path_ has been exhausted. + bool backtrack() { + while (!path_.empty() && path_.back().second.empty()) { + this->label_pool_.release_label(path_.back().first); + path_.pop_back(); + } + if (path_.empty()) { + return false; + } + this->label_pool_.release_label(path_.back().first); + auto* next = path_.back().second.front(); + path_.back().second.pop_front(); + path_.back().first = next; + return true; + } + + // ------------------------------------------------------------------ + // customisation hook + // ------------------------------------------------------------------ + + /// Choose which feasible extensions of @p parent to keep and in what order. + /// + /// On entry, @p feasible contains every feasible extension built by + /// @ref extend_label. On return, @p feasible holds the ordered list of + /// labels to push as a new depth (best-first, since the head is selected + /// as the new "current" and the rest become its siblings), and @p rejects + /// holds any labels that should be released by the caller. + /// + /// The default implementation sorts ascending by label cost and produces + /// no rejects — i.e. classic greedy extension order. + virtual void select_children(Label* parent, + std::list*>& feasible, + std::list*>& rejects) { + (void)parent; + (void)rejects; + feasible.sort([](Label* a, Label* b) { + return a->get_cost() < b->get_cost(); + }); + } + + // ------------------------------------------------------------------ + // misc helpers + // ------------------------------------------------------------------ + + [[nodiscard]] std::string to_string() const { + std::stringstream ss; + size_t n = path_.size(); + for (const auto& p : path_) { + ss << p.first->get_end_node()->id << (--n == 0 ? "" : " -> "); + } + return ss.str(); + } + + // ------------------------------------------------------------------ + // state + // ------------------------------------------------------------------ + + std::list path_; +}; + +} // namespace rcspp diff --git a/src/rcspp/algorithm/diversification_search.hpp b/src/rcspp/algorithm/diversification_search.hpp index 1827a49da..eab253ad7 100644 --- a/src/rcspp/algorithm/diversification_search.hpp +++ b/src/rcspp/algorithm/diversification_search.hpp @@ -4,14 +4,13 @@ #pragma once #include -#include #include -#include #include #include #include "rcspp/algorithm/algorithm.hpp" #include "rcspp/algorithm/greedy.hpp" +#include "rcspp/algorithm/tabu_list.hpp" namespace rcspp { @@ -45,14 +44,13 @@ class DiversificationSearch : public Algorithm std::unique_ptr> algo = nullptr) : Algorithm(resource_factory, std::move(params)), algo_(std::move(algo)), - rnd_(std::random_device{}()) { // NOLINT(whitespace/braces) - rnd_.seed(this->params_.seed); - + tabu_(this->params_.seed) { if (algo_ == nullptr) { // create algorithm params auto alg_params = this->params_; alg_params.stop_after_X_solutions = 1; // only need one solution per iteration alg_params.max_iterations = 20; // ensure early termination if needed // NOLINT + alg_params.release_after_solve = false; // pool reused each iteration; skip shrink algo_ = std::make_unique>( resource_factory, alg_params); @@ -79,13 +77,12 @@ class DiversificationSearch : public Algorithm } size_t i = 0; - while (i < this->params_.max_iterations && - this->solutions_.size() < this->params_.stop_after_X_solutions) { + while (!this->should_stop(i)) { ++i; // solve (important to clear the label pool, as the graph is changing) std::vector sols = - algo_->solve(graph_copy_.get(), this->cost_upper_bound_); + algo_->solve(graph_copy_.get(), this->cost_upper_bound_).solutions; if (sols.empty()) { break; } @@ -104,21 +101,14 @@ class DiversificationSearch : public Algorithm added = true; } - // increase tenure + // grow extra tenure when no novel solution was added (matches the + // original heuristic; success leaves the extra tenure unchanged) if (!added) { - tabu_tenure_extra_ = 1 + (2 * tabu_tenure_extra_); + tabu_.grow_extra(); } - // decrease tenure and remove expired - for (auto it = removed_tabu_arc_ids_.begin(); it != removed_tabu_arc_ids_.end();) { - if (it->second == 0) { - graph_copy_->restore_arc(it->first); - it = removed_tabu_arc_ids_.erase(it); - } else { - --(it->second); - ++it; - } - } + // decrement tenures and restore arcs whose tenure expired + tabu_.age([&](size_t arc_id) { graph_copy_->restore_arc(arc_id); }); } LOG_DEBUG("DiversificationSearch: WHILE nb iter: ", i, "\n"); @@ -133,14 +123,8 @@ class DiversificationSearch : public Algorithm this->params_.forbidden_tabu.contains(arc->destination->id)) { continue; } - // remove arc and add to tabu list if (graph_copy_->remove_arc(arc_id)) { - size_t tenure = this->params_.tabu_tenure + tabu_tenure_extra_; - if (this->params_.tabu_random_noise) { - std::uniform_int_distribution dist(tenure > 1 ? -1 : 0, 1); - tenure += dist(rnd_); - } - removed_tabu_arc_ids_[arc_id] = tenure; + tabu_.add(arc_id, this->params_.tabu_tenure, this->params_.tabu_random_noise); } } } @@ -162,9 +146,7 @@ class DiversificationSearch : public Algorithm private: std::unique_ptr> graph_copy_; std::unique_ptr> algo_; - std::map removed_tabu_arc_ids_; - size_t tabu_tenure_extra_{0}; - std::mt19937_64 rnd_; + TabuList tabu_; }; } // namespace rcspp diff --git a/src/rcspp/algorithm/dominance_algorithm.hpp b/src/rcspp/algorithm/dominance_algorithm.hpp index 8fb64479c..5eec60b61 100644 --- a/src/rcspp/algorithm/dominance_algorithm.hpp +++ b/src/rcspp/algorithm/dominance_algorithm.hpp @@ -22,8 +22,40 @@ class DominanceAlgorithm : public Algorithm { AlgorithmParams params) : Algorithm(resource_factory, std::move(params)) {} + /// @brief Total number of non-dominated labels currently stored across all + /// nodes (a measure of the labeling effort / dominance strength). + [[nodiscard]] size_t total_non_dominated_labels() const { + size_t total = 0; + for (const auto& labels : non_dominated_labels_by_node_pos_) { + total += labels.get_labels().size(); + } + return total; + } + + /// @brief Number of labels discarded as dominated during the run. + [[nodiscard]] size_t nb_dominated_labels() const { return this->nb_dominated_labels_; } + + /// @brief Number of labels extended during the run. + [[nodiscard]] size_t num_extended_labels() const { return this->num_extended_labels_; } + protected: + /// @brief Release label memory and clear the non-dominated label containers. + /// + /// Overrides @ref Algorithm::release_label_memory() to also clear + /// @ref non_dominated_labels_by_node_pos_ so that no dangling label + /// pointers remain after the pool is freed. Subclasses that own + /// additional label containers (e.g. unprocessed queues) should + /// override this further and call this base implementation. + void release_label_memory() override { + Algorithm::release_label_memory(); + non_dominated_labels_by_node_pos_.clear(); + } + void initialize_labels() override { + // Release all labels from the previous run (including any pending_release ones) + // so the pool is fully reset before we start fresh. + this->label_pool_.release_all_labels(); + non_dominated_labels_by_node_pos_.clear(); non_dominated_labels_by_node_pos_.reserve(this->graph_->get_number_of_nodes()); for (size_t i = 0; i < this->graph_->get_number_of_nodes(); i++) { @@ -43,10 +75,28 @@ class DominanceAlgorithm : public Algorithm { void main_loop() override { // NOLINT size_t i = 0; - while (this->number_of_labels() > 0 && i < this->params_.max_iterations) { - if (this->is_interrupted()) { - break; + while (this->number_of_labels() > 0 && !this->should_stop(i)) { + // Periodic memory check (skip i == 0 to avoid cost on every first iteration). + if (i > 0 && this->memory_limit_.effective_limit > 0 && + i % this->params_.memory_check_interval == 0) { + if (this->memory_limit_.is_exceeded()) { + LOG_WARN("Memory limit (", + this->memory_limit_.effective_limit / (1024ULL * 1024ULL), + " MB) exceeded (current: ", + MemoryInfo::process_bytes() / (1024ULL * 1024ULL), + " MB). Stopping early.\n"); + break; + } + if (this->memory_limit_.is_under_pressure()) { + LOG_INFO("Memory pressure: ", + MemoryInfo::process_bytes() / (1024ULL * 1024ULL), + " MB / ", + this->memory_limit_.effective_limit / (1024ULL * 1024ULL), + " MB. Trimming label queues.\n"); + this->on_memory_pressure(); + } } + ++i; // next label to process @@ -60,13 +110,16 @@ class DominanceAlgorithm : public Algorithm { // label dominated -> continue to next one auto& label = *label_iterator_pair.first; if (label.dominated) { - this->label_pool_.release_label(&label); + this->label_pool_.release_with_ref_count(&label); continue; } if (this->params_.prune_based_on_upper_bound_ && label.get_cost() >= this->best_cost_upper_bound_) { remove_label(label_iterator_pair.second); - this->label_pool_.release_label(&label); + // Use release_with_ref_count (not release_label): this label was added to + // the non-dominated set, so it pins a predecessor whose ref_count must be + // decremented. Plain release_label would leak that predecessor. + this->label_pool_.release_with_ref_count(&label); continue; } @@ -94,7 +147,7 @@ class DominanceAlgorithm : public Algorithm { this->total_full_extend_time_.stop(); } else { remove_label(label_iterator_pair.second); - this->label_pool_.release_label(&label); + this->label_pool_.release_with_ref_count(&label); } } } @@ -132,72 +185,31 @@ class DominanceAlgorithm : public Algorithm { non_dominated_labels_by_node_pos_.at(new_label.get_end_node()->pos()) .add_label(&new_label); add_new_unprocessed_label(std::make_pair(&new_label, new_label_it)); + // Pin predecessor: keep it alive until this label is released. + new_label.set_prev_label(label_ptr); } else { if (!feasible) { ++this->nb_infeasible_labels_; } else { ++this->nb_dominated_labels_; } + // new_label was never a predecessor; release immediately. this->label_pool_.release_label(&new_label); } } - std::vector get_path_arc_ids(const Label& label) override { // NOLINT + /// @brief Reconstruct the path by following prev_label pointers. + /// + /// O(hops): every accepted label stores a pointer to its predecessor, + /// kept alive via @ref ref_count until this label is released. + std::vector get_path_arc_ids(const Label& label) override { std::vector path_arc_ids; - - auto in_arc_ptr = label.get_in_arc(); - - if (in_arc_ptr != nullptr) { - path_arc_ids.push_back(in_arc_ptr->id); - - auto prev_node_ptr = in_arc_ptr->origin; - - const Label* current_label_ptr = &label; - - while (prev_node_ptr != nullptr) { - bool found = false; - for (const auto label_ptr : - non_dominated_labels_by_node_pos_.at(prev_node_ptr->pos()).get_labels()) { - // if cannot reach the current label from this label, skip it - if (!label_ptr->is_reachable(in_arc_ptr->destination->id)) { - continue; - } - auto& next_label_ref = - this->label_pool_.get_next_label(in_arc_ptr->destination); - label_ptr->extend(*in_arc_ptr, &next_label_ref); - - if (next_label_ref <= *current_label_ptr) { - current_label_ptr = label_ptr; - found = true; - break; - } - } - - if (!found) { - // if at source, we find a feasible path. - // We check only here to authorize to pass several times by the source if - // needed - if (prev_node_ptr->source) { - prev_node_ptr = nullptr; - } else { // otherwise, no feasible path has been found - LOG_ERROR( - "Error while extracting path: could not find previous label.\n"); - return {}; - } - } else { - in_arc_ptr = current_label_ptr->get_in_arc(); - if (in_arc_ptr != nullptr) { - path_arc_ids.push_back(in_arc_ptr->id); - prev_node_ptr = in_arc_ptr->origin; - } else { - prev_node_ptr = nullptr; - } - } - } + const Label* cur = &label; + while (cur != nullptr && cur->get_in_arc() != nullptr) { + path_arc_ids.push_back(cur->get_in_arc()->id); + cur = cur->prev_label; } - std::ranges::reverse(path_arc_ids); - return path_arc_ids; } @@ -307,10 +319,10 @@ struct NodeUnprocessedLabelsManager { void resize_unprocessed_labels( std::list>* unprocessed_labels, size_t new_size, LabelPool* label_pool, bool sort) { - int num_exceeding_labels = unprocessed_labels->size() - new_size; - if (num_exceeding_labels <= 0) { + if (unprocessed_labels->size() <= new_size) { return; } + size_t num_exceeding_labels = unprocessed_labels->size() - new_size; if (sort) { // sort labels by cost (ascending) @@ -329,7 +341,7 @@ struct NodeUnprocessedLabelsManager { for (auto& p : *unprocessed_labels) { if (i++ >= new_size) { if (p.first->dominated && label_pool) { - label_pool->release_label(p.first); + label_pool->release_with_ref_count(p.first); p.first = nullptr; } else { store_truncated_unprocessed_label(p); @@ -362,6 +374,66 @@ struct NodeUnprocessedLabelsManager { assert(check_number_of_unprocessed_labels()); } + /// @brief Trim all per-node unprocessed queues to at most max_per_node labels. + /// + /// Dominated excess labels are immediately recycled into @p pool. + /// Non-dominated excess labels are stored in the truncated queue for a + /// subsequent phase, consistent with resize_unprocessed_labels(). + /// + /// @param max_per_node Maximum labels to retain per node (cheapest ones). + /// @param pool Label pool to recycle dominated labels into. May be nullptr. + void trim_all_queues(size_t max_per_node, LabelPool* pool) { + resize_current_unprocessed_labels(max_per_node, pool); + for (auto& labels_at_node : unprocessed_labels_by_node_pos_) { + resize_unprocessed_labels(&labels_at_node, max_per_node, pool, /*sort=*/true); + } + } + + /// @brief Release and discard all labels currently in the truncated queue. + /// + /// For each truncated label: invokes @p remove_from_nondom (a callable with + /// signature `void(const std::list*>::iterator&)`) to + /// remove it from the non-dominated container, then recycles it into @p pool. + /// Clears the truncated queues afterwards. + /// + /// Truncated labels are not counted in @ref num_unprocessed_labels_, so no + /// counter update is needed. + /// + /// @param pool Pool to recycle labels into. + /// @param remove_from_nondom Callable that removes a label from its node's + /// non-dominated set given the list iterator. + template + void release_truncated_labels(LabelPool* pool, + RemoveFn&& remove_from_nondom) { + for (auto& truncated_list : truncated_unprocessed_labels_by_node_pos_) { + for (auto& [label_ptr, label_iter] : truncated_list) { + remove_from_nondom(label_iter); + // Truncated labels were non-dominated (added to the set), so they pin a + // predecessor and may themselves be pinned: release_with_ref_count keeps the + // ref_count chain balanced instead of leaking it. + pool->release_with_ref_count(label_ptr); + } + truncated_list.clear(); + } + } + + /// @brief Clear all unprocessed and truncated queues. + /// + /// Does NOT release the Label objects (pool owns them). Call this + /// after the pool has been freed so no dangling pointers remain in + /// the queues. + void clear_all_queues() { + current_unprocessed_labels_.clear(); + for (auto& labels : unprocessed_labels_by_node_pos_) { + labels.clear(); + } + for (auto& labels : truncated_unprocessed_labels_by_node_pos_) { + labels.clear(); + } + num_unprocessed_labels_ = 0; + current_unprocessed_node_pos_ = 0; + } + [[nodiscard]] bool check_number_of_unprocessed_labels() const { size_t total_labels = 0; for (const auto& labels_at_node : unprocessed_labels_by_node_pos_) { diff --git a/src/rcspp/algorithm/greedy.hpp b/src/rcspp/algorithm/greedy.hpp index 7b0c157e8..8d40b136b 100644 --- a/src/rcspp/algorithm/greedy.hpp +++ b/src/rcspp/algorithm/greedy.hpp @@ -40,10 +40,7 @@ class GreedyAlgorithm : public Algorithm { protected: void main_loop() override { size_t i = 0; - while (this->number_of_labels() > 0 && i < this->params_.max_iterations) { - if (this->is_interrupted()) { - break; - } + while (this->number_of_labels() > 0 && !this->should_stop(i)) { ++i; // get the label diff --git a/src/rcspp/algorithm/improving_tabu_search.hpp b/src/rcspp/algorithm/improving_tabu_search.hpp new file mode 100644 index 000000000..da0974c02 --- /dev/null +++ b/src/rcspp/algorithm/improving_tabu_search.hpp @@ -0,0 +1,253 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include +#include + +#include "rcspp/algorithm/backtracking_dive_algorithm.hpp" +#include "rcspp/algorithm/tabu_list.hpp" + +namespace rcspp { + +/// @brief Classical improving tabu search for RCSPP. +/// +/// Two-phase algorithm built on the same DFS-with-backtracking engine as +/// @ref GreedyAlgorithm and @ref TabuSearchAlgorithm: +/// +/// **Phase 1 — construction**: a pure greedy dive (no tabu) finds an initial +/// feasible solution and records its cost as the best known upper bound. +/// +/// **Phase 2 — improvement**: repeated tabu-filtered dives from the source, +/// each one constrained to find a path strictly cheaper than the current best. +/// Classical tabu-search mechanisms are applied: +/// - **Tabu list**: arcs used in the most recently found path are forbidden +/// for @c tabu_tenure iterations, preventing cycling. +/// - **Aspiration criterion**: when every extension from a node is tabu, the +/// cheapest tabu extension is used anyway (last-resort aspiration). +/// - **Intensification**: when a strictly improving solution is found, the +/// adaptive tenure is shrunk so the search stays near the good region. +/// - **Diversification**: when @c diversification_tenure consecutive dives +/// fail to improve, the adaptive tenure is grown to force exploration of +/// unexplored regions; the counter then resets. +/// +/// All solutions found in both phases are collected. The algorithm is not +/// optimal; it is intended as a fast primal heuristic. +/// +/// @tparam ResourceType Composed resource type (must satisfy ResourceTypeConcept). +/// @tparam LabelContainerType Non-dominated label container (default: LabelList). +template > + requires ResourceTypeConcept +class ImprovingTabuSearch : public BacktrackingDiveAlgorithm { + public: + /// @brief Construct with a resource factory and algorithm parameters. + /// + /// @param resource_factory Factory for source-node label initialisation. + /// @param params Algorithm configuration. Relevant fields: + /// - @c max_iterations – improvement-phase iteration budget. + /// - @c tabu_tenure – base tabu tenure for arcs. + /// - @c tabu_random_noise – whether to add jitter to tenure. + /// - @c stop_after_X_solutions – early-stop threshold. + /// - @c diversification_tenure – no-improve count before grow_extra(). + ImprovingTabuSearch(ResourceFactory* resource_factory, + AlgorithmParams params) + : BacktrackingDiveAlgorithm(resource_factory, + std::move(params)), + tabu_(this->params_.seed) {} + + ~ImprovingTabuSearch() override = default; + + [[nodiscard]] bool is_optimal() const override { return false; } + + protected: + // ─── child selection ───────────────────────────────────────────────── + + /// @brief Select children: filter tabu arcs, fall back to them on aspiration. + /// + /// Non-tabu extensions are kept in @p feasible (sorted ascending by cost). + /// If all extensions are tabu, the cheapest one is kept (aspiration criterion). + void select_children(Label* parent, std::list*>& feasible, + std::list*>& rejects) override { + if (!tabu_active_) { + // Phase 1: pure greedy — no filtering. + (void)parent; + feasible.sort([](Label* a, Label* b) { + return a->get_cost() < b->get_cost(); + }); + return; + } + + // Partition into non-tabu and tabu extensions. + std::list*> non_tabu; + std::list*> tabu_only; + for (auto* l : feasible) { + const auto* arc = l->get_in_arc(); + if (arc != nullptr && tabu_.is_tabu(arc->id)) { + tabu_only.push_back(l); + } else { + non_tabu.push_back(l); + } + } + + std::list*>* chosen = nullptr; + std::list*>* discarded = nullptr; + if (!non_tabu.empty()) { + chosen = &non_tabu; + discarded = &tabu_only; + } else { + // Aspiration: nothing non-tabu — use cheapest tabu extension. + chosen = &tabu_only; + discarded = &non_tabu; + } + + (void)parent; + chosen->sort([](Label* a, Label* b) { + return a->get_cost() < b->get_cost(); + }); + rejects.splice(rejects.end(), *discarded); + feasible = std::move(*chosen); + } + + // ─── main loop ─────────────────────────────────────────────────────── + + void initialize(const Graph* graph, double cost_upper_bound) override { + Algorithm::initialize(graph, cost_upper_bound); + tabu_.clear(); + tabu_active_ = false; + } + + void main_loop() override { // NOLINT(readability-function-cognitive-complexity) + if (this->params_.max_iterations >= MAX_INT) { + LOG_ERROR( + "ImprovingTabuSearch: max_iterations must be finite for the improvement " + "phase.\n"); + return; + } + + // ── Phase 1: greedy construction (tabu inactive) ────────────────── + this->seed_path_from_sources(); + if (this->path_.empty()) { + return; + } + + bool reached = dive_to_sink(); + if (!reached) { + this->clear_path(); + return; + } + + auto* init_label = this->path_.back().first; + double best_cost = init_label->get_cost(); + if (best_cost < this->cost_upper_bound_) { + this->best_cost_upper_bound_ = best_cost; + this->extract_solution(*init_label); + apply_tabu(*init_label); + } + this->clear_path(); + + if (this->should_stop()) { + return; + } + + // ── Phase 2: tabu-filtered improvement ──────────────────────────── + tabu_active_ = true; + size_t no_improve_count = 0; + + for (size_t i = 0; !this->should_stop(i); ++i) { + this->seed_path_from_sources(); + if (this->path_.empty()) { + break; + } + + reached = dive_to_sink(); + + if (reached) { + auto* sink_label = this->path_.back().first; + double cost = sink_label->get_cost(); + + if (cost < this->cost_upper_bound_) { + this->extract_solution(*sink_label); + if (cost + this->params_.tolerance < best_cost) { + // Strictly improving solution — intensify. + best_cost = cost; + this->best_cost_upper_bound_ = best_cost; + no_improve_count = 0; + tabu_.shrink_extra(); + } else { + // Novel but not strictly improving — count as no-improve. + no_improve_count++; + } + } else { + no_improve_count++; + } + + apply_tabu(*sink_label); + + if (this->solutions_.size() >= this->params_.stop_after_X_solutions) { + this->clear_path(); + break; + } + } else { + if (tabu_.empty()) { + this->clear_path(); + break; + } + tabu_.grow_extra(); + no_improve_count++; + } + + // Diversification: too many non-improving iterations. + if (no_improve_count >= this->params_.diversification_tenure) { + tabu_.grow_extra(); + no_improve_count = 0; + } + + tabu_.age(); + this->clear_path(); + } + } + + private: + // ─── helpers ───────────────────────────────────────────────────────── + bool dive_to_sink() { + while (!this->path_.empty()) { + auto* current = this->path_.back().first; + if (current->get_end_node()->sink) { + return true; + } + if (this->extend_label(current)) { + continue; + } + if (!this->backtrack()) { + return false; + } + } + return false; + } + + void apply_tabu(const Label& sink_label) { + for (const auto& entry : this->path_) { + const auto* arc = entry.first->get_in_arc(); + if (arc == nullptr) { + continue; + } + if (this->params_.forbidden_tabu.contains(arc->origin->id) || + this->params_.forbidden_tabu.contains(arc->destination->id)) { + continue; + } + tabu_.add(arc->id, this->params_.tabu_tenure, this->params_.tabu_random_noise); + if (entry.first == &sink_label) { + break; + } + } + } + + // ─── state ─────────────────────────────────────────────────────────── + TabuList tabu_; + bool tabu_active_ = false; +}; + +} // namespace rcspp diff --git a/src/rcspp/algorithm/label_buckets.hpp b/src/rcspp/algorithm/label_buckets.hpp index 98adcb492..211cd0c33 100644 --- a/src/rcspp/algorithm/label_buckets.hpp +++ b/src/rcspp/algorithm/label_buckets.hpp @@ -5,14 +5,17 @@ #include #include -#include -#include +#include #include #include "rcspp/label/label.hpp" namespace rcspp { +/// @brief Flat list of non-dominated labels for a single node. +/// +/// Used as the default label container in dominance algorithms. Every +/// add / erase / dominance-check is O(N) in the number of stored labels. template class LabelList { using LabelPosition = std::list*>::iterator; @@ -21,16 +24,21 @@ class LabelList { explicit LabelList() = default; virtual ~LabelList() = default; + /// @brief Returns an empty container with the same configuration. [[nodiscard]] LabelList copy() const { return LabelList(); } + /// @brief Read-only access to the underlying label list. [[nodiscard]] const std::list*>& get_labels() const { return labels_; } + /// @brief Appends @p label at the end and returns its iterator. virtual LabelPosition add_label(Label* label) { return labels_.insert(labels_.end(), label); } + /// @brief Removes the label at position @p pos. virtual void erase_label(const LabelPosition& pos) { labels_.erase(pos); } + /// @brief Logs all stored labels at TRACE level. virtual void print_labels() const { if (LOG_TRACE_ACTIVE()) { for (auto label_ptr : labels_) { @@ -39,8 +47,9 @@ class LabelList { } } + /// @brief Marks and removes all labels dominated by @p label. + /// @return Number of labels removed. virtual size_t remove_dominated_labels(const Label& label) { - // Remove all dominated label size_t removed = 0; for (auto non_dominated_label_it = labels_.begin(); non_dominated_label_it != labels_.end();) { @@ -55,6 +64,7 @@ class LabelList { return removed; } + /// @brief Returns true if any stored label dominates @p label. [[nodiscard]] virtual bool is_dominated(const Label& label) const { for (const auto non_dominated_label_ptr : labels_) { if (&label == non_dominated_label_ptr) { @@ -71,9 +81,50 @@ class LabelList { std::list*> labels_; }; +/// @brief Bucket-partitioned label container with O(log B) lookup via binary search. +/// +/// Labels are partitioned into buckets along the *bucket resource* axis; within +/// each bucket they are sorted by the *sort resource*. This allows +/// - `remove_dominated_labels` to skip lower buckets entirely (a new label at +/// resource value v cannot dominate labels with bucket-resource < v - range), +/// - `is_dominated` to skip upper buckets (a stored label at resource value s +/// cannot dominate a new label with bucket-resource < s), and +/// - each bucket to apply an early-exit sort-resource check inside the inner loop. +/// +/// ## Data structure choices +/// +/// Bucket boundaries are stored in a `std::vector` for cache locality. Binary +/// search (`find_first_not_after` / `find_first_before`) locates the relevant +/// bucket window in O(log B) instead of the O(B) linear scan a list would +/// require. +/// +/// A hash map `begin_label_to_bucket_idx_` records, for each label that is +/// currently a bucket begin, its bucket index. This makes `erase_label` O(1): +/// a single map lookup determines whether the erased label is a bucket boundary +/// and, if so, which bucket to update. Without this map, `erase_label` would +/// need an O(B) `std::find_if` scan over all bucket begins. +/// +/// ## Instrumentation +/// +/// Both `remove_dominated_labels` and `is_dominated` accumulate counters for +/// total labels available and labels actually visited. `print_labels` reports +/// both visit ratios so pruning efficiency can be compared symmetrically. +/// +/// ## Adaptive bucket range +/// +/// `range_buckets_` is fixed at construction. `suggest_range(target_buckets)` +/// estimates the resource spread from the peak simultaneous bucket count and +/// returns a range calibrated for a chosen number of buckets. Call it after a +/// warm-up phase and pass the result to the next `LabelBuckets` constructor to +/// improve bucket utilisation. +/// +/// @tparam BucketResource Resource type used to partition labels into buckets. +/// @tparam SortResource Resource type used to sort labels within a bucket. +/// @tparam ResourceType Full composite resource type of the labels. template class LabelBuckets : public LabelList { using LabelPosition = std::list*>::iterator; + using BucketIdx = size_t; template struct Bucket { @@ -84,19 +135,21 @@ class LabelBuckets : public LabelList { const RType* begin_value; double range; - [[nodiscard]] bool is_within_bucket(const RType& value) const { - // neither before, nor after the bucket - return !is_before_bucket(value) && !is_after_bucket(value); - } - + /// @brief True when @p value is strictly before this bucket's range. [[nodiscard]] bool is_before_bucket(const RType& value) const { return !begin_value->is_lower(value); } + /// @brief True when @p value is strictly after this bucket's range. [[nodiscard]] bool is_after_bucket(const RType& value) const { return begin_value->is_lower(value, -range); } + /// @brief True when @p value falls within [begin_value, begin_value + range]. + [[nodiscard]] bool is_within_bucket(const RType& value) const { + return !is_before_bucket(value) && !is_after_bucket(value); + } + void update_begin(const LabelPosition& new_begin, const RType* new_min_value) { begin = new_begin; begin_value = new_min_value; @@ -105,141 +158,141 @@ class LabelBuckets : public LabelList { void update_end(const LabelPosition& new_end) { end = new_end; } }; - using BucketPosition = std::list>>::iterator; - public: + /// @brief Constructs a bucket container. + /// + /// @param range_buckets Width of each bucket along the bucket resource axis. + /// @param bucket_resource_index Index of the bucket resource in the composite resource. + /// @param sort_resource_index Index of the sort resource in the composite resource. LabelBuckets(size_t range_buckets, size_t bucket_resource_index, size_t sort_resource_index) : range_buckets_(range_buckets), bucket_resource_index_(bucket_resource_index), sort_resource_index_(sort_resource_index) {} + /// @brief Returns an empty container with the same configuration. [[nodiscard]] LabelBuckets copy() const { return LabelBuckets(range_buckets_, bucket_resource_index_, sort_resource_index_); } + /// @brief Inserts @p label into the appropriate bucket (creating one if needed). + /// + /// The correct bucket is located in O(log B) via binary search; the label is + /// then placed at its sorted position within the bucket in O(labels_per_bucket). + /// + /// @return Iterator to the newly inserted label. LabelPosition add_label(Label* label) override { - const auto& label_bucket_resource = get_bucket_resource(*label); - auto bit = buckets_.begin(); - while (bit != buckets_.end()) { - auto& bucket = *bit; - if (bucket.is_before_bucket(label_bucket_resource)) { - // Insert a new bucket before the current one and insert the label there - auto pos = this->labels_.insert(bucket.begin, label); - insert_bucket(bit, pos, &label_bucket_resource); - return pos; - } - if (bucket.is_within_bucket(label_bucket_resource)) { - // If the label is also at-or-after the next bucket, let the next bucket own it. - // This prevents new labels from landing in an overlapping range and keeps each - // bucket's effective coverage clipped to [begin_value, - // next_bucket.begin_value). - auto next_bit = std::next(bit); - if (next_bit != buckets_.end() && - !next_bit->is_before_bucket(label_bucket_resource)) { - ++bit; - continue; - } + const auto& lbr = get_bucket_resource(*label); - // Insert the label in the correct position within the bucket based on the sort - // resource - const auto& label_sort_resource = get_sort_resource(*label); - auto it = bucket.begin; - while (it != bucket.end && get_sort_resource(**it) <= label_sort_resource) { - ++it; - } - // insert label at the right position in the list of labels - auto pos = this->labels_.insert(it, label); - - // update begin if necessary - if (it == bucket.begin) { - update_bucket_begin(bit, pos, &label_bucket_resource); - } + // O(log B): skip all "after" buckets whose range lies entirely below lbr. + BucketIdx idx = find_first_not_after(lbr); + while (idx < buckets_.size()) { + if (buckets_[idx].is_before_bucket(lbr)) { + // lbr precedes this bucket's range — open a new bucket before it. + auto pos = this->labels_.insert(buckets_[idx].begin, label); + insert_bucket(idx, pos, &lbr); return pos; } - ++bit; + // Within range: if the next bucket also claims lbr, prefer the later one. + if (idx + 1 < buckets_.size() && !buckets_[idx + 1].is_before_bucket(lbr)) { + ++idx; + continue; + } + // Insert at the sort-resource-ordered position within this bucket. + const auto& lsr = get_sort_resource(*label); + auto it = buckets_[idx].begin; + const auto bucket_end = buckets_[idx].end; + while (it != bucket_end && get_sort_resource(**it) <= lsr) { + ++it; + } + auto pos = this->labels_.insert(it, label); + if (it == buckets_[idx].begin) { + update_bucket_begin(idx, pos, nullptr, &lbr); + } + return pos; } - // Insert a new bucket at the end of the list and insert the label there + // lbr lies after all existing buckets — open a new one at the end. auto pos = this->labels_.insert(this->labels_.end(), label); - insert_bucket(buckets_.end(), pos, &label_bucket_resource); + insert_bucket(buckets_.size(), pos, &lbr); return pos; } + /// @brief Removes the label at @p pos in O(1). + /// + /// The `begin_label_to_bucket_idx_` map is consulted first: if the label is a + /// bucket begin the bucket is updated or removed; otherwise only the label list + /// is touched. void erase_label(const LabelPosition& pos) override { - // Find the bucket whose begin equals pos before erasing (pos is valid here). - // Iterator comparison is used instead of is_within_bucket because begin_value can drift - // after update_bucket_begin calls, making range-based lookup return the wrong bucket. - auto bit = std::find_if(buckets_.begin(), buckets_.end(), [&](const auto& bucket) { - return bucket.begin == pos; - }); - - // erase the label from the list of labels - auto it = this->labels_.erase(pos); - - // if pos was interior to a bucket (not any bucket's begin), nothing to update - if (bit == buckets_.end()) { - return; + Label* label = *pos; + auto map_it = begin_label_to_bucket_idx_.find(label); + + auto it = this->labels_.erase(pos); // pos is now invalid + + if (map_it == begin_label_to_bucket_idx_.end()) { + return; // Interior label: no bucket bookkeeping needed. } - // pos was the begin of *bit; remove or advance the bucket - if (it == bit->end) { - remove_bucket(bit); + const BucketIdx idx = map_it->second; + // Pass the captured pointer so helpers do not dereference the + // now-invalid bucket begin iterator. + if (it == buckets_[idx].end) { + remove_bucket(idx, label); } else { - update_bucket_begin(bit, it); + update_bucket_begin(idx, it, label); } } + /// @brief Marks and removes all labels dominated by @p label. + /// + /// Iterates upper buckets backward; stops as soon as a bucket is entirely + /// "after" the new label (lower buckets cannot be dominated). Within each + /// bucket the sort-resource ordering provides an additional early exit. + /// + /// @return Number of labels removed. size_t remove_dominated_labels(const Label& label) override { - // if no bucket, no label, return 0 if (buckets_.empty()) { return 0; } - // Remove all dominated label (starting by upper buckets). Lower buckets cannot be - // dominated - num_labels_ += this->labels_.size(); + num_rm_labels_ += this->labels_.size(); size_t removed = 0; - const auto& label_bucket_resource = get_bucket_resource(label); - const auto& label_sort_resource = get_sort_resource(label); - auto bit = buckets_.end(); - while (bit != buckets_.begin()) { - --bit; - auto& bucket = *bit; - // if the label is after the bucket, we can stop, as all the remaining lower buckets - // are before the label and cannot be dominated - if (bucket.is_after_bucket(label_bucket_resource)) { - break; - } - // check if the labels can be dominated in the bucket - auto current_label_it = bucket.end; + const auto& lbr = get_bucket_resource(label); + const auto& lsr = get_sort_resource(label); + + // O(log B): first bucket index potentially containing dominated labels. + const BucketIdx first_idx = find_first_not_after(lbr); + if (first_idx >= buckets_.size()) { + return 0; + } + + BucketIdx idx = buckets_.size(); + while (idx > first_idx) { + --idx; + + auto label_it = buckets_[idx].end; bool reached_begin = false; while (!reached_begin) { - ++num_visited_labels_; - --current_label_it; - reached_begin = (current_label_it == bucket.begin); - auto current = *current_label_it; + ++num_rm_visited_; + --label_it; + reached_begin = (label_it == buckets_[idx].begin); + auto* current = *label_it; if (&label != current && label <= *current) { - // remove the dominated current label current->dominated = true; - current_label_it = this->labels_.erase(current_label_it); - // if this is the first label of the bucket, update the bucket + // Capture the begin pointer BEFORE erasing: the erase + // invalidates the stored bucket begin iterator. + Label* begin_before_erase = reached_begin ? current : nullptr; + label_it = this->labels_.erase(label_it); + ++removed; if (reached_begin) { - if (current_label_it == bucket.end) { - // all labels in the bucket are dominated, we can remove the bucket - bit = remove_bucket(bit); - } else { - // update the begin - update_bucket_begin(bit, current_label_it); + if (label_it == buckets_[idx].end) { + remove_bucket(idx, begin_before_erase); + break; // Bucket gone; continue outer loop. } + update_bucket_begin(idx, label_it, begin_before_erase); } - ++removed; - } else if (!(label_sort_resource <= get_sort_resource(*current))) { - // if not dominated, check if we can stop by comparing the sort resource of - // the current label with the label to remove. If the current label sort - // resource does not dominate the label sort resource, we can stop as the - // following labels in the bucket are sorted by the sort resource and cannot - // be dominated. + } else if (!(lsr <= get_sort_resource(*current))) { + // Sort-resource pruning: remaining labels cannot be dominated. break; } } @@ -248,33 +301,33 @@ class LabelBuckets : public LabelList { return removed; } + /// @brief Returns true if any stored label dominates @p label. + /// + /// Iterates lower buckets forward; stops at the first bucket that starts + /// above the new label (upper buckets cannot dominate it). [[nodiscard]] bool is_dominated(const Label& label) const override { - // if no bucket, no label, return false if (buckets_.empty()) { return false; } - // Check all labels (starting by the lower buckets). Upper buckets cannot dominate - const auto& label_bucket_resource = get_bucket_resource(label); - const auto& label_sort_resource = get_sort_resource(label); - for (const auto& bucket : buckets_) { - // if the label is before the bucket, we can stop, as all the following buckets are - // after the label and cannot dominate - if (bucket.is_before_bucket(label_bucket_resource)) { - break; - } - // check if the labels can dominate in the bucket + num_dom_labels_ += this->labels_.size(); + const auto& lbr = get_bucket_resource(label); + const auto& lsr = get_sort_resource(label); + + // O(log B): stop before buckets whose begin_value already exceeds lbr. + const BucketIdx end_idx = find_first_before(lbr); + + for (BucketIdx idx = 0; idx < end_idx; ++idx) { + const auto& bucket = buckets_[idx]; for (auto it = bucket.begin; it != bucket.end; ++it) { + ++num_dom_visited_; if (&label == *it) { continue; } if (**it <= label) { return true; } - // if the sort resource of the current label does not dominate the label - // sort resource, we can stop, as the following labels in the bucket are - // sorted by the sort resource and cannot dominate - if (!(get_sort_resource(**it) <= label_sort_resource)) { + if (!(get_sort_resource(**it) <= lsr)) { break; } } @@ -283,21 +336,61 @@ class LabelBuckets : public LabelList { return false; } + /// @brief Logs label list and bucket-efficiency statistics at TRACE level. void print_labels() const override { LabelList::print_labels(); - const double visit_ratio = - num_labels_ == 0 ? 0.0 : num_visited_labels_ * 1.0 / num_labels_; - LOG_TRACE("Ratio of visits: ", visit_ratio, "\n"); + const double rm_ratio = + num_rm_labels_ == 0 ? 0.0 : static_cast(num_rm_visited_) / num_rm_labels_; + const double dom_ratio = num_dom_labels_ == 0 + ? 0.0 + : static_cast(num_dom_visited_) / num_dom_labels_; + LOG_TRACE("remove_dominated visit ratio: ", rm_ratio, "\n"); + LOG_TRACE("is_dominated visit ratio: ", dom_ratio, "\n"); + } + + /// @brief Suggests a bucket range for a desired number of buckets. + /// + /// The estimate is derived from the peak simultaneous bucket count observed + /// during the solve, which approximates the resource spread. Call after a + /// warm-up phase and pass the result to the next `LabelBuckets` constructor: + /// + /// ```cpp + /// size_t better_range = container.suggest_range(50); + /// auto next = LabelBuckets(better_range, bi, si); + /// ``` + /// + /// @param target_buckets Desired number of buckets in the next phase. + /// @return Suggested range_buckets value (>= 1). + [[nodiscard]] size_t suggest_range(size_t target_buckets) const { + if (target_buckets == 0 || max_live_buckets_ == 0) { + return range_buckets_; + } + const size_t estimated_span = max_live_buckets_ * range_buckets_; + return std::max(1, (estimated_span + target_buckets - 1) / target_buckets); } private: size_t range_buckets_; size_t bucket_resource_index_; size_t sort_resource_index_; - std::list>> buckets_; + std::vector>> buckets_; + + /// Maps each label that is currently a bucket begin to its bucket index. + /// Maintained by insert_bucket / remove_bucket / update_bucket_begin so + /// that erase_label can find the owning bucket in O(1). + std::unordered_map*, size_t> begin_label_to_bucket_idx_; + + // remove_dominated_labels instrumentation. + size_t num_rm_labels_{0}; + size_t num_rm_visited_{0}; + // is_dominated instrumentation (mutable: is_dominated is const). + mutable size_t num_dom_labels_{0}; + mutable size_t num_dom_visited_{0}; + + // Peak simultaneous bucket count; drives suggest_range(). + size_t max_live_buckets_{0}; - size_t num_labels_{0}; - size_t num_visited_labels_{0}; + // ── Resource accessors ─────────────────────────────────────────────────── [[nodiscard]] const Resource& get_bucket_resource( const Label& label) const { @@ -309,50 +402,124 @@ class LabelBuckets : public LabelList { return get_resource(label, sort_resource_index_); } - void insert_bucket(const BucketPosition& bit, const LabelPosition& begin, + template + [[nodiscard]] static const Resource& get_resource(const Label& label, + size_t resource_index) { + return label.get_resource().template get_component(resource_index); + } + + // ── Binary search helpers ──────────────────────────────────────────────── + + /// @brief Returns the first bucket index where `!is_after_bucket(v)`. + /// + /// `is_after_bucket(v)` is monotonically decreasing across the sorted + /// bucket vector (true for low-index, false for high-index), so a standard + /// lower-bound search locates the transition in O(log B). + [[nodiscard]] BucketIdx find_first_not_after(const Resource& v) const { + BucketIdx lo = 0; + BucketIdx hi = buckets_.size(); + while (lo < hi) { + const BucketIdx mid = lo + ((hi - lo) / 2); + if (buckets_[mid].is_after_bucket(v)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + } + + /// @brief Returns the first bucket index where `is_before_bucket(v)`. + /// + /// `is_before_bucket(v)` is monotonically increasing (false for low-index, + /// true for high-index), so a standard lower-bound search applies. + [[nodiscard]] BucketIdx find_first_before(const Resource& v) const { + BucketIdx lo = 0; + BucketIdx hi = buckets_.size(); + while (lo < hi) { + const BucketIdx mid = lo + ((hi - lo) / 2); + if (buckets_[mid].is_before_bucket(v)) { + hi = mid; + } else { + lo = mid + 1; + } + } + return lo; + } + + // ── Bucket maintenance ─────────────────────────────────────────────────── + + /// @brief Inserts a new bucket at @p idx, shifting all later bucket indices. + /// + /// The `begin_label_to_bucket_idx_` map is updated (O(B)) before the vector + /// insert so that all stored indices remain correct. + void insert_bucket(BucketIdx idx, const LabelPosition& begin, const Resource* begin_bucket_resource = nullptr) { - // update previous end to the begin of the new bucket, as the current bucket will be - // added - if (bit != buckets_.begin()) { - std::prev(bit)->update_end(begin); + if (idx > 0) { + buckets_[idx - 1].update_end(begin); } - // insert the new bucket and bucket resource if not provided if (begin_bucket_resource == nullptr) { begin_bucket_resource = &get_bucket_resource(**begin); } - buckets_.emplace(bit, begin, begin_bucket_resource, range_buckets_); + // Shift all map entries at index >= idx upward before the vector insert. + for (auto& [lbl, bidx] : begin_label_to_bucket_idx_) { + if (bidx >= idx) { + ++bidx; + } + } + buckets_.emplace(buckets_.begin() + static_cast(idx), + begin, + begin_bucket_resource, + range_buckets_); + begin_label_to_bucket_idx_[*begin] = idx; + + if (buckets_.size() > max_live_buckets_) { + max_live_buckets_ = buckets_.size(); + } } - BucketPosition remove_bucket(const BucketPosition& bit) { - // update previous end to the end of the current bucket, as the current bucket will be - // removed - if (bit != buckets_.begin()) { - // the new end is the end of the removed bucket, as the following bucket will be - // after the removed bucket - std::prev(bit)->update_end(bit->end); + /// @brief Removes the bucket at @p idx, shifting all later bucket indices down. + /// + /// @param erased_begin The label that WAS the bucket begin; pass when the + /// corresponding list erase has already invalidated the + /// stored iterator. Pass nullptr when the iterator is + /// still valid. + void remove_bucket(BucketIdx idx, Label* erased_begin = nullptr) { + Label* begin_label = + (erased_begin != nullptr) ? erased_begin : *buckets_[idx].begin; + begin_label_to_bucket_idx_.erase(begin_label); + if (idx > 0) { + buckets_[idx - 1].update_end(buckets_[idx].end); + } + buckets_.erase(buckets_.begin() + static_cast(idx)); + for (auto& [lbl, bidx] : begin_label_to_bucket_idx_) { + if (bidx > idx) { + --bidx; + } } - // erase the bucket and return the next bucket position - return buckets_.erase(bit); } - void update_bucket_begin(const BucketPosition& bit, const LabelPosition& new_begin, + /// @brief Advances the begin of bucket @p idx to @p new_begin. + /// + /// @param erased_begin Old begin label when the old iterator has + /// already been invalidated (see remove_bucket). + /// Pass nullptr when the iterator is still valid. + /// @param begin_bucket_resource Resource of @p new_begin; computed lazily if + /// nullptr. + void update_bucket_begin(BucketIdx idx, const LabelPosition& new_begin, + Label* erased_begin = nullptr, const Resource* begin_bucket_resource = nullptr) { - // update previous end to the new begin of the current bucket, as the current bucket - // will be updated - if (bit != buckets_.begin()) { - std::prev(bit)->update_end(new_begin); + Label* old_begin = + (erased_begin != nullptr) ? erased_begin : *buckets_[idx].begin; + begin_label_to_bucket_idx_.erase(old_begin); + if (idx > 0) { + buckets_[idx - 1].update_end(new_begin); } - // update the current bucket begin and bucket resource if not provided if (begin_bucket_resource == nullptr) { begin_bucket_resource = &get_bucket_resource(**new_begin); } - bit->update_begin(new_begin, begin_bucket_resource); - } - - template - [[nodiscard]] static const Resource& get_resource(const Label& label, - size_t resource_index) { - return label.get_resource().template get_component(resource_index); + buckets_[idx].update_begin(new_begin, begin_bucket_resource); + begin_label_to_bucket_idx_[*new_begin] = idx; } }; } // namespace rcspp diff --git a/src/rcspp/algorithm/pulling_dominance_algorithm.hpp b/src/rcspp/algorithm/pulling_dominance_algorithm.hpp index facfb24d8..ad31588f3 100644 --- a/src/rcspp/algorithm/pulling_dominance_algorithm.hpp +++ b/src/rcspp/algorithm/pulling_dominance_algorithm.hpp @@ -33,6 +33,27 @@ class PullingDominanceAlgorithm : public DominanceAlgorithm 0 && i < this->params_.max_iterations) { + // Periodic memory check (pulling: each iteration processes one node). + if (i > 0 && this->memory_limit_.effective_limit > 0 && + i % this->params_.memory_check_interval == 0) { + if (this->memory_limit_.is_exceeded()) { + LOG_WARN("Memory limit (", + this->memory_limit_.effective_limit / (1024ULL * 1024ULL), + " MB) exceeded (current: ", + MemoryInfo::process_bytes() / (1024ULL * 1024ULL), + " MB). Stopping early.\n"); + break; + } + if (this->memory_limit_.is_under_pressure()) { + LOG_INFO("Memory pressure: ", + MemoryInfo::process_bytes() / (1024ULL * 1024ULL), + " MB / ", + this->memory_limit_.effective_limit / (1024ULL * 1024ULL), + " MB. Trimming label queues.\n"); + this->on_memory_pressure(); + } + } + ++i; // save unprocessed labels for the current node @@ -48,20 +69,23 @@ class PullingDominanceAlgorithm : public DominanceAlgorithmcurrent_unprocessed_labels_.end();) { auto& label = *it->first; - // label dominated -> continue to next one + // label dominated -> continue to next one. + // release_with_ref_count (not release_label): in pulling a label may have + // already served as an origin in a previous loop (ref_count > 0) and it + // pins its own predecessor, so the ref_count chain must be unwound here. if (label.dominated) { - this->label_pool_.release_label(&label); + this->label_pool_.release_with_ref_count(&label); it = erase_unprocessed_label(it); // erase label } else if (this->params_.prune_based_on_upper_bound_ && label.get_cost() >= this->best_cost_upper_bound_) { // label cost too high -> continue to next one this->remove_label(it->second); - this->label_pool_.release_label(&label); + this->label_pool_.release_with_ref_count(&label); it = erase_unprocessed_label(it); // erase label } else if (std::isinf(label.get_cost())) { // label cost too high -> continue to next one this->remove_label(it->second); - this->label_pool_.release_label(&label); + this->label_pool_.release_with_ref_count(&label); it = erase_unprocessed_label(it); // erase label } else { // check if sink and update best solution @@ -144,7 +168,7 @@ class PullingDominanceAlgorithm : public DominanceAlgorithmsink) { - this->resize_current_unprocessed_labels(this->params_.num_labels_to_extend_by_node, + this->resize_current_unprocessed_labels(this->effective_max_labels_per_node_, &this->label_pool_); } this->total_full_extend_time_.stop(); @@ -170,6 +194,34 @@ class PullingDominanceAlgorithm : public DominanceAlgorithmrestore_truncated_unprocessed_labels(); } + /// @brief Trim per-node queues when memory pressure is detected. + /// + /// Same two-phase behaviour as @ref PushingDominanceAlgorithm::on_memory_pressure(): + /// first call trims + stores aside; subsequent calls also release stored-aside labels. + void on_memory_pressure() override { + const size_t limit = this->params_.memory_pressure_max_labels_per_node; + + this->effective_max_labels_per_node_ = limit; + + if (this->memory_pressure_triggered_) { + this->release_truncated_labels( + &this->label_pool_, + [this](const typename std::list*>::iterator& it) { + this->remove_label(it); + }); + } + this->memory_pressure_triggered_ = true; + + this->trim_all_queues(limit, &this->label_pool_); + } + + /// @brief Release label memory and clear all unprocessed queues. + void release_label_memory() override { + DominanceAlgorithm::release_label_memory(); + this->clear_all_queues(); + first_loop_ = true; + } + bool first_loop_ = true; }; } // namespace rcspp diff --git a/src/rcspp/algorithm/pushing_dominance_algorithm.hpp b/src/rcspp/algorithm/pushing_dominance_algorithm.hpp index 669273f1c..802f135a7 100644 --- a/src/rcspp/algorithm/pushing_dominance_algorithm.hpp +++ b/src/rcspp/algorithm/pushing_dominance_algorithm.hpp @@ -3,6 +3,7 @@ #pragma once +#include #include #include "rcspp/algorithm/dominance_algorithm.hpp" @@ -42,7 +43,7 @@ class PushingDominanceAlgorithm : public DominanceAlgorithmcurrent_unprocessed_labels_ = std::move( this->unprocessed_labels_by_node_pos_.at(this->current_unprocessed_node_pos_)); // truncate/limit the number of labels extended per node - this->resize_current_unprocessed_labels(this->params_.num_labels_to_extend_by_node, + this->resize_current_unprocessed_labels(this->effective_max_labels_per_node_, &this->label_pool_); } @@ -65,5 +66,44 @@ class PushingDominanceAlgorithm : public DominanceAlgorithmrestore_truncated_unprocessed_labels(); } + + /// @brief Trim per-node queues when memory pressure is detected. + /// + /// **First call**: trims all per-node queues to + /// @ref AlgorithmBaseParams::memory_pressure_max_labels_per_node labels. + /// Dominated excess labels are immediately recycled; non-dominated excess + /// labels are stored aside for the next phase (like truncated labeling). + /// Also tightens the per-node extension cap permanently so that future + /// extensions do not re-inflate the queues. + /// + /// **Subsequent calls**: additionally releases the labels that were stored + /// aside on the first call, since memory is still under pressure and they + /// would only be restored at the next phase anyway. + void on_memory_pressure() override { + const size_t limit = this->params_.memory_pressure_max_labels_per_node; + + // Permanently tighten the per-node extension cap. + this->effective_max_labels_per_node_ = limit; + + if (this->memory_pressure_triggered_) { + // Second+ call: labels stored aside are still consuming memory. + // Release them (remove from non-dominated set + return to pool). + this->release_truncated_labels( + &this->label_pool_, + [this](const typename std::list*>::iterator& it) { + this->remove_label(it); + }); + } + this->memory_pressure_triggered_ = true; + + // Trim the current unprocessed queues. + this->trim_all_queues(limit, &this->label_pool_); + } + + /// @brief Release label memory and clear all unprocessed queues. + void release_label_memory() override { + DominanceAlgorithm::release_label_memory(); + this->clear_all_queues(); + } }; } // namespace rcspp diff --git a/src/rcspp/algorithm/simple_dominance_algorithm.hpp b/src/rcspp/algorithm/simple_dominance_algorithm.hpp index 0f72a2483..4740304f2 100644 --- a/src/rcspp/algorithm/simple_dominance_algorithm.hpp +++ b/src/rcspp/algorithm/simple_dominance_algorithm.hpp @@ -32,14 +32,16 @@ class SimpleDominanceAlgorithm : public DominanceAlgorithmdominated) { - this->label_pool_.release_label(label_iterator_pair.first); + this->label_pool_.release_with_ref_count(label_iterator_pair.first); } else { // truncate/limit the number of labels extended per node size_t& num_extended_labels_for_node = number_of_extended_labels_per_node_.at( label_iterator_pair.first->get_end_node()->pos()); - if (num_extended_labels_for_node < this->params_.num_labels_to_extend_by_node) { + if (num_extended_labels_for_node < this->effective_max_labels_per_node_) { ++num_extended_labels_for_node; break; // found a label to process } @@ -67,6 +69,66 @@ class SimpleDominanceAlgorithm : public DominanceAlgorithmparams_.memory_pressure_max_labels_per_node; + + // Permanently tighten the per-node extension cap. + this->effective_max_labels_per_node_ = limit; + + if (this->memory_pressure_triggered_) { + // Release labels stored aside on the previous call. + for (auto& [label_ptr, label_iter] : unprocessed_truncated_labels_) { + this->remove_label(label_iter); + this->label_pool_.release_with_ref_count(label_ptr); + } + unprocessed_truncated_labels_.clear(); + } + this->memory_pressure_triggered_ = true; + + const size_t max_total = limit * this->graph_->get_number_of_nodes(); + if (unprocessed_labels_.size() <= max_total) { + return; + } + unprocessed_labels_.sort([](const LabelIteratorPair& a, + const LabelIteratorPair& b) { + // Non-dominated first; among equal dominance, cheaper first. + if (a.first->dominated != b.first->dominated) { + return !a.first->dominated; + } + return a.first->get_cost() < b.first->get_cost(); + }); + while (unprocessed_labels_.size() > max_total) { + auto& p = unprocessed_labels_.back(); + if (p.first->dominated) { + this->label_pool_.release_with_ref_count(p.first); + } else { + unprocessed_truncated_labels_.push_back(std::move(p)); + } + unprocessed_labels_.pop_back(); + } + } + + /// @brief Release label memory and clear all unprocessed label lists. + void release_label_memory() override { + DominanceAlgorithm::release_label_memory(); + unprocessed_labels_.clear(); + unprocessed_truncated_labels_.clear(); + std::ranges::fill(number_of_extended_labels_per_node_.begin(), + number_of_extended_labels_per_node_.end(), + 0); + } + std::list> unprocessed_labels_; std::list> unprocessed_truncated_labels_; std::vector number_of_extended_labels_per_node_; diff --git a/src/rcspp/algorithm/tabu_list.hpp b/src/rcspp/algorithm/tabu_list.hpp new file mode 100644 index 000000000..f4a7ee223 --- /dev/null +++ b/src/rcspp/algorithm/tabu_list.hpp @@ -0,0 +1,102 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include + +namespace rcspp { + +/** + * @brief Reusable tabu-list bookkeeping shared by tabu-style RCSPP algorithms. + * + * Tracks arc ids (by @c size_t key) with a remaining tenure (in iterations), an adaptive + * "extra" tenure that the calling algorithm grows on duplicate solutions and shrinks on + * novel ones, optional ±1 random jitter on freshly added entries, and a per-iteration + * @ref age step. + * + * This class is consumed by composition: it does not assume how the algorithm dives, + * filters arcs, or interprets a solution. Two examples in this package: + * + * - @ref TabuSearchAlgorithm consults @ref is_tabu while extending labels and calls + * @ref add for each arc on a found path. + * - @ref DiversificationSearch removes arcs from a graph clone when calling @ref add + * and passes a "restore arc" callback to @ref age so expired arcs come back. + * + * The @ref grow_extra / @ref shrink_extra split (rather than a single update) is + * deliberate: each algorithm decides which heuristic of "what counts as a successful + * iteration" applies to it. + */ +class TabuList { + public: + explicit TabuList(int seed) : rnd_(seed) {} + + /// True iff @p arc_id is currently in the tabu set with positive tenure. + [[nodiscard]] bool is_tabu(size_t arc_id) const { + auto it = tenure_.find(arc_id); + return it != tenure_.end() && it->second > 0; + } + + /// Insert @p arc_id with tenure = base_tenure + extra (+ optional ±1 noise). + /// If the arc is already in the list, the larger of the two tenures wins. + /// Returns the tenure actually assigned this call. + size_t add(size_t arc_id, size_t base_tenure, bool noise) { + size_t t = base_tenure + extra_; + if (noise && t > 0) { + std::uniform_int_distribution d(t > 1 ? -1 : 0, 1); + t = static_cast(static_cast(t) + d(rnd_)); + } + auto& current = tenure_[arc_id]; + current = std::max(current, t); + return t; + } + + /// Decrement every tenure; remove expired entries. @p on_expire is called once + /// for each arc whose tenure just hit zero (e.g. to restore the arc on a graph + /// clone). Pass a no-op for "just forget the entry". + template + void age(OnExpire&& on_expire) { + for (auto it = tenure_.begin(); it != tenure_.end();) { + if (it->second <= 1) { + on_expire(it->first); + it = tenure_.erase(it); + } else { + --(it->second); + ++it; + } + } + } + + /// Convenience overload when no expiry callback is needed. + void age() { + auto noop = [](size_t expired_arc_id) noexcept { (void)expired_arc_id; }; + age(noop); + } + + /// Adaptive tenure: grow when an iteration produced nothing useful (e.g. a + /// duplicate solution or a dead-end). Mirrors the heuristic in the original + /// DiversificationSearch. + void grow_extra() { extra_ = 1 + 2 * extra_; } + + /// Adaptive tenure: shrink (down to zero) when an iteration produced something + /// new. Use only if your algorithm wants to relax the list on success; calling + /// only @ref grow_extra is also a valid policy. + void shrink_extra() { extra_ = extra_ > 0 ? extra_ - 1 : 0; } + + void clear() { + tenure_.clear(); + extra_ = 0; + } + + [[nodiscard]] bool empty() const { return tenure_.empty(); } + [[nodiscard]] size_t extra() const { return extra_; } + + private: + std::map tenure_; // arc_id -> remaining tenure + size_t extra_{0}; + std::mt19937_64 rnd_; +}; + +} // namespace rcspp diff --git a/src/rcspp/algorithm/tabu_search.hpp b/src/rcspp/algorithm/tabu_search.hpp new file mode 100644 index 000000000..e42a1c64c --- /dev/null +++ b/src/rcspp/algorithm/tabu_search.hpp @@ -0,0 +1,195 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include + +#include "rcspp/algorithm/backtracking_dive_algorithm.hpp" +#include "rcspp/algorithm/tabu_list.hpp" + +namespace rcspp { + +/** + * @brief TabuSearchAlgorithm for Resource Constrained Shortest Path Problems (RCSPP). + * + * Self-contained tabu search built in the same spirit as @ref GreedyAlgorithm: each + * iteration performs a greedy dive from a source to a sink, sorting siblings by cost and + * backtracking on dead-ends. The difference is that arcs used in previously discovered + * solutions are added to a tabu list with a tenure (in iterations) and are skipped during + * extension while their tenure remains positive. An aspiration rule allows a tabu arc to + * be used if no non-tabu extension is feasible from the current label, so the search can + * always make progress. + * + * Compared to @ref DiversificationSearch (which wraps another algorithm and physically + * removes arcs from a cloned graph), TabuSearchAlgorithm is a single-pass labeller with + * the lighter overhead of a tabu lookup at extension time. It shares all DFS plumbing + * with @ref GreedyAlgorithm via @ref BacktrackingDiveAlgorithm and only customises: + * - @ref select_children to filter on the tabu list (with aspiration), and + * - @ref main_loop to drive episodic dives, sink extraction, tabu update and reset. + * + * Use this when you want a fast diversifying constructor that produces multiple distinct + * feasible paths quickly (e.g. to seed a tighter UB for a dominance run, or to feed a + * column generator), without the cost of repeatedly cloning and mutating the graph. + */ +template > +class TabuSearchAlgorithm : public BacktrackingDiveAlgorithm { + public: + TabuSearchAlgorithm(ResourceFactory* resource_factory, + AlgorithmParams params) + : BacktrackingDiveAlgorithm(resource_factory, + std::move(params)), + tabu_(this->params_.seed) {} + + protected: + void initialize(const Graph* graph, double cost_upper_bound) override { + Algorithm::initialize(graph, cost_upper_bound); + tabu_.clear(); + } + + // ------------------------------------------------------------------ + // child selection: tabu filter + aspiration + // ------------------------------------------------------------------ + + void select_children(Label* parent, std::list*>& feasible, + std::list*>& rejects) override { + std::list*> non_tabu; + std::list*> tabu_only; + for (auto* l : feasible) { + const auto* in_arc = l->get_in_arc(); + if (in_arc != nullptr && tabu_.is_tabu(in_arc->id)) { + tabu_only.push_back(l); + } else { + non_tabu.push_back(l); + } + } + + std::list*> chosen; + if (!non_tabu.empty()) { + // discard tabu candidates + rejects.splice(rejects.end(), tabu_only); + chosen = std::move(non_tabu); + } else { + // aspiration: nothing else available, fall back to tabu set + chosen = std::move(tabu_only); + } + + (void)parent; + chosen.sort([](Label* a, Label* b) { + return a->get_cost() < b->get_cost(); + }); + feasible = std::move(chosen); + } + + // ------------------------------------------------------------------ + // main loop: episodic dives + // ------------------------------------------------------------------ + + void main_loop() override { + if (this->params_.max_iterations >= MAX_INT) { + LOG_ERROR( + "max_iterations must be set to a finite value for TabuSearchAlgorithm.\n"); + return; + } + + size_t i = 0; + while (!this->should_stop(i)) { + ++i; + + bool reached_sink = dive_to_sink(); + if (reached_sink) { + auto* sink_label = this->path_.back().first; + bool added = false; + if (sink_label->get_cost() < this->cost_upper_bound_) { + if (sink_label->get_cost() + this->params_.tolerance < + this->best_cost_upper_bound_) { + this->best_cost_upper_bound_ = sink_label->get_cost(); + LOG_DEBUG("Found a better solution with cost ", + sink_label->get_cost(), + "\n"); + } + size_t before = this->solutions_.size(); + this->extract_solution(*sink_label); + added = (this->solutions_.size() > before); + } + apply_tabu(*sink_label, added); + } else { + if (tabu_.empty()) { + // truly no feasible path exists + LOG_DEBUG("TabuSearchAlgorithm: no feasible path, stopping.\n"); + this->clear_path(); + return; + } + tabu_.grow_extra(); + } + + tabu_.age(); + this->clear_path(); + this->seed_path_from_sources(); + } + + LOG_DEBUG("TabuSearchAlgorithm: WHILE nb iter: ", i, "\n"); + } + + private: + // ------------------------------------------------------------------ + // dive + // ------------------------------------------------------------------ + + bool dive_to_sink() { + while (!this->path_.empty()) { + auto* current = this->path_.back().first; + if (current->get_end_node()->sink) { + return true; + } + if (this->extend_label(current)) { + continue; + } + if (!this->backtrack()) { + return false; + } + } + return false; + } + + // ------------------------------------------------------------------ + // tabu update + // ------------------------------------------------------------------ + + /// Walk the current path_ up to @p sink_label and add each in-arc to the + /// tabu list (skipping arcs whose endpoints are forbidden). Adapts the + /// adaptive extra tenure based on whether the iteration produced a new + /// solution. + void apply_tabu(const Label& sink_label, bool added_new_solution) { + for (const auto& p : this->path_) { + const auto* in_arc = p.first->get_in_arc(); + if (in_arc == nullptr) { + continue; + } + if (this->params_.forbidden_tabu.contains(in_arc->origin->id) || + this->params_.forbidden_tabu.contains(in_arc->destination->id)) { + continue; + } + tabu_.add(in_arc->id, this->params_.tabu_tenure, this->params_.tabu_random_noise); + if (p.first == &sink_label) { + break; + } + } + + if (added_new_solution) { + tabu_.shrink_extra(); + } else { + tabu_.grow_extra(); + } + } + + // ------------------------------------------------------------------ + // state + // ------------------------------------------------------------------ + + TabuList tabu_; +}; + +} // namespace rcspp diff --git a/src/rcspp/graph/graph.hpp b/src/rcspp/graph/graph.hpp index 7d624759b..87177801a 100644 --- a/src/rcspp/graph/graph.hpp +++ b/src/rcspp/graph/graph.hpp @@ -249,7 +249,17 @@ class Graph { [[nodiscard]] size_t get_nodes_size() const { return nodes_by_id_.size(); } - // Iterate over all active arcs without allocating. fn receives a const Arc& reference. + /// @brief Iterates over all active arcs without allocating, passing a mutable Arc&. + template + void for_each_arc(F&& fn) { + for (auto& arc : arcs_) { + if (arc) { + fn(*arc); + } + } + } + + /// @brief Iterates over all active arcs without allocating, passing a const Arc&. template void for_each_arc(F&& fn) const { for (const auto& arc : arcs_) { diff --git a/src/rcspp/label/label.hpp b/src/rcspp/label/label.hpp index 7ff21b89d..e7c1d8d72 100644 --- a/src/rcspp/label/label.hpp +++ b/src/rcspp/label/label.hpp @@ -74,8 +74,20 @@ class Label { [[nodiscard]] const Arc* get_in_arc() const { return in_arc_; } + void set_prev_label(Label* predecessor) { + prev_label = predecessor; + ++predecessor->ref_count; + } + bool dominated; + // Predecessor label set at extension time; valid as long as ref_count keeps it pinned. + Label* prev_label = nullptr; + // Number of alive successors that reference this label as their predecessor. + uint8_t ref_count = 0; + // True when the algorithm wanted to release this label but ref_count was > 0. + bool pending_release = false; + private: // Resource consumed by the label. std::unique_ptr> resource_; diff --git a/src/rcspp/label/label_factory.hpp b/src/rcspp/label/label_factory.hpp index 1cd3ebe9d..92375e215 100644 --- a/src/rcspp/label/label_factory.hpp +++ b/src/rcspp/label/label_factory.hpp @@ -38,6 +38,9 @@ class LabelFactory { label->in_arc_ = in_arc; label->out_arc_ = out_arc; label->dominated = false; + label->prev_label = nullptr; + label->ref_count = 0; + label->pending_release = false; label->get_resource().reset(*end_node->resource); } diff --git a/src/rcspp/label/label_pool.hpp b/src/rcspp/label/label_pool.hpp index b984c3e04..7c4f96493 100644 --- a/src/rcspp/label/label_pool.hpp +++ b/src/rcspp/label/label_pool.hpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include #include @@ -51,13 +53,43 @@ class LabelPool { } ++nb_labels_; + // reset also prev label + label_ptr->prev_label = nullptr; + label_ptr->ref_count = 0; + label_ptr->pending_release = false; + return *label_ptr; } + /// @brief Return a label to the pool's free list. void release_label(Label* label_ptr) { available_labels_.push_back(label_ptr); } + /// @brief Release a label, cascading up the prev_label chain as predecessors become free. + /// + /// If @p label_ptr is still referenced by alive successors (@ref ref_count > 0), it is + /// marked @ref pending_release and left in place; the last successor's release will + /// cascade back up through this method. + void release_with_ref_count(Label* label_ptr) { + while (label_ptr != nullptr) { + if (label_ptr->ref_count > 0) { + label_ptr->pending_release = true; + break; + } + Label* prev = label_ptr->prev_label; + if (prev != nullptr) { + --prev->ref_count; + } + release_label(label_ptr); + if (prev == nullptr || !prev->pending_release) { + break; + } + label_ptr = prev; + } + } + + /// @brief Return all labels to the free list without destroying them. void release_all_labels() { available_labels_.clear(); for (auto& label_uptr : labels_) { @@ -70,11 +102,94 @@ class LabelPool { available_labels_.clear(); } + /// @brief Free all label memory and release backing storage to the OS. + /// + /// Unlike clear(), which only destroys label objects but retains the + /// vector capacity for reuse, release() also calls shrink_to_fit() on + /// both internal vectors. Use this at the end of a solve to reclaim + /// RAM when the pool will not be reused immediately. + void release() { + labels_.clear(); + labels_.shrink_to_fit(); + available_labels_.clear(); + available_labels_.shrink_to_fit(); + } + [[nodiscard]] int64_t get_nb_created_labels() const { return nb_created_labels_; } [[nodiscard]] int64_t get_nb_reused_labels() const { return nb_reused_labels_; } + /// @brief Number of labels currently on the free list (available for reuse). + [[nodiscard]] size_t get_nb_available_labels() const { return available_labels_.size(); } + + /// @brief Total number of label objects owned by the pool (in use + available). + [[nodiscard]] size_t get_nb_total_labels() const { return labels_.size(); } + + /// @brief Diagnostic: verify the prev_label / ref_count bookkeeping is consistent. + /// + /// Considers a label "in use" when it is not on the free list. For every in-use label + /// it checks that: + /// - its @ref Label::ref_count equals the number of in-use labels that name it as their + /// @ref Label::prev_label (no leaked over-count, no missing reference), and + /// - its @ref Label::prev_label (when set) points to a label that is itself still in use + /// (no dangling predecessor that was already recycled). + /// + /// Returns true when both invariants hold for every in-use label. A false result means a + /// release path pushed a still-pinned label onto the free list without going through + /// @ref release_with_ref_count (i.e. it used the raw @ref release_label), leaking the + /// predecessor's reference or recycling a label that a live successor still points to. + /// Intended for tests; O(total labels). + [[nodiscard]] bool check_ref_count_consistency() const { + std::unordered_set*> free_set(available_labels_.begin(), + available_labels_.end()); + auto in_use = [&free_set](const Label* label) { + return free_set.find(label) == free_set.end(); + }; + + // Count, for each label, how many in-use labels reference it as their predecessor. + std::unordered_map*, size_t> referencing_count; + for (const auto& label_uptr : labels_) { + const Label* label = label_uptr.get(); + if (!in_use(label) || label->prev_label == nullptr) { + continue; + } + if (!in_use(label->prev_label)) { + return false; // dangling: in-use label points to a recycled predecessor + } + ++referencing_count[label->prev_label]; + } + + // Every in-use label's ref_count must equal its in-use referrer count. + for (const auto& label_uptr : labels_) { + const Label* label = label_uptr.get(); + if (!in_use(label)) { + continue; + } + const auto it = referencing_count.find(label); + const size_t expected = (it == referencing_count.end()) ? 0 : it->second; + if (label->ref_count != expected) { + return false; + } + } + return true; + } + private: + /// @brief Unconditionally return a label to the free list and cascade to its parent. + /// + /// Decrements the parent's @ref child_refcount_ and recurses into the parent when it + /// becomes zero and is already dominated — avoiding a separate traversal at solve end. + void do_release(Label* label) { + if (label->parent_ != nullptr) { + auto* parent = label->parent_; + label->parent_ = nullptr; + if (--parent->child_refcount_ == 0 && parent->dominated) { + do_release(parent); + } + } + available_labels_.push_back(label); + } + std::unique_ptr> label_factory_; std::vector>> labels_; std::vector*> available_labels_; diff --git a/src/rcspp/preprocessor/bellman_ford_algorithm.hpp b/src/rcspp/preprocessor/bellman_ford_algorithm.hpp index 3a11a1ba8..1c2f4ac20 100644 --- a/src/rcspp/preprocessor/bellman_ford_algorithm.hpp +++ b/src/rcspp/preprocessor/bellman_ford_algorithm.hpp @@ -33,57 +33,14 @@ class BellmanFordAlgorithm { double weight; }; - public: - // compute shortest paths from any of the given targets to all nodes (forward) or from all - // nodes to any of the given targets (backward) - // cost = nullopt -> use default arc cost - template - requires is_numerical_resource_v - static Distance solve(const Graph>& graph_, - const std::vector& target_ids, - std::optional cost_index = std::nullopt, - bool forward = true) { - // Distance from source to each node - Distance distance(target_ids, graph_); - - // Prepare distance table - std::vector arc_relaxations; - graph_.for_each_arc([&](const auto& arc) { - // fetch cost - if (cost_index.has_value()) { - // get the origin cost of the cost resource - const auto& origin_cost_resource = - arc.origin->resource->template get_component( - cost_index.value()); - double origin_cost = origin_cost_resource.get_value().get_value(); - // extend the resource - Resource> resource( - *arc.destination->resource); - arc.extender->extend(*arc.origin->resource, &resource); - // fetch the new value of the cost resource - const auto& cost_resource = - resource.template get_component(cost_index.value()); - double cost = cost_resource.get_value().get_value(); - // compute the weight, i.e., cost difference - arc_relaxations.emplace_back(arc.origin->id, - arc.destination->id, - cost - origin_cost); - } else { - // use default cost - arc_relaxations.emplace_back(arc.origin->id, arc.destination->id, arc.cost); - } - }); - - // In backward shortest path computation, we need to reverse the order of arc - // relaxations to ensure that relaxation proceeds from destination to origin, correctly - // propagating distances from all nodes to the target(s). The goal is to be more - // efficient if the arcs are correctly ordered - if (!forward) { - std::ranges::reverse(arc_relaxations); - } - - // Relax arcs |N|-1 times, on |N| iteration -> check for negative-weight cycles - const size_t nodes_size = graph_.get_nodes_size(); + /// @brief Run the Bellman–Ford relaxation loop on a pre-built arc list. + /// + /// @param distance Distance map initialised with target nodes set to 0. + /// @param arc_relaxations Arcs with weights; already reversed if backward. + /// @param forward If false, relaxation propagates from destination to origin. + /// @param nodes_size Number of nodes (loop iteration bound). + static void run_relaxations(Distance& distance, std::vector& arc_relaxations, + bool forward, size_t nodes_size) { for (size_t i = 0; i < nodes_size; ++i) { bool modified = false; bool last_iteration = (i == nodes_size - 1); @@ -103,10 +60,96 @@ class BellmanFordAlgorithm { } } if (!modified) { - break; // No changes in this iteration, so we can stop early + break; } } + } + + public: + /// @brief Compute shortest paths using @p arc.cost as the arc weight. + /// + /// Does not require a cost resource type — works with any graph composition. + /// @p forward computes distances from the targets; !@p forward computes + /// distances to the targets. + /// + /// @note @p forward has no default value to prevent ambiguity with the + /// @p cost_index overload when called with a plain @c bool argument. + /// + /// @param graph_ The graph. + /// @param target_ids Source nodes (forward) or sink nodes (!forward). + /// @param forward Direction of the shortest-path computation. + /// @return Distance map keyed by node ID. + template + static Distance solve(const Graph>& graph_, + const std::vector& target_ids, bool forward) { + Distance distance(target_ids, graph_); + std::vector arc_relaxations; + graph_.for_each_arc([&](const auto& arc) { + arc_relaxations.emplace_back(arc.origin->id, arc.destination->id, arc.cost); + }); + if (!forward) { + std::ranges::reverse(arc_relaxations); + } + run_relaxations(distance, arc_relaxations, forward, graph_.get_nodes_size()); + return distance; + } + + /// @brief Compute shortest paths from/to target nodes. + /// + /// When @p cost_index is set, the arc weight is derived from the + /// @p CostResourceType resource component at that index (via arc extension). + /// When @p cost_index is @c std::nullopt, @p arc.cost is used directly. + /// + /// @tparam CostResourceType Numerical resource type used when cost_index is set. + /// @param graph_ The graph. + /// @param target_ids Source nodes (forward) or sink nodes (!forward). + /// @param cost_index Resource component index for arc weights, or nullopt. + /// @param forward Direction of the shortest-path computation. + /// @return Distance map keyed by node ID. + template + requires is_numerical_resource_v + static Distance solve(const Graph>& graph_, + const std::vector& target_ids, + std::optional cost_index = std::nullopt, + bool forward = true) { + if (!cost_index.has_value()) { + return solve(graph_, target_ids, forward); + } + + // compute shortest paths from any of the given targets to all nodes (forward) or from + // all nodes to any of the given targets (backward) + Distance distance(target_ids, graph_); + + std::vector arc_relaxations; + graph_.for_each_arc([&](const auto& arc) { + // get the origin cost of the cost resource + const auto& origin_cost_resource = + arc.origin->resource->template get_component( + cost_index.value()); + double origin_cost = origin_cost_resource.get_value().get_value(); + // extend the resource + Resource> resource( + *arc.destination->resource); + arc.extender->extend(*arc.origin->resource, &resource); + // fetch the new value of the cost resource + const auto& cost_resource = + resource.template get_component(cost_index.value()); + double cost = cost_resource.get_value().get_value(); + // compute the weight, i.e., cost difference + arc_relaxations.emplace_back(arc.origin->id, + arc.destination->id, + cost - origin_cost); + }); + + // In backward shortest path computation, we need to reverse the order of arc + // relaxations to ensure that relaxation proceeds from destination to origin, correctly + // propagating distances from all nodes to the target(s). The goal is to be more + // efficient if the arcs are correctly ordered + if (!forward) { + std::ranges::reverse(arc_relaxations); + } + run_relaxations(distance, arc_relaxations, forward, graph_.get_nodes_size()); return distance; } }; diff --git a/src/rcspp/preprocessor/connectivity_matrix.hpp b/src/rcspp/preprocessor/connectivity_matrix.hpp index 7f7897a45..e82699701 100644 --- a/src/rcspp/preprocessor/connectivity_matrix.hpp +++ b/src/rcspp/preprocessor/connectivity_matrix.hpp @@ -143,6 +143,15 @@ class ConnectivityMatrix { adj[i].push_back(it->second); } } + for (const auto arc_ptr : node->in_arcs) { + const auto it = id_to_index_.find(arc_ptr->origin->id); + if (it != id_to_index_.end()) { + auto it2 = std::ranges::find(adj[i], it->second); + if (it2 == adj[i].end()) { + adj[i].push_back(it->second); + } + } + } } // Tarjan's algorithm (iterative) to compute strongly connected components (SCCs) diff --git a/src/rcspp/rcspp.hpp b/src/rcspp/rcspp.hpp index 7e2e9b8d4..39259cf61 100644 --- a/src/rcspp/rcspp.hpp +++ b/src/rcspp/rcspp.hpp @@ -2,15 +2,20 @@ #pragma once #include "rcspp/algorithm/algorithm.hpp" +#include "rcspp/algorithm/astar_dominance_algorithm.hpp" +#include "rcspp/algorithm/backtracking_dive_algorithm.hpp" #include "rcspp/algorithm/diversification_search.hpp" #include "rcspp/algorithm/dominance_algorithm.hpp" #include "rcspp/algorithm/greedy.hpp" +#include "rcspp/algorithm/improving_tabu_search.hpp" #include "rcspp/algorithm/label_buckets.hpp" #include "rcspp/algorithm/pulling_dominance_algorithm.hpp" #include "rcspp/algorithm/pushing_dominance_algorithm.hpp" #include "rcspp/algorithm/simple_dominance_algorithm.hpp" #include "rcspp/algorithm/solution.hpp" #include "rcspp/algorithm/solution_pool.hpp" +#include "rcspp/algorithm/tabu_list.hpp" +#include "rcspp/algorithm/tabu_search.hpp" #include "rcspp/general/clonable.hpp" #include "rcspp/graph/arc.hpp" #include "rcspp/graph/graph.hpp" @@ -37,6 +42,7 @@ #include "rcspp/resource/composition/functions/cost/composition_cost_function.hpp" #include "rcspp/resource/composition/functions/dominance/composition_dominance_function.hpp" #include "rcspp/resource/composition/functions/extension/composition_extension_function.hpp" +#include "rcspp/resource/composition/functions/extension/ng_unreachable_composition_extension_function.hpp" #include "rcspp/resource/composition/functions/extension/reachable_composition_extension_function.hpp" #include "rcspp/resource/composition/functions/feasibility/composition_feasibility_function.hpp" #include "rcspp/resource/composition/functions/feasibility/reachable_composition_feasibility_function.hpp" @@ -71,5 +77,6 @@ #include "rcspp/resource/resource_graph.hpp" #include "rcspp/resource/resource_traits.hpp" #include "rcspp/utils/logger.hpp" +#include "rcspp/utils/memory.hpp" #include "rcspp/utils/timer.hpp" #include "rcspp/utils/utils.hpp" diff --git a/src/rcspp/resource/base/resource_factory.hpp b/src/rcspp/resource/base/resource_factory.hpp index bb77a5023..038e4b61f 100644 --- a/src/rcspp/resource/base/resource_factory.hpp +++ b/src/rcspp/resource/base/resource_factory.hpp @@ -96,6 +96,17 @@ class ResourceFactory { return new_resource; } + /// @brief Replace the extension function used to build extenders. + /// + /// Must be called before any extenders are created (i.e. before arcs are + /// added to the graph), since extenders are cloned from it per arc. + /// + /// @param extension_function The new extension function to install. + void set_extension_function( + std::unique_ptr> extension_function) { + extension_function_ = std::move(extension_function); + } + /// @brief Return a deep copy of this factory (prototype + extension function). [[nodiscard]] virtual std::unique_ptr> clone() const { auto cloned = std::make_unique>(); diff --git a/src/rcspp/resource/composition/functions/extension/composition_extension_function.hpp b/src/rcspp/resource/composition/functions/extension/composition_extension_function.hpp index 926bef4bf..ee20c296f 100644 --- a/src/rcspp/resource/composition/functions/extension/composition_extension_function.hpp +++ b/src/rcspp/resource/composition/functions/extension/composition_extension_function.hpp @@ -24,6 +24,7 @@ class CompositionExtensionFunction resource, extender, [](auto& ext_res, const auto& res, const auto& exp) { exp.extend(res, &ext_res); }); + post_extend(resource, extender, extended_resource); } void extend_back(const Resource& resource, @@ -35,6 +36,28 @@ class CompositionExtensionFunction [](auto& ext_res, const auto& res, const auto& exp) { exp.extend_back(res, &ext_res); }); + post_extend_back(resource, extender, extended_resource); + } + + protected: + /// @brief Hook fired after the per-component forward extension completes. + /// + /// The default is a no-op. Subclasses override this to apply + /// composition-level post-processing that needs access to the whole + /// extended resource and the arc extender (e.g. cross-resource updates). + /// + /// @param resource The parent (pre-extension) composite resource. + /// @param extender The arc extender that was applied. + /// @param extended_resource The just-extended composite resource to adjust. + virtual void post_extend(const Resource& /*resource*/, + const Extender& /*extender*/, + Resource* /*extended_resource*/) {} + + /// @brief Backward counterpart of @ref post_extend; defaults to it. + virtual void post_extend_back(const Resource& resource, + const Extender& extender, + Resource* extended_resource) { + post_extend(resource, extender, extended_resource); } }; } // namespace rcspp diff --git a/src/rcspp/resource/composition/functions/extension/ng_unreachable_composition_extension_function.hpp b/src/rcspp/resource/composition/functions/extension/ng_unreachable_composition_extension_function.hpp new file mode 100644 index 000000000..3f588d417 --- /dev/null +++ b/src/rcspp/resource/composition/functions/extension/ng_unreachable_composition_extension_function.hpp @@ -0,0 +1,116 @@ +// Copyright (c) 2025 Laboratory for Combinatorial Optimization in Real-time Environment. +// All rights reserved. + +#pragma once + +#include +#include +#include + +#include "rcspp/general/clonable.hpp" +#include "rcspp/graph/arc.hpp" +#include "rcspp/resource/composition/functions/extension/composition_extension_function.hpp" +#include "rcspp/resource/composition/resource_type_composition.hpp" +#include "rcspp/resource/functions/extension/extension_function.hpp" + +namespace rcspp { + +/// @brief ng-route memory with Feillet-style unreachable-set augmentation. +/// +/// Drop-in replacement for @ref CompositionExtensionFunction that, after the +/// standard per-component extension, folds the resource-unreachable members of +/// the destination node's ng-neighbourhood N_j into the ng-memory bitmask Π. +/// +/// Reachability is tested the only general way — by actually building the trial +/// label along arc j->x and checking its feasibility (reachability is the +/// composition of extension and feasibility, so there is no node-only shortcut +/// that generalises across resources). For every neighbour x in N_j that is not +/// already in Π, if the extension L->x is infeasible then x is unreachable and is +/// folded into Π. +/// +/// This is **pure acceleration**: under monotone (non-decreasing) resources an +/// unreachable node is permanently unreachable, so forbidding it via Π never +/// removes a feasible route — the relaxation (LP bound) is unchanged, only the +/// number of non-dominated labels drops (dominance still uses plain inclusion on +/// the augmented Π). +/// +/// @tparam NgResourceType The set/bitset resource type holding the ng-memory. +/// @tparam ResourceTypes The composition's component resource types. +template + requires ResourceTypeConcept && (ResourceTypeConcept && ...) +class NgUnreachableCompositionExtensionFunction + : public Clonable, + CompositionExtensionFunction, + ExtensionFunction>> { + using CompositionType = ResourceTypeComposition; + using ArcType = Arc; + + public: + /// @brief Construct the augmenting extension function. + /// + /// @param ng_arcs_by_node_id For each node j, the outgoing arcs j->x to the + /// ng-neighbours x in N_j (must outlive this function and its clones; + /// may be populated after construction — it is read at extension time). + /// @param ng_resource_index Occurrence index of the ng resource within its + /// type's component vector (0 when it is the only one of its type). + explicit NgUnreachableCompositionExtensionFunction( + const std::map>* ng_arcs_by_node_id, + size_t ng_resource_index = 0) + : ng_arcs_by_node_id_(ng_arcs_by_node_id), ng_resource_index_(ng_resource_index) {} + + protected: + const std::map>* ng_arcs_by_node_id_; + size_t ng_resource_index_; + // Node the current arc extends into (set per arc by preprocess). + size_t destination_id_ = 0; + + // Re-entrancy guard shared across all clones in a thread: the trial + // extensions below reuse the (augmented) arc extenders, so without this + // post_extend would recurse indefinitely. Suppressing augmentation inside + // a trial is also exactly what we want — the trial must measure the plain + // extension's feasibility. + static thread_local bool in_trial_extension_; + + void preprocess(size_t /*origin_id*/, size_t destination_id) override { + destination_id_ = destination_id; + } + + void post_extend(const Resource& /*resource*/, + const Extender& /*extender*/, + Resource* extended_resource) override { + if (in_trial_extension_ || ng_arcs_by_node_id_ == nullptr) { + return; + } + auto it = ng_arcs_by_node_id_->find(destination_id_); + if (it == ng_arcs_by_node_id_->end()) { + return; + } + auto& ng = extended_resource->template get_component(ng_resource_index_) + .get_value(); + + in_trial_extension_ = true; + for (const ArcType* arc : it->second) { + const size_t x = arc->destination->id; + // Skip neighbours already remembered in the ng-memory. + if (ng.contains(x)) { + continue; + } + // Build the trial label L->x and fold x in if it is infeasible. + // The trial is built from x's prototype resource so that its + // feasibility functions are preprocessed for x. + Resource trial(*arc->destination->resource); + arc->extender->extend(*extended_resource, &trial); + if (!trial.is_feasible()) { + ng.add(x); + } + } + in_trial_extension_ = false; + } +}; + +template + requires ResourceTypeConcept && (ResourceTypeConcept && ...) +thread_local bool NgUnreachableCompositionExtensionFunction< + NgResourceType, ResourceTypes...>::in_trial_extension_ = false; + +} // namespace rcspp diff --git a/src/rcspp/resource/concrete/numerical_resource.hpp b/src/rcspp/resource/concrete/numerical_resource.hpp index 03bfad4db..1a5a707c0 100644 --- a/src/rcspp/resource/concrete/numerical_resource.hpp +++ b/src/rcspp/resource/concrete/numerical_resource.hpp @@ -56,14 +56,18 @@ class NumericalResource { [[nodiscard]] std::string to_string() const { return std::to_string(value_); } - [[nodiscard]] bool leq(const NumericalResource& other) const { + [[nodiscard]] bool leq(const NumericalResource& other) const { return leq(other.get_value()); } + [[nodiscard]] bool leq(const NumericalResource& other, double delta) const { + return leq(other.get_value() + delta); + } + // bool operator<=(const NumericalResource& other) const { [[nodiscard]] bool leq(T other_value) const { return value_leq(value_, other_value); } - [[nodiscard]] bool geq(const NumericalResource& other) const { + [[nodiscard]] bool geq(const NumericalResource& other) const { return geq(other.get_value()); } diff --git a/src/rcspp/resource/resource_graph.hpp b/src/rcspp/resource/resource_graph.hpp index 9c2aea549..a75ac9cbe 100644 --- a/src/rcspp/resource/resource_graph.hpp +++ b/src/rcspp/resource/resource_graph.hpp @@ -58,6 +58,15 @@ class ResourceGraph : public Graph> { virtual ~ResourceGraph() = default; + /// @brief Replace the composition-level extension function (e.g. to install + /// a post-processing hook). Must be called before arcs are added. + /// + /// @param extension_function The composition extension function to install. + void set_composition_extension_function( + std::unique_ptr> extension_function) { + resource_factory_.set_extension_function(std::move(extension_function)); + } + template void add_resource(std::unique_ptr> extension_function, std::unique_ptr> feasibility_function, @@ -262,7 +271,7 @@ class ResourceGraph : public Graph> { typename CostResourceType = RealResource, typename LabelContainerType = LabelList> requires is_numerical_resource_v - std::vector solve( + SolveResult solve( double upper_bound = std::numeric_limits::infinity(), AlgorithmParams params = AlgorithmParams(), bool preprocess = true, size_t cost_index = 0) { @@ -276,8 +285,8 @@ class ResourceGraph : public Graph> { typename CostResourceType = RealResource, typename LabelContainerType = LabelList> requires is_numerical_resource_v - std::vector solve(AlgorithmParams params, - bool preprocess = true, size_t cost_index = 0) { + SolveResult solve(AlgorithmParams params, bool preprocess = true, + size_t cost_index = 0) { AlgorithmType algorithm(&resource_factory_, params); return solve, @@ -287,9 +296,33 @@ class ResourceGraph : public Graph> { cost_index); } + /// @brief Solve using base algorithm parameters (without explicit container type). + /// + /// Convenience overload that wraps @p base_params in a default-constructed + /// AlgorithmParams so callers only need to set the base fields (e.g. memory + /// limits, stop conditions) without knowing the internal ResourceCompositionType. + /// + /// @param base_params Base algorithm parameters (memory limits, stop conditions…). + /// @param upper_bound Cost upper bound; solutions above this are discarded. + /// @param preprocess Whether to run preprocessing before solving. + /// @param cost_index Index of the cost component to use. + template