Skip to content

feat: axis masking via cross-axis constraints (#31)#40

Merged
jc-macdonald merged 2 commits intomainfrom
feature/31-axis-masking
Apr 7, 2026
Merged

feat: axis masking via cross-axis constraints (#31)#40
jc-macdonald merged 2 commits intomainfrom
feature/31-axis-masking

Conversation

@jc-macdonald
Copy link
Copy Markdown
Collaborator

Adds a constraints block to filter Cartesian product expansion, avoiding empty compartments.

Issues

Refs #31

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
Copy link
Copy Markdown
Collaborator

@TimothyWillard TimothyWillard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/op_system/specs.py
Comment on lines +469 to +504
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I think the annotation rule_axes_raw: object isn't quite right, I think it could be rule_axes_raw: Sequence[str]? See https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence.
  2. 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.
  3. 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.

Comment thread src/op_system/specs.py Outdated
Comment on lines +489 to +493
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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment thread src/op_system/specs.py
Comment on lines +507 to +514
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).
"""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/op_system/specs.py Outdated
Args:
rule: Raw rule object (expected to be a mapping).
label: Human-readable label for error messages (e.g.
``constraints[0].allow[1]``).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/op_system/specs.py Outdated
Comment on lines +637 to +641
out.append({
"axes": tuple(rule_axes),
"mode": mode,
"rules": tuple(validated_rules),
})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/op_system/test_op_system_specs.py Outdated
Comment on lines +687 to +691
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"]}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@jc-macdonald
Copy link
Copy Markdown
Collaborator Author

All six review threads have been addressed in 894ed52:

  1. Thread 1rule_axes_raw annotated as Sequence[str]; added Args and Examples sections to _validate_constraint_axes docstring.
  2. Thread 2 — Walrus operator to avoid double .strip() call.
  3. Thread 3 — Added Args section to _resolve_constraint_mode docstring.
  4. Thread 4 — Single backticks (Markdown) replacing double backticks (rst) in all constraint docstrings.
  5. Thread 5 — Introduced ConstraintRule NamedTuple replacing plain dict return type.
  6. Thread 6 — Collapsed multi-assert tests into single expected-object comparisons.

Root CI passes clean (ruff, mypy, 62/62 tests, docs). The provider mypy failure (StateChangeEnum attr-defined) is pre-existing and tracked in #51.

@jc-macdonald jc-macdonald merged commit 5fb995f into main Apr 7, 2026
5 checks passed
@jc-macdonald jc-macdonald deleted the feature/31-axis-masking branch April 7, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants