Severity: Medium
Canonical-form enforcement leaks at several composite/categorical seams: composite spaces validate more laxly than their own leaf spaces, CategoricalSpace.normalize doesn't canonicalize its output, and CategoricalSpace accepts non-finite choices at construction. All three let non-canonical or invalid candidates survive validation and diverge later under ==/hash-based dedup or crash mid-run.
1. Composite validate is laxer than the leaf contract — src/variopt/spaces/composites/adapters.py:581-602, 840-846
require_real_candidate coerces int -> float before delegating, so TupleSpace/RecordSpace.validate accept an int at a RealSpace leaf that RealSpace.validate/ArraySpace.validate both reject directly:
TupleSpace(RealSpace(0.0, 10.0)).validate((3,)) # passes
RealSpace(0.0, 10.0).validate(3) # raises TypeError
2. CategoricalSpace.normalize returns the raw boundary value, not the matched choice — src/variopt/spaces/scalar.py:590-604
With choices (1.0, 2.0), normalize(1) returns int 1 instead of the declared float choice 1.0; with choices (0, 1), normalize(True) returns True unchanged. Two "equal" candidates (per candidates_equal) then have different hash/== under plain Python equality, and JSON serialization preserves the non-canonical type. replace_leaf_values (scalar.py:740-742) does this correctly for comparison.
3. Non-finite float choices are accepted at CategoricalSpace.__init__ — src/variopt/spaces/scalar.py:579-585, 615-617
CategoricalSpace((1.0, float("nan"))) constructs successfully; sample() can return NaN, and validate(nan) then raises ValueError (because nan != nan breaks membership), crashing mid-optimization far from the misdeclared space.
Fix direction
Type-check without coercion in the composite validate seam (type(value) is float) so composite and leaf strictness agree; have CategoricalSpace.normalize return the matched choice object; reject non-finite float choices at CategoricalSpace.__init__, matching RealSpace's bound rigor.
Severity: Medium
Canonical-form enforcement leaks at several composite/categorical seams: composite spaces validate more laxly than their own leaf spaces,
CategoricalSpace.normalizedoesn't canonicalize its output, andCategoricalSpaceaccepts non-finite choices at construction. All three let non-canonical or invalid candidates survive validation and diverge later under==/hash-based dedup or crash mid-run.1. Composite validate is laxer than the leaf contract —
src/variopt/spaces/composites/adapters.py:581-602, 840-846require_real_candidatecoercesint -> floatbefore delegating, soTupleSpace/RecordSpace.validateaccept anintat aRealSpaceleaf thatRealSpace.validate/ArraySpace.validateboth reject directly:2.
CategoricalSpace.normalizereturns the raw boundary value, not the matched choice —src/variopt/spaces/scalar.py:590-604With choices
(1.0, 2.0),normalize(1)returnsint 1instead of the declaredfloatchoice1.0; with choices(0, 1),normalize(True)returnsTrueunchanged. Two "equal" candidates (percandidates_equal) then have differenthash/==under plain Python equality, and JSON serialization preserves the non-canonical type.replace_leaf_values(scalar.py:740-742) does this correctly for comparison.3. Non-finite float choices are accepted at
CategoricalSpace.__init__—src/variopt/spaces/scalar.py:579-585, 615-617CategoricalSpace((1.0, float("nan")))constructs successfully;sample()can return NaN, andvalidate(nan)then raisesValueError(becausenan != nanbreaks membership), crashing mid-optimization far from the misdeclared space.Fix direction
Type-check without coercion in the composite validate seam (
type(value) is float) so composite and leaf strictness agree; haveCategoricalSpace.normalizereturn the matchedchoiceobject; reject non-finite float choices atCategoricalSpace.__init__, matchingRealSpace's bound rigor.