Skip to content

Commit 4ce4604

Browse files
Spencer Bryngelsonclaude
andcommitted
Improve parameter registry: deduplicate family resolution, fix Mapping protocol, harden validation
- Extract triplicated family pattern-matching into single _resolve_family() function - Fix _FamilyAwareMapping to use proper KeysView/ItemsView/ValuesView from Mapping ABC - Simplify _ParamTypeMapping to delegate to _FamilyAwareMapping instead of reimplementing - Add IndexedFamily.__post_init__ validating base_name and max_index - Wrap families property in MappingProxyType to prevent post-freeze mutation - Guard all_params access before freeze when families are registered - Fix family schema regex to reject index 0 ([1-9]\d* instead of \d+) - Add targeted error messages for family attribute typos (valid attrs list) - Remove dead NI=1000 constant, fix stale docstring - Add 26 new tests covering family resolution, rejection cases, and schema validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6aec6d5 commit 4ce4604

7 files changed

Lines changed: 296 additions & 106 deletions

File tree

toolchain/mfc/params/definitions.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from .schema import ParamDef, ParamType
1313

1414
# Index limits
15-
NP, NF, NI, NA, NPR, NB = 10, 10, 1000, 4, 10, 10 # patches, fluids, ibs, acoustic, probes, bc_patches
15+
NP, NF, NA, NPR, NB = 10, 10, 4, 10, 10 # patches, fluids, acoustic, probes, bc_patches
1616

1717

1818
# Auto-generated Descriptions
@@ -1137,7 +1137,9 @@ def _load():
11371137
_r(f"bub_pp%{a}", REAL, {"bubbles"}, math=sym)
11381138

11391139
# patch_ib (immersed boundaries) — registered as indexed family for O(1) lookup.
1140-
# Max index is NI; attributes are pattern-matched, not enumerated.
1140+
# max_index is intentionally None (unlimited): the Fortran side allocates
1141+
# 1:num_ibs and the case_validator enforces num_ibs >= 1 when ib is enabled.
1142+
# Indices beyond num_ibs are harmless (ignored at runtime).
11411143
_ib_tags = {"ib"}
11421144
_ib_attrs: Dict[str, tuple] = {}
11431145
for a in ["geometry", "moving_ibm"]:

toolchain/mfc/params/registry.py

Lines changed: 63 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from collections.abc import Mapping
3434
from dataclasses import dataclass, field
3535
from functools import lru_cache
36+
from types import MappingProxyType
3637
from typing import Any, Dict, Iterator, Optional, Set, Tuple
3738

3839
from .schema import ParamDef, ParamType
@@ -48,6 +49,33 @@ class RegistryFrozenError(RuntimeError):
4849
_INDEXED_RE = re.compile(r"^([a-zA-Z_]\w*)\((\d+)\)%(.+)$")
4950

5051

