Repository: kasiopea-org/pisek (language: python)

Issue:
Rewrite server mode
And #108


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 98ae934e..351b82f8 100644
--- a/README.md
+++ b/README.md
@@ -35,12 +35,12 @@ It tests all task parts (generator, checker, solutions and judge).
 
 ### Task testing overview
 
-What pisek verifies (roughly in order):
+What pisek verifies:
  - Samples exist
  - The generator generates inputs
-    - A Kasiopea generator respects the seed and is deterministic
+    - Generator is deterministic
+    - Generator respects seed on seeded inputs
  - The checker accepts all inputs
-    - If `checker_distinguishes_subtasks=on`, it rejects inputs for harder subtasks
  - The judge works
     - It accepts the samples
     - It doesn't crash on malicious output
@@ -64,38 +64,11 @@ For fast testing of only the solution `solve_cool.cpp` use:
 pisek test solution solve_cool
 ```
 
-For testing on multiple inputs use (only in Kasiopea mode) use:
-```bash
-pisek test solution solve_cool -n 42
-```
-
 Similarly the generator can be tested using:
 ```bash
 pisek test generator
 ```
 
-### Useful options
-
-Test all solutions, don't stop on first failure:
-```bash
-pisek test --full
-```
-
-Test each solution on all inputs, even when the result is clear:
-```bash
-pisek test --all-inputs
-```
-
-Use a different time limit (in seconds) for testing solutions:
-```bash
-pisek test --timeout 5
-```
-
-Interpret warnings as failures (for a final check):
-```bash
-pisek test --strict
-```
-
 ### Cleaning
 
 Pisek can create a lot of files used for testing. Remove them by running:
diff --git a/config-v3-documentation b/config-v3-documentation
index cac96424..f0057cc6 100644
--- a/config-v3-documentation
+++ b/config-v3-documentation
@@ -23,13 +23,6 @@ name=a-plus-b
 use=organization-config
 # Config to use defaults from
 
-contest_type=cms
-# Environment for which is task developed
-# kasiopea (default) / cmscontest_type=cms
-#
-# Currently determines generator / judge format
-# TODO: Remove this key and split to multiple ones
-
 task_type=communication
 # Task type:
 # - batch (default)
@@ -55,6 +48,14 @@ data_subdir=tests/
 in_gen=src/gen
 # Source code of generator (without suffix)
 
+gen_type=opendata-v1
+# Specifies how generator generates inputs
+# - cms-old
+#       ./gen [folder]
+#       Generates all inputs into fiven folder
+# - opendata-v1
+#       ./gen [subtask] [seed] > input
+
 in_format=text
 # Format of input:
 #   text    ASCII plain text (LF newlines)
@@ -85,7 +86,7 @@ out_judge=judge
 judge_type=cms
 # Specifies how to call judge and how judge reports result
 # Only for task_type=batch and out_check=judge
-# - cms 
+# - cms
 #       https://cms.readthedocs.io/en/v1.4/Task%20types.html?highlight=manager#checker
 # - opendata-v1
 #       ./judge [subtask] [seed] < ouput
@@ -297,10 +298,6 @@ output_max_size=5
 
 # Values are on/off and keys defaults to these values: 
 
-checker_distinguishes_subtasks=off
-# checks that this subtasks's inputs doesn't pass checker in predecessor subtasks
-# Whether one/all must not pass depends on scoring 
-
 solution_for_each_subtask=off
 # checks that a dedicated solution exists for each subtask (aside from samples)
 # A dedicated solution for a subtask is one that:
diff --git a/fixtures/guess/config b/fixtures/guess/config
index 14426987..9303068f 100644
--- a/fixtures/guess/config
+++ b/fixtures/guess/config
@@ -5,7 +5,6 @@
 name=guess
 # CMS tasks have a different judge and generator format than Kasiopea
 # (generator doesn't require seed)
-contest_type=cms
 use=interactive
 
 solutions_subdir=solutions/
@@ -13,7 +12,6 @@ version=v3
 
 [tests]
 # generator source file
-gen_type=cms-old
 in_gen=src/gen
 
 # checker=check
diff --git a/fixtures/pisek/configs/interactive b/fixtures/pisek/configs/interactive
index 60e04f0f..d46a6b2e 100644
--- a/fixtures/pisek/configs/interactive
+++ b/fixtures/pisek/configs/interactive
@@ -1,5 +1,7 @@
 [task]
-contest_type=cms
 scoring=min
 task_type=communication
 version=v3
+
+[tests]
+gen_type=cms-old
diff --git a/fixtures/sum_cms/check.py b/fixtures/sum_cms/check.py
index adc0ea32..7fcef180 100644
--- a/fixtures/sum_cms/check.py
+++ b/fixtures/sum_cms/check.py
@@ -6,6 +6,7 @@
 
 def main(diff):
     BOUNDS = [(0, 1e9), (-1e9, 1e9), (-1e18, 1e18)]
+    assert 0 <= diff < len(BOUNDS)
     read_values(2, *BOUNDS[diff])
 
 
diff --git a/fixtures/sum_cms/check_loose.py b/fixtures/sum_cms/check_loose.py
deleted file mode 100644
index 1863d46f..00000000
--- a/fixtures/sum_cms/check_loose.py
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-
-
-if __name__ == "__main__":
-    parser = argparse.ArgumentParser(
-        description="Nacte ze stdin vstupy ulohy a zkontroluje jejich spravnost"
-    )
-    parser.add_argument("subtask", type=int, help="subtask (indexovany od 1)")
-    args = parser.parse_args()
-
-    # Does not do any actual checking.
diff --git a/fixtures/sum_cms/sample_02.bad b/fixtures/sum_cms/sample_02.bad
deleted file mode 100644
index b1bd38b6..00000000
--- a/fixtures/sum_cms/sample_02.bad
+++ /dev/null
@@ -1,1 +0,0 @@
-13
diff --git a/fixtures/sum_kasiopea/check.py b/fixtures/sum_kasiopea/check.py
index 4ad89981..bbfcbcf7 100644
--- a/fixtures/sum_kasiopea/check.py
+++ b/fixtures/sum_kasiopea/check.py
@@ -6,6 +6,7 @@
 
 def main(diff):
     BOUNDS = [(-1e9, 1e9), (-1e18, 1e18)]
+    assert 0 <= diff < len(BOUNDS)
 
     t = read_values(1)[0]
     for ti in range(t):
diff --git a/fixtures/sum_kasiopea/check_loose.py b/fixtures/sum_kasiopea/check_loose.py
deleted file mode 100644
index 1863d46f..00000000
--- a/fixtures/sum_kasiopea/check_loose.py
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-
-
-if __name__ == "__main__":
-    parser = argparse.ArgumentParser(
-        description="Nacte ze stdin vstupy ulohy a zkontroluje jejich spravnost"
-    )
-    parser.add_argument("subtask", type=int, help="subtask (indexovany od 1)")
-    args = parser.parse_args()
-
-    # Does not do any actual checking.
diff --git a/pisek/__main__.py b/pisek/__main__.py
index a4fc2709..e4fe7976 100644
--- a/pisek/__main__.py
+++ b/pisek/__main__.py
@@ -155,11 +155,11 @@ def add_argument_dataset(parser):
         help="Interpret warnings as failures (for final check).",
     )
     parser_test.add_argument(
-        "--inputs",
+        "--repeat-inputs",
         "-n",
         type=int,
-        default=5,
-        help="Test on INPUTS difference inputs (only for Kasiopea tasks).",
+        default=1,
+        help="Generate REPEAT_INPUTS times more inputs. (Seeded inputs only)",
     )
     parser_test.add_argument(
         "--all-inputs",
diff --git a/pisek/cms/__init__.py b/pisek/cms/__init__.py
index 4735ae34..aa32b896 100644
--- a/pisek/cms/__init__.py
+++ b/pisek/cms/__init__.py
@@ -54,11 +54,6 @@
 
 
 def prepare_files(env: Env):
-    contest_type = env.config.contest_type
-
-    if contest_type != "cms":
-        raise RuntimeError(f"Cannot upload {contest_type}-type task to CMS")
-
     env = env.fork()
     env.solutions = [env.config.primary_solution]
     env.target = TestingTarget.solution
diff --git a/pisek/cms/result.py b/pisek/cms/result.py
index a703ba9b..17d4896c 100644
--- a/pisek/cms/result.py
+++ b/pisek/cms/result.py
@@ -20,7 +20,7 @@
 from pisek.cms.submission import get_submission
 from pisek.env.env import Env
 from pisek.task_jobs.testing_log import TESTING_LOG
-from pisek.task_jobs.solution_result import Verdict
+from pisek.task_jobs.solution.solution_result import Verdict
 from pisek.config.task_config import SolutionConfig, SubtaskConfig
 from pisek.utils.terminal import colored_env
 from pisek.utils.text import eprint, tab
diff --git a/pisek/config/cms-defaults b/pisek/config/cms-defaults
index c207dd59..86217fe9 100644
--- a/pisek/config/cms-defaults
+++ b/pisek/config/cms-defaults
@@ -2,7 +2,6 @@
 
 [task]
 version=v3
-contest_type=cms
 task_type=batch
 scoring=min
 solutions_subdir=.
@@ -78,7 +77,6 @@ input_max_size=0
 output_max_size=0
 
 [checks]
-checker_distinguishes_subtasks=on
 solution_for_each_subtask=off
 
 [cms]
diff --git a/pisek/config/config_hierarchy.py b/pisek/config/config_hierarchy.py
index a099fd82..7fa8873c 100644
--- a/pisek/config/config_hierarchy.py
+++ b/pisek/config/config_hierarchy.py
@@ -65,6 +65,7 @@ def __init__(
         self,
         task_path: str,
         no_colors: bool = False,
+        info: bool = True,
         pisek_directory: Optional[str] = None,
     ) -> None:
         self._task_path = task_path
@@ -73,8 +74,8 @@ def __init__(
         self._config_paths: list[str] = []
         self._configs: list[ConfigParser] = []
 
-        self._load_config(os.path.join(task_path, CONFIG_FILENAME))
-        self._load_config(GLOBAL_DEFAULTS, info=False)
+        self._load_config(os.path.join(task_path, CONFIG_FILENAME), no_colors, info)
+        self._load_config(GLOBAL_DEFAULTS, no_colors, False)
 
         self._used_keys: dict[str, set[str]] = defaultdict(set)
 
diff --git a/pisek/config/config_types.py b/pisek/config/config_types.py
index 93dcca00..2880083e 100644
--- a/pisek/config/config_types.py
+++ b/pisek/config/config_types.py
@@ -27,7 +27,6 @@ class OutCheck(StrEnum):
 class GenType(StrEnum):
     opendata_v1 = "opendata-v1"
     cms_old = "cms-old"
-    pisek_v1 = "pisek-v1"
 
 
 class JudgeType(StrEnum):
diff --git a/pisek/config/kasiopea-defaults b/pisek/config/kasiopea-defaults
index 74b9d7d0..8dbfcffc 100644
--- a/pisek/config/kasiopea-defaults
+++ b/pisek/config/kasiopea-defaults
@@ -2,7 +2,6 @@
 
 [task]
 version=v3
-contest_type=kasiopea
 task_type=batch
 scoring=equal
 solutions_subdir=.
@@ -78,7 +77,6 @@ input_max_size=50
 output_max_size=10
 
 [checks]
-checker_distinguishes_subtasks=on
 solution_for_each_subtask=off
 
 [cms]
diff --git a/pisek/config/task_config.py b/pisek/config/task_config.py
index 32ab51cd..8b73febb 100644
--- a/pisek/config/task_config.py
+++ b/pisek/config/task_config.py
@@ -47,7 +47,7 @@
     CMSScoreMode,
 )
 from pisek.env.context import init_context
-from pisek.task_jobs.solution_result import SUBTASK_SPEC
+from pisek.task_jobs.solution.solution_result import SUBTASK_SPEC
 
 
 MaybeInt = Annotated[
@@ -87,7 +87,6 @@ class TaskConfig(BaseEnv):
     """Configuration of task loaded from config file."""
 
     name: str
-    contest_type: str
     task_type: TaskType
     scoring: Scoring
 
@@ -96,7 +95,7 @@ class TaskConfig(BaseEnv):
     data_subdir: TaskPathFromStr
 
     in_gen: TaskPathFromStr
-    gen_type: Optional[GenType]
+    gen_type: GenType
     checker: OptionalTaskPathFromStr
     out_check: OutCheck
     out_judge: OptionalTaskPathFromStr
@@ -163,7 +162,6 @@ def __init__(self, **kwargs):
     def load_dict(configs: ConfigHierarchy) -> ConfigValuesDict:
         GLOBAL_KEYS = [
             ("task", "name"),
-            ("task", "contest_type"),
             ("task", "task_type"),
             ("task", "scoring"),
             ("task", "solutions_subdir"),
@@ -203,6 +201,7 @@ def load_dict(configs: ConfigHierarchy) -> ConfigValuesDict:
 
         # Load subtasks
         args["subtasks"] = subtasks = {}
+        # Sort so subtasks.keys() returns subtasks in sorted order
         for section in sorted(section_names, key=lambda cv: cv.value):
             section_name = section.value
             if m := re.match(r"test(\d{2})", section_name):
@@ -225,18 +224,6 @@ def load_dict(configs: ConfigHierarchy) -> ConfigValuesDict:
 
         return args
 
-    @field_validator("contest_type", mode="after")
-    @classmethod
-    def validate_contest_type(cls, value: str) -> str:
-        # TODO: Redo to general task types
-        ALLOWED = ["kasiopea", "cms"]
-        if value not in ALLOWED:
-            raise PydanticCustomError(
-                "contest_type_invalid",
-                f"Must be one of ({', '.join(ALLOWED)})",
-            )
-        return value
-
     @model_validator(mode="after")
     def validate_model(self):
         if (
@@ -371,7 +358,7 @@ def validate_globs(cls, value: list[str], info: ValidationInfo) -> list[str]:
                 glob = f"{info.data['num']:02}*.in"
             if not glob.endswith(".in"):
                 raise PydanticCustomError(
-                    "in_globs_end_in", "In_globs must end with '.in'"
+                    "in_globs_end_in", "In_globs must end with '*.in'"
                 )
             globs.append(glob)
 
@@ -660,7 +647,6 @@ class ChecksConfig(BaseEnv):
 
     _section: str = "checks"
 
-    checker_distinguishes_subtasks: bool
     solution_for_each_subtask: bool
 
     @classmethod
@@ -709,7 +695,9 @@ def load_config(
 ) -> Optional[TaskConfig]:
     """Loads config from given path."""
     try:
-        config_hierarchy = ConfigHierarchy(path, no_colors, pisek_directory)
+        config_hierarchy = ConfigHierarchy(
+            path, no_colors, not suppress_warnings, pisek_directory
+        )
         config_values = TaskConfig.load_dict(config_hierarchy)
         config = TaskConfig(**_to_values(config_values))
         config_hierarchy.check_unused_keys()
diff --git a/pisek/config/update_config.py b/pisek/config/update_config.py
index 4cc3263e..8ee10469 100644
--- a/pisek/config/update_config.py
+++ b/pisek/config/update_config.py
@@ -182,7 +182,7 @@ def update_to_v3(config: ConfigParser, task_path: str) -> None:
     if contest_type not in ["kasiopea", "cms"]:
         raise TaskConfigError(f"Invalid contest_type: '{contest_type}'")
     config["task"]["use"] = f"@{contest_type}"
-    # TODO: del config["task"]["contest_type"]
+    maybe_delete_key(config, "task", "contest_type")
 
     if "limits" not in config:
         config.add_section("limits")
diff --git a/pisek/env/base_env.py b/pisek/env/base_env.py
index 72cc2eee..d9616aee 100644
--- a/pisek/env/base_env.py
+++ b/pisek/env/base_env.py
@@ -14,18 +14,11 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
-from enum import StrEnum, auto
-from pydantic import Field
-from typing import Any, TYPE_CHECKING, Callable, TypeVar
-
+from typing import Any, TYPE_CHECKING, Callable
 
 from pisek.env.context import ContextModel
 
 
-T = TypeVar("T")
-TFunc = Callable[..., T]
-
-
 class BaseEnv(ContextModel):
     """
     Collection of environment variables which logs whether each variable was accessed.
diff --git a/pisek/env/env.py b/pisek/env/env.py
index 4f89ff5b..91518dbe 100644
--- a/pisek/env/env.py
+++ b/pisek/env/env.py
@@ -48,7 +48,7 @@ class Env(BaseEnv):
         timeout: Timeout for (overrides config)
         skip_on_timeout: If to skip testing after solutions fails on one output (Useful only if scoring=equal)
         all_inputs: Finish testing all inputs of a solution
-        inputs: Number of inputs generated (Only for task_type=kasiopea)
+        repeat_inputs: Generate REPEAT_INPUTS times more inputs. (Seeded inputs only)
     """
 
     target: TestingTarget
@@ -64,7 +64,7 @@ class Env(BaseEnv):
     timeout: Optional[float] = Field(ge=0)
     skip_on_timeout: bool
     all_inputs: bool
-    inputs: int = Field(ge=1)
+    repeat_inputs: int = Field(ge=1)
 
     @staticmethod
     def load(
@@ -81,7 +81,7 @@ def load(
         testing_log: bool = False,
         solutions: Optional[list[str]] = None,
         timeout: Optional[float] = None,
-        inputs: int = 5,
+        repeat_inputs: int = 1,
         pisek_dir: Optional[str] = None,
         **_,
     ) -> Optional["Env"]:
@@ -112,5 +112,5 @@ def load(
             timeout=timeout,
             skip_on_timeout=skip_on_timeout,
             all_inputs=all_inputs,
-            inputs=inputs,
+            repeat_inputs=repeat_inputs,
         )
diff --git a/pisek/jobs/jobs.py b/pisek/jobs/jobs.py
index c811e143..3e91dd17 100644
--- a/pisek/jobs/jobs.py
+++ b/pisek/jobs/jobs.py
@@ -20,7 +20,7 @@
 from enum import Enum, auto
 from functools import wraps
 import sys
-from typing import Optional, AbstractSet, MutableSet, Any
+from typing import Optional, AbstractSet, MutableSet, Any, Callable, NamedTuple
 import yaml
 
 import os.path
@@ -69,6 +69,12 @@ def wrapped_init(self, env: Env, *args, **kwargs):
         cls.__init__ = wrapped_init
 
 
+class RequiredBy(NamedTuple):
+    pipeline_item: "PipelineItem"
+    name: Optional[str]
+    run_condition: Callable[[Any], bool]
+
+
 class PipelineItem(ABC):
     """Generic PipelineItem with state and dependencies."""
 
@@ -83,7 +89,7 @@ def __init__(self, name: str) -> None:
         self.dirty = False  # Prints something to console?
 
         self.prerequisites = 0
-        self.required_by: list[tuple["PipelineItem", Optional[str]]] = []
+        self.required_by: list[RequiredBy] = []
         self.prerequisites_results: dict[str, Any] = {}
 
     def _print(self, msg: str, end: str = "\n", stderr: bool = False) -> None:
@@ -109,7 +115,7 @@ def cancel(self) -> None:
         if self.state in (State.succeeded, State.cancelled):
             return  # No need to cancel
         self.state = State.cancelled
-        for item, _ in self.required_by:
+        for item, _, _ in self.required_by:
             if not item.run_always:
                 item.cancel()
 
@@ -121,16 +127,24 @@ def _check_prerequisites(self) -> None:
             )
 
     def add_prerequisite(
-        self, item: "PipelineItem", name: Optional[str] = None
+        self,
+        item: Optional["PipelineItem"],
+        name: Optional[str] = None,
+        condition: Callable[[Any], bool] = lambda _: True,
     ) -> None:
         """Adds given PipelineItem as a prerequisite to this job."""
+        if item is None:
+            return
+
         self.prerequisites += 1
-        item.required_by.append((self, name))
+        item.required_by.append(RequiredBy(self, name, condition))
 
     def finish(self) -> None:
         """Notifies PipelineItems that depend on this job."""
-        for item, name in self.required_by:
-            if item.run_always or self.state == State.succeeded:
+        for item, name, condition in self.required_by:
+            if item.run_always or (
+                self.state == State.succeeded and condition(self.result)
+            ):
                 item.prerequisites -= 1
                 if name is not None:
                     item.prerequisites_results[name] = deepcopy(self.result)
@@ -266,7 +280,7 @@ def run_job(self, cache: Cache) -> None:
             self.state = State.succeeded
 
     @abstractmethod
-    def _run(self):
+    def _run(self) -> Any:
         """What this job actually does (without all the management)."""
         pass
 
diff --git a/pisek/jobs/task_pipeline.py b/pisek/jobs/task_pipeline.py
index 17a1e7b1..66606779 100644
--- a/pisek/jobs/task_pipeline.py
+++ b/pisek/jobs/task_pipeline.py
@@ -19,23 +19,21 @@
 from pisek.jobs.job_pipeline import JobPipeline
 from pisek.env.env import Env, TestingTarget
 
-from pisek.task_jobs.task_job import (
+from pisek.task_jobs.task_manager import (
     TOOLS_MAN_CODE,
     INPUTS_MAN_CODE,
     GENERATOR_MAN_CODE,
     CHECKER_MAN_CODE,
     JUDGE_MAN_CODE,
     SOLUTION_MAN_CODE,
-    DATA_MAN_CODE,
 )
 
 from pisek.task_jobs.tools import ToolsManager
-from pisek.task_jobs.data import DataManager
-from pisek.task_jobs.generator import GeneratorManager
+from pisek.task_jobs.data.manager import DataManager
+from pisek.task_jobs.generator.manager import PrepareGenerator, RunGenerator
 from pisek.task_jobs.checker import CheckerManager
 from pisek.task_jobs.judge import JudgeManager
-from pisek.task_jobs.solution import SolutionManager
-from pisek.task_jobs.data import DataCheckingManager
+from pisek.task_jobs.solution.manager import SolutionManager
 from pisek.task_jobs.testing_log import CreateTestingLog
 from pisek.task_jobs.completeness_check import CompletenessCheck
 
@@ -47,53 +45,55 @@ def __init__(self, env: Env):
         super().__init__()
         named_pipeline = [
             tools := (ToolsManager(), TOOLS_MAN_CODE),
-            generator := (GeneratorManager(), GENERATOR_MAN_CODE),
+            generator := (PrepareGenerator(), GENERATOR_MAN_CODE),
+            checker := (CheckerManager(), CHECKER_MAN_CODE),
             inputs := (DataManager(), INPUTS_MAN_CODE),
         ]
         generator[0].add_prerequisite(*tools)
+        checker[0].add_prerequisite(*tools)
         inputs[0].add_prerequisite(*generator)
+        inputs[0].add_prerequisite(*checker)
 
-        if env.target != "solution":
-            named_pipeline.append(checker := (CheckerManager(), CHECKER_MAN_CODE))
-            checker[0].add_prerequisite(*inputs)
+        if env.target == TestingTarget.generator:
+            named_pipeline.append(gen_inputs := (RunGenerator(), ""))
+            gen_inputs[0].add_prerequisite(*inputs)
 
-        solutions = []
-        if env.solutions:
+        else:
             named_pipeline.append(judge := (JudgeManager(), JUDGE_MAN_CODE))
             judge[0].add_prerequisite(*inputs)
 
-            if env.config.judge_needs_out or (
-                env.config.primary_solution in env.solutions
-            ):
-                named_pipeline.append(
-                    primary_solution := (
-                        SolutionManager(env.config.primary_solution),
-                        f"{SOLUTION_MAN_CODE}{env.config.primary_solution}",
-                    )
-                )
-                solutions.append(primary_solution)
+            # First solution generates inputs
+            first_solution_name = (
+                env.config.primary_solution
+                if env.config.judge_needs_out
+                or env.config.primary_solution in env.solutions
+                else env.solutions[0]
+            )
 
-        for sol_name in env.solutions:
-            if sol_name == env.config.primary_solution:
-                continue
             named_pipeline.append(
-                solution := (
-                    SolutionManager(sol_name),
-                    f"{SOLUTION_MAN_CODE}{sol_name}",
+                first_solution := (
+                    SolutionManager(first_solution_name, True),
+                    f"{SOLUTION_MAN_CODE}{first_solution_name}",
                 )
             )
-            if env.config.judge_needs_out:
-                solution[0].add_prerequisite(*primary_solution)
-            solutions.append(solution)
-
-        for solution in solutions:
-            solution[0].add_prerequisite(*inputs)
-            solution[0].add_prerequisite(*judge)
-
-        named_pipeline.append(data_check := (DataCheckingManager(), DATA_MAN_CODE))
-        data_check[0].add_prerequisite(*inputs)
-        for solution in solutions:
-            data_check[0].add_prerequisite(*solution)
+            solutions = [first_solution]
+            first_solution[0].add_prerequisite(*judge)
+
+            for sol_name in env.solutions:
+                if sol_name == first_solution_name:
+                    continue
+                named_pipeline.append(
+                    solution := (
+                        SolutionManager(sol_name, False),
+                        f"{SOLUTION_MAN_CODE}{sol_name}",
+                    )
+                )
+
+                solution[0].add_prerequisite(*first_solution)
+                solutions.append(solution)
+
+            for solution in solutions:
+                solution[0].add_prerequisite(*inputs)
 
         if env.testing_log:
             named_pipeline.append(testing_log := (CreateTestingLog(), ""))
diff --git a/pisek/server.py b/pisek/server.py
deleted file mode 100644
index ddc19b11..00000000
--- a/pisek/server.py
+++ /dev/null
@@ -1,183 +0,0 @@
-# pisek  - Tool for developing tasks for programming competitions.
-#
-# Copyright (c)   2019 - 2022 Václav Volhejn <vaclav.volhejn@gmail.com>
-# Copyright (c)   2019 - 2022 Jiří Beneš <mail@jiribenes.com>
-# Copyright (c)   2020 - 2022 Michal Töpfer <michal.topfer@gmail.com>
-# Copyright (c)   2022        Jiří Kalvoda <jirikalvoda@kam.mff.cuni.cz>
-# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# any later version.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-from collections import deque
-import os
-from typing import Optional
-from functools import partial
-
-import pisek.utils.util as util
-from pisek.jobs.job_pipeline import JobPipeline
-from pisek.utils.pipeline_tools import run_pipeline
-from pisek.env.env import Env
-from pisek.jobs.cache import Cache
-
-from pisek.task_jobs.tools import ToolsManager
-from pisek.task_jobs.generator import RunOnlineGeneratorMan
-from pisek.task_jobs.solution import RunPrimarySolutionMan
-from pisek.task_jobs.solution_result import SolutionResult
-from pisek.task_jobs.judge import RunKasiopeaJudgeMan
-
-
-class KasiopeaInputCase:
-    def __init__(self, path: str, subtask: int, seed: int):
-        self.path = path
-
-        self.subtask: int = subtask
-        self.seed: int = seed
-
-        self.input: Optional[str] = None
-        self.correct_output: Optional[str] = None
-
-    def gen_input(self, input_: Optional[str] = None) -> None:
-        if input_ is None:
-            input_ = util.get_input_name(self.seed, self.subtask)
-        self.input = input_
-
-        res = run_pipeline(
-            self.path,
-            partial(
-                ServerGenKasiopea, subtask=self.subtask, seed=self.seed, file=self.input
-            ),
-        )
-        if res != 0:
-            raise RuntimeError("Generating input failed.")
-
-    def gen_correct_output(
-        self, input_: Optional[str] = None, correct_output: Optional[str] = None
-    ) -> None:
-        self.input = input_ or self.input
-        if self._needs_generating(self.input):
-            self.gen_input(self.input)
-        assert self.input is not None
-
-        if correct_output is None:
-            correct_output = util.get_output_name(self.input, "")
-        self.correct_output = correct_output
-
-        res = run_pipeline(
-            self.path,
-            partial(ServerSolve, input_=self.input, output=self.correct_output),
-        )
-
-        if res != 0:
-            raise RuntimeError("Generating correct output failed.")
-
-    def judge(
-        self,
-        output: str,
-        input_: Optional[str] = None,
-        correct_output: Optional[str] = None,
-    ):
-        env = Env.load()
-        if env is None:
-            return 1
-
-        self.input = input_ or self.input
-        if env.config.judge_needs_in:
-            if self._needs_generating(self.input):
-                self.gen_input(self.input)
-            assert self.input is not None
-        else:
-            self.input = "/dev/null"
-
-        self.correct_output = correct_output or self.correct_output
-        if env.config.judge_needs_out:
-            if self._needs_generating(self.correct_output):
-                self.gen_correct_output(self.correct_output)
-            assert self.correct_output is not None
-        else:
-            self.correct_output = "/dev/null"
-
-        pipeline = ServerJudgeKasiopea(
-            env.fork(),
-            subtask=self.subtask,
-            seed=self.seed,
-            input_=self.input,
-            output=output,
-            correct_output=self.correct_output,
-        )
-        res = pipeline.run_jobs(cache := Cache(env), env)
-
-        if res != 0:
-            raise RuntimeError("Judging failed.")
-
-        judging_res = pipeline.judge_result()
-        return judging_res.points
-
-    def _needs_generating(self, put: Optional[str]):
-        return put is None or not os.path.exists(os.path.join(self.path, "data/", put))
-
-
-class ServerGenKasiopea(JobPipeline):
-    """Generate an input."""
-
-    def __init__(self, env, subtask: int, seed: int, file: str):
-        super().__init__()
-        if env.config.contest_type == "cms":
-            raise NotImplementedError("RunGen for cms is not implemented.")
-
-        self.pipeline = deque(
-            [
-                tools := ToolsManager(),
-                generator := RunOnlineGeneratorMan(subtask, seed, file),
-            ]
-        )
-        generator.add_prerequisite(tools)
-
-
-class ServerSolve(JobPipeline):
-    """Run a primary solution."""
-
-    def __init__(self, env, input_: str, output: Optional[str]):
-        super().__init__()
-
-        self.pipeline = deque(
-            [
-                tools := ToolsManager(),
-                solve := RunPrimarySolutionMan(input_, output),
-            ]
-        )
-        solve.add_prerequisite(tools)
-
-
-class ServerJudgeKasiopea(JobPipeline):
-    """Run a primary solution."""
-
-    def __init__(
-        self,
-        env,
-        subtask: int,
-        seed: int,
-        input_: str,
-        output: str,
-        correct_output: str,
-    ):
-        super().__init__()
-
-        self.pipeline = deque(
-            [
-                tools := ToolsManager(),
-                judge := RunKasiopeaJudgeMan(
-                    subtask, seed, input_, output, correct_output
-                ),
-            ]
-        )
-        judge.add_prerequisite(tools)
-        self._judge_man = judge
-
-    def judge_result(self) -> SolutionResult:
-        return self._judge_man.judge_result()
diff --git a/pisek/task_jobs/checker.py b/pisek/task_jobs/checker.py
index 47a6f9f1..a47d07be 100644
--- a/pisek/task_jobs/checker.py
+++ b/pisek/task_jobs/checker.py
@@ -20,9 +20,8 @@
 from pisek.env.env import Env
 from pisek.utils.paths import TaskPath
 from pisek.config.task_config import ProgramType
-from pisek.config.config_types import Scoring
 from pisek.utils.terminal import colored_env
-from pisek.task_jobs.task_job import TaskJobManager
+from pisek.task_jobs.task_manager import TaskJobManager
 from pisek.task_jobs.run_result import RunResult, RunResultKind
 from pisek.task_jobs.program import ProgramsJob
 from pisek.task_jobs.compile import Compile
@@ -33,7 +32,7 @@ class CheckerManager(TaskJobManager):
 
     def __init__(self):
         self.skipped_checker = ""
-        super().__init__("Running checker")
+        super().__init__("Prepare checker")
 
     def _get_jobs(self) -> list[Job]:
         if self._env.config.checker is None:
@@ -48,45 +47,7 @@ def _get_jobs(self) -> list[Job]:
                 )
             return []
 
-        checker = self._env.config.checker
-
-        jobs: list[Job] = [compile_checker := Compile(self._env, checker)]
-
-        self.loose_subtasks = []
-        for sub_num, sub in self._env.config.subtasks.items():
-            if sub_num == 0:
-                continue  # Skip samples
-            for inp in self._subtask_inputs(sub):
-                jobs.append(
-                    check := CheckerJob(
-                        self._env, checker, inp, sub_num, RunResultKind.OK
-                    )
-                )
-                check.add_prerequisite(compile_checker)
-            if self._env.config.checks.checker_distinguishes_subtasks:
-                if sub.direct_predecessors:
-                    self.loose_subtasks.append(LooseCheckJobGroup(sub_num))
-                    for pred in sub.direct_predecessors:
-                        if pred == 0:
-                            continue  # Skip samples
-                        self.loose_subtasks[-1].jobs[pred] = []
-                        for inp in self._subtask_new_inputs(sub):
-                            jobs.append(
-                                check := CheckerJob(self._env, checker, inp, pred, None)
-                            )
-                            self.loose_subtasks[-1].jobs[pred].append(check)
-                            check.add_prerequisite(compile_checker)
-
-        return jobs
-
-    def _evaluate(self) -> None:
-        if len(self.jobs) == 0:
-            return
-
-        for loose_subtask in self.loose_subtasks:
-            err = loose_subtask.failed(self._env.config.scoring)
-            if err is not None:
-                raise PipelineItemFailure(err)
+        return [Compile(self._env, self._env.config.checker)]
 
     def _get_status(self) -> str:
         if self.skipped_checker:
@@ -98,50 +59,6 @@ def _get_status(self) -> str:
             return super()._get_status()
 
 
-class LooseCheckJobGroup:
-    """
-    Groups jobs on subtask where checker is run on predecessors instead.
-    Checking that checker is strict enough - checking fails when run on predecessor.
-    """
-
-    def __init__(self, num: int):
-        self.num = num
-        self.jobs: dict[int, list[CheckerJob]] = {}
-
-    def failed(self, scoring: Scoring) -> Optional[str]:
-        """Returns whether jobs resulted as expected."""
-
-        def result_kind(job: CheckerJob) -> RunResultKind:
-            if job.result is None:
-                raise RuntimeError(f"Job {job.name} has not finished yet.")
-            return job.result.kind
-
-        for pred in self.jobs:
-            results = list(map(result_kind, self.jobs[pred]))
-            if scoring == Scoring.equal and RunResultKind.OK in results:
-                job = self._index_job(pred, results, RunResultKind.OK)
-                return (
-                    f"Checker is not strict enough:\n"
-                    f"All inputs of subtask {self.num} should have not passed on predecessor subtask {pred}\n"
-                    f"but on input {job.input} did not."
-                )
-            if scoring == Scoring.min and RunResultKind.RUNTIME_ERROR not in results:
-                return (
-                    f"Checker is not strict enough:\n"
-                    f"An input of subtask {self.num} should have not passed on predecessor subtask {pred}\n"
-                    f"but all have passed."
-                )
-            if RunResultKind.TIMEOUT in results:
-                to_job = self._index_job(pred, results, RunResultKind.TIMEOUT)
-                return f"Checker timeouted on input {to_job.input}, subtask {self.num}."
-        return None
-
-    def _index_job(
-        self, pred: int, results: list[RunResultKind], result: RunResultKind
-    ) -> "CheckerJob":
-        return self.jobs[pred][results.index(result)]
-
-
 class CheckerJob(ProgramsJob):
     """Runs checker on single input."""
 
@@ -151,7 +68,7 @@ def __init__(
         checker: TaskPath,
         input_: TaskPath,
         subtask: int,
-        expected: Optional[RunResultKind],
+        expected: Optional[RunResultKind] = RunResultKind.OK,
         **kwargs,
     ):
         super().__init__(
diff --git a/pisek/task_jobs/compile.py b/pisek/task_jobs/compile.py
index 53ba2898..7ef73a75 100644
--- a/pisek/task_jobs/compile.py
+++ b/pisek/task_jobs/compile.py
@@ -22,7 +22,7 @@
 
 from pisek.env.env import Env
 from pisek.utils.paths import TaskPath
-from pisek.jobs.jobs import State, PipelineItemFailure
+from pisek.jobs.jobs import PipelineItemFailure
 from pisek.task_jobs.program import ProgramsJob
 
 
diff --git a/pisek/task_jobs/completeness_check.py b/pisek/task_jobs/completeness_check.py
index e886071e..b998e1f9 100644
--- a/pisek/task_jobs/completeness_check.py
+++ b/pisek/task_jobs/completeness_check.py
@@ -14,7 +14,11 @@
 from pisek.env.env import TestingTarget
 from pisek.config.config_types import JudgeType
 from pisek.jobs.jobs import Job
-from pisek.task_jobs.task_job import TaskJobManager, JUDGE_MAN_CODE, SOLUTION_MAN_CODE
+from pisek.task_jobs.task_manager import (
+    TaskJobManager,
+    JUDGE_MAN_CODE,
+    SOLUTION_MAN_CODE,
+)
 
 
 class CompletenessCheck(TaskJobManager):
diff --git a/pisek/task_jobs/data.py b/pisek/task_jobs/data.py
deleted file mode 100644
index ba2f7c03..00000000
--- a/pisek/task_jobs/data.py
+++ /dev/null
@@ -1,215 +0,0 @@
-# pisek  - Tool for developing tasks for programming competitions.
-#
-# Copyright (c)   2019 - 2022 Václav Volhejn <vaclav.volhejn@gmail.com>
-# Copyright (c)   2019 - 2022 Jiří Beneš <mail@jiribenes.com>
-# Copyright (c)   2020 - 2022 Michal Töpfer <michal.topfer@gmail.com>
-# Copyright (c)   2022        Jiří Kalvoda <jirikalvoda@kam.mff.cuni.cz>
-# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# any later version.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-import re
-from typing import Any, Callable
-
-from pisek.config.config_types import DataFormat
-from pisek.jobs.jobs import Job, PipelineItemFailure
-from pisek.env.env import Env
-from pisek.utils.paths import TaskPath
-from pisek.task_jobs.task_job import (
-    TaskJob,
-    TaskJobManager,
-    GENERATOR_MAN_CODE,
-    INPUTS_MAN_CODE,
-    SOLUTION_MAN_CODE,
-)
-from pisek.task_jobs.solution_result import Verdict
-from pisek.task_jobs.tools import IsClean
-
-
-class DataManager(TaskJobManager):
-    """Moves data to correct folders."""
-
-    def __init__(self) -> None:
-        self._static_inputs: list[TaskPath] = []
-        self._generated_inputs: list[TaskPath] = []
-        self._static_outputs: list[TaskPath] = []
-        super().__init__("Moving data")
-
-    def _get_jobs(self) -> list[Job]:
-        self._static_inputs = self.globs_to_files(
-            self._env.config.input_globs, TaskPath.static_path(self._env, ".")
-        )
-        self._static_outputs = self.globs_to_files(
-            map(
-                lambda g: re.sub(r"\.in$", ".out", g),
-                self._env.config.input_globs,
-            ),
-            TaskPath.static_path(self._env, "."),
-        )
-        self._generated_inputs = self.prerequisites_results[GENERATOR_MAN_CODE][
-            "inputs"
-        ]
-
-        all_input_files = self._static_inputs + self._generated_inputs
-        all_output_files = self._static_outputs
-        jobs: list[Job] = []
-
-        if self._env.config.task_type != "communication":
-            for static_inp in self._static_inputs:
-                static_out = static_inp.replace_suffix(".out")
-                if static_out not in self._static_outputs:
-                    raise PipelineItemFailure(
-                        f"Missing matching output '{static_out:p}' for static input '{static_inp:p}'."
-                    )
-
-        self._inputs: list[TaskPath] = []
-        self._outputs: list[TaskPath] = []
-        link: LinkData
-        for fname in all_input_files:
-            jobs.append(link := LinkInput(self._env, fname))
-            self._inputs.append(link.dest)
-        for fname in all_output_files:
-            jobs.append(link := LinkOutput(self._env, fname))
-            self._outputs.append(link.dest)
-
-        for num, sub in self._env.config.subtasks.items():
-            inputs = list(filter(lambda p: sub.in_subtask(p.name), all_input_files))
-            if len(inputs) == 0:
-                raise PipelineItemFailure(
-                    f"No inputs for subtask {num} with globs {sub.all_globs}."
-                )
-
-        return jobs
-
-    def _compute_result(self) -> dict[str, Any]:
-        res = {
-            "inputs": self._inputs,
-            "outputs": self._outputs,
-            "all": self._inputs + self._outputs,
-        }
-
-        return res
-
-
-class DataJob(TaskJob):
-    def __init__(self, env: Env, name: str, data: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env,
-            name=name,
-            **kwargs,
-        )
-        self.data = data
-
-
-class LinkData(DataJob):
-    """Copy data to into dest folder."""
-
-    def __init__(self, env: Env, data: TaskPath, dest: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env, name=f"Copy {data:p} to {dest:p}/", data=data, **kwargs
-        )
-        self.dest = TaskPath(dest.path, self.data.name)
-
-    def _run(self):
-        self._link_file(self.data, self.dest, overwrite=True)
-
-
-class LinkInput(LinkData):
-    """Copy input to its place."""
-
-    def __init__(self, env: Env, input_: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env, data=input_, dest=TaskPath.input_path(self._env, "."), **kwargs
-        )
-
-
-class LinkOutput(LinkData):
-    """Copy output to its place."""
-
-    def __init__(self, env: Env, output: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env, data=output, dest=TaskPath.output_path(self._env, "."), **kwargs
-        )
-
-
-MB = 1024 * 1024
-
-
-class DataCheckingManager(TaskJobManager):
-    def __init__(self):
-        super().__init__("Checking data")
-
-    def _get_jobs(self) -> list[Job]:
-        jobs: list[Job] = []
-
-        inputs = []
-        outputs = []
-        for name, data in self.prerequisites_results.items():
-            if name.startswith(INPUTS_MAN_CODE):
-                inputs += data["inputs"]
-            elif name.startswith(SOLUTION_MAN_CODE):
-                outs = data["outputs"]
-                outputs += (
-                    outs[Verdict.ok]
-                    + outs[Verdict.partial_ok]
-                    + outs[Verdict.wrong_answer]
-                )
-        for inp in inputs:
-            if self._env.config.in_format == DataFormat.text:
-                jobs.append(IsClean(self._env, inp))
-
-            if self._env.config.limits.input_max_size != 0:
-                jobs.append(InputSmall(self._env, inp))
-
-        for out in outputs:
-            if self._env.config.out_format == DataFormat.text:
-                jobs.append(IsClean(self._env, out))
-
-            if self._env.config.limits.output_max_size != 0:
-                jobs.append(OutputSmall(self._env, out))
-
-        return jobs
-
-
-class InputSmall(DataJob):
-    """Checks that input is small enough to download."""
-
-    def __init__(self, env: Env, input_: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env,
-            name=f"Input {input_:n} is smaller than {env.config.limits.input_max_size}MB",
-            data=input_,
-            **kwargs,
-        )
-
-    def _run(self):
-        max_size = self._env.config.limits.input_max_size
-        if (sz := self._file_size(self.data)) > max_size * MB:
-            raise PipelineItemFailure(
-                f"Input {self.data:p} is bigger than {max_size}MB: {(sz+MB-1)//MB}MB"
-            )
-
-
-class OutputSmall(DataJob):
-    """Checks that output is small enough to upload."""
-
-    def __init__(self, env: Env, output: TaskPath, **kwargs) -> None:
-        super().__init__(
-            env=env,
-            name=f"Output {output:n} is smaller than {env.config.limits.output_max_size}MB",
-            data=output,
-            **kwargs,
-        )
-
-    def _run(self):
-        max_size = self._env.config.limits.output_max_size
-        if (sz := self._file_size(self.data)) > max_size * MB:
-            raise PipelineItemFailure(
-                f"Output {self.data} is bigger than {max_size}MB: {(sz+MB-1)//MB}MB"
-            )
diff --git a/pisek/task_jobs/data/__init__.py b/pisek/task_jobs/data/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pisek/task_jobs/data/data.py b/pisek/task_jobs/data/data.py
new file mode 100644
index 00000000..181871d3
--- /dev/null
+++ b/pisek/task_jobs/data/data.py
@@ -0,0 +1,102 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2019 - 2022 Václav Volhejn <vaclav.volhejn@gmail.com>
+# Copyright (c)   2019 - 2022 Jiří Beneš <mail@jiribenes.com>
+# Copyright (c)   2020 - 2022 Michal Töpfer <michal.topfer@gmail.com>
+# Copyright (c)   2022        Jiří Kalvoda <jirikalvoda@kam.mff.cuni.cz>
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from pisek.jobs.jobs import PipelineItemFailure
+from pisek.env.env import Env
+from pisek.utils.paths import TaskPath
+from pisek.task_jobs.task_job import TaskJob
+
+
+class DataJob(TaskJob):
+    def __init__(self, env: Env, name: str, data: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env,
+            name=name,
+            **kwargs,
+        )
+        self.data = data
+
+
+class LinkData(DataJob):
+    """Link data to into dest folder."""
+
+    def __init__(self, env: Env, data: TaskPath, dest: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env, name=f"Link {data:p} to {dest:p}/", data=data, **kwargs
+        )
+        self.dest = TaskPath(dest.path, self.data.name)
+
+    def _run(self):
+        self._link_file(self.data, self.dest, overwrite=True)
+
+
+class LinkInput(LinkData):
+    """Copy input to its place."""
+
+    def __init__(self, env: Env, input_: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env, data=input_, dest=TaskPath.input_path(self._env, "."), **kwargs
+        )
+
+
+class LinkOutput(LinkData):
+    """Copy output to its place."""
+
+    def __init__(self, env: Env, output: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env, data=output, dest=TaskPath.output_path(self._env, "."), **kwargs
+        )
+
+
+MB = 1024 * 1024
+
+
+class InputSmall(DataJob):
+    """Checks that input is small enough to download."""
+
+    def __init__(self, env: Env, input_: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env,
+            name=f"Input {input_:n} is smaller than {env.config.limits.input_max_size}MB",
+            data=input_,
+            **kwargs,
+        )
+
+    def _run(self):
+        max_size = self._env.config.limits.input_max_size
+        if (sz := self._file_size(self.data)) > max_size * MB:
+            raise PipelineItemFailure(
+                f"Input {self.data:p} is bigger than {max_size}MB: {(sz+MB-1)//MB}MB"
+            )
+
+
+class OutputSmall(DataJob):
+    """Checks that output is small enough to upload."""
+
+    def __init__(self, env: Env, output: TaskPath, **kwargs) -> None:
+        super().__init__(
+            env=env,
+            name=f"Output {output:n} is smaller than {env.config.limits.output_max_size}MB",
+            data=output,
+            **kwargs,
+        )
+
+    def _run(self):
+        max_size = self._env.config.limits.output_max_size
+        if (sz := self._file_size(self.data)) > max_size * MB:
+            raise PipelineItemFailure(
+                f"Output {self.data} is bigger than {max_size}MB: {(sz+MB-1)//MB}MB"
+            )
diff --git a/pisek/task_jobs/data/manager.py b/pisek/task_jobs/data/manager.py
new file mode 100644
index 00000000..cce00cbb
--- /dev/null
+++ b/pisek/task_jobs/data/manager.py
@@ -0,0 +1,111 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import re
+from typing import Any
+
+from pisek.utils.paths import TaskPath
+from pisek.jobs.jobs import Job, PipelineItemFailure
+from pisek.config.config_types import TaskType, DataFormat
+from pisek.task_jobs.task_manager import TaskJobManager, GENERATOR_MAN_CODE
+from pisek.task_jobs.generator.input_info import InputInfo
+from pisek.task_jobs.tools import IsClean
+from pisek.task_jobs.checker import CheckerJob
+
+from .data import LinkInput, LinkOutput, InputSmall, OutputSmall
+
+TEST_SEED = 25265
+
+
+class DataManager(TaskJobManager):
+    """Moves data to correct folders."""
+
+    def __init__(self) -> None:
+        super().__init__("Processing data")
+
+    def _get_jobs(self) -> list[Job]:
+        static_inputs = self.globs_to_files(
+            self._env.config.input_globs, TaskPath.static_path(self._env, ".")
+        )
+
+        if self._env.config.task_type == TaskType.communication:
+            static_outputs = []
+        else:
+            static_outputs = [path.replace_suffix(".out") for path in static_inputs]
+            for static_inp, static_out in zip(static_inputs, static_outputs):
+                if not static_out.exists():
+                    raise PipelineItemFailure(
+                        f"Missing matching output '{static_out:p}' for static input '{static_inp:p}'."
+                    )
+
+        all_input_infos: list[InputInfo] = [
+            InputInfo.static(inp.name.removesuffix(".in")) for inp in static_inputs
+        ] + self.prerequisites_results[GENERATOR_MAN_CODE]["inputs"]
+        all_input_infos.sort(key=lambda info: info.name)
+
+        self._input_infos: dict[int, list[InputInfo]] = {}
+        for num, sub in self._env.config.subtasks.items():
+            self._input_infos[sub.num] = []
+            for input_info in all_input_infos:
+                if sub.in_subtask(input_info.task_path(self._env, TEST_SEED).name):
+                    self._input_infos[sub.num].append(input_info)
+            if len(self._input_infos[sub.num]) == 0:
+                raise PipelineItemFailure(
+                    f"No inputs for subtask {num} with globs {sub.all_globs}."
+                )
+
+        used_inputs = set(sum(self._input_infos.values(), start=[]))
+        unused_inputs = list(set(all_input_infos) - used_inputs)
+        if len(unused_inputs):
+            if self._env.verbosity <= 0:
+                CUTOFF = 3
+                unused_inputs_text = ", ".join(
+                    map(lambda inp: inp.name, unused_inputs[:CUTOFF])
+                )
+                if len(unused_inputs) > CUTOFF:
+                    unused_inputs_text += ",…"
+                self._warn(
+                    f"{len(unused_inputs)} unused inputs. ({unused_inputs_text})"
+                )
+            else:
+                for inp in unused_inputs:
+                    self._warn(f"Unused input: '{inp.name}'")
+
+        jobs: list[Job] = []
+        for path in static_inputs:
+            jobs.append(LinkInput(self._env, path))
+
+        for path in static_outputs:
+            jobs.append(LinkOutput(self._env, path))
+
+        for subtask, inputs in self._input_infos.items():
+            for inp in inputs:
+                if inp.is_generated:
+                    continue
+
+                if subtask > 0 and self._env.config.checker is not None:
+                    jobs.append(
+                        CheckerJob(
+                            self._env,
+                            self._env.config.checker,
+                            TaskPath.static_path(
+                                self._env, inp.task_path(self._env).name
+                            ),
+                            subtask,
+                        )
+                    )
+
+        return jobs
+
+    def _compute_result(self) -> dict[str, Any]:
+        res = {"input_info": self._input_infos}
+        return res
diff --git a/pisek/task_jobs/generator.py b/pisek/task_jobs/generator.py
deleted file mode 100644
index df90d960..00000000
--- a/pisek/task_jobs/generator.py
+++ /dev/null
@@ -1,287 +0,0 @@
-# pisek  - Tool for developing tasks for programming competitions.
-#
-# Copyright (c)   2019 - 2022 Václav Volhejn <vaclav.volhejn@gmail.com>
-# Copyright (c)   2019 - 2022 Jiří Beneš <mail@jiribenes.com>
-# Copyright (c)   2020 - 2022 Michal Töpfer <michal.topfer@gmail.com>
-# Copyright (c)   2022        Jiří Kalvoda <jirikalvoda@kam.mff.cuni.cz>
-# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# any later version.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-import glob
-import random
-import os
-import shutil
-from typing import Any
-
-from pisek.env.env import Env
-from pisek.utils.paths import TaskPath, GENERATED_SUBDIR
-from pisek.config.task_config import ProgramType
-from pisek.jobs.jobs import Job, PipelineItemFailure
-from pisek.task_jobs.task_job import TaskJob, TaskJobManager
-from pisek.task_jobs.run_result import RunResult, RunResultKind
-from pisek.task_jobs.program import ProgramsJob
-from pisek.task_jobs.compile import Compile
-
-
-class GeneratorManager(TaskJobManager):
-    """Manager that generates inputs and test generator."""
-
-    def __init__(self):
-        self._inputs = []
-        super().__init__("Running generator")
-
-    def _get_jobs(self) -> list[Job]:
-        generator = self._env.config.in_gen
-
-        jobs: list[Job] = [compile_gen := Compile(self._env, generator)]
-
-        if self._env.config.contest_type == "kasiopea":
-            random.seed(4)  # Reproducibility!
-            seeds = random.sample(range(0, 16**4), self._env.inputs)
-            for sub_num, _ in self._env.config.subtasks.items():
-                if sub_num == 0:
-                    continue  # skip samples
-                last_gen: OnlineGeneratorGenerate
-                for i, seed in enumerate(seeds):
-                    self._inputs.append(
-                        input_ := TaskPath.generated_input_file(
-                            self._env, int(sub_num), seed
-                        )
-                    )
-
-                    jobs.append(
-                        gen := OnlineGeneratorGenerate(
-                            self._env, generator, input_, sub_num, seed
-                        )
-                    )
-                    gen.add_prerequisite(compile_gen)
-                    if i == 0:
-                        jobs.append(
-                            det := OnlineGeneratorDeterministic(
-                                self._env, generator, input_, sub_num, seed
-                            )
-                        )
-                        det.add_prerequisite(gen)
-                    elif i == 1:
-                        jobs.append(
-                            rs := OnlineGeneratorRespectsSeed(
-                                self._env,
-                                sub_num,
-                                last_gen.seed,
-                                gen.seed,
-                                last_gen.input_,
-                                gen.input_,
-                            )
-                        )
-                        rs.add_prerequisite(last_gen)
-                        rs.add_prerequisite(gen)
-                    last_gen = gen
-        else:
-            jobs.append(gen2 := OfflineGeneratorGenerate(self._env, generator))
-            gen2.add_prerequisite(compile_gen)
-
-        return jobs
-
-    def _compute_result(self) -> dict[str, Any]:
-        res = {}
-        if self._env.config.contest_type == "kasiopea":
-            res["inputs"] = self._inputs
-        else:
-            res["inputs"] = self.globs_to_files(
-                self._env.config.input_globs,
-                TaskPath.generated_path(self._env, "."),
-            )
-
-        return res
-
-
-class RunOnlineGeneratorMan(TaskJobManager):
-    def __init__(self, subtask: int, seed: int, file: str):
-        self._subtask = subtask
-        self._seed = seed
-        self._file = file
-        super().__init__("Running generator")
-
-    def _get_jobs(self) -> list[Job]:
-        jobs: list[Job] = [
-            compile_gen := Compile(self._env, self._env.config.in_gen),
-            gen := OnlineGeneratorGenerate(
-                self._env,
-                self._env.config.in_gen,
-                TaskPath(self._file),
-                self._subtask,
-                self._seed,
-            ),
-        ]
-        gen.add_prerequisite(compile_gen)
-
-        return jobs
-
-
-class OnlineGeneratorJob(ProgramsJob):
-    """Abstract class for jobs with OnlineGenerator."""
-
-    def __init__(
-        self,
-        env: Env,
-        name: str,
-        generator: TaskPath,
-        input_: TaskPath,
-        subtask: int,
-        seed: int,
-        **kwargs,
-    ) -> None:
-        super().__init__(env=env, name=name, **kwargs)
-        self.generator = generator
-        self.subtask = subtask
-        self.seed = seed
-        self.input_ = input_
-
-    def _gen(self, input_file: TaskPath, seed: int, subtask: int) -> RunResult:
-        if seed < 0:
-            raise PipelineItemFailure(f"Seed {seed} is negative.")
-
-        input_dir = os.path.dirname(input_file.path)
-
-        difficulty = str(subtask)
-        hexa_seed = f"{seed:x}"
-
-        result = self._run_program(
-            ProgramType.in_gen,
-            self.generator,
-            args=[difficulty, hexa_seed],
-            stdout=input_file,
-            stderr=TaskPath.log_file(self._env, self.input_.name, self.generator.name),
-        )
-        if result.kind != RunResultKind.OK:
-            raise self._create_program_failure(
-                f"{self.generator} failed on subtask {subtask}, seed {seed:x}:", result
-            )
-
-        return result
-
-
-class OnlineGeneratorGenerate(OnlineGeneratorJob):
-    """Generates single input using OnlineGenerator."""
-
-    def __init__(
-        self,
-        env: Env,
-        generator: TaskPath,
-        input_: TaskPath,
-        subtask: int,
-        seed: int,
-        **kwargs,
-    ) -> None:
-        super().__init__(
-            env=env,
-            name=f"Generate {input_.name}",
-            generator=generator,
-            input_=input_,
-            subtask=subtask,
-            seed=seed,
-            **kwargs,
-        )
-
-    def _run(self) -> RunResult:
-        return self._gen(self.input_, self.seed, self.subtask)
-
-
-class OnlineGeneratorDeterministic(OnlineGeneratorJob):
-    """Test whether generating given input again has same result."""
-
-    def __init__(
-        self,
-        env: Env,
-        generator: TaskPath,
-        input_: TaskPath,
-        subtask: int,
-        seed: int,
-        **kwargs,
-    ) -> None:
-        super().__init__(
-            env=env,
-            name=f"Generator is deterministic (subtask {subtask}, seed {seed:x})",
-            generator=generator,
-            input_=input_,
-            subtask=subtask,
-            seed=seed,
-            **kwargs,
-        )
-
-    def _run(self) -> None:
-        copy_file = self.input_.replace_suffix(".in2")
-        self._gen(copy_file, self.seed, self.subtask)
-        if not self._files_equal(self.input_, copy_file):
-            raise PipelineItemFailure(
-                f"Generator is not deterministic. Files {self.input_:p} and {copy_file:p} differ "
-                f"(subtask {self.subtask}, seed {self.seed})",
-            )
-
-
-class OnlineGeneratorRespectsSeed(TaskJob):
-    """Test whether two files generated with different seed are different."""
-
-    def __init__(
-        self,
-        env: Env,
-        subtask: int,
-        seed1: int,
-        seed2: int,
-        file1: TaskPath,
-        file2: TaskPath,
-        **kwargs,
-    ) -> None:
-        self.file1, self.file2 = file1, file2
-        self.subtask = subtask
-        self.seed1, self.seed2 = seed1, seed2
-        super().__init__(
-            env=env,
-            name=f"Generator respects seeds ({self.file1:n} and {self.file2:n} are different)",
-            **kwargs,
-        )
-
-    def _run(self) -> None:
-        if self._files_equal(self.file1, self.file2):
-            raise PipelineItemFailure(
-                f"Generator doesn't respect seed."
-                f"Files {self.file1:n} (seed {self.seed1:x}) and {self.file2:n} (seed {self.seed2:x}) are same."
-            )
-
-
-class OfflineGeneratorGenerate(ProgramsJob):
-    """Job that generates all inputs using OfflineGenerator."""
-
-    def __init__(self, env: Env, generator: TaskPath, **kwargs) -> None:
-        super().__init__(env=env, name="Generate inputs", **kwargs)
-        self.generator = generator
-
-    def _gen(self) -> None:
-        """Generates all inputs."""
-        gen_dir = TaskPath.generated_path(self._env, ".")
-        try:
-            shutil.rmtree(gen_dir.path)
-        except FileNotFoundError:
-            pass
-        self.makedirs(gen_dir, exist_ok=False)
-
-        run_result = self._run_program(
-            ProgramType.in_gen,
-            self.generator,
-            args=[gen_dir.path],
-            stderr=TaskPath.log_file(self._env, None, self.generator.name),
-        )
-        self._access_file(gen_dir)
-
-        if run_result.kind != RunResultKind.OK:
-            raise self._create_program_failure("Generator failed:", run_result)
-
-    def _run(self) -> None:
-        self._gen()
diff --git a/pisek/task_jobs/generator/__init__.py b/pisek/task_jobs/generator/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pisek/task_jobs/generator/base_classes.py b/pisek/task_jobs/generator/base_classes.py
new file mode 100644
index 00000000..8a77620d
--- /dev/null
+++ b/pisek/task_jobs/generator/base_classes.py
@@ -0,0 +1,126 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from abc import abstractmethod
+
+from pisek.env.env import Env
+from pisek.jobs.jobs import PipelineItemFailure
+from pisek.utils.paths import TaskPath
+from pisek.task_jobs.task_job import TaskJob
+from pisek.task_jobs.program import ProgramsJob
+
+from .input_info import InputInfo
+
+
+class GeneratorListInputs(ProgramsJob):
+    """Lists all inputs generator can generate."""
+
+    def __init__(
+        self, env: Env, generator: TaskPath, *, name: str = "", **kwargs
+    ) -> None:
+        self.generator = generator
+        super().__init__(env=env, name=name or "List generator inputs", **kwargs)
+
+    @abstractmethod
+    def _run(self) -> list[InputInfo]:
+        pass
+
+
+class GenerateInput(ProgramsJob):
+    """Generates input with given name."""
+
+    def __init__(
+        self,
+        env: Env,
+        generator: TaskPath,
+        input_info: InputInfo,
+        seed: int,
+        *,
+        name: str = "",
+        **kwargs,
+    ) -> None:
+        assert input_info.is_generated
+
+        self.generator = generator
+        self.seed = seed
+        self.input_info = input_info
+        self.input_path = input_info.task_path(env, seed)
+        super().__init__(
+            env=env, name=name or f"Generate {self.input_path.name}", **kwargs
+        )
+
+    def _run(self) -> None:
+        self._gen()
+
+    @abstractmethod
+    def _gen(self) -> None:
+        pass
+
+
+class GeneratorTestDeterminism(ProgramsJob):
+    """Tests determinism of generating a given input."""
+
+    def __init__(
+        self,
+        env: Env,
+        generator: TaskPath,
+        input_info: InputInfo,
+        seed: int,
+        *,
+        name: str = "",
+        **kwargs,
+    ) -> None:
+        assert input_info.is_generated and input_info.seeded
+
+        self.generator = generator
+        self.seed = seed
+        self.input_info = input_info
+        self.input = input_info.task_path(env, seed)
+        super().__init__(env=env, name=name or f"Generate {self.input.name}", **kwargs)
+
+    def _run(self) -> None:
+        original = self.input.replace_suffix(".in2")
+        self._rename_file(self.input, original)
+        self._gen()
+        if not self._files_equal(self.input, original):
+            raise PipelineItemFailure(
+                f"Generator is not deterministic. Files {self.input:p} and {original:p} differ"
+                + (f" (seed {self.seed:x})" if self.input_info.seeded else "")
+                + "."
+            )
+
+    @abstractmethod
+    def _gen(self) -> None:
+        pass
+
+
+class GeneratorRespectsSeed(TaskJob):
+    def __init__(self, env: Env, input_info: InputInfo, seed1: int, seed2: int) -> None:
+        assert input_info.is_generated and input_info.seeded
+
+        self.input_info = input_info
+        self.seed1 = seed1
+        self.seed2 = seed2
+        self.input1 = input_info.task_path(self._env, seed1)
+        self.input2 = input_info.task_path(self._env, seed2)
+        super().__init__(
+            env=env,
+            name=f"Generator respects seed ({self.input1:n} and {self.input2:n})",
+        )
+
+    def _run(self) -> None:
+        if self._files_equal(self.input1, self.input2):
+            raise PipelineItemFailure(
+                f"Inputs generated with different seed are same:\n"
+                f"- {self.input1:p} (seed {self.seed1:x})\n"
+                f"- {self.input2:p} (seed {self.seed2:x})"
+            )
diff --git a/pisek/task_jobs/generator/cms_old.py b/pisek/task_jobs/generator/cms_old.py
new file mode 100644
index 00000000..a5d5c19c
--- /dev/null
+++ b/pisek/task_jobs/generator/cms_old.py
@@ -0,0 +1,78 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import os
+import shutil
+
+from pisek.env.env import Env
+from pisek.config.config_types import ProgramType
+from pisek.utils.paths import TaskPath
+from pisek.task_jobs.program import RunResultKind
+
+from .input_info import InputInfo
+from .base_classes import GeneratorListInputs, GenerateInput
+
+
+class CmsOldListInputs(GeneratorListInputs):
+    """Lists all inputs for cms-old generator - by running it."""
+
+    def __init__(self, env: Env, generator: TaskPath, **kwargs) -> None:
+        super().__init__(env, generator, name="Run generator", **kwargs)
+
+    def _run(self) -> list[InputInfo]:
+        """Generates all inputs."""
+        gen_dir = TaskPath.generated_path(self._env, ".")
+        try:
+            shutil.rmtree(gen_dir.path)
+        except FileNotFoundError:
+            pass
+        self.makedirs(gen_dir, exist_ok=False)
+
+        run_result = self._run_program(
+            ProgramType.in_gen,
+            self.generator,
+            args=[gen_dir.path],
+            stderr=TaskPath.log_file(self._env, None, self.generator.name),
+        )
+        self._access_file(gen_dir)
+
+        if run_result.kind != RunResultKind.OK:
+            raise self._create_program_failure("Generator failed:", run_result)
+
+        inputs = []
+        for inp in os.listdir(TaskPath.generated_path(self._env, ".").path):
+            if inp.endswith(".in"):
+                inputs.append(
+                    InputInfo.generated(inp.removesuffix(".in"), seeded=False)
+                )
+        return inputs
+
+
+class CmsOldGenerate(GenerateInput):
+    def __init__(
+        self, env: Env, generator: TaskPath, input_info: InputInfo, seed: int, **kwargs
+    ) -> None:
+        super().__init__(
+            env,
+            generator,
+            input_info,
+            seed,
+            name=f"Serve {input_info.task_path(env).name}",
+            **kwargs,
+        )
+
+    def _gen(self):
+        self._link_file(
+            TaskPath.generated_path(self._env, self.input_path.name),
+            TaskPath.input_path(self._env, self.input_path.name),
+            overwrite=True,
+        )
diff --git a/pisek/task_jobs/generator/input_info.py b/pisek/task_jobs/generator/input_info.py
new file mode 100644
index 00000000..15c89a97
--- /dev/null
+++ b/pisek/task_jobs/generator/input_info.py
@@ -0,0 +1,64 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from dataclasses import dataclass
+from typing import Optional
+import yaml
+
+from pisek.env.env import Env
+from pisek.utils.paths import TaskPath
+
+
+@dataclass(frozen=True)
+class InputInfo:
+    name: str
+    repeat: int = 1
+    is_generated: bool = True
+    seeded: bool = True
+
+    @staticmethod
+    def generated(name: str, repeat: int = 1, seeded: bool = True) -> "InputInfo":
+        return InputInfo(name, repeat, True, seeded)
+
+    @staticmethod
+    def static(name: str) -> "InputInfo":
+        return InputInfo(name, 1, False, False)
+
+    def task_path(self, env: Env, seed: Optional[int] = None) -> TaskPath:
+        filename = self.name
+        if self.seeded:
+            assert seed is not None
+            filename += f"_{seed:x}"
+        filename += ".in"
+
+        return TaskPath.input_path(env, filename)
+
+
+def input_info_representer(dumper, input_info: InputInfo):
+    return dumper.represent_sequence(
+        "!InputInfo",
+        [
+            input_info.name,
+            input_info.repeat,
+            input_info.is_generated,
+            input_info.seeded,
+        ],
+    )
+
+
+def input_info_constructor(loader, value):
+    [name, repeat, generated, seeded] = loader.construct_sequence(value)
+    return InputInfo(name, repeat, generated, seeded)
+
+
+yaml.add_representer(InputInfo, input_info_representer)
+yaml.add_constructor("!InputInfo", input_info_constructor)
diff --git a/pisek/task_jobs/generator/manager.py b/pisek/task_jobs/generator/manager.py
new file mode 100644
index 00000000..66d2d681
--- /dev/null
+++ b/pisek/task_jobs/generator/manager.py
@@ -0,0 +1,250 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2019 - 2022 Václav Volhejn <vaclav.volhejn@gmail.com>
+# Copyright (c)   2019 - 2022 Jiří Beneš <mail@jiribenes.com>
+# Copyright (c)   2020 - 2022 Michal Töpfer <michal.topfer@gmail.com>
+# Copyright (c)   2022        Jiří Kalvoda <jirikalvoda@kam.mff.cuni.cz>
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import random
+from typing import Any, Optional
+
+from pisek.env.env import Env
+from pisek.utils.paths import TaskPath
+from pisek.config.config_types import GenType, DataFormat
+from pisek.jobs.jobs import Job, JobManager
+from pisek.task_jobs.task_manager import TaskJobManager
+from pisek.task_jobs.compile import Compile
+from pisek.task_jobs.program import RunResultKind
+from pisek.task_jobs.data.data import InputSmall, OutputSmall
+from pisek.task_jobs.tools import IsClean
+from pisek.task_jobs.checker import CheckerJob
+from pisek.task_jobs.solution.solution import RunBatchSolution
+
+from .input_info import InputInfo
+from .base_classes import (
+    GeneratorListInputs,
+    GenerateInput,
+    GeneratorTestDeterminism,
+    GeneratorRespectsSeed,
+)
+from .cms_old import CmsOldListInputs, CmsOldGenerate
+from .opendata_v1 import (
+    OpendataV1ListInputs,
+    OpendataV1Generate,
+    OpendataV1TestDeterminism,
+)
+
+SEED_RANGE = range(0, 16**8)
+
+
+class PrepareGenerator(TaskJobManager):
+    """Prepares generator for use."""
+
+    def __init__(self):
+        self._inputs = []
+        super().__init__("Prepare generator")
+
+    def _get_jobs(self) -> list[Job]:
+        generator = self._env.config.in_gen
+
+        jobs: list[Job] = [
+            compile_gen := Compile(self._env, generator),
+            list_inputs := list_inputs_job(self._env, generator),
+        ]
+        list_inputs.add_prerequisite(compile_gen)
+        self._list_inputs = list_inputs
+
+        return jobs
+
+    def _compute_result(self) -> dict[str, Any]:
+        return {"inputs": self._list_inputs.result}
+
+
+def list_inputs_job(env: Env, generator: TaskPath) -> GeneratorListInputs:
+    return {
+        GenType.opendata_v1: OpendataV1ListInputs,
+        GenType.cms_old: CmsOldListInputs,
+    }[env.config.gen_type](env=env, generator=generator)
+
+
+def generate_input(
+    env: Env, generator: TaskPath, input_info: InputInfo, seed: int
+) -> GenerateInput:
+    return {
+        GenType.opendata_v1: OpendataV1Generate,
+        GenType.cms_old: CmsOldGenerate,
+    }[
+        env.config.gen_type
+    ](env=env, generator=generator, input_info=input_info, seed=seed)
+
+
+def generator_test_determinism(
+    env: Env, generator: TaskPath, input_info: InputInfo, seed: int
+) -> Optional[GeneratorTestDeterminism]:
+    TEST_DETERMINISM = {GenType.opendata_v1: OpendataV1TestDeterminism}
+
+    if env.config.gen_type not in TEST_DETERMINISM:
+        return None
+    return TEST_DETERMINISM[env.config.gen_type](
+        env=env, generator=generator, input_info=input_info, seed=seed
+    )
+
+
+class InputsInfoMixin(JobManager):
+    def __init__(self, name: str, **kwargs) -> None:
+        self._gen_inputs_job: dict[int, GenerateInput] = {}
+        super().__init__(name=name, **kwargs)
+
+    def _input_info_jobs(self, input_info: InputInfo, subtask: int) -> list[Job]:
+        repeat = input_info.repeat * (
+            self._env.repeat_inputs if input_info.seeded else 1
+        )
+
+        jobs: list[Job] = []
+
+        seeds = random.Random(4).sample(SEED_RANGE, repeat)  # Reproducibility!
+        for i, seed in enumerate(seeds):
+            if self._skip_input(input_info, seed, subtask):
+                continue
+
+            inp_jobs = self._generate_input_jobs(input_info, seed, subtask, i == 0)
+            out_jobs = self._solution_jobs(input_info, seed, subtask)
+            if seed in self._gen_inputs_job and len(out_jobs) > 0:
+                out_jobs[0].add_prerequisite(self._gen_inputs_job[seed])
+
+            jobs += inp_jobs + out_jobs
+
+        jobs += self._respects_seed_jobs(input_info, seeds, subtask)
+        return jobs
+
+    def _skip_input(self, input_info: InputInfo, seed: int, subtask: int) -> bool:
+        return not self._env.config.subtasks[subtask].new_in_subtask(
+            input_info.task_path(self._env, seed).name
+        )
+
+    def _generate_input_jobs(
+        self, input_info: InputInfo, seed: int, subtask: int, test_determinism: bool
+    ) -> list[Job]:
+        jobs: list[Job] = []
+        input_path = input_info.task_path(self._env, seed)
+
+        gen_inp: Optional[GenerateInput]
+        if input_info.is_generated:
+            jobs.append(gen_inp := self._generate_input_job(input_info, seed))
+            self._gen_inputs_job[seed] = gen_inp
+        else:
+            gen_inp = None
+
+        if input_info.is_generated and test_determinism:
+            test_det = generator_test_determinism(
+                self._env, self._env.config.in_gen, input_info, seed
+            )
+            if test_det is not None:
+                jobs.append(test_det)
+                test_det.add_prerequisite(gen_inp)
+
+        jobs += self._check_input_jobs(input_path)
+
+        if self._env.config.checker is not None and subtask > 0:
+            jobs.append(
+                check_input := CheckerJob(
+                    self._env,
+                    self._env.config.checker,
+                    input_path,
+                    subtask,
+                )
+            )
+            check_input.add_prerequisite(gen_inp)
+
+        return jobs
+
+    def _generate_input_job(self, input_info: InputInfo, seed: int) -> GenerateInput:
+        gen_inp = generate_input(self._env, self._env.config.in_gen, input_info, seed)
+        self._gen_inputs_job[seed] = gen_inp
+        return gen_inp
+
+    def _solution_jobs(
+        self, input_info: InputInfo, seed: int, subtask: int
+    ) -> list[Job]:
+        return []
+
+    def _respects_seed_jobs(
+        self, input_info: InputInfo, seeds: list[int], subtask: int
+    ) -> list[Job]:
+        if not (input_info.is_generated and input_info.seeded):
+            return []
+
+        if not self._env.config.subtasks[subtask].new_in_subtask(
+            input_info.task_path(self._env, seeds[0]).name
+        ):
+            return []
+
+        jobs: list[Job] = []
+        if len(seeds) == 1:
+            while (seed := random.choice(SEED_RANGE)) == seeds[0]:
+                pass
+            seeds.append(seed)
+            jobs += [self._generate_input_job(input_info, seed)]
+
+        jobs.append(
+            check_seeded := GeneratorRespectsSeed(self._env, input_info, *seeds[:2])
+        )
+        for i in range(2):
+            check_seeded.add_prerequisite(self._gen_inputs_job[seeds[i]])
+
+        return jobs
+
+    def _check_input_jobs(
+        self, input_path: TaskPath, prerequisite: Optional[Job] = None
+    ) -> list[Job]:
+        jobs: list[Job] = []
+        if self._env.config.in_format == DataFormat.text:
+            jobs.append(input_clean := IsClean(self._env, input_path))
+            input_clean.add_prerequisite(prerequisite)
+
+        if self._env.config.limits.input_max_size != 0:
+            jobs.append(input_small := InputSmall(self._env, input_path))
+            input_small.add_prerequisite(prerequisite)
+
+        return jobs
+
+    def _check_output_jobs(
+        self, output_path: TaskPath, prerequisite: Optional[RunBatchSolution]
+    ) -> list[Job]:
+        jobs: list[Job] = []
+        if self._env.config.out_format == DataFormat.text:
+            jobs.append(out_clean := IsClean(self._env, output_path))
+            if prerequisite is not None:
+                out_clean.add_prerequisite(
+                    prerequisite,
+                    condition=lambda r: r.kind == RunResultKind.OK,
+                )
+
+        if self._env.config.limits.output_max_size != 0:
+            jobs.append(out_small := OutputSmall(self._env, output_path))
+            out_small.add_prerequisite(prerequisite)
+
+        return jobs
+
+
+class RunGenerator(TaskJobManager, InputsInfoMixin):
+    def __init__(self) -> None:
+        super().__init__("Run generator")
+
+    def _get_jobs(self) -> list[Job]:
+        jobs = []
+
+        for sub_num, inputs in self._all_inputs().items():
+            for inp in inputs:
+                jobs += self._input_info_jobs(inp, sub_num)
+
+        return jobs
diff --git a/pisek/task_jobs/generator/opendata_v1.py b/pisek/task_jobs/generator/opendata_v1.py
new file mode 100644
index 00000000..18a61776
--- /dev/null
+++ b/pisek/task_jobs/generator/opendata_v1.py
@@ -0,0 +1,78 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from pisek.env.env import Env
+from pisek.config.config_types import ProgramType
+from pisek.utils.paths import TaskPath
+from pisek.task_jobs.program import ProgramsJob, RunResult, RunResultKind
+
+from .input_info import InputInfo
+from .base_classes import GeneratorListInputs, GenerateInput, GeneratorTestDeterminism
+
+
+class OpendataV1ListInputs(GeneratorListInputs):
+    """Lists all inputs for opendata-v1 generator - one for each subtask."""
+
+    def __init__(self, env: Env, generator: TaskPath, **kwargs) -> None:
+        super().__init__(env=env, generator=generator, **kwargs)
+
+    def _run(self) -> list[InputInfo]:
+        return [
+            InputInfo.generated(f"{subtask:02}")
+            for subtask in self._env.config.subtasks
+            if subtask != 0
+        ]
+
+
+class OpendataV1GeneratorJob(ProgramsJob):
+    """Abstract class for jobs with OnlineGenerator."""
+
+    generator: TaskPath
+    seed: int
+    input_info: InputInfo
+    input_path: TaskPath
+
+    def __init__(self, env: Env, *, name: str = "", **kwargs) -> None:
+        super().__init__(env=env, name=name, **kwargs)
+
+    def _gen(self) -> None:
+        if self.seed < 0:
+            raise ValueError(f"Seed {self.seed} is negative.")
+
+        subtask = int(self.input_info.name)
+
+        result = self._run_program(
+            ProgramType.in_gen,
+            self.generator,
+            args=[str(subtask), f"{self.seed:x}"],
+            stdout=self.input_path,
+            stderr=TaskPath.log_file(
+                self._env, self.input_path.name, self.generator.name
+            ),
+        )
+        if result.kind != RunResultKind.OK:
+            raise self._create_program_failure(
+                f"{self.generator} failed on subtask {subtask}, seed {self.seed:x}:",
+                result,
+            )
+
+
+class OpendataV1Generate(OpendataV1GeneratorJob, GenerateInput):
+    """Generates input with given name."""
+
+    pass
+
+
+class OpendataV1TestDeterminism(OpendataV1GeneratorJob, GeneratorTestDeterminism):
+    """Tests determinism of generating a given input."""
+
+    pass
diff --git a/pisek/task_jobs/judge.py b/pisek/task_jobs/judge.py
index 6d057732..f3f91c5d 100644
--- a/pisek/task_jobs/judge.py
+++ b/pisek/task_jobs/judge.py
@@ -23,16 +23,16 @@
 
 from pisek.env.env import Env
 from pisek.utils.paths import TaskPath
-from pisek.config.config_types import ProgramType, OutCheck, JudgeType
+from pisek.config.config_types import TaskType, ProgramType, OutCheck, JudgeType
 from pisek.jobs.jobs import State, Job, PipelineItemFailure
 from pisek.utils.text import tab
-from pisek.task_jobs.task_job import TaskJobManager
+from pisek.task_jobs.task_manager import TaskJobManager
 from pisek.task_jobs.run_result import RunResult, RunResultKind
 from pisek.task_jobs.program import ProgramsJob
 from pisek.task_jobs.compile import Compile
 from pisek.task_jobs.chaos_monkey import Incomplete, ChaosMonkey
 from pisek.task_jobs.tools import PrepareTokenJudge, Sanitize
-from pisek.task_jobs.solution_result import Verdict, SolutionResult
+from pisek.task_jobs.solution.solution_result import Verdict, SolutionResult
 
 DIFF_NAME = "diff.sh"
 
@@ -56,8 +56,8 @@ def _get_jobs(self) -> list[Job]:
         elif self._env.config.out_check == OutCheck.tokens:
             jobs.append(comp := PrepareTokenJudge(self._env))
 
-        samples = self._get_samples()
-        if self._env.config.task_type == "communication":
+        samples = self._get_static_samples()
+        if self._env.config.task_type == TaskType.communication:
             return jobs
 
         for inp, out in samples:
@@ -115,65 +115,6 @@ def _compute_result(self) -> dict[str, Any]:
         return result
 
 
-class RunKasiopeaJudgeMan(TaskJobManager):
-    def __init__(
-        self,
-        subtask: int,
-        seed: int,
-        input_: str,
-        output: str,
-        correct_output: str,
-    ) -> None:
-        self._subtask = subtask
-        self._seed = seed
-        self._input = input_
-        self._output = output
-        self._correct_output = correct_output
-        super().__init__("Running judge")
-
-    def _get_jobs(self) -> list[Job]:
-        input_, output, correct_output = map(
-            TaskPath,
-            (self._input, self._output, self._correct_output),
-        )
-        clean_output = TaskPath.sanitized_file(self._env, output.name)
-
-        jobs: list[Job] = [
-            sanitize := Sanitize(self._env, output, clean_output),
-            judge := judge_job(
-                input_,
-                clean_output,
-                correct_output,
-                self._subtask,
-                lambda: f"{self._seed:x}",
-                None,
-                self._env,
-            ),
-        ]
-        judge.add_prerequisite(sanitize)
-        if self._env.config.out_check == OutCheck.judge:
-            if self._env.config.out_judge is None:
-                raise RuntimeError(
-                    f"Unset judge for out_check={self._env.config.out_check.name}"
-                )
-            jobs.insert(
-                0,
-                compile_judge := Compile(self._env, self._env.config.out_judge),
-            )
-            judge.add_prerequisite(compile_judge)
-
-        self._judge_job = judge
-
-        return jobs
-
-    def judge_result(self) -> SolutionResult:
-        if self.state != State.succeeded:
-            raise RuntimeError("Judging hasn't successfully finished.")
-        elif not isinstance(self._judge_job.result, SolutionResult):
-            raise RuntimeError("Judging result invalid.")
-        return self._judge_job.result
-
-
 JUDGE_JOB_NAME = r"Judge (\w+)"
 
 
diff --git a/pisek/task_jobs/program.py b/pisek/task_jobs/program.py
index 2ae50046..821f7310 100644
--- a/pisek/task_jobs/program.py
+++ b/pisek/task_jobs/program.py
@@ -26,7 +26,6 @@
 from pisek.utils.paths import TaskPath
 from pisek.jobs.jobs import PipelineItemFailure
 from pisek.utils.text import tab
-from pisek.utils.terminal import colored_env
 from pisek.task_jobs.run_result import RunResultKind, RunResult
 from pisek.task_jobs.task_job import TaskJob
 
diff --git a/pisek/task_jobs/solution/__init__.py b/pisek/task_jobs/solution/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pisek/task_jobs/solution.py b/pisek/task_jobs/solution/manager.py
similarity index 65%
rename from pisek/task_jobs/solution.py
rename to pisek/task_jobs/solution/manager.py
index c173d0ba..6ac3aa6a 100644
--- a/pisek/task_jobs/solution.py
+++ b/pisek/task_jobs/solution/manager.py
@@ -15,35 +15,38 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
-import os
-import tempfile
-import time
 from typing import Any, Optional
 
 from pisek.jobs.jobs import State, Job, PipelineItemFailure
 from pisek.env.env import Env
 from pisek.utils.paths import TaskPath
-from pisek.config.config_types import ProgramType, Scoring
+from pisek.config.config_types import TaskType, Scoring
 from pisek.utils.text import pad, pad_left, tab, POINTS_DEC_PLACES, format_points
 from pisek.utils.terminal import MSG_LEN, colored_env, right_aligned_text
-from pisek.task_jobs.verdicts_eval import evaluate_verdicts
-from pisek.task_jobs.task_job import TaskJobManager
-from pisek.task_jobs.program import RunResult, ProgramsJob
+from pisek.task_jobs.solution.verdicts_eval import evaluate_verdicts
+from pisek.task_jobs.task_manager import TaskJobManager
 from pisek.task_jobs.compile import Compile
-from pisek.task_jobs.solution_result import Verdict, SolutionResult
+from pisek.task_jobs.generator.input_info import InputInfo
+from pisek.task_jobs.generator.manager import InputsInfoMixin
+from pisek.task_jobs.solution.solution_result import Verdict, SolutionResult
 from pisek.task_jobs.judge import judge_job, RunJudge, RunCMSJudge, RunBatchJudge
+from pisek.task_jobs.solution.solution import (
+    RunSolution,
+    RunBatchSolution,
+    RunCommunication,
+)
 
 
-class SolutionManager(TaskJobManager):
+class SolutionManager(TaskJobManager, InputsInfoMixin):
     """Runs a solution and checks if it works as expected."""
 
-    def __init__(self, solution_label: str) -> None:
+    def __init__(self, solution_label: str, generate_inputs: bool) -> None:
         self.solution_label: str = solution_label
+        self._generate_inputs = generate_inputs
         self.solution_points: Optional[float] = None
         self.subtasks: list[SubtaskJobGroup] = []
-        self._outputs: list[tuple[TaskPath, RunJudge]] = []
         self._subtasks_results: dict[int, float] = {}
-        super().__init__(f"Solution {solution_label} Manager")
+        super().__init__(f"Run {solution_label}")
 
     def _get_jobs(self) -> list[Job]:
         self.is_primary: bool = self._env.config.solutions[self.solution_label].primary
@@ -55,56 +58,86 @@ def _get_jobs(self) -> list[Job]:
         self._compile_job = compile_
 
         self._judges: dict[TaskPath, RunJudge] = {}
-        for sub_num, sub in self._env.config.subtasks.items():
+
+        for sub_num, inputs in self._all_inputs().items():
             self.subtasks.append(SubtaskJobGroup(self._env, sub_num))
-            for inp in self._subtask_inputs(sub):
-                if inp not in self._judges:
-                    run_sol: RunSolution
-                    run_judge: RunJudge
-                    if self._env.config.task_type == "batch":
-                        run_sol, run_judge = self._create_batch_jobs(sub_num, inp)
-                        jobs += [run_sol, run_judge]
-                        self._outputs.append((run_judge.output, run_judge))
-
-                    elif self._env.config.task_type == "communication":
-                        run_sol = run_judge = self._create_communication_jobs(inp)
-                        jobs.append(run_sol)
-
-                    self._judges[inp] = run_judge
-                    self.subtasks[-1].new_jobs.append(run_judge)
-                    self.subtasks[-1].new_run_jobs.append(run_sol)
-                else:
-                    self.subtasks[-1].previous_jobs.append(self._judges[inp])
+            for inp in inputs:
+                jobs += self._input_info_jobs(inp, sub_num)
+
+        return jobs
+
+    def _skip_input(self, input_info: InputInfo, seed: int, subtask: int) -> bool:
+        input_path = input_info.task_path(self._env, seed)
+        if input_path in self._judges:
+            self.subtasks[-1].previous_jobs.append(self._judges[input_path])
+        return super()._skip_input(input_info, seed, subtask)
+
+    def _generate_input_jobs(
+        self, input_info: InputInfo, seed: int, subtask: int, test_determinism: bool
+    ) -> list[Job]:
+        if not self._generate_inputs:
+            return []
+        return super()._generate_input_jobs(input_info, seed, subtask, test_determinism)
+
+    def _respects_seed_jobs(
+        self, input_info: InputInfo, seeds: list[int], subtask: int
+    ) -> list[Job]:
+        if not self._generate_inputs:
+            return []
+        return super()._respects_seed_jobs(input_info, seeds, subtask)
+
+    def _solution_jobs(
+        self, input_info: InputInfo, seed: int, subtask: int
+    ) -> list[Job]:
+        input_path = input_info.task_path(self._env, seed)
+
+        jobs: list[Job] = []
+
+        run_sol: RunSolution
+        run_judge: RunJudge
+        if self._env.config.task_type == TaskType.batch:
+            if not input_info.is_generated and self._generate_inputs:
+                jobs += self._check_input_jobs(
+                    self._get_reference_output(input_info, seed)
+                )
+
+            run_batch_sol, run_judge = self._create_batch_jobs(
+                input_info, seed, subtask
+            )
+            run_sol = run_batch_sol
+            jobs += [run_batch_sol, run_judge]
+            jobs += self._check_output_jobs(run_batch_sol.output, run_batch_sol)
+
+        elif self._env.config.task_type == TaskType.communication:
+            run_sol = run_judge = self._create_communication_jobs(input_path)
+            jobs.append(run_sol)
+
+        self._judges[input_path] = run_judge
+        self.subtasks[-1].new_jobs.append(run_judge)
+        self.subtasks[-1].new_run_jobs.append(run_sol)
 
         return jobs
 
     def _create_batch_jobs(
-        self, sub_num: int, inp: TaskPath
-    ) -> tuple["RunSolution", RunBatchJudge]:
+        self, input_info: InputInfo, seed: int, subtask: int
+    ) -> tuple[RunBatchSolution, RunBatchJudge]:
         """Create RunSolution and RunBatchJudge jobs for batch task type."""
+        input_path = input_info.task_path(self._env, seed)
         run_solution = RunBatchSolution(
             self._env,
             self._solution,
             self.is_primary,
-            inp,
+            input_path,
         )
         run_solution.add_prerequisite(self._compile_job)
 
-        if sub_num == 0:
-            c_out = TaskPath.output_static_file(self._env, inp.name)
-        else:
-            primary_sol = self._env.config.solutions[
-                self._env.config.primary_solution
-            ].raw_source
-            c_out = TaskPath.output_file(self._env, inp.name, primary_sol)
-
-        out = TaskPath.output_file(self._env, inp.name, self._solution.name)
+        out = TaskPath.output_file(self._env, input_path.name, self._solution.name)
         run_judge = judge_job(
-            inp,
+            input_path,
             out,
-            c_out,
-            sub_num,
-            lambda: self._get_seed(inp.name),
+            self._get_reference_output(input_info, seed),
+            subtask,
+            lambda: f"{seed:x}",
             None,
             self._env,
         )
@@ -112,7 +145,7 @@ def _create_batch_jobs(
 
         return (run_solution, run_judge)
 
-    def _create_communication_jobs(self, inp: TaskPath) -> "RunCommunication":
+    def _create_communication_jobs(self, inp: TaskPath) -> RunCommunication:
         """Create RunCommunication job for communication task type."""
         if self._env.config.out_judge is None:
             raise RuntimeError("Unset judge for communication.")
@@ -190,16 +223,6 @@ def _evaluate(self) -> None:
 
     def _compute_result(self) -> dict[str, Any]:
         result: dict[str, Any] = {}
-        result["outputs"] = {
-            Verdict.ok: [],
-            Verdict.partial_ok: [],
-            Verdict.wrong_answer: [],
-            Verdict.timeout: [],
-            Verdict.error: [],
-        }
-        for output, job in self._outputs:
-            if job.result is not None:
-                result["outputs"][job.result.verdict].append(output)
 
         result["results"] = {}
         result["judge_outs"] = set()
@@ -431,166 +454,3 @@ def _as_expected(self, expected_str: str) -> tuple[bool, bool, Optional[RunJudge
     def cancel(self):
         for job in self.new_run_jobs:
             job.cancel()
-
-
-class RunPrimarySolutionMan(TaskJobManager):
-    def __init__(self, input_: str, output: Optional[str]):
-        self._input = input_
-        self._output = output
-        super().__init__("Running primary solution")
-
-    def _get_jobs(self) -> list[Job]:
-        solution = self._env.config.solutions[self._env.config.primary_solution].source
-
-        jobs: list[Job] = [
-            compile := Compile(self._env, solution, True),
-            run_solution := RunBatchSolution(
-                self._env,
-                solution,
-                True,
-                TaskPath(self._input),
-                TaskPath(self._output) if self._output else None,
-            ),
-        ]
-        run_solution.add_prerequisite(compile)
-
-        return jobs
-
-
-RUN_JOB_NAME = r"Run (.*) on input (.*)"
-
-
-class RunSolution(ProgramsJob):
-    """Runs solution on given input."""
-
-    def __init__(
-        self,
-        env: Env,
-        name: str,
-        solution: TaskPath,
-        is_primary: bool,
-        **kwargs,
-    ) -> None:
-        super().__init__(env=env, name=name, **kwargs)
-        self.solution = solution
-        self.is_primary = is_primary
-
-    def _solution_type(self) -> ProgramType:
-        return (ProgramType.solve) if self.is_primary else (ProgramType.sec_solve)
-
-
-class RunBatchSolution(RunSolution):
-    def __init__(
-        self,
-        env: Env,
-        solution: TaskPath,
-        is_primary: bool,
-        input_: TaskPath,
-        output: Optional[TaskPath] = None,
-        **kwargs,
-    ) -> None:
-        name = RUN_JOB_NAME.replace(r"(.*)", solution.name, 1).replace(
-            r"(.*)", input_.name, 1
-        )
-        super().__init__(
-            env=env, name=name, solution=solution, is_primary=is_primary, **kwargs
-        )
-        self.input = input_
-        self.output = (
-            output
-            if output
-            else TaskPath.output_file(self._env, self.input.name, solution.name)
-        )
-        self.log_file = TaskPath.log_file(self._env, input_.name, self.solution.name)
-
-    def _run(self) -> RunResult:
-        return self._run_program(
-            program_type=self._solution_type(),
-            program=self.solution,
-            stdin=self.input,
-            stdout=self.output,
-            stderr=self.log_file,
-        )
-
-
-class RunCommunication(RunCMSJudge, RunSolution):
-    def __init__(
-        self,
-        env: Env,
-        solution: TaskPath,
-        is_primary: bool,
-        judge: TaskPath,
-        input_: TaskPath,
-        expected_points: Optional[float] = None,
-        **kwargs,
-    ):
-        super().__init__(
-            env=env,
-            name=RUN_JOB_NAME.replace(r"(.*)", solution.name, 1).replace(
-                r"(.*)", input_.name, 1
-            ),
-            judge=judge,
-            input_=input_,
-            expected_points=expected_points,
-            judge_log_file=TaskPath.log_file(
-                self._env, input_.name, f"{solution.name}.{judge.name}"
-            ),
-            solution=solution,
-            is_primary=is_primary,
-            **kwargs,
-        )
-        self.solution = solution
-        self.sol_log_file = TaskPath.log_file(
-            self._env, self.input.name, self.solution.name
-        )
-
-    def _get_solution_run_res(self) -> RunResult:
-        with tempfile.TemporaryDirectory() as fifo_dir:
-            fifo_from_solution = os.path.join(fifo_dir, "solution-to-manager")
-            fifo_to_solution = os.path.join(fifo_dir, "manager-to-solution")
-
-            os.mkfifo(fifo_from_solution)
-            os.mkfifo(fifo_to_solution)
-
-            pipes = [
-                os.open(fifo_from_solution, os.O_RDWR),
-                os.open(fifo_to_solution, os.O_RDWR),
-                # Open fifos to prevent blocking on future opens
-                fd_from_solution := os.open(fifo_from_solution, os.O_WRONLY),
-                fd_to_solution := os.open(fifo_to_solution, os.O_RDONLY),
-            ]
-
-            self._load_program(
-                ProgramType.judge,
-                self.judge,
-                stdin=self.input,
-                stdout=self.points_file,
-                stderr=self.judge_log_file,
-                args=[fifo_from_solution, fifo_to_solution],
-            )
-
-            self._load_program(
-                self._solution_type(),
-                self.solution,
-                stdin=fd_to_solution,
-                stdout=fd_from_solution,
-                stderr=self.sol_log_file,
-            )
-
-            def close_pipes(_):
-                time.sleep(0.05)
-                for pipe in pipes:
-                    os.close(pipe)
-
-            self._load_callback(close_pipes)
-
-            judge_res, sol_res = self._run_programs()
-            self._judge_run_result = judge_res
-
-            return sol_res
-
-    def _judge(self) -> SolutionResult:
-        return self._load_solution_result(self._judge_run_result)
-
-    def _judging_message(self) -> str:
-        return f"solution {self.solution:p} on input {self.input:p}"
diff --git a/pisek/task_jobs/solution/solution.py b/pisek/task_jobs/solution/solution.py
new file mode 100644
index 00000000..4a5623dd
--- /dev/null
+++ b/pisek/task_jobs/solution/solution.py
@@ -0,0 +1,163 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+# Copyright (c)   2024        Benjamin Swart <benjaminswart@email.cz>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+import os
+import tempfile
+import time
+from typing import Optional
+
+from pisek.env.env import Env
+from pisek.utils.paths import TaskPath
+from pisek.config.config_types import ProgramType
+from pisek.task_jobs.program import RunResult, ProgramsJob
+from pisek.task_jobs.solution.solution_result import SolutionResult
+from pisek.task_jobs.judge import RunCMSJudge
+
+
+RUN_JOB_NAME = r"Run (.*) on input (.*)"
+
+
+class RunSolution(ProgramsJob):
+    """Runs solution on given input."""
+
+    def __init__(
+        self,
+        env: Env,
+        name: str,
+        solution: TaskPath,
+        is_primary: bool,
+        **kwargs,
+    ) -> None:
+        super().__init__(env=env, name=name, **kwargs)
+        self.solution = solution
+        self.is_primary = is_primary
+
+    def _solution_type(self) -> ProgramType:
+        return (ProgramType.solve) if self.is_primary else (ProgramType.sec_solve)
+
+
+class RunBatchSolution(RunSolution):
+    def __init__(
+        self,
+        env: Env,
+        solution: TaskPath,
+        is_primary: bool,
+        input_: TaskPath,
+        output: Optional[TaskPath] = None,
+        **kwargs,
+    ) -> None:
+        name = RUN_JOB_NAME.replace(r"(.*)", solution.name, 1).replace(
+            r"(.*)", input_.name, 1
+        )
+        super().__init__(
+            env=env, name=name, solution=solution, is_primary=is_primary, **kwargs
+        )
+        self.input = input_
+        self.output = (
+            output
+            if output
+            else TaskPath.output_file(self._env, self.input.name, solution.name)
+        )
+        self.log_file = TaskPath.log_file(self._env, input_.name, self.solution.name)
+
+    def _run(self) -> RunResult:
+        return self._run_program(
+            program_type=self._solution_type(),
+            program=self.solution,
+            stdin=self.input,
+            stdout=self.output,
+            stderr=self.log_file,
+        )
+
+
+class RunCommunication(RunCMSJudge, RunSolution):
+    def __init__(
+        self,
+        env: Env,
+        solution: TaskPath,
+        is_primary: bool,
+        judge: TaskPath,
+        input_: TaskPath,
+        expected_points: Optional[float] = None,
+        **kwargs,
+    ):
+        super().__init__(
+            env=env,
+            name=RUN_JOB_NAME.replace(r"(.*)", solution.name, 1).replace(
+                r"(.*)", input_.name, 1
+            ),
+            judge=judge,
+            input_=input_,
+            expected_points=expected_points,
+            judge_log_file=TaskPath.log_file(
+                self._env, input_.name, f"{solution.name}.{judge.name}"
+            ),
+            solution=solution,
+            is_primary=is_primary,
+            **kwargs,
+        )
+        self.solution = solution
+        self.sol_log_file = TaskPath.log_file(
+            self._env, self.input.name, self.solution.name
+        )
+
+    def _get_solution_run_res(self) -> RunResult:
+        with tempfile.TemporaryDirectory() as fifo_dir:
+            fifo_from_solution = os.path.join(fifo_dir, "solution-to-manager")
+            fifo_to_solution = os.path.join(fifo_dir, "manager-to-solution")
+
+            os.mkfifo(fifo_from_solution)
+            os.mkfifo(fifo_to_solution)
+
+            pipes = [
+                os.open(fifo_from_solution, os.O_RDWR),
+                os.open(fifo_to_solution, os.O_RDWR),
+                # Open fifos to prevent blocking on future opens
+                fd_from_solution := os.open(fifo_from_solution, os.O_WRONLY),
+                fd_to_solution := os.open(fifo_to_solution, os.O_RDONLY),
+            ]
+
+            self._load_program(
+                ProgramType.judge,
+                self.judge,
+                stdin=self.input,
+                stdout=self.points_file,
+                stderr=self.judge_log_file,
+                args=[fifo_from_solution, fifo_to_solution],
+            )
+
+            self._load_program(
+                self._solution_type(),
+                self.solution,
+                stdin=fd_to_solution,
+                stdout=fd_from_solution,
+                stderr=self.sol_log_file,
+            )
+
+            def close_pipes(_):
+                time.sleep(0.05)
+                for pipe in pipes:
+                    os.close(pipe)
+
+            self._load_callback(close_pipes)
+
+            judge_res, sol_res = self._run_programs()
+            self._judge_run_result = judge_res
+
+            return sol_res
+
+    def _judge(self) -> SolutionResult:
+        return self._load_solution_result(self._judge_run_result)
+
+    def _judging_message(self) -> str:
+        return f"solution {self.solution:p} on input {self.input:p}"
diff --git a/pisek/task_jobs/solution_result.py b/pisek/task_jobs/solution/solution_result.py
similarity index 100%
rename from pisek/task_jobs/solution_result.py
rename to pisek/task_jobs/solution/solution_result.py
diff --git a/pisek/task_jobs/verdicts_eval.py b/pisek/task_jobs/solution/verdicts_eval.py
similarity index 93%
rename from pisek/task_jobs/verdicts_eval.py
rename to pisek/task_jobs/solution/verdicts_eval.py
index e56e7845..d081f60d 100644
--- a/pisek/task_jobs/verdicts_eval.py
+++ b/pisek/task_jobs/solution/verdicts_eval.py
@@ -15,7 +15,11 @@
 
 from pisek.config.task_config import TaskConfig
 from pisek.config.config_types import Scoring
-from pisek.task_jobs.solution_result import Verdict, SUBTASK_SPEC, verdict_always
+from pisek.task_jobs.solution.solution_result import (
+    Verdict,
+    SUBTASK_SPEC,
+    verdict_always,
+)
 
 
 def evaluate_verdicts(
diff --git a/pisek/task_jobs/task_job.py b/pisek/task_jobs/task_job.py
index 22a81f16..a53486b8 100644
--- a/pisek/task_jobs/task_job.py
+++ b/pisek/task_jobs/task_job.py
@@ -19,7 +19,7 @@
 from math import ceil
 import os
 import shutil
-from typing import Any, Callable, Iterable, Literal
+from typing import Any, Callable, Iterable, Literal, Optional
 
 import subprocess
 from pisek.env.env import Env
@@ -27,31 +27,14 @@
 from pisek.utils.paths import TaskPath
 from pisek.utils.terminal import colored_env
 from pisek.utils.text import tab
-from pisek.config.task_config import SubtaskConfig, ProgramType
+from pisek.config.task_config import ProgramType
 from pisek.jobs.jobs import Job
-from pisek.jobs.status import StatusJobManager
-
-
-TOOLS_MAN_CODE = "tools"
-GENERATOR_MAN_CODE = "generator"
-INPUTS_MAN_CODE = "inputs"
-CHECKER_MAN_CODE = "checker"
-JUDGE_MAN_CODE = "judge"
-SOLUTION_MAN_CODE = "solution_"
-DATA_MAN_CODE = "data"
+from pisek.task_jobs.generator.input_info import InputInfo
 
 
 class TaskHelper:
     _env: Env
 
-    def _get_seed(self, input_name: str):
-        """Get seed from input name."""
-        parts = os.path.splitext(os.path.basename(input_name))[0].split("_")
-        if len(parts) == 1:
-            return "0"
-        else:
-            return parts[-1]
-
     def _get_limits(self, program_type: ProgramType) -> dict[str, Any]:
         """Get execution limits for given program type."""
         limits: ProgramLimits = getattr(self._env.config.limits, program_type.name)
@@ -68,6 +51,16 @@ def _get_limits(self, program_type: ProgramType) -> dict[str, Any]:
             "process_limit": limits.process_limit,
         }
 
+    def _get_reference_output(self, input_info: InputInfo, seed: Optional[int] = None):
+        input_path = input_info.task_path(self._env, seed)
+        if input_info.is_generated:
+            primary_sol = self._env.config.solutions[
+                self._env.config.primary_solution
+            ].raw_source
+            return TaskPath.output_file(self._env, input_path.name, primary_sol)
+        else:
+            return TaskPath.output_static_file(self._env, input_path.name)
+
     def globs_to_files(
         self, globs: Iterable[str], directory: TaskPath
     ) -> list[TaskPath]:
@@ -113,30 +106,6 @@ def make_filedirs(path: TaskPath, exist_ok: bool = True):
         os.makedirs(os.path.dirname(path.path), exist_ok=exist_ok)
 
 
-class TaskJobManager(StatusJobManager, TaskHelper):
-    """JobManager class that implements useful methods"""
-
-    def _get_samples(self) -> list[tuple[TaskPath, TaskPath]]:
-        """Returns the list [(sample1.in, sample1.out), …]."""
-        ins = self._subtask_inputs(self._env.config.subtasks[0])
-        outs = (TaskPath.output_static_file(self._env, inp.name) for inp in ins)
-        return list(zip(ins, outs))
-
-    def _all_inputs(self) -> list[TaskPath]:
-        """Get all input files"""
-        return self.prerequisites_results[INPUTS_MAN_CODE]["inputs"]
-
-    def _subtask_inputs(self, subtask: SubtaskConfig) -> list[TaskPath]:
-        """Get all inputs of given subtask."""
-        return list(filter(lambda p: subtask.in_subtask(p.name), self._all_inputs()))
-
-    def _subtask_new_inputs(self, subtask: SubtaskConfig) -> list[TaskPath]:
-        """Get new inputs of given subtask."""
-        return list(
-            filter(lambda p: subtask.new_in_subtask(p.name), self._all_inputs())
-        )
-
-
 class TaskJob(Job, TaskHelper):
     """Job class that implements useful methods"""
 
@@ -179,6 +148,11 @@ def _copy_file(self, filename: TaskPath, dst: TaskPath):
         self.make_filedirs(dst)
         return shutil.copy(filename.path, dst.path)
 
+    @_file_access(2)
+    def _rename_file(self, filename: TaskPath, dst: TaskPath) -> None:
+        self.make_filedirs(dst)
+        return os.rename(filename.path, dst.path)
+
     @_file_access(2)
     def _link_file(self, filename: TaskPath, dst: TaskPath, overwrite: bool = False):
         self.make_filedirs(dst)
diff --git a/pisek/task_jobs/task_manager.py b/pisek/task_jobs/task_manager.py
new file mode 100644
index 00000000..c62a167f
--- /dev/null
+++ b/pisek/task_jobs/task_manager.py
@@ -0,0 +1,49 @@
+# pisek  - Tool for developing tasks for programming competitions.
+#
+# Copyright (c)   2023        Daniel Skýpala <daniel@honza.info>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# any later version.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+from pisek.utils.paths import TaskPath
+from pisek.config.task_config import SubtaskConfig
+from pisek.jobs.status import StatusJobManager
+from pisek.task_jobs.task_job import TaskHelper
+from pisek.task_jobs.generator.input_info import InputInfo
+
+
+TOOLS_MAN_CODE = "tools"
+GENERATOR_MAN_CODE = "generator"
+INPUTS_MAN_CODE = "inputs"
+CHECKER_MAN_CODE = "checker"
+JUDGE_MAN_CODE = "judge"
+SOLUTION_MAN_CODE = "solution_"
+DATA_MAN_CODE = "data"
+
+
+class TaskJobManager(StatusJobManager, TaskHelper):
+    """JobManager class that implements useful methods"""
+
+    def _get_static_samples(self) -> list[tuple[TaskPath, TaskPath]]:
+        """Returns the list [(sample1.in, sample1.out), …]."""
+        ins = filter(
+            lambda inp: not inp.is_generated,
+            self._subtask_inputs(self._env.config.subtasks[0]),
+        )
+        return [
+            (inp.task_path(self._env), TaskPath.output_static_file(self._env, inp.name))
+            for inp in ins
+        ]
+
+    def _subtask_inputs(self, subtask: SubtaskConfig) -> list[InputInfo]:
+        """Get all inputs of given subtask."""
+        return self.prerequisites_results[INPUTS_MAN_CODE]["input_info"][subtask.num]
+
+    def _all_inputs(self) -> dict[int, list[InputInfo]]:
+        """Get all inputs grouped by subtask."""
+        return self.prerequisites_results[INPUTS_MAN_CODE]["input_info"]
diff --git a/pisek/task_jobs/tools.py b/pisek/task_jobs/tools.py
index 207f4a06..5cc7245a 100644
--- a/pisek/task_jobs/tools.py
+++ b/pisek/task_jobs/tools.py
@@ -14,16 +14,16 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
-import os
 from importlib.resources import files
 from typing import Optional
 
 import subprocess
-from pisek.jobs.jobs import State, Job, PipelineItemFailure
+from pisek.jobs.jobs import Job, PipelineItemFailure
 from pisek.env.env import Env
 from pisek.utils.paths import TaskPath
 from pisek.config.task_config import ProgramType
-from pisek.task_jobs.task_job import TaskJob, TaskJobManager
+from pisek.task_jobs.task_job import TaskJob
+from pisek.task_jobs.task_manager import TaskJobManager
 from pisek.task_jobs.program import ProgramsJob
 
 
diff --git a/pisek/utils/paths.py b/pisek/utils/paths.py
index 52b7c7ba..bcdde71e 100644
--- a/pisek/utils/paths.py
+++ b/pisek/utils/paths.py
@@ -74,6 +74,9 @@ def replace_suffix(self, new_suffix: str) -> "TaskPath":
     def join(self, *path: str) -> "TaskPath":
         return TaskPath(os.path.join(self.path, *path))
 
+    def exists(self) -> bool:
+        return os.path.exists(self.path)
+
     @staticmethod
     def from_abspath(*path: str) -> "TaskPath":
         return TaskPath(os.path.relpath(os.path.join(*path), "."))
diff --git a/pisek/visualize.py b/pisek/visualize.py
index 50d4c142..be102717 100644
--- a/pisek/visualize.py
+++ b/pisek/visualize.py
@@ -22,8 +22,8 @@
 from pisek.config.task_config import TaskConfig
 from pisek.config.config_types import Scoring
 from pisek.config.select_solutions import expand_solutions, UnknownSolutions
-from pisek.task_jobs.solution_result import Verdict
-from pisek.task_jobs.verdicts_eval import evaluate_verdicts
+from pisek.task_jobs.solution.solution_result import Verdict
+from pisek.task_jobs.solution.verdicts_eval import evaluate_verdicts
 
 
 class MissingSolution(Exception):


Test patch:
diff --git a/pisek/task_jobs/testing_log.py b/pisek/task_jobs/testing_log.py
index 7ad89e4c..f325c10d 100644
--- a/pisek/task_jobs/testing_log.py
+++ b/pisek/task_jobs/testing_log.py
@@ -18,12 +18,11 @@
 
 from pisek.utils.paths import TaskPath
 from pisek.jobs.jobs import Job, PipelineItemFailure
-from pisek.utils.terminal import colored_env
-from pisek.task_jobs.task_job import (
+from pisek.task_jobs.task_manager import (
     TaskJobManager,
     SOLUTION_MAN_CODE,
 )
-from pisek.task_jobs.solution_result import SolutionResult
+from pisek.task_jobs.solution.solution_result import SolutionResult
 
 TESTING_LOG = "testing_log.json"
 
diff --git a/tests/test_cms.py b/tests/test_cms.py
index 8801a471..9596c1f2 100644
--- a/tests/test_cms.py
+++ b/tests/test_cms.py
@@ -89,16 +89,6 @@ def modify_task(self):
         overwrite_file(self.task_dir, "judge.cpp", "judge_invalid_score.cpp")
 
 
-class TestLooseChecker(TestSumCMS):
-    """A checker that cannot distinguish between subtasks."""
-
-    def expecting_success(self):
-        return False
-
-    def modify_task(self):
-        overwrite_file(self.task_dir, "check.py", "check_loose.py")
-
-
 class TestStrictChecker(TestSumCMS):
     """A checker whose bounds are stricter than what the generator creates."""
 
@@ -116,9 +106,8 @@ def expecting_success(self):
         return False
 
     def modify_task(self):
-        sample = ["3", "1 2", "-8 5", "0 0"]
-        with open(os.path.join(self.task_dir, "sample.in"), "w") as f:
-            f.write("\n".join(sample))
+        with open(os.path.join(self.task_dir, "sample_01.in"), "w") as f:
+            f.write("1 2")
 
 
 class TestNoLFInTextInput(TestSumCMS):
diff --git a/tests/test_kasiopea.py b/tests/test_kasiopea.py
index 7e3887df..7cda7972 100644
--- a/tests/test_kasiopea.py
+++ b/tests/test_kasiopea.py
@@ -197,16 +197,6 @@ def modify_task(self):
             f.write("\r\n".join(new_program))
 
 
-class TestLooseChecker(TestSumKasiopea):
-    """A checker that cannot distinguish between subtasks."""
-
-    def expecting_success(self):
-        return False
-
-    def modify_task(self):
-        overwrite_file(self.task_dir, "check.py", "check_loose.py")
-
-
 class TestStrictChecker(TestSumKasiopea):
     """A checker whose bounds are stricter than what the generator creates."""