From 4c45399e9cde2bc4afb2737301479c32b5804cc3 Mon Sep 17 00:00:00 2001 From: Shehab Yasser Date: Mon, 6 Jul 2026 07:51:47 +0300 Subject: [PATCH] refactor(harbor): split Mode A / Mode B into discriminated-union configs (supersedes #20) BuildConfig and ServeConfig become discriminated unions on `mode`: a shared extra="forbid" base plus per-mode subclasses (BuildConfigA/B, ServeConfigA/B). A Mode-A config that sets a Mode-B-only field (feedback_transcripts, expose_attempt_detail, instruct_multifidelity, feedback_max_bytes, harbor, partition, inner_task) or a Mode-B config that sets a Mode-A-only field (sample_timeout, task, task_project, task_module, dataset) is now a load-time ValidationError instead of a silently-ignored no-op. A discriminated union is not a class, so the BuildConfig.from_file and ServeConfig.from_file classmethods become module-level loaders (load_build_config, load_serve_config) built on pydantic TypeAdapter, keeping the relative-path resolution and defaulting mode to "A" when omitted. Call sites in the CLI, compiler, and serve entrypoint updated; _serve_config and build_components narrow by isinstance / mode. Deletes PR #20's _warn_mode_b_sample_timeout and _warn_mode_a_ignores_feedback_levers (plus the compiler's Mode-A feedback-lever warning) and their tests: under the type split those conditions are structurally impossible. Co-Authored-By: Claude Opus 4.8 (1M context) --- vero/src/vero/harbor/build/__init__.py | 15 +- vero/src/vero/harbor/build/compiler.py | 104 ++++++------ vero/src/vero/harbor/build/config.py | 136 ++++++++++------ vero/src/vero/harbor/cli.py | 8 +- vero/src/vero/harbor/serve.py | 157 +++++++++--------- vero/tests/test_harbor_app.py | 4 +- vero/tests/test_harbor_build.py | 212 +++++++++++++++++-------- vero/tests/test_harbor_serve.py | 71 +++++---- 8 files changed, 426 insertions(+), 281 deletions(-) diff --git a/vero/src/vero/harbor/build/__init__.py b/vero/src/vero/harbor/build/__init__.py index 17711fd..f48f170 100644 --- a/vero/src/vero/harbor/build/__init__.py +++ b/vero/src/vero/harbor/build/__init__.py @@ -1,6 +1,17 @@ """The `vero harbor build` compiler: BuildConfig -> a runnable Harbor task dir.""" from vero.harbor.build.compiler import compile_task -from vero.harbor.build.config import BuildConfig +from vero.harbor.build.config import ( + BuildConfig, + BuildConfigA, + BuildConfigB, + load_build_config, +) -__all__ = ["BuildConfig", "compile_task"] +__all__ = [ + "BuildConfig", + "BuildConfigA", + "BuildConfigB", + "compile_task", + "load_build_config", +] diff --git a/vero/src/vero/harbor/build/compiler.py b/vero/src/vero/harbor/build/compiler.py index b91f02f..7d1605a 100644 --- a/vero/src/vero/harbor/build/compiler.py +++ b/vero/src/vero/harbor/build/compiler.py @@ -18,7 +18,7 @@ from jinja2 import Environment, FileSystemLoader from vero.evaluation.engine import EvalRequest -from vero.harbor.build.config import BuildConfig +from vero.harbor.build.config import BuildConfigA, BuildConfigB from vero.harbor.protocol import StatusSummary logger = logging.getLogger(__name__) @@ -210,24 +210,14 @@ def _validate_partition_names( ) -def _serve_config(config: BuildConfig, dataset_id: str | None, base_commit: str) -> dict: - harbor = None - if config.harbor is not None: - # Local inner task -> baked sidecar-only path; registry ref -> pass through. - harbor = {**config.harbor} - if config.inner_task: - harbor["task_source"] = INNER_TASK - targets = [ - { - "task": config.task, - "dataset_id": dataset_id, - "split": t.split, - "reward_key": t.reward_key, - "sample_ids": t.sample_ids, - } - for t in config.targets - ] - return { +def _serve_config( + config: BuildConfigA | BuildConfigB, dataset_id: str | None, base_commit: str +) -> dict: + # Fields common to both ServeConfig variants. Mode-specific keys are added + # below so the wrong-mode key never reaches the (extra="forbid") ServeConfig. + task = config.task if isinstance(config, BuildConfigA) else None + common = { + "mode": config.mode, "repo_path": AGENT_BASELINE, "agent_repo_path": WORK_AGENT, "session_id": SESSION_ID, @@ -237,33 +227,58 @@ def _serve_config(config: BuildConfig, dataset_id: str | None, base_commit: str) {"split": b.split, "dataset_id": dataset_id, **b.model_dump(exclude={"split"}, exclude_none=True)} for b in config.budgets ], - "task": config.task, - "task_project": config.task_project, - "task_module": config.task_module, - "harbor": harbor, "reward_mode": config.reward_mode, "selection_split": config.selection_split, - "targets": targets, + "targets": [ + { + "task": task, + "dataset_id": dataset_id, + "split": t.split, + "reward_key": t.reward_key, + "sample_ids": t.sample_ids, + } + for t in config.targets + ], "base_commit": base_commit, "submit_enabled": config.submit_enabled, "score_baseline": config.score_baseline, - "feedback_transcripts": config.feedback_transcripts, - "feedback_max_bytes": config.feedback_max_bytes, - "instruct_multifidelity": config.instruct_multifidelity, - "expose_attempt_detail": config.expose_attempt_detail, "agent_volume": AGENT_VOLUME, "admin_volume": ADMIN_VOLUME, "admin_token_path": TOKEN_PATH, "timeout": config.timeout, - "sample_timeout": config.sample_timeout, "max_concurrency": config.max_concurrency, "host": "0.0.0.0", "port": 8000, } + if isinstance(config, BuildConfigA): + return { + **common, + "task": config.task, + "task_project": config.task_project, + "task_module": config.task_module, + "sample_timeout": config.sample_timeout, + } + # Mode B: local inner task -> baked sidecar-only path; registry ref passes through. + harbor = None + if config.harbor is not None: + harbor = {**config.harbor} + if config.inner_task: + harbor["task_source"] = INNER_TASK + return { + **common, + "harbor": harbor, + "feedback_transcripts": config.feedback_transcripts, + "feedback_max_bytes": config.feedback_max_bytes, + "instruct_multifidelity": config.instruct_multifidelity, + "expose_attempt_detail": config.expose_attempt_detail, + } def compile_task( - config: BuildConfig, out_dir: Path | str, *, vero_root: Path | None = None + config: BuildConfigA | BuildConfigB, + out_dir: Path | str, + *, + vero_root: Path | None = None, ) -> Path: """Compile ``config`` into a Harbor task directory at ``out_dir``.""" import json @@ -272,23 +287,10 @@ def compile_task( vero_root = vero_root or PACKAGE_DIR - # Mode A ignores the Mode-B-only feedback levers (they ride the nested - # `harbor run` collation, which Mode A never runs). Warn loudly at build time - # so a config that sets them in Mode A learns they will do nothing, rather - # than silently getting no feedback. - if config.mode == "A": - mode_b_only = [ - n - for n in ("feedback_transcripts", "expose_attempt_detail") - if getattr(config, n) - ] - if mode_b_only: - logger.warning( - "Mode A build sets Mode-B-only lever(s) %s; these have no effect " - "in Mode A (they ride the nested `harbor run` collation) and will " - "be ignored.", - ", ".join(mode_b_only), - ) + # The Mode-A "you set a Mode-B-only lever" warning (and its ServeConfig twin) + # is superseded by the Mode-A / Mode-B type split: a Mode-A config that sets + # feedback_transcripts / expose_attempt_detail is now a load-time + # ValidationError, so the condition is structurally impossible here. out = Path(out_dir) if out.exists(): @@ -315,7 +317,7 @@ def compile_task( # the dataset into vh before the dir is torn down, so cleanup is safe. with tempfile.TemporaryDirectory() as tmp_str: tmp = Path(tmp_str) - if config.mode == "A": + if isinstance(config, BuildConfigA): if not config.dataset: raise ValueError("Mode A requires a dataset.") dataset_id = _register(config.dataset, vh, tmp) @@ -373,7 +375,9 @@ def compile_task( selection_split=config.selection_split, submit_enabled=config.submit_enabled, eval_num_samples=None, - bake_inner_task=bool(config.inner_task), + # inner_task / instruct_multifidelity are Mode-B-only fields; on a + # Mode-A config they do not exist, so read them only when present. + bake_inner_task=bool(getattr(config, "inner_task", None)), # The free-baseline bullet may only render when the sidecar shipping in # this same tree actually grants the free eval; the feature lives on a # different PR chain than the compiler, and an instruction that promises @@ -392,7 +396,7 @@ def compile_task( # sample's exact score, defeating the non_viewable contract. Only a # viewable split is safe to screen with subsets. no_access splits are # not agent-evaluable at all, so they never count either. - multifidelity=config.instruct_multifidelity + multifidelity=getattr(config, "instruct_multifidelity", False) and {"sample_ids", "num_samples"} <= {f.name for f in dataclasses.fields(EvalRequest)} and any(s.access == "viewable" for s in config.splits), diff --git a/vero/src/vero/harbor/build/config.py b/vero/src/vero/harbor/build/config.py index bc79cd2..07520f2 100644 --- a/vero/src/vero/harbor/build/config.py +++ b/vero/src/vero/harbor/build/config.py @@ -3,15 +3,20 @@ Everything the compiler needs to emit a Harbor optimization task. Mode A (vero runs inference + scoring) and Mode B (nested `harbor run`) share one topology; the differences are which extras the sidecar bakes and which secrets it needs. + +The two modes are DISTINCT types discriminated on `mode`: a Mode-A config that +sets a Mode-B-only field (or vice versa) is a load-time ValidationError, not a +silently-ignored no-op. `extra="forbid"` on the shared base plus the per-mode +subclasses means the wrong-mode key is simply unknown to the resolved variant. """ from __future__ import annotations from pathlib import Path -from typing import Literal +from typing import Annotated, Literal import yaml -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter class SplitAccessSpec(BaseModel): @@ -33,12 +38,14 @@ class TargetSpec(BaseModel): sample_ids: list[int] | None = None -class BuildConfig(BaseModel): - """Inputs to `vero harbor build`.""" +class _BuildConfigBase(BaseModel): + """Fields shared by both modes. Not instantiated directly.""" # Reject unknown top-level keys so a mistyped lever fails loudly at load # time instead of silently disabling the feature: pydantic's default is to # ignore extras, which would turn `feeback_transcripts: true` into a no-op. + # Combined with the Mode-A / Mode-B split below, a wrong-mode field is also + # "unknown" to the resolved variant, so it fails the same way. model_config = ConfigDict(extra="forbid") # identity @@ -48,22 +55,6 @@ class BuildConfig(BaseModel): # the target repo the optimizer edits (baseline in main + sidecar) agent_repo: str - # mode A (scoring in vero): task name + dataset (+ optional separate task project) - mode: Literal["A", "B"] = "A" - task: str | None = None - task_project: str | None = None - task_module: str | None = None - dataset: str | None = Field( - default=None, description="Path to a saved DatasetDict (Mode A)." - ) - - # mode B (scoring in nested harbor): HarborConfig kwargs (task_source filled by the - # compiler from inner_task), the {split: [task_names]} partition, and the inner - # Harbor task dir baked sidecar-only (the protected benchmark, mirrors Mode A's dataset). - harbor: dict | None = None - partition: dict[str, list[str]] | None = None - inner_task: str | None = None - # tiers / budget / reward splits: list[SplitAccessSpec] budgets: list[BudgetSpec] = Field(default_factory=list) @@ -75,21 +66,6 @@ class BuildConfig(BaseModel): # write it to /baseline.json, so a candidate that generalizes # WORSE than the untouched repo is visible as a regression. score_baseline: bool = False - # Lever 1 (Mode B): each FAILED sample (reward 0) of an eval carries the - # tail of its trial transcript in the per-sample `feedback` field. Rides - # the per-sample result files the sidecar writes ONLY for viewable splits, - # so it can never surface for non_viewable / no_access tiers. - feedback_transcripts: bool = False - feedback_max_bytes: int = 3000 - # Lever 2: the compiled instruction teaches multi-fidelity screening (triage - # rough ideas on subset evals via num_samples / sample_ids, confirm survivors - # on the full split). Renders only when the sidecar in the same tree actually - # accepts subset evals; see the compiler's ctx gate. - instruct_multifidelity: bool = False - # Lever 3 (Mode B): each sample's output carries an `attempts` list, one - # {reward, exception} entry per attempt. Same viewable-only exposure as - # feedback_transcripts. - expose_attempt_detail: bool = False # write-access: paths in the target repo the optimizer may NOT edit # (the scorer, by default). Applied as unix perms in main before the agent runs. @@ -104,18 +80,82 @@ class BuildConfig(BaseModel): # eval params baked into the ServeConfig timeout: int = 1800 - sample_timeout: int = 300 max_concurrency: int = 8 - @classmethod - def from_file(cls, path: Path | str) -> BuildConfig: - path = Path(path).resolve() - data = yaml.safe_load(path.read_text()) - # Resolve relative local-path fields against the build.yaml's directory, so a - # config is portable regardless of the working directory it's built from. - base = path.parent - for field in ("agent_repo", "dataset", "inner_task"): - val = data.get(field) - if isinstance(val, str) and not Path(val).is_absolute(): - data[field] = str((base / val).resolve()) - return cls.model_validate(data) + +class BuildConfigA(_BuildConfigBase): + """Mode A: vero runs inference + scoring against a saved dataset.""" + + mode: Literal["A"] = "A" + + # task name + dataset (+ optional separate task project) + task: str | None = None + task_project: str | None = None + task_module: str | None = None + dataset: str | None = Field( + default=None, description="Path to a saved DatasetDict (Mode A)." + ) + # Per-sample vero-scoring cap. Mode-A only: Mode B's nested `harbor run` uses + # each task's OWN harbor-configured timeouts, capped only by `timeout`. + sample_timeout: int = 300 + + +class BuildConfigB(_BuildConfigBase): + """Mode B: scoring runs in a nested `harbor run`.""" + + mode: Literal["B"] + + # HarborConfig kwargs (task_source filled by the compiler from inner_task), the + # {split: [task_names]} partition, and the inner Harbor task dir baked + # sidecar-only (the protected benchmark, mirrors Mode A's dataset). + harbor: dict | None = None + partition: dict[str, list[str]] | None = None + inner_task: str | None = None + # Lever 1: each FAILED sample (reward 0) of an eval carries the tail of its + # trial transcript in the per-sample `feedback` field. Rides the per-sample + # result files the sidecar writes ONLY for viewable splits, so it can never + # surface for non_viewable / no_access tiers. + feedback_transcripts: bool = False + feedback_max_bytes: int = 3000 + # Lever 2: the compiled instruction teaches multi-fidelity screening (triage + # rough ideas on subset evals via num_samples / sample_ids, confirm survivors + # on the full split). Renders only when the sidecar in the same tree actually + # accepts subset evals; see the compiler's ctx gate. + instruct_multifidelity: bool = False + # Lever 3: each sample's output carries an `attempts` list, one + # {reward, exception} entry per attempt. Same viewable-only exposure as + # feedback_transcripts. + expose_attempt_detail: bool = False + + +# Discriminated union on `mode`: `vero harbor build` resolves to exactly one +# variant, and a wrong-mode field (e.g. `feedback_transcripts` under mode A) is +# unknown to that variant and rejected by extra="forbid" at load time. +BuildConfig = Annotated[ + BuildConfigA | BuildConfigB, Field(discriminator="mode") +] + +# A discriminated union is not a class, so validation goes through a TypeAdapter. +_BuildConfigAdapter: TypeAdapter[BuildConfigA | BuildConfigB] = TypeAdapter(BuildConfig) + + +def load_build_config(path: Path | str) -> BuildConfigA | BuildConfigB: + """Load and validate a build.yaml into the correct Mode-A / Mode-B variant. + + Replaces the old ``BuildConfig.from_file`` classmethod: the discriminated + union is not a class, so the loader lives at module level. Relative + local-path fields are resolved against the build.yaml's directory, so a + config is portable regardless of the working directory it's built from. + """ + path = Path(path).resolve() + data = yaml.safe_load(path.read_text()) + # `mode` defaults to "A" when omitted, preserving the pre-split behavior. The + # discriminated union needs the tag explicitly, so inject it before validating. + if isinstance(data, dict): + data.setdefault("mode", "A") + base = path.parent + for field in ("agent_repo", "dataset", "inner_task"): + val = data.get(field) + if isinstance(val, str) and not Path(val).is_absolute(): + data[field] = str((base / val).resolve()) + return _BuildConfigAdapter.validate_python(data) diff --git a/vero/src/vero/harbor/cli.py b/vero/src/vero/harbor/cli.py index 11e2f52..c7fa4b1 100644 --- a/vero/src/vero/harbor/cli.py +++ b/vero/src/vero/harbor/cli.py @@ -53,9 +53,9 @@ def serve_cmd(config_path): @click.option("-o", "--out", required=True, help="Output task directory.") def build_cmd(config_path, out): """Compile a build.yaml into a runnable Harbor optimization task directory.""" - from vero.harbor.build import BuildConfig, compile_task + from vero.harbor.build import compile_task, load_build_config - task_dir = compile_task(BuildConfig.from_file(config_path), out) + task_dir = compile_task(load_build_config(config_path), out) click.echo(f"Compiled task -> {task_dir}") @@ -70,9 +70,9 @@ def run_cmd(config_path, agent, model, provider, extra): import subprocess import tempfile - from vero.harbor.build import BuildConfig, compile_task + from vero.harbor.build import compile_task, load_build_config - task_dir = compile_task(BuildConfig.from_file(config_path), Path(tempfile.mkdtemp()) / "task") + task_dir = compile_task(load_build_config(config_path), Path(tempfile.mkdtemp()) / "task") cmd = ["uvx", "harbor", "run", "-p", str(task_dir), "-a", agent, "-e", provider] if model: cmd += ["-m", model] diff --git a/vero/src/vero/harbor/serve.py b/vero/src/vero/harbor/serve.py index 2c11ed5..603b650 100644 --- a/vero/src/vero/harbor/serve.py +++ b/vero/src/vero/harbor/serve.py @@ -11,9 +11,9 @@ import json import logging from pathlib import Path -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter from vero.core.budget import BudgetLedger, SplitBudget from vero.core.dataset.base import SplitAccess, SplitAccessLevel @@ -44,8 +44,14 @@ class _TargetCfg(BaseModel): sample_ids: list[int] | None = None -class ServeConfig(BaseModel): - """Everything the sidecar needs to assemble itself. Baked by the compiler.""" +class _ServeConfigBase(BaseModel): + """Fields shared by both serve modes. The compiled twin of BuildConfig's + shared base. Not instantiated directly.""" + + # extra="forbid" so a wrong-mode key (e.g. Mode-B feedback levers in a Mode-A + # serve.json) is a load-time error rather than a silently-ignored no-op; the + # Mode-A / Mode-B split makes those keys unknown to the resolved variant. + model_config = ConfigDict(extra="forbid") repo_path: str # sidecar's own repo (baseline target) = the engine workspace agent_repo_path: str # mounted agent workspace (commit-transfer source) @@ -54,13 +60,6 @@ class ServeConfig(BaseModel): split_accesses: list[_SplitAccessCfg] budgets: list[dict] # SplitBudget kwargs - # Mode A - task: str | None = None - task_project: str | None = None - task_module: str | None = None - # Mode B - harbor: dict | None = None # HarborConfig kwargs - # selection / reward reward_mode: Literal["submit", "auto_best"] = "auto_best" selection_split: str = "validation" @@ -77,19 +76,6 @@ class ServeConfig(BaseModel): # auto_best never ships a candidate that fails to beat the untouched baseline # on the selection split; it reverts to base_commit instead (needs base_commit). auto_best_baseline_floor: bool = True - # Lever 1 (Mode B): failed samples carry their trial-transcript tail in the - # per-sample `feedback` field. Exposure stays gated by the sidecar's tier - # routing (per-sample files are written only for viewable splits). - feedback_transcripts: bool = False - feedback_max_bytes: int = 3000 - # Lever 3 (Mode B): sample output carries a per-attempt {reward, exception} - # list. Same viewable-only exposure path as feedback_transcripts. - expose_attempt_detail: bool = False - # Lever 2: consumed at COMPILE time (the instruction's multi-fidelity - # section); recorded here so serve.json mirrors build.yaml. The sidecar's - # subset-eval support itself is unconditional (EvalRequest.num_samples / - # sample_ids), so there is nothing to toggle at serve time. - instruct_multifidelity: bool = False # volumes / token agent_volume: str @@ -98,16 +84,61 @@ class ServeConfig(BaseModel): # eval params timeout: int = 600 - sample_timeout: int = 180 max_concurrency: int = 20 use_copy: bool = True # isolate each eval in a temp copy (clean tree, concurrency-safe) host: str = "0.0.0.0" port: int = 8000 - @classmethod - def from_file(cls, path: Path | str) -> ServeConfig: - return cls.model_validate_json(Path(path).read_text()) + +class ServeConfigA(_ServeConfigBase): + """Mode A: vero runs inference + scoring.""" + + mode: Literal["A"] = "A" + + task: str | None = None + task_project: str | None = None + task_module: str | None = None + # Per-sample vero-scoring cap. Mode-A only (Mode B uses each nested task's + # own harbor-configured timeouts, capped only by `timeout`). + sample_timeout: int = 180 + + +class ServeConfigB(_ServeConfigBase): + """Mode B: scoring runs in a nested `harbor run`.""" + + mode: Literal["B"] + + harbor: dict | None = None # HarborConfig kwargs + # Lever 1: failed samples carry their trial-transcript tail in the per-sample + # `feedback` field. Exposure stays gated by the sidecar's tier routing + # (per-sample files are written only for viewable splits). + feedback_transcripts: bool = False + feedback_max_bytes: int = 3000 + # Lever 3: sample output carries a per-attempt {reward, exception} list. Same + # viewable-only exposure path as feedback_transcripts. + expose_attempt_detail: bool = False + # Lever 2: consumed at COMPILE time (the instruction's multi-fidelity + # section); recorded here so serve.json mirrors build.yaml. The sidecar's + # subset-eval support itself is unconditional (EvalRequest.num_samples / + # sample_ids), so there is nothing to toggle at serve time. + instruct_multifidelity: bool = False + + +# Discriminated union on `mode`, the compiled twin of BuildConfig. +ServeConfig = Annotated[ServeConfigA | ServeConfigB, Field(discriminator="mode")] + +_ServeConfigAdapter: TypeAdapter[ServeConfigA | ServeConfigB] = TypeAdapter(ServeConfig) + + +def load_serve_config(path: Path | str) -> ServeConfigA | ServeConfigB: + """Load and validate a serve.json into the correct Mode-A / Mode-B variant.""" + data = json.loads(Path(path).read_text()) + # `mode` defaults to "A" when absent (older serve.json predates the tag). The + # discriminated union needs the tag explicitly, so inject it before validating. + if isinstance(data, dict): + data.setdefault("mode", "A") + return _ServeConfigAdapter.validate_python(data) def _load_or_build_ledger( @@ -155,45 +186,15 @@ def _load_or_build_ledger( ) -def _warn_mode_b_sample_timeout(config: ServeConfig) -> None: - """sample_timeout only governs Mode A (per-sample vero scoring). In Mode B - the nested `harbor run` applies each task's OWN harbor-configured timeouts; - the only vero-side cap is `timeout` on the whole nested run. An author who - set sample_timeout expecting a per-task cap would silently get none: say so. - """ - if config.harbor is not None and "sample_timeout" in config.model_fields_set: - logger.warning( - "sample_timeout=%s is not enforced in Mode B: nested `harbor run` " - "tasks use their harbor-configured timeouts (tune via " - "harbor.extra_args, e.g. --agent-timeout-multiplier); only " - "`timeout` (%ss) caps the whole nested run.", - config.sample_timeout, - config.timeout, - ) - - -def _warn_mode_a_ignores_feedback_levers(config: ServeConfig) -> None: - """The transcript-feedback / attempt-detail levers ride the Mode-B nested - `harbor run` collation (HarborRunner). Mode A (config.harbor is None) never - builds a HarborRunner, so these do nothing there; say so rather than let an - author think feedback is on. - """ - if config.harbor is not None: - return - mode_b_only = [ - n - for n in ("feedback_transcripts", "expose_attempt_detail") - if getattr(config, n) - ] - if mode_b_only: - logger.warning( - "Mode A serve config sets Mode-B-only lever(s) %s; these have no " - "effect in Mode A (no nested `harbor run`) and will be ignored.", - ", ".join(mode_b_only), - ) +# PR #20's _warn_mode_b_sample_timeout and _warn_mode_a_ignores_feedback_levers +# are superseded by the Mode-A / Mode-B type split: sample_timeout no longer +# exists on Mode B, and the feedback levers no longer exist on Mode A, so both +# warned-about conditions are structurally impossible (a ValidationError at load). -async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Verifier, str]: +async def build_components( + config: ServeConfigA | ServeConfigB, +) -> tuple[EvaluationSidecar, Verifier, str]: """Assemble the sidecar + verifier (sharing one engine) and the admin token.""" vero_home = get_vero_home_dir() @@ -202,25 +203,22 @@ async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Veri # discovered from the AGENT's committed repo, so a committed scorer returning # 1.0 would win the hidden-split/admin reward. Require a sidecar-baked task # project so the scorer is trusted (agent code is layered as --with-editable, - # never the scorer's source). Mode B (config.harbor set) uses an eval_strategy - # that ignores the vero scorer and is exempt. - if config.harbor is None and not config.task_project: + # never the scorer's source). Mode B uses an eval_strategy that ignores the + # vero scorer and is exempt. + if isinstance(config, ServeConfigA) and not config.task_project: raise ValueError( "Mode A requires `task_project` so the scorer is loaded from the " "sidecar-baked task project, not the agent's committed repo. Refusing " "to start: with task_project unset the agent controls its own scoring." ) - _warn_mode_b_sample_timeout(config) - _warn_mode_a_ignores_feedback_levers(config) - workspace = await GitWorkspace.create(config.repo_path) persist_path = Path(config.admin_volume) / "ledger.json" budget = _load_or_build_ledger(config.budgets, persist_path) eval_strategy = None - if config.harbor is not None: + if isinstance(config, ServeConfigB) and config.harbor is not None: from vero.harbor.runner import HarborRunner from vero.harbor.config import HarborConfig @@ -231,13 +229,14 @@ async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Veri expose_attempt_detail=config.expose_attempt_detail, ) + is_mode_a = isinstance(config, ServeConfigA) evaluator = Evaluator( workspace, config.session_id, vero_home=vero_home, use_copy=config.use_copy, - task_project=Path(config.task_project) if config.task_project else None, - task_module=config.task_module, + task_project=Path(config.task_project) if is_mode_a and config.task_project else None, + task_module=config.task_module if is_mode_a else None, eval_strategy=eval_strategy, ) @@ -245,11 +244,11 @@ async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Veri engine = EvaluationEngine( evaluator=evaluator, budget=budget, - default_task=config.task, + default_task=config.task if is_mode_a else None, db=db, run_constraints=BaseEvaluationParameters( timeout=config.timeout, - sample_timeout=config.sample_timeout, + sample_timeout=config.sample_timeout if is_mode_a else config.timeout, max_concurrency=config.max_concurrency, ), session_id=config.session_id, @@ -276,7 +275,7 @@ async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Veri targets=[VerificationTarget(**t.model_dump()) for t in config.targets], selection_split=config.selection_split, base_commit=config.base_commit, - selection_task=config.task, + selection_task=config.task if is_mode_a else None, selection_dataset_id=config.dataset_id, score_baseline=config.score_baseline, baseline_score_attempts=config.baseline_score_attempts, @@ -288,7 +287,7 @@ async def build_components(config: ServeConfig) -> tuple[EvaluationSidecar, Veri return sidecar, verifier, token -async def build_app(config: ServeConfig): +async def build_app(config: ServeConfigA | ServeConfigB): sidecar, verifier, token = await build_components(config) return create_app(sidecar=sidecar, verifier=verifier, admin_token=token) @@ -299,7 +298,7 @@ def serve(config_path: Path | str) -> None: import uvicorn - config = ServeConfig.from_file(config_path) + config = load_serve_config(config_path) app = asyncio.run(build_app(config)) logger.info(f"Serving eval sidecar on {config.host}:{config.port}") uvicorn.run(app, host=config.host, port=config.port) diff --git a/vero/tests/test_harbor_app.py b/vero/tests/test_harbor_app.py index 792b2e8..2dba69b 100644 --- a/vero/tests/test_harbor_app.py +++ b/vero/tests/test_harbor_app.py @@ -95,9 +95,9 @@ class TestServeIntegrityGuards: async def test_mode_a_without_task_project_rejected(self, tmp_path): # Mode A (no `harbor`) with task_project unset would load the scorer from # the agent repo. build_components must refuse to start. - from vero.harbor.serve import ServeConfig, build_components + from vero.harbor.serve import ServeConfigA, build_components - cfg = ServeConfig( + cfg = ServeConfigA( repo_path=str(tmp_path / "repo"), agent_repo_path=str(tmp_path / "agent"), session_id="s", diff --git a/vero/tests/test_harbor_build.py b/vero/tests/test_harbor_build.py index 45908ef..2efdeb2 100644 --- a/vero/tests/test_harbor_build.py +++ b/vero/tests/test_harbor_build.py @@ -14,9 +14,17 @@ import yaml from vero.evaluation.engine import EvalRequest -from vero.harbor.build import BuildConfig, compile_task +from vero.harbor.build import BuildConfigA, BuildConfigB, compile_task, load_build_config +from vero.harbor.build.config import _BuildConfigAdapter from vero.harbor.protocol import StatusSummary -from vero.harbor.serve import ServeConfig +from vero.harbor.serve import load_serve_config + + +def _validate_build(data: dict) -> BuildConfigA | BuildConfigB: + """Validate a raw build.yaml dict through the discriminated union, defaulting + `mode` to "A" the way load_build_config does.""" + data.setdefault("mode", "A") + return _BuildConfigAdapter.validate_python(data) # Whether the sidecar in THIS tree grants the budget-free first baseline eval. # The feature and the compiler live on different PR chains; the instruction @@ -75,11 +83,10 @@ def _dataset(root: Path) -> Path: @pytest.fixture def built(tmp_path, monkeypatch): monkeypatch.setenv("VERO_SKIP_SECRET_CHECK", "1") - config = BuildConfig( + config = BuildConfigA( name="vero/gsm8k-opt", description="optimize gsm8k", agent_repo=str(_agent_repo(tmp_path)), - mode="A", task="gsm8k", task_module="gsm8k_agent.vero_tasks", dataset=str(_dataset(tmp_path)), @@ -98,6 +105,36 @@ def built(tmp_path, monkeypatch): return out +def _inner_task(root: Path) -> Path: + """A minimal local Harbor task dir (has task.toml) so Mode B compiles offline: + a local inner_task skips the registry partition-name enumeration.""" + d = root / "inner-task" + d.mkdir(parents=True) + (d / "task.toml").write_text('[metadata]\nname = "org/task-a"\n') + return d + + +@pytest.fixture +def built_b(tmp_path, monkeypatch): + """A compiled Mode-B task, for the Mode-B-only lever round-trips.""" + monkeypatch.setenv("VERO_SKIP_SECRET_CHECK", "1") + config = BuildConfigB( + mode="B", + name="vero/harbor-opt", + description="optimize a harbor bench", + agent_repo=str(_agent_repo(tmp_path)), + harbor={"agent_import_path": "a:C"}, + partition={"train": ["org/task-a"]}, + inner_task=str(_inner_task(tmp_path)), + splits=[{"split": "train", "access": "viewable"}], + budgets=[{"split": "train", "total_run_budget": 5}], + selection_split="train", + secrets=["OPENAI_API_KEY"], + ) + out = compile_task(config, tmp_path / "task", vero_root=_stub_vero(tmp_path)) + return out + + def test_structure(built): for rel in [ "task.toml", "instruction.md", @@ -111,7 +148,7 @@ def test_structure(built): def test_serve_config_validates(built): - cfg = ServeConfig.from_file(built / "environment" / "sidecar" / "serve.json") + cfg = load_serve_config(built / "environment" / "sidecar" / "serve.json") assert cfg.repo_path == "/opt/agent-baseline" assert cfg.agent_repo_path == "/work/agent" assert cfg.task == "gsm8k" @@ -133,7 +170,7 @@ def test_score_baseline_true_emitted(): # the headline claim "reachable from build.yaml" is what is tested. from vero.harbor.build.compiler import _serve_config - config = BuildConfig.model_validate(yaml.safe_load( + config = _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" "splits:\n" @@ -149,10 +186,9 @@ def test_score_baseline_true_through_compile_task(tmp_path, monkeypatch): # Full pipeline: a True value must survive compile_task into the written # serve.json, not just the _serve_config helper. monkeypatch.setenv("VERO_SKIP_SECRET_CHECK", "1") - config = BuildConfig( + config = BuildConfigA( name="vero/gsm8k-opt", agent_repo=str(_agent_repo(tmp_path)), - mode="A", task="gsm8k", dataset=str(_dataset(tmp_path)), splits=[{"split": "validation", "access": "non_viewable"}], @@ -163,24 +199,36 @@ def test_score_baseline_true_through_compile_task(tmp_path, monkeypatch): assert raw["score_baseline"] is True -def test_feedback_transcripts_reach_serve_json(built): - # Lever 1 plumbing: the flags must be in the compiler <-> serve contract - # (default off) and validate through ServeConfig. - raw = json.loads((built / "environment" / "sidecar" / "serve.json").read_text()) +def test_feedback_transcripts_reach_serve_json(built_b): + # Lever 1 plumbing (Mode B): the flags must be in the compiler <-> serve + # contract (default off) and validate through ServeConfig. + raw = json.loads((built_b / "environment" / "sidecar" / "serve.json").read_text()) assert raw["feedback_transcripts"] is False assert raw["feedback_max_bytes"] == 3000 - cfg = ServeConfig.from_file(built / "environment" / "sidecar" / "serve.json") + cfg = load_serve_config(built_b / "environment" / "sidecar" / "serve.json") assert cfg.feedback_transcripts is False assert cfg.feedback_max_bytes == 3000 +def test_mode_a_serve_json_omits_mode_b_levers(built): + # The type split means a Mode-A serve.json carries no Mode-B-only keys at all + # (rather than carrying them at a silently-ignored default). + raw = json.loads((built / "environment" / "sidecar" / "serve.json").read_text()) + assert raw["mode"] == "A" + for k in ("feedback_transcripts", "feedback_max_bytes", + "expose_attempt_detail", "instruct_multifidelity", "harbor"): + assert k not in raw + + def test_feedback_transcripts_configured_through_yaml(): - # Through the actual YAML path, mirroring the score_baseline exemplar. + # Through the actual YAML path, mirroring the score_baseline exemplar. A + # Mode-B-only lever, so the config declares mode: B. from vero.harbor.build.compiler import _serve_config - config = BuildConfig.model_validate(yaml.safe_load( + config = _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" + "mode: B\n" "splits:\n" " - {split: validation, access: viewable}\n" "feedback_transcripts: true\n" @@ -191,19 +239,20 @@ def test_feedback_transcripts_configured_through_yaml(): assert raw["feedback_max_bytes"] == 512 -def test_expose_attempt_detail_reaches_serve_json(built): - raw = json.loads((built / "environment" / "sidecar" / "serve.json").read_text()) +def test_expose_attempt_detail_reaches_serve_json(built_b): + raw = json.loads((built_b / "environment" / "sidecar" / "serve.json").read_text()) assert raw["expose_attempt_detail"] is False # default off - cfg = ServeConfig.from_file(built / "environment" / "sidecar" / "serve.json") + cfg = load_serve_config(built_b / "environment" / "sidecar" / "serve.json") assert cfg.expose_attempt_detail is False def test_expose_attempt_detail_configured_through_yaml(): from vero.harbor.build.compiler import _serve_config - config = BuildConfig.model_validate(yaml.safe_load( + config = _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" + "mode: B\n" "splits:\n" " - {split: validation, access: viewable}\n" "expose_attempt_detail: true\n" @@ -249,8 +298,8 @@ def test_baseline_archive_failure_raises(tmp_path, monkeypatch): (bad / "src").mkdir(parents=True) (bad / "pyproject.toml").write_text("[project]\nname='x'\nversion='0'\n") subprocess.run(["git", "init", "-q"], cwd=bad, check=True) - config = BuildConfig( - name="vero/x", agent_repo=str(bad), mode="A", task="gsm8k", + config = BuildConfigA( + name="vero/x", agent_repo=str(bad), task="gsm8k", dataset=str(_dataset(tmp_path)), splits=[{"split": "validation", "access": "non_viewable"}], ) @@ -261,8 +310,8 @@ def test_baseline_archive_failure_raises(tmp_path, monkeypatch): def test_missing_secret_fails_build(tmp_path, monkeypatch): monkeypatch.delenv("VERO_SKIP_SECRET_CHECK", raising=False) monkeypatch.delenv("DEFINITELY_MISSING_SECRET", raising=False) - config = BuildConfig( - name="vero/x", agent_repo=str(_agent_repo(tmp_path)), mode="A", task="gsm8k", + config = BuildConfigA( + name="vero/x", agent_repo=str(_agent_repo(tmp_path)), task="gsm8k", dataset=str(_dataset(tmp_path)), splits=[{"split": "validation", "access": "non_viewable"}], secrets=["DEFINITELY_MISSING_SECRET"], @@ -314,19 +363,22 @@ def test_instruction_omits_free_baseline_claim_when_unsupported(built): assert "budget-free" not in text -def _multifidelity_config_with_splits(tmp_path, splits) -> BuildConfig: - return BuildConfig( - name="vero/gsm8k-opt", +def _multifidelity_config_with_splits(tmp_path, splits) -> BuildConfigB: + # instruct_multifidelity is a Mode-B-only lever, so the multi-fidelity gate is + # exercised through a Mode-B config (local inner_task so it compiles offline). + return BuildConfigB( + mode="B", + name="vero/harbor-opt", agent_repo=str(_agent_repo(tmp_path)), - mode="A", - task="gsm8k", - dataset=str(_dataset(tmp_path)), + harbor={"agent_import_path": "a:C"}, + partition={s["split"]: ["org/task-a"] for s in splits}, + inner_task=str(_inner_task(tmp_path)), splits=splits, instruct_multifidelity=True, ) -def _multifidelity_config(tmp_path) -> BuildConfig: +def _multifidelity_config(tmp_path) -> BuildConfigB: # Includes a viewable split so the section renders: multi-fidelity is gated # on a viewable evaluable split existing (subset evals on a non_viewable # split would leak per-sample scores; see the compiler ctx gate). @@ -435,20 +487,21 @@ class _LegacyEvalRequest: assert "Screen cheaply" not in text -def test_instruct_multifidelity_reaches_serve_json(built): - raw = json.loads((built / "environment" / "sidecar" / "serve.json").read_text()) +def test_instruct_multifidelity_reaches_serve_json(built_b): + raw = json.loads((built_b / "environment" / "sidecar" / "serve.json").read_text()) assert raw["instruct_multifidelity"] is False # default off - assert ServeConfig.from_file( - built / "environment" / "sidecar" / "serve.json" + assert load_serve_config( + built_b / "environment" / "sidecar" / "serve.json" ).instruct_multifidelity is False def test_instruct_multifidelity_configured_through_yaml(): from vero.harbor.build.compiler import _serve_config - config = BuildConfig.model_validate(yaml.safe_load( + config = _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" + "mode: B\n" "splits:\n" " - {split: validation, access: non_viewable}\n" "instruct_multifidelity: true\n" @@ -473,10 +526,9 @@ def test_submit_mode_instruction_has_no_baseline_warning(tmp_path, monkeypatch): # mode-agnostic (metering does not depend on the selection mode) and must # survive in both. monkeypatch.setenv("VERO_SKIP_SECRET_CHECK", "1") - config = BuildConfig( + config = BuildConfigA( name="vero/gsm8k-opt", agent_repo=str(_agent_repo(tmp_path)), - mode="A", task="gsm8k", dataset=str(_dataset(tmp_path)), splits=[{"split": "validation", "access": "non_viewable"}], @@ -565,18 +617,20 @@ def test_typo_lever_key_rejected(self): from pydantic import ValidationError with pytest.raises(ValidationError): - BuildConfig.model_validate(yaml.safe_load( + _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" + "mode: B\n" "splits:\n" " - {split: validation, access: non_viewable}\n" "feeback_transcripts: true\n" # typo: feeback (missing 'd') )) def test_known_keys_still_accepted(self): - cfg = BuildConfig.model_validate(yaml.safe_load( + cfg = _validate_build(yaml.safe_load( "name: o/n\n" "agent_repo: .\n" + "mode: B\n" "splits:\n" " - {split: validation, access: non_viewable}\n" "feedback_transcripts: true\n" @@ -584,30 +638,56 @@ def test_known_keys_still_accepted(self): assert cfg.feedback_transcripts is True -class TestModeAIgnoresFeedbackLevers: - """Mode A ignores the Mode-B-only feedback levers (they ride the nested - `harbor run` collation). compile_task must warn so an author does not think - feedback is on when it is not.""" - - def test_mode_a_with_feedback_lever_warns(self, tmp_path, monkeypatch, caplog): - monkeypatch.setenv("VERO_SKIP_SECRET_CHECK", "1") - config = BuildConfig( - name="vero/gsm8k-opt", - agent_repo=str(_agent_repo(tmp_path)), - mode="A", - task="gsm8k", - dataset=str(_dataset(tmp_path)), - splits=[{"split": "validation", "access": "non_viewable"}], - feedback_transcripts=True, - expose_attempt_detail=True, - ) - with caplog.at_level("WARNING", logger="vero.harbor.build.compiler"): - compile_task(config, tmp_path / "task", vero_root=_stub_vero(tmp_path)) - joined = " ".join(caplog.messages) - assert "Mode-B-only" in joined - assert "feedback_transcripts" in joined - assert "expose_attempt_detail" in joined - - def test_mode_a_without_feedback_lever_does_not_warn(self, built, caplog): - # `built` fixture is a Mode A config with the levers off: no warning. - assert not any("Mode-B-only" in m for m in caplog.messages) +class TestModeMismatchRejection: + """The Mode-A / Mode-B split turns a wrong-mode field from a silently-ignored + no-op (previously papered over by PR #20's runtime warnings) into a load-time + ValidationError: the field is simply unknown to the resolved variant + (extra="forbid").""" + + def test_mode_a_with_mode_b_field_rejected(self): + # A Mode-A build.yaml setting a Mode-B-only lever fails at load. + from pydantic import ValidationError + + for lever in ("feedback_transcripts: true", + "expose_attempt_detail: true", + "instruct_multifidelity: true", + "feedback_max_bytes: 512"): + with pytest.raises(ValidationError): + _validate_build(yaml.safe_load( + "name: o/n\n" + "agent_repo: .\n" + "mode: A\n" + "splits:\n" + " - {split: validation, access: non_viewable}\n" + f"{lever}\n" + )) + + def test_mode_a_default_with_mode_b_field_rejected(self): + # Same, but relying on the default mode ("A" when omitted). + from pydantic import ValidationError + + with pytest.raises(ValidationError): + _validate_build(yaml.safe_load( + "name: o/n\n" + "agent_repo: .\n" + "splits:\n" + " - {split: validation, access: non_viewable}\n" + "feedback_transcripts: true\n" + )) + + def test_mode_b_with_mode_a_field_rejected(self): + # A Mode-B build.yaml setting a Mode-A-only field fails at load. + from pydantic import ValidationError + + for field in ("sample_timeout: 500", + "task: gsm8k", + "dataset: /tmp/ds"): + with pytest.raises(ValidationError): + _validate_build(yaml.safe_load( + "name: o/n\n" + "agent_repo: .\n" + "mode: B\n" + "splits:\n" + " - {split: validation, access: viewable}\n" + f"{field}\n" + )) diff --git a/vero/tests/test_harbor_serve.py b/vero/tests/test_harbor_serve.py index 3125ada..200f9c2 100644 --- a/vero/tests/test_harbor_serve.py +++ b/vero/tests/test_harbor_serve.py @@ -16,7 +16,7 @@ from vero.core.dataset.store import resolve_and_save_dataset from vero.evaluation.engine import EvalRequest -from vero.harbor.serve import ServeConfig, build_components +from vero.harbor.serve import ServeConfigA, ServeConfigB, build_components def _git(path: Path, *args: str) -> str: @@ -103,8 +103,8 @@ def fixture(tmp_path, monkeypatch): return agent_dir, head, task_dir, dataset_id, tmp_path -def _serve_config(agent_dir, head, task_dir, dataset_id, tmp) -> ServeConfig: - return ServeConfig( +def _serve_config(agent_dir, head, task_dir, dataset_id, tmp) -> ServeConfigA: + return ServeConfigA( repo_path=str(agent_dir), agent_repo_path=str(agent_dir), session_id="sess", @@ -223,16 +223,28 @@ async def test_ledger_reloads_spent_budget_across_restart(fixture): @pytest.mark.asyncio async def test_feedback_levers_reach_harbor_runner(fixture): - # Lever 1 pass-through: ServeConfig -> build_components -> HarborRunner kwargs - # (mirrors how score_baseline reaches the Verifier). + # Lever 1 pass-through: ServeConfigB -> build_components -> HarborRunner kwargs + # (mirrors how score_baseline reaches the Verifier). Built as a Mode-B config + # directly: the levers are Mode-B-only under the type split. agent_dir, head, task_dir, dataset_id, tmp = fixture - config = _serve_config(agent_dir, head, task_dir, dataset_id, tmp).model_copy( - update={ - "harbor": {"task_source": "org/x", "agent_import_path": "p:C"}, - "feedback_transcripts": True, - "feedback_max_bytes": 512, - "expose_attempt_detail": True, - } + config = ServeConfigB( + mode="B", + repo_path=str(agent_dir), + agent_repo_path=str(agent_dir), + session_id="sess", + dataset_id=dataset_id, + split_accesses=[{"split": "test", "access": "non_viewable"}], + budgets=[{"split": "test", "dataset_id": dataset_id, "total_run_budget": 5}], + harbor={"task_source": "org/x", "agent_import_path": "p:C"}, + feedback_transcripts=True, + feedback_max_bytes=512, + expose_attempt_detail=True, + reward_mode="auto_best", + selection_split="test", + agent_volume=str(tmp / "agent_vol"), + admin_volume=str(tmp / "admin_vol"), + admin_token_path=str(tmp / "admin_vol" / "token"), + timeout=300, ) sidecar, _, _ = await build_components(config) runner = sidecar.engine.evaluator.eval_strategy @@ -241,26 +253,25 @@ async def test_feedback_levers_reach_harbor_runner(fixture): assert runner.expose_attempt_detail is True -def test_mode_b_sample_timeout_warns(caplog): - # Setting sample_timeout in Mode B is a no-op (nested harbor tasks use their - # own timeouts); the author must be told rather than silently ignored. - from vero.harbor.serve import ServeConfig, _warn_mode_b_sample_timeout +def test_mode_mismatch_fields_rejected(): + # PR #20's runtime warnings are superseded by the Mode-A / Mode-B type split: + # a wrong-mode field is now a load-time ValidationError, not a no-op. Mode B + # has no sample_timeout, and Mode A has no feedback levers / harbor. + from pydantic import ValidationError base = dict( repo_path="/r", agent_repo_path="/a", session_id="s", dataset_id="ds", split_accesses=[], budgets=[], agent_volume="/v", admin_volume="/adm", - admin_token_path="/t", harbor={"task_source": "org/x", "agent_import_path": "p:C"}, + admin_token_path="/t", ) - with caplog.at_level("WARNING"): - _warn_mode_b_sample_timeout(ServeConfig(**base, sample_timeout=1200)) - # caplog.messages (getMessage()) rather than r.message: pytest's capture - # handler does not run Formatter.format(), so .message may be unset. - assert any("not enforced in Mode B" in m for m in caplog.messages) - - caplog.clear() - with caplog.at_level("WARNING"): - _warn_mode_b_sample_timeout(ServeConfig(**base)) # not explicitly set - _warn_mode_b_sample_timeout( - ServeConfig(**{**base, "harbor": None, "task_project": "/tp"}, sample_timeout=900) - ) # Mode A: sample_timeout is real - assert not caplog.records + # Mode B rejects sample_timeout. + with pytest.raises(ValidationError): + ServeConfigB(mode="B", harbor=None, sample_timeout=1200, **base) + # Mode A rejects harbor / feedback levers. + with pytest.raises(ValidationError): + ServeConfigA(harbor={"task_source": "org/x"}, **base) + with pytest.raises(ValidationError): + ServeConfigA(feedback_transcripts=True, **base) + # The valid arrangements still construct. + ServeConfigA(sample_timeout=900, task_project="/tp", **base) + ServeConfigB(mode="B", harbor={"task_source": "org/x"}, **base)