52+
def _resolve_family(
53+
name: str, families: Dict[str, "IndexedFamily"]
54+
) -> Optional[Tuple[ParamType, Set[str]]]:
55+
"""
56+
Resolve a parameter name against indexed families.
57+
58+
Returns (ParamType, tags) if the name matches a registered family
59+
attribute, or None otherwise. This is the single implementation of
60+
family pattern-matching used by both _FamilyAwareMapping and
61+
ParamRegistry.
62+
"""
63+
m = _INDEXED_RE.match(name)
64+
if m is None:
65+
return None
66+
base, idx_str, attr = m.groups()
67+
fam = families.get(base)
68+
if fam is None:
69+
return None
70+
idx = int(idx_str)
71+
if idx < 1:
72+
return None
73+
if fam.max_index is not None and idx > fam.max_index:
74+
return None
75+
entry = fam.attrs.get(attr)
76+
return entry if entry is not None else None
77+
78+
5179
@dataclass
5280
class IndexedFamily:
5381
"""
@@ -70,6 +98,12 @@ class IndexedFamily:
7098
tags: Set[str] = field(default_factory=set)
7199
max_index: Optional[int] = None
72100

101+
def __post_init__(self):
102+
if not self.base_name or not re.match(r"^[a-zA-Z_]\w*$", self.base_name):
103+
raise ValueError(f"Invalid base_name: {self.base_name!r}")
104+
if self.max_index is not None and self.max_index < 1:
105+
raise ValueError(f"max_index must be >= 1 or None, got {self.max_index}")
106+
73107

74108
class _FamilyAwareMapping(Mapping):
75109
"""
@@ -82,6 +116,9 @@ class _FamilyAwareMapping(Mapping):
82116
For iteration (items/keys/values/len), only scalar params and one
83117
representative example per family attribute (index=1) are yielded.
84118
This keeps iteration bounded regardless of max_index.
119+
120+
keys(), items(), values() are inherited from collections.abc.Mapping
121+
and return proper KeysView/ItemsView/ValuesView objects.
85122
"""
86123

87124
__slots__ = ("_scalars", "_families", "_examples")
@@ -100,67 +137,41 @@ def __init__(
100137
key = f"{fam.base_name}(1)%{attr_name}"
101138
self._examples[key] = ParamDef(name=key, param_type=ptype, tags=set(tags))
102139

103-
def _resolve_family(self, name: str) -> Optional[ParamDef]:
104-
"""Try to resolve a name against indexed families. Returns ParamDef or None."""
105-
m = _INDEXED_RE.match(name)
106-
if m is None:
107-
return None
108-
base, idx_str, attr = m.groups()
109-
fam = self._families.get(base)
110-
if fam is None:
111-
return None
112-
idx = int(idx_str)
113-
if idx < 1:
114-
return None
115-
if fam.max_index is not None and idx > fam.max_index:
116-
return None
117-
entry = fam.attrs.get(attr)
118-
if entry is None:
140+
def _make_param_def(self, name: str) -> Optional[ParamDef]:
141+
"""Build a ParamDef from a family match, or return None."""
142+
result = _resolve_family(name, self._families)
143+
if result is None:
119144
return None
120-
ptype, tags = entry
145+
ptype, tags = result
121146
return ParamDef(name=name, param_type=ptype, tags=set(tags))
122147

123148
def __getitem__(self, key: str) -> ParamDef:
124149
try:
125150
return self._scalars[key]
126151
except KeyError:
127152
pass
128-
result = self._resolve_family(key)
153+
result = self._make_param_def(key)
129154
if result is not None:
130155
return result
131156
raise KeyError(key)
132157

133158
def __contains__(self, key: object) -> bool:
159+
# Note: this intentionally deviates from the standard Mapping contract.
160+
# `key in self` may be True for family params (e.g., patch_ib(500)%geometry)
161+
# that do NOT appear in iter(self) (which only yields index=1 examples).
134162
if key in self._scalars:
135163
return True
136164
if isinstance(key, str):
137-
return self._resolve_family(key) is not None
165+
return _resolve_family(key, self._families) is not None
138166
return False
139167

140-
def get(self, key: str, default=None):
141-
if key in self._scalars:
142-
return self._scalars[key]
143-
result = self._resolve_family(key)
144-
return result if result is not None else default
145-
146168
def __iter__(self) -> Iterator[str]:
147169
yield from self._scalars
148170
yield from self._examples
149171

150172
def __len__(self) -> int:
151173
return len(self._scalars) + len(self._examples)
152174

153-
def keys(self):
154-
return set(self._scalars.keys()) | set(self._examples.keys())
155-
156-
def items(self):
157-
yield from self._scalars.items()
158-
yield from self._examples.items()
159-
160-
def values(self):
161-
yield from self._scalars.values()
162-
yield from self._examples.values()
163-
164175

165176
class ParamRegistry:
166177
"""
@@ -257,9 +268,9 @@ def register_family(self, family: IndexedFamily) -> None:
257268
self._by_tag[tag].add(example)
258269

259270
@property
260-
def families(self) -> Dict[str, IndexedFamily]:
261-
"""Get all registered indexed families."""
262-
return self._families
271+
def families(self) -> Mapping[str, IndexedFamily]:
272+
"""Get all registered indexed families (read-only view)."""
273+
return MappingProxyType(self._families)
263274

