Skip to content

Commit 0c858a2

Browse files
Merge pull request #859 from DashAISoftware/feat/per-type-task-cardinality
Enhance task column cardinality with type groups
2 parents ad610b7 + 71a2c11 commit 0c858a2

11 files changed

Lines changed: 502 additions & 113 deletions

File tree

DashAI/back/tasks/base_task.py

Lines changed: 248 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,163 @@ def schema(self) -> Dict[str, Any]:
4141
"""
4242
raise NotImplementedError
4343

44+
@classmethod
45+
def _column_groups(cls, side: str) -> List[Dict[str, Any]]:
46+
"""Read one side of the contract as a list of type groups.
47+
48+
A task says what its columns may be as a list of groups, each naming a
49+
set of interchangeable types and how many columns of that set it takes:
50+
51+
.. code-block:: python
52+
53+
"inputs": [
54+
{"types": [Date], "cardinality": 1},
55+
{"types": [Float, Integer], "cardinality": {"min": 1, "max": "n"}},
56+
]
57+
58+
Grouping is what lets a task ask for one date column and any number of
59+
numeric ones at the same time. A single cardinality for the whole side
60+
cannot say that: it can only demand a total, so it would accept two
61+
dates and no numbers as readily as the intended shape.
62+
63+
The older flat spelling, ``inputs_types`` alongside
64+
``inputs_cardinality``, is still read and means a single group, so
65+
tasks and plugins written against it keep working unchanged.
66+
67+
Parameters
68+
----------
69+
side : str
70+
Either ``"inputs"`` or ``"outputs"``.
71+
72+
Returns
73+
-------
74+
list of dict
75+
One entry per group, each with ``"types"`` (a tuple of type
76+
classes), ``"min"`` (an int) and ``"max"`` (an int or ``"n"``).
77+
"""
78+
metadata = cls.metadata
79+
80+
if side in metadata:
81+
declared = metadata[side]
82+
else:
83+
declared = [
84+
{
85+
"types": metadata[f"{side}_types"],
86+
"cardinality": metadata[f"{side}_cardinality"],
87+
}
88+
]
89+
90+
return [
91+
{
92+
"types": tuple(group["types"]),
93+
**cls._bounds(group.get("cardinality", "n")),
94+
}
95+
for group in declared
96+
]
97+
98+
@staticmethod
99+
def _bounds(cardinality: Any) -> Dict[str, Any]:
100+
"""Read a declared cardinality as a minimum and a maximum.
101+
102+
Parameters
103+
----------
104+
cardinality : Any
105+
An int for an exact count, ``"n"`` for any number, or a mapping
106+
with ``"min"`` and ``"max"`` for a range whose ``"max"`` may itself
107+
be ``"n"``.
108+
109+
Returns
110+
-------
111+
dict
112+
A mapping with keys ``"min"`` and ``"max"``.
113+
"""
114+
if isinstance(cardinality, dict):
115+
return {
116+
"min": cardinality.get("min", 0),
117+
"max": cardinality.get("max", "n"),
118+
}
119+
if cardinality == "n":
120+
return {"min": 0, "max": "n"}
121+
return {"min": cardinality, "max": cardinality}
122+
123+
@staticmethod
124+
def _total_cardinality(groups: List[Dict[str, Any]]) -> Any:
125+
"""State a whole side's cardinality the way the flat contract did.
126+
127+
Kept so every consumer of the two-key metadata, the column picker
128+
among them, reads the same value it always did for the tasks that
129+
declare a single group.
130+
131+
Parameters
132+
----------
133+
groups : list of dict
134+
The normalised groups of one side.
135+
136+
Returns
137+
-------
138+
int or str
139+
The exact total when every group is exact, otherwise ``"n"``.
140+
"""
141+
if all(group["min"] == group["max"] for group in groups):
142+
return sum(group["min"] for group in groups)
143+
return "n"
144+
145+
@staticmethod
146+
def _type_name(dashai_type) -> str:
147+
"""Name a type the way the frontend and the columns themselves do.
148+
149+
A DashAI type reports its own name through ``display_name()``, which
150+
matches what a column emits via ``to_string()``. These lists may also
151+
hold foreign classes, for example HuggingFace dataset features or a
152+
plugin's own types, so those fall back to the class name.
153+
154+
Parameters
155+
----------
156+
dashai_type : type
157+
One of the declared type classes.
158+
159+
Returns
160+
-------
161+
str
162+
The name to show for it.
163+
"""
164+
getter = getattr(dashai_type, "display_name", None)
165+
return getter() if callable(getter) else dashai_type.__name__
166+
167+
@staticmethod
168+
def _cardinality_text(group: Dict[str, Any]) -> str:
169+
"""Say a group's cardinality the way an error message should read it.
170+
171+
Parameters
172+
----------
173+
group : dict
174+
A normalised group.
175+
176+
Returns
177+
-------
178+
str
179+
For example ``"1"``, ``"n"``, ``"at least 1"`` or ``"1 to 3"``.
180+
"""
181+
minimum, maximum = group["min"], group["max"]
182+
if maximum == "n":
183+
return "n" if minimum == 0 else f"at least {minimum}"
184+
if minimum == maximum:
185+
return str(minimum)
186+
return f"{minimum} to {maximum}"
187+
44188
@classmethod
45189
def get_metadata(cls) -> Dict[str, Any]:
46190
"""Return serialisable metadata for the current task.
47191
48-
Converts the ``inputs_types`` and ``outputs_types`` entries from class
49-
objects to their string names so the result can be JSON-serialised by
50-
the DashAI frontend.
192+
The contract is reported twice. ``"inputs"`` and ``"outputs"`` carry
193+
the per-group form, which is the only one that can tell "one date and
194+
any number of numbers" apart from "any number of dates or numbers".
195+
``"inputs_types"`` and the three keys beside it carry the flattened
196+
view every existing consumer already reads, unchanged for the tasks
197+
that declare a single group.
198+
199+
Type classes become their string names throughout so the result can be
200+
JSON-serialised by the DashAI frontend.
51201
52202
Parameters
53203
----------
@@ -58,32 +208,99 @@ def get_metadata(cls) -> Dict[str, Any]:
58208
-------
59209
Dict[str, Any]
60210
Dictionary with keys ``"inputs_types"``, ``"outputs_types"``,
61-
``"inputs_cardinality"``, and ``"outputs_cardinality"``.
211+
``"inputs_cardinality"``, ``"outputs_cardinality"``, ``"inputs"``
212+
and ``"outputs"``.
62213
"""
63-
metadata = cls.metadata
214+
parsed_metadata: dict = {}
215+
216+
for side in ("inputs", "outputs"):
217+
groups = cls._column_groups(side)
218+
219+
names: List[str] = []
220+
for group in groups:
221+
for dashai_type in group["types"]:
222+
name = cls._type_name(dashai_type)
223+
if name not in names:
224+
names.append(name)
225+
226+
parsed_metadata[f"{side}_types"] = names
227+
parsed_metadata[f"{side}_cardinality"] = cls._total_cardinality(groups)
228+
parsed_metadata[side] = [
229+
{
230+
"types": [cls._type_name(t) for t in group["types"]],
231+
"min": group["min"],
232+
"max": group["max"],
233+
}
234+
for group in groups
235+
]
64236

65-
def _name(dashai_type) -> str:
66-
"""Name the frontend uses, tolerating types from outside DashAI.
67-
68-
A DashAI type reports its own name through ``display_name()``, which
69-
matches what a column emits via ``to_string()``. These lists may
70-
also hold foreign classes, for example HuggingFace dataset features
71-
or a plugin's own types, so those fall back to the class name.
72-
"""
73-
getter = getattr(dashai_type, "display_name", None)
74-
return getter() if callable(getter) else dashai_type.__name__
75-
76-
inputs_types = [_name(t) for t in metadata["inputs_types"]]
77-
outputs_types = [_name(t) for t in metadata["outputs_types"]]
78-
79-
parsed_metadata: dict = {
80-
"inputs_types": inputs_types,
81-
"outputs_types": outputs_types,
82-
"inputs_cardinality": metadata["inputs_cardinality"],
83-
"outputs_cardinality": metadata["outputs_cardinality"],
84-
}
85237
return parsed_metadata
86238

239+
def _validate_side(
240+
self,
241+
columns: List[str],
242+
types: Dict[str, Any],
243+
side: str,
244+
) -> None:
245+
"""Check one side's columns against the groups the task declares.
246+
247+
Each column is charged to the first group that accepts its type and
248+
still has room, so a task asking for one date and any number of
249+
numbers reads a date, a number and a number as a full match rather
250+
than as three columns competing for one slot.
251+
252+
Parameters
253+
----------
254+
columns : list of str
255+
The selected column names.
256+
types : dict
257+
The dataset's column types, keyed by column name.
258+
side : str
259+
Either ``"inputs"`` or ``"outputs"``.
260+
261+
Raises
262+
------
263+
TypeError
264+
If a column's type belongs to none of the groups.
265+
ValueError
266+
If a group ends up with a number of columns outside its bounds.
267+
"""
268+
groups = self._column_groups(side)
269+
counts = [0] * len(groups)
270+
label = "Input" if side == "inputs" else "Output"
271+
272+
for column in columns:
273+
column_type = types[column]
274+
matching = [
275+
index
276+
for index, group in enumerate(groups)
277+
if isinstance(column_type, group["types"])
278+
]
279+
if not matching:
280+
raise TypeError(
281+
f"{column_type} is not an allowed type for {side[:-1]} columns."
282+
)
283+
284+
with_room = [
285+
index
286+
for index in matching
287+
if groups[index]["max"] == "n" or counts[index] < groups[index]["max"]
288+
]
289+
counts[(with_room or matching)[0]] += 1
290+
291+
for group, count in zip(groups, counts, strict=True):
292+
if count < group["min"] or (group["max"] != "n" and count > group["max"]):
293+
of_types = (
294+
""
295+
if len(groups) == 1
296+
else " for columns of type "
297+
+ ", ".join(self._type_name(t) for t in group["types"])
298+
)
299+
raise ValueError(
300+
f"{label} cardinality ({count}) does not match task "
301+
f"cardinality ({self._cardinality_text(group)}){of_types}"
302+
)
303+
87304
def validate_dataset_for_task(
88305
self,
89306
dataset: "DashAIDataset",
@@ -99,45 +316,14 @@ def validate_dataset_for_task(
99316
Dataset to be validated
100317
dataset_name : str
101318
Dataset name
319+
input_columns : list of str
320+
Names of the columns selected as inputs.
321+
output_columns : list of str
322+
Names of the columns selected as outputs.
102323
"""
103-
metadata = self.metadata
104-
allowed_input_types = tuple(metadata["inputs_types"])
105-
allowed_output_types = tuple(metadata["outputs_types"])
106-
inputs_cardinality = metadata["inputs_cardinality"]
107-
outputs_cardinality = metadata["outputs_cardinality"]
108324
types = dataset._types
109-
# Check input types
110-
for input_col in input_columns:
111-
input_col_type = types[input_col]
112-
113-
if not isinstance(input_col_type, allowed_input_types):
114-
raise TypeError(
115-
f"{input_col_type} is not an allowed type for input columns."
116-
)
117-
118-
# Check output types
119-
for output_col in output_columns:
120-
output_col_type = types[output_col]
121-
122-
if not isinstance(output_col_type, allowed_output_types):
123-
raise TypeError(
124-
f"{output_col_type} is not an allowed type for output columns."
125-
)
126-
127-
# Check input cardinality
128-
if inputs_cardinality != "n" and len(input_columns) != inputs_cardinality:
129-
raise ValueError(
130-
f"Input cardinality ({len(input_columns)}) does not"
131-
f" match task cardinality ({inputs_cardinality})"
132-
)
133-
134-
# Check output cardinality
135-
if outputs_cardinality != "n" and len(output_columns) != outputs_cardinality:
136-
raise ValueError(
137-
f"Output cardinality ({len(output_columns)})"
138-
f" does not "
139-
f"match task cardinality ({outputs_cardinality})"
140-
)
325+
self._validate_side(input_columns, types, "inputs")
326+
self._validate_side(output_columns, types, "outputs")
141327

142328
def prepare_for_task(
143329
self,

0 commit comments

Comments
 (0)