Source code for OCDocker.Workbench.VSDesign

#!/usr/bin/env python3

# Description
###############################################################################
'''
Design assistant for virtual-screening runs (``ocdocker vs`` and
``ocdocker pipeline``): discover receptor/ligand/box candidates, validate a
draft selection, and plan the exact command to run — for one target
(``*_vs_design*``) or a multi-sample batch (``*_vs_campaign*``). Mirrors
:mod:`OCDocker.Workbench.AblationDesign`, but for docking runs instead of
OCScore training. Read-only: nothing here writes files or executes commands —
the plan output is handed to :meth:`OCDocker.Workbench.Jobs.JobManager.launch`
(via ``run_job``/``plan_job``) to actually run.
'''

# Imports
###############################################################################
from __future__ import annotations

import shlex

from pathlib import Path
from typing import Any

from OCDocker.Workbench.Jobs import CAMPAIGN_ENGINES
from OCDocker.Workbench.Jobs import DEFAULT_CAMPAIGN_CORES
from OCDocker.Workbench.Jobs import DEFAULT_CAMPAIGN_ENGINE
from OCDocker.Workbench.Jobs import build_campaign_script
from OCDocker.Workbench.Jobs import build_campaign_snakemake_command
from OCDocker.Workbench.Models import VALID_DOCKING_ENGINES
from OCDocker.Workbench.Models import VALID_RESCORING_ENGINES

# License
###############################################################################
'''Copyright (c) Federal University of Rio de Janeiro (UFRJ), Artur Duque Rossi, and Pedro Henrique Monteiro Torres.

SPDX-License-Identifier: BSD-3-Clause

See the LICENSE file for full terms.
'''

# Constants
###############################################################################

VS_DESIGN_KINDS = ("vs", "pipeline")
DEFAULT_VS_DESIGN_SCAN_DEPTH = 6
DEFAULT_VS_CAMPAIGN_SAMPLE_SCAN_DEPTH = 3
DEFAULT_PIPELINE_ENGINES = ("vina", "smina", "plants")
DEFAULT_VS_ENGINE = "vina"

_RECEPTOR_EXTENSIONS = frozenset({".pdb", ".pdbqt"})
_LIGAND_EXTENSIONS = frozenset({".smi", ".sdf", ".mol2", ".pdbqt"})
_BOX_EXTENSIONS = frozenset({".pdb", ".txt"})

# Functions
###############################################################################
## Private ##


def _is_hidden(path: Path) -> bool:
    '''Return whether a path should be skipped while scanning for candidates.

    Parameters
    ----------
    path : pathlib.Path
        Path to inspect.

    Returns
    -------
    bool
        True when the path is a dotfile/dotdir or a ``__pycache__`` directory.
    '''

    return path.name.startswith(".") or path.name == "__pycache__"


def _iter_candidate_files(root: Path, max_depth: int):
    '''Yield files below a root without descending beyond a depth limit.

    Parameters
    ----------
    root : pathlib.Path
        Directory to scan.
    max_depth : int
        Maximum directory depth below root to descend.

    Yields
    ------
    pathlib.Path
        Discovered file paths, hidden directories skipped.
    '''

    stack: list[tuple[Path, int]] = [(root, 0)]
    while stack:
        current, depth = stack.pop()
        try:
            children = sorted(current.iterdir(), key=lambda child: child.name)
        except OSError:
            continue
        for child in children:
            if _is_hidden(child):
                continue
            if child.is_file():
                yield child
            elif depth < max_depth and child.is_dir() and not child.is_symlink():
                stack.append((child, depth + 1))


def _looks_like_box(path: Path) -> bool:
    '''Return whether a path plausibly names a binding-site box file.

    Parameters
    ----------
    path : pathlib.Path
        Candidate file path.

    Returns
    -------
    bool
        True when the extension and filename match the ``box*.pdb``/``box*.txt`` convention.
    '''

    return path.suffix.lower() in _BOX_EXTENSIONS and path.stem.lower().startswith("box")


def _looks_like_receptor(path: Path) -> bool:
    '''Return whether a path plausibly names a receptor structure file.

    Parameters
    ----------
    path : pathlib.Path
        Candidate file path.

    Returns
    -------
    bool
        True when the extension matches and the filename mentions "receptor".
    '''

    return path.suffix.lower() in _RECEPTOR_EXTENSIONS and "receptor" in path.stem.lower()


def _looks_like_ligand(path: Path) -> bool:
    '''Return whether a path plausibly names a ligand file.

    Parameters
    ----------
    path : pathlib.Path
        Candidate file path.

    Returns
    -------
    bool
        True when the extension matches and the filename or parent directory mentions "ligand".
    '''

    if path.suffix.lower() not in _LIGAND_EXTENSIONS:
        return False
    return "ligand" in path.stem.lower() or "ligand" in path.parent.name.lower()


def _candidate_entry(path: Path, scan_root: Path) -> dict[str, str]:
    '''Build one discovered-candidate payload entry.

    Parameters
    ----------
    path : pathlib.Path
        Discovered file path.
    scan_root : pathlib.Path
        Root the scan started from, used to build a relative display name.

    Returns
    -------
    dict[str, str]
        JSON-safe candidate entry (``path``, ``name``).
    '''

    try:
        name = str(path.relative_to(scan_root))
    except ValueError:
        name = path.name
    return {"path": str(path), "name": name}


def _resolve_path(root: Path, value: str) -> Path:
    '''Resolve a receptor/ligand/box path, relative to root when not absolute.

    Parameters
    ----------
    root : pathlib.Path
        Served Workbench root.
    value : str
        Path string from a design request body.

    Returns
    -------
    pathlib.Path
        Resolved path (not required to exist).
    '''

    candidate = Path(value).expanduser()
    if candidate.is_absolute():
        return candidate
    return (root / candidate).resolve()


def _required_path(root: Path, body: dict[str, Any], key: str, errors: list[str], warnings: list[str], extensions: frozenset[str]) -> Path | None:
    '''Validate one required receptor/ligand/box path field.

    Parameters
    ----------
    root : pathlib.Path
        Served Workbench root, used to resolve relative paths.
    body : dict[str, Any]
        Draft design request body.
    key : str
        Field name (``"receptor"``, ``"ligand"``, or ``"box"``).
    errors : list[str]
        Accumulator for validation errors.
    warnings : list[str]
        Accumulator for validation warnings.
    extensions : frozenset[str]
        Expected file extensions for this field.

    Returns
    -------
    pathlib.Path or None
        Resolved path when present, else None (an error is already recorded).
    '''

    raw = str(body.get(key) or "").strip()
    if not raw:
        errors.append(f"{key} is required.")
        return None
    resolved = _resolve_path(root, raw)
    if not resolved.is_file():
        errors.append(f"{key} path does not exist or is not a file: {resolved}")
        return None
    if resolved.suffix.lower() not in extensions:
        warnings.append(f"{key} has an unusual extension for this field: {resolved.suffix or '(none)'}")
    return resolved


def _resolve_docking_engines(raw: Any, errors: list[str]) -> tuple[str, ...]:
    '''Validate and normalize a list of docking engine names.

    Parameters
    ----------
    raw : Any
        Engine names from a draft request body (a list, or a single string).
    errors : list[str]
        Accumulator for validation errors.

    Returns
    -------
    tuple[str, ...]
        Validated, order-preserved engine names.
    '''

    if raw is None:
        return ()
    values = [raw] if isinstance(raw, str) else list(raw)
    engines = tuple(str(item).strip().lower() for item in values if str(item).strip())
    for engine in engines:
        if engine not in VALID_DOCKING_ENGINES:
            errors.append(f"Unknown docking engine {engine!r}. Expected one of: {sorted(VALID_DOCKING_ENGINES)}.")
    return engines


def _resolve_rescoring_engines(raw: Any, errors: list[str]) -> tuple[str, ...]:
    '''Validate and normalize a list of rescoring engine names.

    Parameters
    ----------
    raw : Any
        Rescoring engine names from a draft request body.
    errors : list[str]
        Accumulator for validation errors.

    Returns
    -------
    tuple[str, ...]
        Validated, order-preserved engine names.
    '''

    if raw is None:
        return ()
    values = [raw] if isinstance(raw, str) else list(raw)
    engines = tuple(str(item).strip().lower() for item in values if str(item).strip())
    for engine in engines:
        if engine not in VALID_RESCORING_ENGINES:
            errors.append(f"Unknown rescoring engine {engine!r}. Expected one of: {sorted(VALID_RESCORING_ENGINES)}.")
    return engines