264275
@property
265276
def all_params(self) -> Mapping[str, ParamDef]:
@@ -272,10 +283,17 @@ def all_params(self) -> Mapping[str, ParamDef]:
272283
- Iteration: yields scalar params + one example per family attr
273284
274285
If the registry is frozen, returns an immutable view.
275-
If not frozen, returns the internal dict (no family support).
286+
If not frozen and no families are registered, returns the internal dict.
287+
If not frozen but families exist, raises RuntimeError (the plain dict
288+
cannot resolve family params — call freeze() first).
276289
"""
277290
if self._frozen and self._all_params_view is not None:
278291
return self._all_params_view
292+
if self._families:
293+
raise RuntimeError(
294+
"Cannot access all_params before freeze() when indexed families "
295+
"are registered. Call freeze() first."
296+
)
279297
return self._params
280298

281299
def is_known_param(self, name: str) -> bool:
@@ -287,20 +305,7 @@ def is_known_param(self, name: str) -> bool:
287305
"""
288306
if name in self._params:
289307
return True
290-
# Check indexed families via pattern matching
291-
m = _INDEXED_RE.match(name)
292-
if m is None:
293-
return False
294-
base, idx_str, attr = m.groups()
295-
fam = self._families.get(base)
296-
if fam is None:
297-
return False
298-
idx = int(idx_str)
299-
if idx < 1:
300-
return False
301-
if fam.max_index is not None and idx > fam.max_index:
302-
return False
303-
return attr in fam.attrs
308+
return _resolve_family(name, self._families) is not None
304309

305310
def get_param_def(self, name: str) -> Optional[ParamDef]:
306311
"""
@@ -310,22 +315,10 @@ def get_param_def(self, name: str) -> Optional[ParamDef]:
310315
"""
311316
if name in self._params:
312317
return self._params[name]
313-
m = _INDEXED_RE.match(name)
314-
if m is None:
315-
return None
316-
base, idx_str, attr = m.groups()
317-
fam = self._families.get(base)
318-
if fam is None:
319-
return None
320-
idx = int(idx_str)
321-
if idx < 1:
322-
return None
323-
if fam.max_index is not None and idx > fam.max_index:
324-
return None
325-
entry = fam.attrs.get(attr)
326-
if entry is None:
318+
result = _resolve_family(name, self._families)
319+
if result is None:
327320
return None
328-
ptype, tags = entry
321+
ptype, tags = result
329322
return ParamDef(name=name, param_type=ptype, tags=set(tags))
330323

331324
def get_params_by_tag(self, tag: str) -> Dict[str, ParamDef]:
@@ -390,7 +383,7 @@ def get_json_schema(self) -> Dict[str, Any]:
390383
# Escape the attr name but replace sub-indices with \(\d+\)
391384
attr_pattern = re.sub(r"\(\d+\)", "__IDX__", attr_name)
392385
attr_pattern = re.escape(attr_pattern).replace("__IDX__", r"\(\d+\)")
393-
pattern = f"^{base_esc}\\(\\d+\\)%{attr_pattern}$"
386+
pattern = f"^{base_esc}\\([1-9]\\d*\\)%{attr_pattern}$"
394387
if pattern not in pattern_props:
395388
pattern_props[pattern] = ptype.json_schema
396389

toolchain/mfc/params/validate.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,39 @@
4444
from .suggest import suggest_parameter
4545

4646

47+
def _family_attr_error(name: str) -> Optional[str]:
48+
"""
49+
Check if name matches a known family base but has an invalid attribute.
50+
51+
Returns a targeted error message if so, or None if it doesn't match
52+
any family pattern.
53+
"""
54+
from .registry import _INDEXED_RE
55+
56+
m = _INDEXED_RE.match(name)
57+
if m is None:
58+
return None
59+
base, _, attr = m.groups()
60+
fam = REGISTRY.families.get(base)
61+
if fam is None:
62+
return None
63+
# Known family, unknown attribute — provide targeted message
64+
valid = sorted(fam.attrs.keys())
65+
# Truncate long lists
66+
if len(valid) > 8:
67+
shown = ", ".join(valid[:8]) + f", ... ({len(valid)} total)"
68+
else:
69+
shown = ", ".join(valid)
70+
return f"Unknown attribute '{attr}' for {base}. Valid attributes: {shown}"
71+
72+
4773
def check_unknown_params(params: Dict[str, Any]) -> List[str]:
4874
"""
4975
Check for unknown parameters and suggest corrections.
5076
51-
Uses fuzzy matching via rapidfuzz to provide "Did you mean?" suggestions
52-
for parameter names that don't exist in the registry.
77+
For indexed family params with a known base but invalid attribute,
78+
provides a targeted "valid attributes" message. Otherwise, uses
79+
fuzzy matching to provide "Did you mean?" suggestions.
5380
5481
Args:
5582
params: Dictionary of parameter name -> value
@@ -61,8 +88,12 @@ def check_unknown_params(params: Dict[str, Any]) -> List[str]:
6188

6289
for name in params.keys():
6390
if not REGISTRY.is_known_param(name):
64-
suggestions = suggest_parameter(name)
65-
errors.append(unknown_param_error(name, suggestions))
91+
family_err = _family_attr_error(name)
92+
if family_err:
93+
errors.append(family_err)
94+
else:
95+
suggestions = suggest_parameter(name)
96+
errors.append(unknown_param_error(name, suggestions))
6697

6798
return errors
6899

toolchain/mfc/params_tests/test_integration.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,33 @@ def test_get_json_schema_has_all_params(self):
7777
self.assertEqual(len(properties), scalar_count)
7878
self.assertGreater(len(pattern_props), 0)
7979

80+
def test_family_pattern_matches_param_names(self):
81+
"""patternProperties regexes should match valid family param names."""
82+
import re
83+
84+
schema = REGISTRY.get_json_schema()
85+
patterns = schema.get("patternProperties", {})
86+
87+
# These valid names must match at least one pattern
88+
valid_names = [
89+
"patch_ib(1)%geometry",
90+
"patch_ib(99)%vel(1)",
91+
"patch_ib(5)%model_translate(2)",
92+
"patch_ib(1000)%radius",
93+
]
94+
for name in valid_names:
95+
matched = any(re.match(p, name) for p in patterns)
96+
self.assertTrue(matched, f"'{name}' did not match any patternProperties regex")
97+
98+
# These invalid names must NOT match any pattern
99+
invalid_names = [
100+
"patch_ib(0)%geometry",
101+
"patch_ib(1)%bogus_attr",
102+
]
103+
for name in invalid_names:
104+
matched = any(re.match(p, name) for p in patterns)
105+
self.assertFalse(matched, f"'{name}' should not match any patternProperties regex")
106+
80107
def test_core_params_in_schema(self):
81108
"""Core params should be in JSON schema."""
82109
schema = REGISTRY.get_json_schema()
@@ -139,9 +166,11 @@ def test_case_dicts_all_contains_registry_params(self):
139166
# Family params should also be recognized via pattern matching
140167
self.assertIn("patch_ib(1)%geometry", case_dicts.ALL)
141168
self.assertIn("patch_ib(500)%radius", case_dicts.ALL)
142-
# Out-of-range index or bogus attr should not match
169+
# Bogus attr, unknown family, and zero index should not match
143170
self.assertNotIn("nonexistent_param", case_dicts.ALL)
144171
self.assertNotIn("patch_ib(1)%bogus_attr", case_dicts.ALL)
172+
self.assertNotIn("patch_ib(0)%geometry", case_dicts.ALL)
173+
self.assertNotIn("unknown_family(1)%geometry", case_dicts.ALL)
145174

146175
def test_case_optimization_params_from_registry(self):
147176
"""CASE_OPTIMIZATION should be populated from registry."""

0 commit comments

Comments
 (0)