feat: axis masking via cross-axis constraints (#31)#40
Conversation
Add cross-axis constraint normalization infrastructure: - _validate_constraint_axes(): validates axes list per constraint rule - _resolve_constraint_mode(): determines allow/exclude mode - _validate_constraint_rule(): validates individual rule mappings - _normalize_constraints(): main entry point, parses and validates the constraints block from a spec dict Each constraint rule must reference at least two axes, specify either 'allow' or 'exclude' (not both), and only reference known axes/coords. Also adds PLC2701 to test per-file-ignores in pyproject.toml to allow importing private helpers for direct unit testing. Tests: 14 new tests covering happy paths and error cases. Refs: #31
TimothyWillard
left a comment
There was a problem hiding this comment.
Made a few comments where I think improvements would be nice. I also think some of this validation stuff can be simplified in the future by leveraging pydantic.
| def _validate_constraint_axes( | ||
| rule_axes_raw: object, | ||
| *, | ||
| idx: int, | ||
| axis_names: set[str], | ||
| ) -> tuple[list[str], set[str]]: | ||
| """Validate and return the axes list for a single constraint rule. | ||
|
|
||
| Returns: | ||
| Tuple of (ordered axis name list, axis name set). | ||
| """ | ||
| if not isinstance(rule_axes_raw, (list, tuple)) or len(rule_axes_raw) < 2: | ||
| _raise_invalid_rhs_spec( | ||
| detail=( | ||
| f"constraints[{idx}].axes must be a list of at least two axis names" | ||
| ) | ||
| ) | ||
| rule_axes: list[str] = [] | ||
| seen: set[str] = set() | ||
| for j, ax_name in enumerate(rule_axes_raw): | ||
| if not isinstance(ax_name, str) or not ax_name.strip(): | ||
| _raise_invalid_rhs_spec( | ||
| detail=f"constraints[{idx}].axes[{j}] must be a non-empty string" | ||
| ) | ||
| ax_s = ax_name.strip() | ||
| if ax_s not in axis_names: | ||
| _raise_invalid_rhs_spec( | ||
| detail=f"constraints[{idx}].axes references unknown axis {ax_s!r}" | ||
| ) | ||
| if ax_s in seen: | ||
| _raise_invalid_rhs_spec( | ||
| detail=f"constraints[{idx}].axes contains duplicate axis {ax_s!r}" | ||
| ) | ||
| seen.add(ax_s) | ||
| rule_axes.append(ax_s) | ||
| return rule_axes, seen |
There was a problem hiding this comment.
- I think the annotation
rule_axes_raw: objectisn't quite right, I think it could berule_axes_raw: Sequence[str]? See https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence. - I think it would be helpful if the docstring could also contain a description of the arguments, I know this is not publicly exported, but helpful for future development work.
- Similar to (2), I think this is a prime candidate of a function that could have an examples section. In fact since this function is just doing some string manipulation and checking I think the doctest could replace unit tests for this particular function.
| if not isinstance(ax_name, str) or not ax_name.strip(): | ||
| _raise_invalid_rhs_spec( | ||
| detail=f"constraints[{idx}].axes[{j}] must be a non-empty string" | ||
| ) | ||
| ax_s = ax_name.strip() |
There was a problem hiding this comment.
| if not isinstance(ax_name, str) or not ax_name.strip(): | |
| _raise_invalid_rhs_spec( | |
| detail=f"constraints[{idx}].axes[{j}] must be a non-empty string" | |
| ) | |
| ax_s = ax_name.strip() | |
| if not isinstance(ax_name, str) or not (ax_s := ax_name.strip()): | |
| _raise_invalid_rhs_spec( | |
| detail=f"constraints[{idx}].axes[{j}] must be a non-empty string" | |
| ) |
To avoid doing this operation twice. Or just move the ax_s = ax_name.strip() line before this conditional.
| def _resolve_constraint_mode( | ||
| entry_map: Mapping[str, Any], *, idx: int | ||
| ) -> tuple[str, list[Any]]: | ||
| """Determine allow/exclude mode and return raw rules list. | ||
|
|
||
| Returns: | ||
| Tuple of (mode string, raw rule list). | ||
| """ |
There was a problem hiding this comment.
I think my same comments about the _validate_constraint_axes docstring also apply here. See https://github.com/ACCIDDA/op_system/pull/40/changes#r2996408053.
| Args: | ||
| rule: Raw rule object (expected to be a mapping). | ||
| label: Human-readable label for error messages (e.g. | ||
| ``constraints[0].allow[1]``). |
There was a problem hiding this comment.
Not a big deal, but this project uses mkdocs so documentation is rendered as markdown not rst so double backticks not required. Not applicable here since this is a private function and won't have it's documentation rendered, but just a heads up.
| out.append({ | ||
| "axes": tuple(rule_axes), | ||
| "mode": mode, | ||
| "rules": tuple(validated_rules), | ||
| }) |
There was a problem hiding this comment.
Since the keys are fixed this actually seems like a prime place for using either a typed dict or named tuple (depending on if the entries need to be mutable or not). This would provide typing safety around accessing members of the object. See https://typing.python.org/en/latest/spec/typeddict.html or https://typing.python.org/en/latest/spec/namedtuples.html.
| assert len(result) == 1 | ||
| rule = result[0] | ||
| assert rule["axes"] == ("age", "vax") | ||
| assert rule["mode"] == "exclude" | ||
| assert rule["rules"][0] == {"age": ["u65"], "vax": ["dose1", "dose2"]} |
There was a problem hiding this comment.
I think this can be simplified down to 1 assert statement with the exact expected output object. Pytest introspection abilities can handle traversing dicts/lists to check for nested equality and point out differences.
- Thread 1: annotate rule_axes_raw as Sequence[str], add Args/Examples to _validate_constraint_axes docstring - Thread 2: use walrus operator to avoid double .strip() - Thread 3: add Args to _resolve_constraint_mode docstring - Thread 4: single backticks (Markdown) instead of double (rst) - Thread 5: replace plain dict with ConstraintRule NamedTuple - Thread 6: collapse test asserts into single expected-object comparison
|
All six review threads have been addressed in 894ed52:
Root CI passes clean (ruff, mypy, 62/62 tests, docs). The provider |
Adds a
constraintsblock to filter Cartesian product expansion, avoiding empty compartments.Issues
Refs #31