diff --git a/DashAI/back/tasks/base_task.py b/DashAI/back/tasks/base_task.py
index 357ba00b3..d2a3b10b3 100644
--- a/DashAI/back/tasks/base_task.py
+++ b/DashAI/back/tasks/base_task.py
@@ -41,13 +41,163 @@ def schema(self) -> Dict[str, Any]:
"""
raise NotImplementedError
+ @classmethod
+ def _column_groups(cls, side: str) -> List[Dict[str, Any]]:
+ """Read one side of the contract as a list of type groups.
+
+ A task says what its columns may be as a list of groups, each naming a
+ set of interchangeable types and how many columns of that set it takes:
+
+ .. code-block:: python
+
+ "inputs": [
+ {"types": [Date], "cardinality": 1},
+ {"types": [Float, Integer], "cardinality": {"min": 1, "max": "n"}},
+ ]
+
+ Grouping is what lets a task ask for one date column and any number of
+ numeric ones at the same time. A single cardinality for the whole side
+ cannot say that: it can only demand a total, so it would accept two
+ dates and no numbers as readily as the intended shape.
+
+ The older flat spelling, ``inputs_types`` alongside
+ ``inputs_cardinality``, is still read and means a single group, so
+ tasks and plugins written against it keep working unchanged.
+
+ Parameters
+ ----------
+ side : str
+ Either ``"inputs"`` or ``"outputs"``.
+
+ Returns
+ -------
+ list of dict
+ One entry per group, each with ``"types"`` (a tuple of type
+ classes), ``"min"`` (an int) and ``"max"`` (an int or ``"n"``).
+ """
+ metadata = cls.metadata
+
+ if side in metadata:
+ declared = metadata[side]
+ else:
+ declared = [
+ {
+ "types": metadata[f"{side}_types"],
+ "cardinality": metadata[f"{side}_cardinality"],
+ }
+ ]
+
+ return [
+ {
+ "types": tuple(group["types"]),
+ **cls._bounds(group.get("cardinality", "n")),
+ }
+ for group in declared
+ ]
+
+ @staticmethod
+ def _bounds(cardinality: Any) -> Dict[str, Any]:
+ """Read a declared cardinality as a minimum and a maximum.
+
+ Parameters
+ ----------
+ cardinality : Any
+ An int for an exact count, ``"n"`` for any number, or a mapping
+ with ``"min"`` and ``"max"`` for a range whose ``"max"`` may itself
+ be ``"n"``.
+
+ Returns
+ -------
+ dict
+ A mapping with keys ``"min"`` and ``"max"``.
+ """
+ if isinstance(cardinality, dict):
+ return {
+ "min": cardinality.get("min", 0),
+ "max": cardinality.get("max", "n"),
+ }
+ if cardinality == "n":
+ return {"min": 0, "max": "n"}
+ return {"min": cardinality, "max": cardinality}
+
+ @staticmethod
+ def _total_cardinality(groups: List[Dict[str, Any]]) -> Any:
+ """State a whole side's cardinality the way the flat contract did.
+
+ Kept so every consumer of the two-key metadata, the column picker
+ among them, reads the same value it always did for the tasks that
+ declare a single group.
+
+ Parameters
+ ----------
+ groups : list of dict
+ The normalised groups of one side.
+
+ Returns
+ -------
+ int or str
+ The exact total when every group is exact, otherwise ``"n"``.
+ """
+ if all(group["min"] == group["max"] for group in groups):
+ return sum(group["min"] for group in groups)
+ return "n"
+
+ @staticmethod
+ def _type_name(dashai_type) -> str:
+ """Name a type the way the frontend and the columns themselves do.
+
+ A DashAI type reports its own name through ``display_name()``, which
+ matches what a column emits via ``to_string()``. These lists may also
+ hold foreign classes, for example HuggingFace dataset features or a
+ plugin's own types, so those fall back to the class name.
+
+ Parameters
+ ----------
+ dashai_type : type
+ One of the declared type classes.
+
+ Returns
+ -------
+ str
+ The name to show for it.
+ """
+ getter = getattr(dashai_type, "display_name", None)
+ return getter() if callable(getter) else dashai_type.__name__
+
+ @staticmethod
+ def _cardinality_text(group: Dict[str, Any]) -> str:
+ """Say a group's cardinality the way an error message should read it.
+
+ Parameters
+ ----------
+ group : dict
+ A normalised group.
+
+ Returns
+ -------
+ str
+ For example ``"1"``, ``"n"``, ``"at least 1"`` or ``"1 to 3"``.
+ """
+ minimum, maximum = group["min"], group["max"]
+ if maximum == "n":
+ return "n" if minimum == 0 else f"at least {minimum}"
+ if minimum == maximum:
+ return str(minimum)
+ return f"{minimum} to {maximum}"
+
@classmethod
def get_metadata(cls) -> Dict[str, Any]:
"""Return serialisable metadata for the current task.
- Converts the ``inputs_types`` and ``outputs_types`` entries from class
- objects to their string names so the result can be JSON-serialised by
- the DashAI frontend.
+ The contract is reported twice. ``"inputs"`` and ``"outputs"`` carry
+ the per-group form, which is the only one that can tell "one date and
+ any number of numbers" apart from "any number of dates or numbers".
+ ``"inputs_types"`` and the three keys beside it carry the flattened
+ view every existing consumer already reads, unchanged for the tasks
+ that declare a single group.
+
+ Type classes become their string names throughout so the result can be
+ JSON-serialised by the DashAI frontend.
Parameters
----------
@@ -58,32 +208,99 @@ def get_metadata(cls) -> Dict[str, Any]:
-------
Dict[str, Any]
Dictionary with keys ``"inputs_types"``, ``"outputs_types"``,
- ``"inputs_cardinality"``, and ``"outputs_cardinality"``.
+ ``"inputs_cardinality"``, ``"outputs_cardinality"``, ``"inputs"``
+ and ``"outputs"``.
"""
- metadata = cls.metadata
+ parsed_metadata: dict = {}
+
+ for side in ("inputs", "outputs"):
+ groups = cls._column_groups(side)
+
+ names: List[str] = []
+ for group in groups:
+ for dashai_type in group["types"]:
+ name = cls._type_name(dashai_type)
+ if name not in names:
+ names.append(name)
+
+ parsed_metadata[f"{side}_types"] = names
+ parsed_metadata[f"{side}_cardinality"] = cls._total_cardinality(groups)
+ parsed_metadata[side] = [
+ {
+ "types": [cls._type_name(t) for t in group["types"]],
+ "min": group["min"],
+ "max": group["max"],
+ }
+ for group in groups
+ ]
- def _name(dashai_type) -> str:
- """Name the frontend uses, tolerating types from outside DashAI.
-
- A DashAI type reports its own name through ``display_name()``, which
- matches what a column emits via ``to_string()``. These lists may
- also hold foreign classes, for example HuggingFace dataset features
- or a plugin's own types, so those fall back to the class name.
- """
- getter = getattr(dashai_type, "display_name", None)
- return getter() if callable(getter) else dashai_type.__name__
-
- inputs_types = [_name(t) for t in metadata["inputs_types"]]
- outputs_types = [_name(t) for t in metadata["outputs_types"]]
-
- parsed_metadata: dict = {
- "inputs_types": inputs_types,
- "outputs_types": outputs_types,
- "inputs_cardinality": metadata["inputs_cardinality"],
- "outputs_cardinality": metadata["outputs_cardinality"],
- }
return parsed_metadata
+ def _validate_side(
+ self,
+ columns: List[str],
+ types: Dict[str, Any],
+ side: str,
+ ) -> None:
+ """Check one side's columns against the groups the task declares.
+
+ Each column is charged to the first group that accepts its type and
+ still has room, so a task asking for one date and any number of
+ numbers reads a date, a number and a number as a full match rather
+ than as three columns competing for one slot.
+
+ Parameters
+ ----------
+ columns : list of str
+ The selected column names.
+ types : dict
+ The dataset's column types, keyed by column name.
+ side : str
+ Either ``"inputs"`` or ``"outputs"``.
+
+ Raises
+ ------
+ TypeError
+ If a column's type belongs to none of the groups.
+ ValueError
+ If a group ends up with a number of columns outside its bounds.
+ """
+ groups = self._column_groups(side)
+ counts = [0] * len(groups)
+ label = "Input" if side == "inputs" else "Output"
+
+ for column in columns:
+ column_type = types[column]
+ matching = [
+ index
+ for index, group in enumerate(groups)
+ if isinstance(column_type, group["types"])
+ ]
+ if not matching:
+ raise TypeError(
+ f"{column_type} is not an allowed type for {side[:-1]} columns."
+ )
+
+ with_room = [
+ index
+ for index in matching
+ if groups[index]["max"] == "n" or counts[index] < groups[index]["max"]
+ ]
+ counts[(with_room or matching)[0]] += 1
+
+ for group, count in zip(groups, counts, strict=True):
+ if count < group["min"] or (group["max"] != "n" and count > group["max"]):
+ of_types = (
+ ""
+ if len(groups) == 1
+ else " for columns of type "
+ + ", ".join(self._type_name(t) for t in group["types"])
+ )
+ raise ValueError(
+ f"{label} cardinality ({count}) does not match task "
+ f"cardinality ({self._cardinality_text(group)}){of_types}"
+ )
+
def validate_dataset_for_task(
self,
dataset: "DashAIDataset",
@@ -99,45 +316,14 @@ def validate_dataset_for_task(
Dataset to be validated
dataset_name : str
Dataset name
+ input_columns : list of str
+ Names of the columns selected as inputs.
+ output_columns : list of str
+ Names of the columns selected as outputs.
"""
- metadata = self.metadata
- allowed_input_types = tuple(metadata["inputs_types"])
- allowed_output_types = tuple(metadata["outputs_types"])
- inputs_cardinality = metadata["inputs_cardinality"]
- outputs_cardinality = metadata["outputs_cardinality"]
types = dataset._types
- # Check input types
- for input_col in input_columns:
- input_col_type = types[input_col]
-
- if not isinstance(input_col_type, allowed_input_types):
- raise TypeError(
- f"{input_col_type} is not an allowed type for input columns."
- )
-
- # Check output types
- for output_col in output_columns:
- output_col_type = types[output_col]
-
- if not isinstance(output_col_type, allowed_output_types):
- raise TypeError(
- f"{output_col_type} is not an allowed type for output columns."
- )
-
- # Check input cardinality
- if inputs_cardinality != "n" and len(input_columns) != inputs_cardinality:
- raise ValueError(
- f"Input cardinality ({len(input_columns)}) does not"
- f" match task cardinality ({inputs_cardinality})"
- )
-
- # Check output cardinality
- if outputs_cardinality != "n" and len(output_columns) != outputs_cardinality:
- raise ValueError(
- f"Output cardinality ({len(output_columns)})"
- f" does not "
- f"match task cardinality ({outputs_cardinality})"
- )
+ self._validate_side(input_columns, types, "inputs")
+ self._validate_side(output_columns, types, "outputs")
def prepare_for_task(
self,
diff --git a/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx b/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx
index 7afe6007e..85859df9b 100644
--- a/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx
+++ b/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx
@@ -408,6 +408,31 @@ function PrepareDatasetStep({
inputColumnNames,
]);
+ const columnGroupsOf = (side) => {
+ const metadata = taskRequirements?.metadata ?? {};
+ if (Array.isArray(metadata[side]) && metadata[side].length > 0) {
+ return metadata[side];
+ }
+ const cardinality = metadata[`${side}_cardinality`];
+ return [
+ {
+ types: metadata[`${side}_types`] ?? [],
+ min: cardinality === "n" ? 0 : cardinality,
+ max: cardinality,
+ },
+ ];
+ };
+
+ const describeCardinality = ({ min, max }) => {
+ if (max === "n") {
+ return min ? t("experiments:label.cardinalityAtLeast", { min }) : "n";
+ }
+ if (min === max) {
+ return String(max);
+ }
+ return t("experiments:label.cardinalityBetween", { min, max });
+ };
+
const renderTypesAsChips = (typesList) => {
if (!typesList || typesList.length === 0) {
return {t("common:any")};
@@ -483,7 +508,9 @@ function PrepareDatasetStep({
bgcolor: (theme) =>
`${theme.palette[columnsAreValid ? "success" : "error"].main}40`,
border: (theme) =>
- `1px solid ${theme.palette[columnsAreValid ? "success" : "error"].main}`,
+ `1px solid ${
+ theme.palette[columnsAreValid ? "success" : "error"].main
+ }`,
}}
data-tour="models-validation-alert"
>
@@ -496,54 +523,35 @@ function PrepareDatasetStep({
)}
-
-
-
- The input columns must be of the types
- {renderTypesAsChips(taskRequirements.metadata.inputs_types)}
-
- , and they should have a cardinality of
-
- {{
- cardinality:
- taskRequirements.metadata.inputs_cardinality,
- }}
- .
-
-
-
-
-
-
-
-
- The output columns must be of the types
- {renderTypesAsChips(taskRequirements.metadata.outputs_types)}
-
- , and they should have a cardinality of
- {{
- cardinality:
- taskRequirements.metadata.outputs_cardinality,
+ {["inputs", "outputs"].map((side) =>
+ columnGroupsOf(side).map((group, index) => (
+
+
-
-
-
+ >
+
+ The columns must be of the types
+ {renderTypesAsChips(group.types)}
+ , and they should have a cardinality of
+
+ {{ cardinality: describeCardinality(group) }}.
+
+
+
+
+ )),
+ )}
)}
diff --git a/DashAI/front/src/types/task.ts b/DashAI/front/src/types/task.ts
index 33e17cfe2..ad5b8d2d4 100644
--- a/DashAI/front/src/types/task.ts
+++ b/DashAI/front/src/types/task.ts
@@ -5,8 +5,15 @@ export interface ITask {
description: string;
type: string;
}
+export interface ITaskColumnGroup {
+ types: string[];
+ min: number;
+ max: "n" | number;
+}
export interface ITaskMetadataParameters {
+ inputs?: ITaskColumnGroup[];
+ outputs?: ITaskColumnGroup[];
inputs_columns: string[];
outputs_columns: string[];
inputs_cardinality: "n" | number;
diff --git a/DashAI/front/src/utils/i18n/locales/de/experiments.json b/DashAI/front/src/utils/i18n/locales/de/experiments.json
index be70c1492..3f091f917 100644
--- a/DashAI/front/src/utils/i18n/locales/de/experiments.json
+++ b/DashAI/front/src/utils/i18n/locales/de/experiments.json
@@ -43,6 +43,8 @@
"currentOptimizerSettings": "Aktuelle Optimierereinstellungen {{optimizer}}",
"datasetInputColumnRequirements": "<0>Die Eingabespalten müssen folgende Typen haben0><1>1><2>, und eine Kardinalität von 2><3>{{cardinality}}.3>",
"datasetOutputColumnRequirements": "<0>Die Ausgabespalten müssen folgende Typen haben0><1>1><2>, und eine Kardinalität von 2><3>{{cardinality}}.3>",
+ "cardinalityAtLeast": "mindestens {{min}}",
+ "cardinalityBetween": "{{min}} bis {{max}}",
"duration": "Dauer",
"endTime": "Endzeit",
"experimentName": "Experimentname",
diff --git a/DashAI/front/src/utils/i18n/locales/en/experiments.json b/DashAI/front/src/utils/i18n/locales/en/experiments.json
index 2322e6854..d166e2a37 100644
--- a/DashAI/front/src/utils/i18n/locales/en/experiments.json
+++ b/DashAI/front/src/utils/i18n/locales/en/experiments.json
@@ -43,6 +43,8 @@
"currentOptimizerSettings": "Current Optimizer Settings {{optimizer}}",
"datasetInputColumnRequirements": "<0>The input columns must be of the types0><1>1><2>, and they should have a cardinality of 2><3>{{cardinality}}.3>",
"datasetOutputColumnRequirements": "<0>The output columns must be of the types0><1>1><2>, and they should have a cardinality of 2><3>{{cardinality}}.3>",
+ "cardinalityAtLeast": "at least {{min}}",
+ "cardinalityBetween": "{{min}} to {{max}}",
"duration": "Duration",
"endTime": "End Time",
"experimentName": "Experiment Name",
diff --git a/DashAI/front/src/utils/i18n/locales/es/experiments.json b/DashAI/front/src/utils/i18n/locales/es/experiments.json
index b9dd6b244..53cdeb71b 100644
--- a/DashAI/front/src/utils/i18n/locales/es/experiments.json
+++ b/DashAI/front/src/utils/i18n/locales/es/experiments.json
@@ -43,6 +43,8 @@
"currentOptimizerSettings": "Configuración Actual del Optimizador {{optimizer}}",
"datasetInputColumnRequirements": "<0>Las columnas de entrada deben ser de los tipos0><1>1><2>, y deben tener una cardinalidad de 2><3>{{cardinality}}.3>",
"datasetOutputColumnRequirements": "<0>Las columnas de salida deben ser de los tipos0><1>1><2>, y deben tener una cardinalidad de 2><3>{{cardinality}}.3>",
+ "cardinalityAtLeast": "al menos {{min}}",
+ "cardinalityBetween": "{{min}} a {{max}}",
"duration": "Duración",
"endTime": "Hora de Finalización",
"experimentName": "Nombre del Experimento",
diff --git a/DashAI/front/src/utils/i18n/locales/pt/experiments.json b/DashAI/front/src/utils/i18n/locales/pt/experiments.json
index 823e7244d..5e68b14b8 100644
--- a/DashAI/front/src/utils/i18n/locales/pt/experiments.json
+++ b/DashAI/front/src/utils/i18n/locales/pt/experiments.json
@@ -43,6 +43,8 @@
"currentOptimizerSettings": "Configuração Atual do Otimizador {{optimizer}}",
"datasetInputColumnRequirements": "<0>As colunas de entrada devem ser dos tipos0><1>1><2>, e devem ter uma cardinalidade de 2><3>{{cardinality}}.3>",
"datasetOutputColumnRequirements": "<0>As colunas de saída devem ser dos tipos0><1>1><2>, e devem ter uma cardinalidade de 2><3>{{cardinality}}.3>",
+ "cardinalityAtLeast": "pelo menos {{min}}",
+ "cardinalityBetween": "{{min}} a {{max}}",
"duration": "Duração",
"endTime": "Hora de Conclusão",
"experimentName": "Nome do Experimento",
diff --git a/DashAI/front/src/utils/i18n/locales/zh/experiments.json b/DashAI/front/src/utils/i18n/locales/zh/experiments.json
index ab2512e1f..e1c6aa541 100644
--- a/DashAI/front/src/utils/i18n/locales/zh/experiments.json
+++ b/DashAI/front/src/utils/i18n/locales/zh/experiments.json
@@ -43,6 +43,8 @@
"currentOptimizerSettings": "当前优化器设置 {{optimizer}}",
"datasetInputColumnRequirements": "<0>输入列的类型必须为0><1>1><2>,且基数应为2><3>{{cardinality}}。3>",
"datasetOutputColumnRequirements": "<0>输出列的类型必须为0><1>1><2>,且基数应为2><3>{{cardinality}}。3>",
+ "cardinalityAtLeast": "至少 {{min}}",
+ "cardinalityBetween": "{{min}} 到 {{max}}",
"duration": "时长",
"endTime": "结束时间",
"experimentName": "实验名称",
diff --git a/tests/back/api/test_components_api.py b/tests/back/api/test_components_api.py
index d1381f796..03cfea82f 100644
--- a/tests/back/api/test_components_api.py
+++ b/tests/back/api/test_components_api.py
@@ -176,6 +176,8 @@ def test_get_component_by_id(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": "n",
"outputs_cardinality": 1,
+ "inputs": [{"types": ["ClassLabel", "Value"], "min": 0, "max": "n"}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 1.",
"display_name": "Test Task 1",
@@ -198,6 +200,8 @@ def test_get_component_by_id(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": 1,
"outputs_cardinality": 1,
+ "inputs": [{"types": ["Image"], "min": 1, "max": 1}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 2.",
"display_name": None,
@@ -322,6 +326,8 @@ def test_get_components_select_only_tasks(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": "n",
"outputs_cardinality": 1,
+ "inputs": [{"types": ["ClassLabel", "Value"], "min": 0, "max": "n"}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 1.",
"display_name": "Test Task 1",
@@ -341,6 +347,8 @@ def test_get_components_select_only_tasks(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": 1,
"outputs_cardinality": 1,
+ "inputs": [{"types": ["Image"], "min": 1, "max": 1}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 2.",
"display_name": None,
@@ -494,6 +502,8 @@ def test_get_components_ignore_models(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": "n",
"outputs_cardinality": 1,
+ "inputs": [{"types": ["ClassLabel", "Value"], "min": 0, "max": "n"}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 1.",
"display_name": "Test Task 1",
@@ -513,6 +523,8 @@ def test_get_components_ignore_models(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": 1,
"outputs_cardinality": 1,
+ "inputs": [{"types": ["Image"], "min": 1, "max": 1}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 2.",
"display_name": None,
@@ -700,6 +712,8 @@ def test_get_components_related_inverse_relation(client: TestClient):
"outputs_types": ["ClassLabel"],
"inputs_cardinality": "n",
"outputs_cardinality": 1,
+ "inputs": [{"types": ["ClassLabel", "Value"], "min": 0, "max": "n"}],
+ "outputs": [{"types": ["ClassLabel"], "min": 1, "max": 1}],
},
"description": "Task 1.",
"display_name": "Test Task 1",
diff --git a/tests/back/tasks/test_task_column_groups.py b/tests/back/tasks/test_task_column_groups.py
new file mode 100644
index 000000000..33a04fcdd
--- /dev/null
+++ b/tests/back/tasks/test_task_column_groups.py
@@ -0,0 +1,156 @@
+"""Tests for the per-group column contract shared by every task."""
+
+import pandas as pd
+import pytest
+
+from DashAI.back.dataloaders.classes.dashai_dataset import (
+ to_dashai_dataset,
+ transform_dataset_with_schema,
+)
+from DashAI.back.tasks.base_task import BaseTask
+from DashAI.back.types.categorical import Categorical
+from DashAI.back.types.value_types import Date, Float, Integer
+
+SCHEMA = {
+ "date": {"type": "Date", "dtype": "%Y-%m-%d"},
+ "other_date": {"type": "Date", "dtype": "%Y-%m-%d"},
+ "price": {"type": "Float", "dtype": "float64"},
+ "units": {"type": "Integer", "dtype": "int64"},
+ "note": {"type": "Text", "dtype": "string"},
+}
+
+DATES = ["2026-01-01", "2026-01-02", "2026-01-03"]
+
+
+def _dataset(**columns):
+ frame = pd.DataFrame(columns)
+ return transform_dataset_with_schema(
+ to_dashai_dataset(frame), {name: SCHEMA[name] for name in frame.columns}
+ )
+
+
+class _GroupedTask(BaseTask):
+ """One date, at least one number, and a single numeric target."""
+
+ metadata = {
+ "inputs": [
+ {"types": [Date], "cardinality": 1},
+ {"types": [Float, Integer], "cardinality": {"min": 1, "max": "n"}},
+ ],
+ "outputs": [{"types": [Float, Integer], "cardinality": 1}],
+ }
+
+ def num_labels(self, dataset, output_column):
+ return None
+
+
+class _FlatTask(BaseTask):
+ """A task written against the older two-key contract."""
+
+ metadata = {
+ "inputs_types": [Float, Integer, Categorical],
+ "outputs_types": [Float, Integer],
+ "inputs_cardinality": "n",
+ "outputs_cardinality": 1,
+ }
+
+ def num_labels(self, dataset, output_column):
+ return None
+
+
+def test_each_group_gets_the_columns_of_its_own_types():
+ dataset = _dataset(date=DATES, price=[1.0, 2.0, 3.0], units=[1, 2, 3])
+
+ prepared = _GroupedTask().prepare_for_task(dataset, ["date", "units"], ["price"])
+
+ assert len(prepared) == 3
+
+
+def test_a_group_takes_as_many_columns_as_its_maximum_allows():
+ dataset = _dataset(date=DATES, price=[1.0, 2.0, 3.0], units=[1, 2, 3])
+
+ prepared = _GroupedTask().prepare_for_task(
+ dataset, ["date", "price", "units"], ["price"]
+ )
+
+ assert len(prepared) == 3
+
+
+def test_a_missing_group_is_rejected_even_though_the_total_would_fit():
+ dataset = _dataset(date=DATES, other_date=DATES, price=[1.0, 2.0, 3.0])
+
+ with pytest.raises(ValueError, match="Input cardinality"):
+ _GroupedTask().prepare_for_task(dataset, ["date", "other_date"], ["price"])
+
+
+def test_a_group_over_its_maximum_is_rejected():
+ dataset = _dataset(date=DATES, other_date=DATES, price=[1.0, 2.0, 3.0])
+
+ with pytest.raises(ValueError, match="Input cardinality"):
+ _GroupedTask().prepare_for_task(
+ dataset, ["date", "other_date", "price"], ["price"]
+ )
+
+
+def test_a_group_under_its_minimum_is_rejected():
+ dataset = _dataset(date=DATES, price=[1.0, 2.0, 3.0])
+
+ with pytest.raises(ValueError, match="Input cardinality"):
+ _GroupedTask().prepare_for_task(dataset, ["date"], ["price"])
+
+
+def test_a_type_in_no_group_is_rejected():
+ dataset = _dataset(date=DATES, note=["a", "b", "c"], price=[1.0, 2.0, 3.0])
+
+ with pytest.raises(TypeError):
+ _GroupedTask().prepare_for_task(dataset, ["date", "note"], ["price"])
+
+
+def test_the_error_names_the_group_that_is_short():
+ dataset = _dataset(date=DATES, price=[1.0, 2.0, 3.0])
+
+ with pytest.raises(ValueError, match="Float, Integer"):
+ _GroupedTask().prepare_for_task(dataset, ["date"], ["price"])
+
+
+def test_the_metadata_reports_both_the_groups_and_the_flat_view():
+ metadata = _GroupedTask.get_metadata()
+
+ assert metadata["inputs"] == [
+ {"types": ["Date"], "min": 1, "max": 1},
+ {"types": ["Float", "Integer"], "min": 1, "max": "n"},
+ ]
+ assert metadata["outputs"] == [{"types": ["Float", "Integer"], "min": 1, "max": 1}]
+ assert metadata["inputs_types"] == ["Date", "Float", "Integer"]
+ assert metadata["inputs_cardinality"] == "n"
+ assert metadata["outputs_cardinality"] == 1
+
+
+def test_a_task_on_the_older_contract_reads_as_a_single_group():
+ metadata = _FlatTask.get_metadata()
+
+ assert metadata["inputs"] == [
+ {"types": ["Float", "Integer", "Categorical"], "min": 0, "max": "n"}
+ ]
+ assert metadata["inputs_cardinality"] == "n"
+ assert metadata["outputs"] == [{"types": ["Float", "Integer"], "min": 1, "max": 1}]
+ assert metadata["outputs_cardinality"] == 1
+
+
+def test_the_older_contract_still_validates_the_way_it_did():
+ dataset = _dataset(price=[1.0, 2.0, 3.0], units=[1, 2, 3], note=["a", "b", "c"])
+
+ with pytest.raises(TypeError):
+ _FlatTask().prepare_for_task(dataset, ["price", "note"], ["units"])
+
+ with pytest.raises(ValueError, match="Output cardinality"):
+ _FlatTask().prepare_for_task(dataset, ["price"], ["units", "price"])
+
+
+def test_a_single_group_error_does_not_name_its_types():
+ dataset = _dataset(price=[1.0, 2.0, 3.0], units=[1, 2, 3])
+
+ with pytest.raises(ValueError, match="Output cardinality") as raised:
+ _FlatTask().prepare_for_task(dataset, ["price"], ["units", "price"])
+
+ assert "for columns of type" not in str(raised.value)
diff --git a/tests/back/tasks/test_tasks.py b/tests/back/tasks/test_tasks.py
index 5c682afb5..12377696d 100644
--- a/tests/back/tasks/test_tasks.py
+++ b/tests/back/tasks/test_tasks.py
@@ -134,11 +134,15 @@ def test_get_tabular_class_task_metadata():
tabular_class_task = TabularClassificationTask()
metadata = tabular_class_task.get_metadata()
- assert len(metadata.keys()) == 4
+ assert len(metadata.keys()) == 6
assert metadata["inputs_types"] == ["Float", "Integer", "Categorical"]
assert metadata["outputs_types"] == ["Categorical"]
assert metadata["inputs_cardinality"] == "n"
assert metadata["outputs_cardinality"] == 1
+ assert metadata["inputs"] == [
+ {"types": ["Float", "Integer", "Categorical"], "min": 0, "max": "n"}
+ ]
+ assert metadata["outputs"] == [{"types": ["Categorical"], "min": 1, "max": 1}]
@pytest.fixture(scope="module", name="text_classification_dataset")
@@ -195,11 +199,13 @@ def test_get_text_class_task_metadata():
text_class_task = TextClassificationTask()
metadata = text_class_task.get_metadata()
- assert len(metadata.keys()) == 4
+ assert len(metadata.keys()) == 6
assert metadata["inputs_types"] == ["Text"]
assert metadata["outputs_types"] == ["Categorical"]
assert metadata["inputs_cardinality"] == 1
assert metadata["outputs_cardinality"] == 1
+ assert metadata["inputs"] == [{"types": ["Text"], "min": 1, "max": 1}]
+ assert metadata["outputs"] == [{"types": ["Categorical"], "min": 1, "max": 1}]
@pytest.fixture(scope="module", name="translation_dataset")
@@ -256,11 +262,13 @@ def test_get_translation_task_metadata():
translation_task = TranslationTask()
metadata = translation_task.get_metadata()
- assert len(metadata.keys()) == 4
+ assert len(metadata.keys()) == 6
assert metadata["inputs_types"] == ["Text"]
assert metadata["outputs_types"] == ["Text"]
assert metadata["inputs_cardinality"] == 1
assert metadata["outputs_cardinality"] == 1
+ assert metadata["inputs"] == [{"types": ["Text"], "min": 1, "max": 1}]
+ assert metadata["outputs"] == [{"types": ["Text"], "min": 1, "max": 1}]
# Generative tasks