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