From 71a2c11d62831909624b6180cec0436e15c8c392 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Fri, 4 Sep 2026 12:44:16 -0400 Subject: [PATCH] feat: let a task set cardinality per column type A task stated its column contract as one list of allowed types plus one cardinality for the whole side. That cannot express "one date column and any number of numeric ones": a single total accepts two dates and no numbers just as readily. Tasks now declare a list of groups, each naming interchangeable types and how many columns of that set it takes, the way BaseGenerativeTask states its per type counts. Cardinality may be an exact int, "n", or a {min, max} range. The older two key spelling is read as a single group, so existing tasks and plugins are unchanged, and get_metadata reports both views so every current consumer keeps reading what it read before. The column picker banner renders one requirement line per group. --- DashAI/back/tasks/base_task.py | 310 ++++++++++++++---- .../modelSession/PrepareDatasetStep.jsx | 104 +++--- DashAI/front/src/types/task.ts | 7 + .../utils/i18n/locales/de/experiments.json | 2 + .../utils/i18n/locales/en/experiments.json | 2 + .../utils/i18n/locales/es/experiments.json | 2 + .../utils/i18n/locales/pt/experiments.json | 2 + .../utils/i18n/locales/zh/experiments.json | 2 + tests/back/api/test_components_api.py | 14 + tests/back/tasks/test_task_column_groups.py | 156 +++++++++ tests/back/tasks/test_tasks.py | 14 +- 11 files changed, 502 insertions(+), 113 deletions(-) create mode 100644 tests/back/tasks/test_task_column_groups.py 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 haben<1><2>, und eine Kardinalität von <3>{{cardinality}}.", "datasetOutputColumnRequirements": "<0>Die Ausgabespalten müssen folgende Typen haben<1><2>, und eine Kardinalität von <3>{{cardinality}}.", + "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 types<1><2>, and they should have a cardinality of <3>{{cardinality}}.", "datasetOutputColumnRequirements": "<0>The output columns must be of the types<1><2>, and they should have a cardinality of <3>{{cardinality}}.", + "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 tipos<1><2>, y deben tener una cardinalidad de <3>{{cardinality}}.", "datasetOutputColumnRequirements": "<0>Las columnas de salida deben ser de los tipos<1><2>, y deben tener una cardinalidad de <3>{{cardinality}}.", + "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 tipos<1><2>, e devem ter uma cardinalidade de <3>{{cardinality}}.", "datasetOutputColumnRequirements": "<0>As colunas de saída devem ser dos tipos<1><2>, e devem ter uma cardinalidade de <3>{{cardinality}}.", + "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>输入列的类型必须为<1><2>,且基数应为<3>{{cardinality}}。", "datasetOutputColumnRequirements": "<0>输出列的类型必须为<1><2>,且基数应为<3>{{cardinality}}。", + "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