def _validate_campaign_row(root_path: Path, row: dict[str, Any], index: int) -> tuple[list[str], list[str], dict[str, Any]]:
    '''Validate one manifest row of a VS campaign draft.

    Parameters
    ----------
    root_path : pathlib.Path
        Served Workbench root, used to resolve relative paths.
    row : dict[str, Any]
        Draft row: ``sample``, ``row_kind`` (``"vs"`` or ``"pipeline"``),
        ``receptor``, ``ligand``, ``box``, ``engines``, optional
        ``rescoring_engines``.
    index : int
        1-based row position, used to prefix error/warning messages.

    Returns
    -------
    tuple[list[str], list[str], dict[str, Any]]
        Row-prefixed errors, row-prefixed warnings, and the resolved row.
    '''

    row_errors: list[str] = []
    row_warnings: list[str] = []
    sample = str(row.get("sample") or f"row-{index}")
    row_kind = str(row.get("row_kind") or "vs")
    if row_kind not in VS_DESIGN_KINDS:
        row_errors.append(f"row_kind must be one of {VS_DESIGN_KINDS}, got {row_kind!r}.")

    receptor = _required_path(root_path, row, "receptor", row_errors, row_warnings, _RECEPTOR_EXTENSIONS)
    ligand = _required_path(root_path, row, "ligand", row_errors, row_warnings, _LIGAND_EXTENSIONS)
    box = _required_path(root_path, row, "box", row_errors, row_warnings, _BOX_EXTENSIONS)

    default_engines = DEFAULT_PIPELINE_ENGINES if row_kind == "pipeline" else (DEFAULT_VS_ENGINE,)
    engines = _resolve_docking_engines(row.get("engines"), row_errors) or default_engines
    if row_kind == "vs" and len(engines) > 1:
        row_warnings.append("vs rows only use the first engine listed; the rest are ignored.")
    rescoring_engines = _resolve_rescoring_engines(row.get("rescoring_engines"), row_errors)

    resolved_row = {
        "sample": sample,
        "row_kind": row_kind,
        "receptor": str(receptor) if receptor else None,
        "ligand": str(ligand) if ligand else None,
        "box": str(box) if box else None,
        "engines": list(engines),
        "rescoring_engines": list(rescoring_engines) if rescoring_engines else None,
    }
    prefixed_errors = [f"Row {index} ({sample}): {message}" for message in row_errors]
    prefixed_warnings = [f"Row {index} ({sample}): {message}" for message in row_warnings]
    return prefixed_errors, prefixed_warnings, resolved_row


## Public ##


[docs] def discover_vs_design_candidates( root: str | Path, *, input_dir: str | Path | None = None, max_depth: int = DEFAULT_VS_DESIGN_SCAN_DEPTH, ) -> dict[str, Any]: '''Discover receptor/ligand/box candidates under a workspace. Best-effort, depth-limited scan by filename/extension heuristics — there is no single fixed input layout in OCDocker, unlike OCScore's ``raw_prepare/``. Results are candidates for the caller to choose from, never a hard requirement; an empty or ambiguous scan is not an error. Parameters ---------- root : str or pathlib.Path Served Workbench root. input_dir : str, pathlib.Path, or None Optional subdirectory to scan instead of the whole served root. max_depth : int Maximum directory depth below the scan root to descend. Returns ------- dict[str, Any] JSON-safe payload: ``candidates`` (``receptors``, ``ligands``, ``boxes``), ``scan_root``, ``issues``. ''' scan_root = Path(input_dir).expanduser().resolve() if input_dir else Path(root).expanduser().resolve() issues: list[str] = [] receptors: list[dict[str, str]] = [] ligands: list[dict[str, str]] = [] boxes: list[dict[str, str]] = [] if not scan_root.is_dir(): issues.append(f"Scan root does not exist or is not a directory: {scan_root}") return { "candidates": {"receptors": [], "ligands": [], "boxes": []}, "scan_root": str(scan_root), "issues": issues, } for path in _iter_candidate_files(scan_root, max_depth): if _looks_like_box(path): boxes.append(_candidate_entry(path, scan_root)) elif _looks_like_receptor(path): receptors.append(_candidate_entry(path, scan_root)) elif _looks_like_ligand(path): ligands.append(_candidate_entry(path, scan_root)) if not receptors and not ligands and not boxes: issues.append("No receptor, ligand, or box candidates found under the scanned root.") return { "candidates": {"receptors": receptors, "ligands": ligands, "boxes": boxes}, "scan_root": str(scan_root), "issues": issues, }
[docs] def preview_vs_design(root: str | Path, body: dict[str, Any]) -> dict[str, Any]: '''Validate one draft VS design without running anything. Parameters ---------- root : str or pathlib.Path Served Workbench root, used to resolve relative paths. body : dict[str, Any] Draft design: ``kind`` (``"vs"`` or ``"pipeline"``), ``receptor``, ``ligand``, ``box`` (paths), plus kind-specific fields — ``engine`` (``vs``) or ``engines``/``rescoring_engines`` (``pipeline``). Returns ------- dict[str, Any] ``{"valid", "errors", "warnings", "resolved"}``. ''' root_path = Path(root).expanduser().resolve() errors: list[str] = [] warnings: list[str] = [] kind = str(body.get("kind") or "").strip() if kind not in VS_DESIGN_KINDS: errors.append(f"kind must be one of {VS_DESIGN_KINDS}, got {kind!r}.") receptor = _required_path(root_path, body, "receptor", errors, warnings, _RECEPTOR_EXTENSIONS) ligand = _required_path(root_path, body, "ligand", errors, warnings, _LIGAND_EXTENSIONS) box = _required_path(root_path, body, "box", errors, warnings, _BOX_EXTENSIONS) resolved: dict[str, Any] = { "kind": kind or None, "receptor": str(receptor) if receptor else None, "ligand": str(ligand) if ligand else None, "box": str(box) if box else None, } if kind == "vs": engine = str(body.get("engine") or DEFAULT_VS_ENGINE).strip().lower() if engine not in VALID_DOCKING_ENGINES: errors.append(f"Unknown docking engine {engine!r}. Expected one of: {sorted(VALID_DOCKING_ENGINES)}.") resolved["engine"] = engine elif kind == "pipeline": engines = _resolve_docking_engines(body.get("engines"), errors) or DEFAULT_PIPELINE_ENGINES rescoring_engines = _resolve_rescoring_engines(body.get("rescoring_engines"), errors) resolved["engines"] = list(engines) resolved["rescoring_engines"] = list(rescoring_engines) if rescoring_engines else None cluster_min = body.get("cluster_min") cluster_max = body.get("cluster_max") if cluster_min is not None and cluster_max is not None: try: if float(cluster_min) >= float(cluster_max): errors.append("cluster_min must be less than cluster_max.") except (TypeError, ValueError): errors.append("cluster_min and cluster_max must be numeric.") return {"valid": not errors, "errors": errors, "warnings": warnings, "resolved": resolved}
[docs] def plan_vs_design(root: str | Path, body: dict[str, Any]) -> dict[str, Any]: '''Build the exact ``ocdocker vs``/``pipeline`` argv for a valid draft. Parameters ---------- root : str or pathlib.Path Served Workbench root, used to resolve relative paths. body : dict[str, Any] Same draft shape as :func:`preview_vs_design`, plus optional ``all_boxes``, ``name``, ``outdir``, ``timeout``, ``store_db``, and (``vs`` only) ``skip_rescore``/``skip_split``, or (``pipeline`` only) ``cluster_min``/``cluster_max``/``cluster_step``/``strict_engines``. Returns ------- dict[str, Any] ``{"kind", "args", "cwd", "shell_command"}`` — ``args`` and ``kind`` are ready to pass directly to :meth:`OCDocker.Workbench.Jobs.JobManager.launch` (or the ``run_job``/``plan_job`` API and MCP tools). Raises ------ ValueError If the draft is not valid (call :func:`preview_vs_design` first). ''' preview = preview_vs_design(root, body) if not preview["valid"]: raise ValueError("Cannot plan an invalid VS design: " + "; ".join(preview["errors"])) resolved = preview["resolved"] kind = resolved["kind"] args: list[str] = ["--receptor", resolved["receptor"], "--ligand", resolved["ligand"], "--box", resolved["box"]] if kind == "vs": args.extend(["--engine", resolved["engine"]]) if body.get("skip_rescore"): args.append("--skip-rescore") if body.get("skip_split"): args.append("--skip-split") else: args.extend(["--engines", ",".join(resolved["engines"])]) if resolved["rescoring_engines"]: args.extend(["--rescoring-engines", ",".join(resolved["rescoring_engines"])]) if body.get("cluster_min") is not None: args.extend(["--cluster-min", str(body["cluster_min"])]) if body.get("cluster_max") is not None: args.extend(["--cluster-max", str(body["cluster_max"])]) if body.get("cluster_step") is not None: args.extend(["--cluster-step", str(body["cluster_step"])]) if body.get("strict_engines"): args.append("--strict-engines") if body.get("all_boxes"): args.append("--all-boxes") if body.get("name"): args.extend(["--name", str(body["name"])]) if body.get("outdir"): args.extend(["--outdir", str(body["outdir"])]) if body.get("timeout") is not None: args.extend(["--timeout", str(body["timeout"])]) if body.get("store_db"): args.append("--store-db") cwd = str(body.get("cwd")) if body.get("cwd") else None shell_command = " ".join(shlex.quote(part) for part in ("ocdocker", kind, *args)) return {"kind": kind, "args": args, "cwd": cwd, "shell_command": shell_command}
[docs] def discover_vs_campaign_candidates( root: str | Path, *, input_dir: str | Path | None = None, sample_scan_depth: int = DEFAULT_VS_CAMPAIGN_SAMPLE_SCAN_DEPTH, ) -> dict[str, Any]: '''Discover a draft multi-sample manifest from an ``input/{sample}/...`` layout. Matches the convention used by ``examples/19_Snakefile_ocdocker_pipeline.smk`` and ``examples/20_Snakefile_ocdocker_granular_pipeline.smk``: one subdirectory per sample directly under the scan root, each containing a receptor/ligand/box file. Best-effort — a workspace not organized this way yields an empty manifest with an explanatory issue, not an error; the caller can still hand-author a manifest directly. Parameters ---------- root : str or pathlib.Path Served Workbench root. input_dir : str, pathlib.Path, or None Optional subdirectory to scan instead of the whole served root (typically an ``input/`` directory containing one folder per sample). When omitted, ``root/input`` is used automatically if present, otherwise ``root`` itself. sample_scan_depth : int Maximum directory depth below each sample directory to descend while looking for its receptor/ligand/box files. Returns ------- dict[str, Any] JSON-safe payload: ``manifest`` (list of draft rows), ``scan_root``, ``issues``. ''' if input_dir: scan_root = Path(input_dir).expanduser().resolve() else: root_path = Path(root).expanduser().resolve() default_input_dir = root_path / "input" scan_root = default_input_dir if default_input_dir.is_dir() else root_path issues: list[str] = [] if not scan_root.is_dir(): issues.append(f"Scan root does not exist or is not a directory: {scan_root}") return {"manifest": [], "scan_root": str(scan_root), "issues": issues} manifest: list[dict[str, Any]] = [] sample_dirs = sorted( (child for child in scan_root.iterdir() if child.is_dir() and not _is_hidden(child)), key=lambda child: child.name, ) for sample_dir in sample_dirs: receptor = ligand = box = None for path in _iter_candidate_files(sample_dir, sample_scan_depth): if box is None and _looks_like_box(path): box = path elif receptor is None and _looks_like_receptor(path): receptor = path elif ligand is None and _looks_like_ligand(path): ligand = path if receptor and ligand and box: manifest.append({ "sample": sample_dir.name, "row_kind": "vs", "receptor": str(receptor), "ligand": str(ligand), "box": str(box), "engines": [DEFAULT_VS_ENGINE], }) else: missing = [name for name, value in (("receptor", receptor), ("ligand", ligand), ("box", box)) if value is None] issues.append(f"Sample {sample_dir.name!r} is missing {', '.join(missing)} — skipped.") if not manifest: issues.append("No complete sample directories (receptor + ligand + box) found under the scanned root.") return {"manifest": manifest, "scan_root": str(scan_root), "issues": issues}
[docs] def preview_vs_campaign(root: str | Path, body: dict[str, Any]) -> dict[str, Any]: '''Validate a draft multi-sample VS campaign manifest without running anything. Parameters ---------- root : str or pathlib.Path Served Workbench root, used to resolve relative paths. body : dict[str, Any] Draft campaign: ``manifest`` — a non-empty list of rows, each shaped like :func:`discover_vs_campaign_candidates`'s output rows (``sample``, ``row_kind``, ``receptor``, ``ligand``, ``box``, ``engines``, optional ``rescoring_engines``). Returns ------- dict[str, Any] ``{"valid", "errors", "warnings", "resolved": {"rows": [...]}}``. ''' root_path = Path(root).expanduser().resolve() manifest = body.get("manifest") or [] errors: list[str] = [] warnings: list[str] = [] resolved_rows: list[dict[str, Any]] = [] if not manifest: errors.append("manifest must contain at least one row.") seen_samples: set[str] = set() for index, row in enumerate(manifest, start=1): row_errors, row_warnings, resolved_row = _validate_campaign_row(root_path, row, index) errors.extend(row_errors) warnings.extend(row_warnings) resolved_rows.append(resolved_row) sample = resolved_row["sample"] if sample in seen_samples: errors.append(f"Duplicate sample name: {sample!r}.") seen_samples.add(sample) return {"valid": not errors, "errors": errors, "warnings": warnings, "resolved": {"rows": resolved_rows}}
[docs] def plan_vs_campaign(root: str | Path, body: dict[str, Any]) -> dict[str, Any]: '''Build the ``vs_campaign`` job payload for a valid draft manifest. Parameters ---------- root : str or pathlib.Path Served Workbench root, used to resolve relative paths. body : dict[str, Any] Same ``manifest`` shape as :func:`preview_vs_campaign`, plus optional common settings: ``engine`` (``"shell"`` default, or ``"snakemake"`` for real DAG orchestration — see :func:`OCDocker.Workbench.Jobs.build_campaign_snakemake_command`), ``cores`` (``engine="snakemake"`` only, default :data:`OCDocker.Workbench.Jobs.DEFAULT_CAMPAIGN_CORES`), ``outdir`` (shared base output directory — every row still writes to its own ``<outdir>/<sample>``, never a single shared directory), ``timeout``, ``store_db``, and ``cwd`` (the campaign job's working directory). Returns ------- dict[str, Any] ``{"kind": "vs_campaign", "engine", "manifest", "args", "cwd", "shell_command"}`` (plus ``"cores"`` for ``engine="snakemake"``) — ready to pass directly to :meth:`OCDocker.Workbench.Jobs.JobManager.launch` (or the ``run_job``/``plan_job`` API and MCP tools). Raises ------ ValueError If the draft is not valid (call :func:`preview_vs_campaign` first) or ``engine`` is unrecognized. ''' preview = preview_vs_campaign(root, body) if not preview["valid"]: raise ValueError("Cannot plan an invalid VS campaign: " + "; ".join(preview["errors"])) engine = str(body.get("engine") or DEFAULT_CAMPAIGN_ENGINE) if engine not in CAMPAIGN_ENGINES: raise ValueError(f"Unknown VS campaign engine {engine!r}. Expected one of: {CAMPAIGN_ENGINES}.") rows = preview["resolved"]["rows"] args: list[str] = [] if body.get("timeout") is not None: args.extend(["--timeout", str(body["timeout"])]) if body.get("store_db"): args.append("--store-db") results_dir = str(body["outdir"]) if body.get("outdir") else None cwd = str(body.get("cwd")) if body.get("cwd") else None if engine == "snakemake": cores = int(body.get("cores") or DEFAULT_CAMPAIGN_CORES) command = build_campaign_snakemake_command(rows, args, cores=cores, results_dir=results_dir) shell_command = " ".join(shlex.quote(part) for part in command) return { "kind": "vs_campaign", "engine": engine, "manifest": rows, "args": args, "cores": cores, "results_dir": results_dir, "cwd": cwd, "shell_command": shell_command, } shell_command = build_campaign_script(rows, args, results_dir=results_dir) return { "kind": "vs_campaign", "engine": engine, "manifest": rows, "args": args, "results_dir": results_dir, "cwd": cwd, "shell_command": shell_command, }
__all__ = [ "DEFAULT_PIPELINE_ENGINES", "DEFAULT_VS_CAMPAIGN_SAMPLE_SCAN_DEPTH", "DEFAULT_VS_DESIGN_SCAN_DEPTH", "DEFAULT_VS_ENGINE", "VS_DESIGN_KINDS", "discover_vs_campaign_candidates", "discover_vs_design_candidates", "plan_vs_campaign", "plan_vs_design", "preview_vs_campaign", "preview_vs_design", ]