Skip to content

Cardinality: one-sided bounds are legal (min-only / max-only) - #139

Merged
cafzal merged 1 commit into
mainfrom
claude/cardinality-one-sided
Aug 4, 2026
Merged

Cardinality: one-sided bounds are legal (min-only / max-only)#139
cafzal merged 1 commit into
mainfrom
claude/cardinality-one-sided

Conversation

@cafzal

@cafzal cafzal commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Defect

The cardinality constraint schema required BOTH min and max, so the natural one-sided user statement — "fund at least 20", "at most 24 assets" — could not be typed as written. Agents had to invent the missing bound (a fabricated floor or an options-count cap), putting a number in the model the user never stated.

Root cause

CardinalityConstraint declared min: int; max: int (both required), and every consumer downstream — merged_cardinality, the validators, the NSGA encoding, the exact MILP builders, the audit negation, the binding analytics, the prose renderers — assumed both bounds present.

Fix

  • Schema (engine/models.py): min and max are each optional; an absent bound is unbounded on that side. A row with neither bound states no rule and is rejected at the schema.
  • Resolver (engine/optimizer.py): merged_cardinality returns tuple[int|None, int|None] — each side merged over the rows that state it. _parse_constraints keeps its defaults on an absent side, so a max-only row preserves the EA's never-propose-empty search floor exactly as no-row does.
  • Exact path (solvers/highs_backend.py, solvers/cuopt_backend.py, solvers/_scalarization.py): the MILP builders add only the stated bound row; the empty-intersection decline fires only when both sides exist.
  • Per-side reasoning: validate's min>max / min>available / forced>max checks, infeasibility diagnosis, audit negation (one disjunct per stated bound; a vacuous min-0-no-max property is declined rather than certified as a tautology), and the binding-constraint analytics (engine/metrics.py, engine/explorer.py) each guard the absent side.
  • One renderer: new optimizer.cardinality_range_str serves validate's echo, the constraints-merged note, and the formulation card, so a one-sided row renders "select ≥20" / "select ≤24" and never "select None–20".
  • Agent guidance: skills/problem_framing/references/schemas.md now says each bound is optional — state only the one the user gave, never invent the other. architecture.md's model/update row notes the one-sided form.

Tests

New tests/test_cardinality_one_sided.py (25 tests), written failing against main's behavior first: schema accepts min-only/max-only and rejects a bound-less row; resolver + validation per-side; NSGA solve respects each one-sided bound; exact HiGHS overlay respects each; MILP data carries (2, None)/(None, 3); audit negation one-sided + vacuous decline; server formatting and merge note; binding analytics no longer TypeError.

Full suite: 1072 passed, 2 skipped (was 1047 before the new file).

The schema required BOTH min and max, so the natural one-sided statement
("fund at least 20", "at most 24") could not be typed and agents had to
invent the missing bound. min and max are now each optional (absent =
unbounded on that side; a row with neither is rejected), resolved through
merged_cardinality as tuple[int|None, int|None] and honored end to end:
NSGA encoding keeps its defaults on the absent side (search floor
included), the exact MILP builders (HiGHS + cuOpt) add only the stated
row, validate/infeasibility/audit-negation reason per-side, binding
analytics skip an absent bound, and one shared renderer
(cardinality_range_str) keeps every prose echo free of 'None'.
problem_framing schema reference now tells agents to state only the bound
the user gave.
@cafzal
cafzal merged commit 8c56c72 into main Aug 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant