diff --git a/architecture.md b/architecture.md index 60b0e11..0b6bb90 100644 --- a/architecture.md +++ b/architecture.md @@ -24,7 +24,7 @@ Frontier exposes 4 tools – 3 domain tools with multiple actions, plus a skill | Tool | Action | Purpose | |------|--------|---------| | **model** | `create` | Start a new optimization problem (name, domain, context, approach). Constraints passed here are checked on the spot, like an update's — the response carries `validation_issues` and `constraints_merged_note`. | -| | `update` | Add/modify objectives (≥2 enforced; 2–7 is the designed envelope), options, scores, constraints, reference points, scenarios. Scores and interaction matrices merge (upsert) — matrices by objective, and cell-wise within a matrix when `mode="upsert"`, so one larger than a single tool call is built across several (same merge as scenario overrides, via `optimizer.apply_matrix_override`); objectives, options, constraints, and reference points are full replacement — the contract is restated on each data param's schema description, `status` echoes the constraint count, and any update that *shrinks* the constraint set (a partial replacement list, or an objectives/options replacement cascade-dropping referencing rules) carries a `constraints_note` so rules can't silently vanish; several `allocation_bound` rows on ONE option apply *intersected* (the tightest box — max of the mins, min of the maxes, the way several `objective_bound` rows on one objective all bind), echoed as `constraints_merged_note`, reported by `validate` (a warning per merged option, an error when the intersection is empty), and read back as the applied box — one line per option, captioned with the row count — in `get`'s formulation card and its ASCII twin (which renders EVERY collapsing rule as the rule applied, whole-plan types included), so a floor row sent beside a cap row keeps both; the whole-plan types take one row each — several `max_allocation` rows resolve to the tightest cap and several `cardinality` rows to the intersection of their ranges (`optimizer.merged_max_allocation` / `merged_cardinality`, the single resolvers every consumer reads — the NSGA encoding, the exact backends' MILP data, the pre-solve checks, the infeasibility diagnosis, and the allocation quality flags — so no two of them can reason about different rows), echoed in that same `constraints_merged_note` (one builder writes it, so a model carrying both kinds reports both) with a `validate` warning per merged type and an error on an empty cardinality intersection; matrix writes echo `interaction_matrix_cells` per touched objective (post-merge cell count, plus a note naming the cause when the count *fell* — the signature of a chunk sent without `mode="upsert"` wiping its predecessors). Marks results stale on structural changes — each stored frontier compared against the fingerprint of the inputs *it* reads (base runs against the base inputs; the scenario set against those plus `scenario_config`), so a scenarios-only edit never flags a base frontier it cannot affect. | +| | `update` | Add/modify objectives (≥2 enforced; 2–7 is the designed envelope), options, scores, constraints, reference points, scenarios. Scores and interaction matrices merge (upsert) — matrices by objective, and cell-wise within a matrix when `mode="upsert"`, so one larger than a single tool call is built across several (same merge as scenario overrides, via `optimizer.apply_matrix_override`); objectives, options, constraints, and reference points are full replacement — the contract is restated on each data param's schema description, `status` echoes the constraint count, and any update that *shrinks* the constraint set (a partial replacement list, or an objectives/options replacement cascade-dropping referencing rules) carries a `constraints_note` so rules can't silently vanish; several `allocation_bound` rows on ONE option apply *intersected* (the tightest box — max of the mins, min of the maxes, the way several `objective_bound` rows on one objective all bind), echoed as `constraints_merged_note`, reported by `validate` (a warning per merged option, an error when the intersection is empty), and read back as the applied box — one line per option, captioned with the row count — in `get`'s formulation card and its ASCII twin (which renders EVERY collapsing rule as the rule applied, whole-plan types included), so a floor row sent beside a cap row keeps both; the whole-plan types take one row each — several `max_allocation` rows resolve to the tightest cap and several `cardinality` rows to the intersection of their ranges — a `cardinality` row may be one-sided (`min` alone or `max` alone, matching how users state the rule; the absent bound is unbounded on that side, through every consumer down to the exact MILP rows) — (`optimizer.merged_max_allocation` / `merged_cardinality`, the single resolvers every consumer reads — the NSGA encoding, the exact backends' MILP data, the pre-solve checks, the infeasibility diagnosis, and the allocation quality flags — so no two of them can reason about different rows), echoed in that same `constraints_merged_note` (one builder writes it, so a model carrying both kinds reports both) with a `validate` warning per merged type and an error on an empty cardinality intersection; matrix writes echo `interaction_matrix_cells` per touched objective (post-merge cell count, plus a note naming the cause when the count *fell* — the signature of a chunk sent without `mode="upsert"` wiping its predecessors). Marks results stale on structural changes — each stored frontier compared against the fingerprint of the inputs *it* reads (base runs against the base inputs; the scenario set against those plus `scenario_config`), so a scenarios-only edit never flags a base frontier it cannot affect. | | | `get` | Return problem state. Defaults to the `summary` slice (counts + status flags + the decision-question `context` — always small). Optional `section` for targeted slices: summary, objectives, options, scores, constraints, matrices, scenarios, run, runs, exact_run, curated, references — or `full` for the complete dump (opt-in; can exceed token caps on large models). | | | `list` | List all problems with metadata snapshots | | | `delete` | Remove a problem and its data file | diff --git a/engine/explorer.py b/engine/explorer.py index 3f82e1a..58fc53a 100644 --- a/engine/explorer.py +++ b/engine/explorer.py @@ -3596,8 +3596,9 @@ def _binding_cardinality(c, solutions, objectives) -> dict | None: if len(counts) < 2: return None - at_max = counts == c.max - at_min = counts == c.min + # Bounds may be one-sided — an absent side is unbounded, so nothing binds on it. + at_max = counts == c.max if c.max is not None else np.zeros(len(counts), dtype=bool) + at_min = counts == c.min if c.min is not None else np.zeros(len(counts), dtype=bool) # Determine which side is binding if at_max.sum() > 0: binding_level, adjacent_level = c.max, c.max - 1 diff --git a/engine/metrics.py b/engine/metrics.py index 5e3f7e4..e66b5fb 100644 --- a/engine/metrics.py +++ b/engine/metrics.py @@ -570,9 +570,10 @@ def _check_binding_objective_bound(constraint, solutions, results): def _check_binding_cardinality(constraint, solutions, results): + # Bounds may be one-sided (an absent side is unbounded — nothing to bind on). for sol in solutions: count = len(sol.selected_options) - if count == constraint.max: + if constraint.max is not None and count == constraint.max: results.append({ "pattern": "binding_constraint", "severity": "info", @@ -580,7 +581,7 @@ def _check_binding_cardinality(constraint, solutions, results): "actual_value": count, }) return - if count == constraint.min and constraint.min > 1: + if constraint.min is not None and count == constraint.min and constraint.min > 1: results.append({ "pattern": "binding_constraint", "severity": "info", diff --git a/engine/models.py b/engine/models.py index d43f7c5..5d6ef4e 100644 --- a/engine/models.py +++ b/engine/models.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator # Reject non-finite floats (inf / nan) on user-supplied numeric fields. A NaN score # silently passes validation, serializes to JSON `null` on save, then raises an @@ -127,10 +127,21 @@ class _Motivated(BaseModel): class CardinalityConstraint(_Motivated): """How many options the plan selects. A whole-plan rule, so ONE row states it: several rows apply intersected (max of the mins, min of the maxes), which `validate` echoes, - and an empty intersection is a validation error.""" + and an empty intersection is a validation error. + + One-sided rows are legal — "fund at least 20" is `min` alone, "at most 24" is `max` + alone — and an absent bound leaves that side unbounded. A row with neither bound + states no rule, so it is rejected here rather than carried as a silent no-op.""" type: Literal["cardinality"] = "cardinality" - min: int - max: int + min: int | None = None + max: int | None = None + + @model_validator(mode="after") + def _at_least_one_bound(self): + if self.min is None and self.max is None: + raise ValueError("cardinality needs min, max, or both — a row with neither " + "bound states no rule.") + return self class ForceIncludeConstraint(_Motivated): diff --git a/engine/optimizer.py b/engine/optimizer.py index cef3301..2384a18 100644 --- a/engine/optimizer.py +++ b/engine/optimizer.py @@ -325,11 +325,14 @@ def merged_max_allocation(constraints) -> int | None: return min(caps) if caps else None -def merged_cardinality(constraints) -> tuple[int, int] | None: +def merged_cardinality(constraints) -> tuple[int | None, int | None] | None: """The selection-count range actually applied: the intersection of every ``cardinality`` row — max of the mins, min of the maxes (``None`` when the model carries none, leaving the caller's own default in place). + Rows may be one-sided ("at least 20" is ``min`` alone), so either side of the merged + range can be ``None`` — unbounded there, again leaving the caller's default in place. + An empty intersection (merged min > merged max) is returned intact and reported by ``validate``: two rows that cannot both hold are a conflict to name, not something to clamp into a range neither row asked for. @@ -337,7 +340,20 @@ def merged_cardinality(constraints) -> tuple[int, int] | None: rows = [c for c in (constraints or []) if getattr(c, "type", "") == "cardinality"] if not rows: return None - return (max(int(c.min) for c in rows), min(int(c.max) for c in rows)) + return (max((int(c.min) for c in rows if c.min is not None), default=None), + min((int(c.max) for c in rows if c.max is not None), default=None)) + + +def cardinality_range_str(card: tuple[int | None, int | None]) -> str: + """One renderer for a (min, max) selection range everywhere prose states it, so a + one-sided range never renders as 'None': (2, None) → '≥2', (None, 3) → '≤3', + (2, 3) → '2–3', (3, 3) → 'exactly 3'.""" + lo, hi = card + if lo is None: + return f"≤{hi}" + if hi is None: + return f"≥{lo}" + return f"exactly {lo}" if lo == hi else f"{lo}–{hi}" def singleton_constraint_merges(constraints) -> list[dict]: @@ -546,7 +562,8 @@ def validate(problem: Problem) -> ValidationResult: # (the intersection of every row), so validate and the engine reason about one model. available = len(opt_names - forced_out) for c in problem.constraints: - if c.type == "cardinality" and c.min > c.max: + if (c.type == "cardinality" and c.min is not None and c.max is not None + and c.min > c.max): issues.append(ValidationIssue( severity="error", message=f"Cardinality min ({c.min}) > max ({c.max}).", @@ -554,10 +571,12 @@ def validate(problem: Problem) -> ValidationResult: card = merged_cardinality(problem.constraints) if card is not None: card_min, card_max = card + empty_range = (card_min is not None and card_max is not None + and card_min > card_max) for m in singleton_constraint_merges(problem.constraints): if m["type"] != "cardinality": continue - if card_min > card_max: + if empty_range: issues.append(ValidationIssue( severity="error", message=(f"{m['rows']} cardinality rows intersect to an empty range " @@ -568,16 +587,16 @@ def validate(problem: Problem) -> ValidationResult: issues.append(ValidationIssue( severity="warning", message=(f"{m['rows']} cardinality rows apply intersected, as the " - f"tightest range (select {card_min}–{card_max}). Send one " - "cardinality row to state that directly."), + f"tightest range (select {cardinality_range_str(card)}). Send " + "one cardinality row to state that directly."), )) - if card_min <= card_max: - if card_min > available: + if not empty_range: + if card_min is not None and card_min > available: issues.append(ValidationIssue( severity="error", message=f"Cardinality min ({card_min}) exceeds available options ({available}).", )) - if len(forced_in) > card_max: + if card_max is not None and len(forced_in) > card_max: issues.append(ValidationIssue( severity="error", message=f"force_include count ({len(forced_in)}) exceeds cardinality max ({card_max}).", @@ -913,10 +932,15 @@ def _opt_idx(name: str, c) -> int: # Whole-plan constraints resolve through the shared functions the validator also # reads, so the two sides can never reason about different rows (see the resolution - # note above them). A single row of either type resolves to itself. + # note above them). A single row of either type resolves to itself. A one-sided row + # leaves the absent side at the default above — a max-only row says nothing about the + # minimum, so the EA's search floor (or the model's honest 0) stays in force. card = merged_cardinality(problem.constraints) if card is not None: - cardinality_min, cardinality_max = card + if card[0] is not None: + cardinality_min = card[0] + if card[1] is not None: + cardinality_max = card[1] max_allocation = merged_max_allocation(problem.constraints) return { @@ -1923,9 +1947,15 @@ def unit(name: str) -> list[float]: if t == "cardinality": # holds: min ≤ count ≤ max → witness: count≤min-1 OR count≥max+1 ones = [1.0] * n disjuncts = [] - if prop.min > 0: + if prop.min is not None and prop.min > 0: disjuncts.append([(ones, "le", prop.min - 1)]) - disjuncts.append([(ones, "ge", prop.max + 1)]) + if prop.max is not None: + disjuncts.append([(ones, "ge", prop.max + 1)]) + if not disjuncts: + # min 0 with no max binds nothing — every plan satisfies it, so a "holds" + # verdict would certify a tautology. Decline rather than solve. + raise ValueError("cardinality property with no binding bound (min 0 or absent, " + "max absent) is vacuous — every plan satisfies it.") return disjuncts if t == "objective_bound": ocol = {o.name: j for j, o in enumerate(problem.objectives)} @@ -2878,10 +2908,14 @@ def analyze_infeasibility(problem: Problem) -> dict: # back empty, so it reasons over the plans the search actually proposes. card_min, card_max = 1, n_options # Through the resolver, like the rest of the stack: this explains why the SEARCH came - # back empty, so it has to reason about the range the search actually applied. + # back empty, so it has to reason about the range the search actually applied — a + # one-sided row leaves the absent side at the search default above. card = merged_cardinality(problem.constraints) if card is not None: - card_min, card_max = card + if card[0] is not None: + card_min = card[0] + if card[1] is not None: + card_max = card[1] obj_bounds = [] for c in problem.constraints: if c.type == "objective_bound": diff --git a/mcp_server/server.py b/mcp_server/server.py index 2f3f024..6709796 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -539,8 +539,10 @@ def _attach_constraint_merge_note(result: dict, p: Problem) -> None: for m in optimizer.singleton_constraint_merges(p.constraints): if m["type"] == "max_allocation": parts.append(f"{m['rows']} max_allocation rows → ≤{m['max']}% per option") - elif m["min"] <= m["max"]: - parts.append(f"{m['rows']} cardinality rows → select {m['min']}–{m['max']}") + elif (m["min"] is None or m["max"] is None or m["min"] <= m["max"]): + # Either side may be None — one-sided rows leave that side unbounded. + parts.append(f"{m['rows']} cardinality rows → select " + f"{optimizer.cardinality_range_str((m['min'], m['max']))}") if not parts: return result["constraints_merged_note"] = ( @@ -958,8 +960,9 @@ def _format_constraint(c, units: dict | None = None) -> str: unit = units.get(obj, "") return f"{obj} {op} {_fmt_num(d.get('value'))}{(' ' + unit) if unit else ''}" if t == "cardinality": - lo, hi = d.get("min"), d.get("max") - return f"select exactly {lo}" if lo == hi else f"select {lo}–{hi}" + # One renderer with validate's echo and the merge note, so a one-sided row + # ("at least 20" = min alone) never reads as "select None–20". + return f"select {optimizer.cardinality_range_str((d.get('min'), d.get('max')))}" if t == "force_include": return f"must include {d.get('option')}" if t == "force_exclude": diff --git a/skills/problem_framing/references/schemas.md b/skills/problem_framing/references/schemas.md index f60121e..a22a448 100644 --- a/skills/problem_framing/references/schemas.md +++ b/skills/problem_framing/references/schemas.md @@ -7,7 +7,7 @@ JSON shapes for the structured fields passed through `model/create` and `model/u Pass to the `constraints` param as a list of dicts: ``` -{"type": "cardinality", "min": , "max": } (whole-plan: ONE row states the selection count — several rows apply intersected, and an empty intersection is a validation error) +{"type": "cardinality", "min": , "max": } (whole-plan: ONE row states the selection count; each bound is optional — state only the one the user gave ("at least 20" is min alone, "at most 24" is max alone; absent = unbounded that side), never invent the other; several rows apply intersected, and an empty intersection is a validation error) {"type": "force_include", "option": ""} {"type": "force_exclude", "option": ""} {"type": "objective_bound", "objective": "", "operator": "min"|"max", "value": } diff --git a/solvers/__init__.py b/solvers/__init__.py index 1820b9e..ab21b3c 100644 --- a/solvers/__init__.py +++ b/solvers/__init__.py @@ -107,7 +107,7 @@ def exact_solver_fits(problem: "Problem") -> tuple[bool, str]: # scope — they fold into the variable box as a 1% floor / 0 cap.) combinatorial = sorted({c.type for c in (problem.constraints or []) if c.type in ("exclusion_pair", "dependency") - or (c.type == "cardinality" and int(c.min) > 1)}) + or (c.type == "cardinality" and int(c.min or 0) > 1)}) if combinatorial: return False, ( f"{', '.join(combinatorial)} constraints on a proportional allocation are " diff --git a/solvers/_scalarization.py b/solvers/_scalarization.py index 74906a8..b3be27e 100644 --- a/solvers/_scalarization.py +++ b/solvers/_scalarization.py @@ -144,7 +144,9 @@ def _ref(name, kind, c): # pass searched, and `explore certify` compares those two frontiers: an optimality gap # and a never-dominates invariant measured across two feasible sets certify nothing. mc["card"] = _opt.merged_cardinality(problem.constraints) - if mc["card"] is not None and mc["card"][0] > mc["card"][1]: + # Either side may be None (a one-sided row) — unbounded there, nothing to conflict on. + if (mc["card"] is not None and mc["card"][0] is not None + and mc["card"][1] is not None and mc["card"][0] > mc["card"][1]): lo, hi = mc["card"] raise ValueError( f"cardinality rows intersect to an empty range (merged min {lo} > merged max " diff --git a/solvers/cuopt_backend.py b/solvers/cuopt_backend.py index cdb3ea0..03a18b3 100644 --- a/solvers/cuopt_backend.py +++ b/solvers/cuopt_backend.py @@ -557,9 +557,12 @@ def _solve_milp_cuopt(min_coef, eps_list, mc, n, exact=False): expr = sum(float(coef[i]) * x[i] for i in range(n)) prob.addConstraint((expr >= float(rhs)) if op == "ge" else (expr <= float(rhs)), name="eps") if mc["card"] is not None: + # One-sided ranges are legal — an absent bound adds no row (unbounded that side). lo, hi = mc["card"] - prob.addConstraint(sum(x) >= lo, name="card_lo") - prob.addConstraint(sum(x) <= hi, name="card_hi") + if lo is not None: + prob.addConstraint(sum(x) >= lo, name="card_lo") + if hi is not None: + prob.addConstraint(sum(x) <= hi, name="card_hi") for coef, op, val in mc["bounds"]: expr = sum(float(coef[i]) * x[i] for i in range(n)) prob.addConstraint((expr <= val) if op == "max" else (expr >= val), name="bound") diff --git a/solvers/highs_backend.py b/solvers/highs_backend.py index d4533fc..a627243 100644 --- a/solvers/highs_backend.py +++ b/solvers/highs_backend.py @@ -258,9 +258,12 @@ def _add_milp_constraints(h, n, eps_list, mc): expr = (x * [float(c) for c in coef]).sum() h.addConstr(expr >= float(rhs) if op == "ge" else expr <= float(rhs)) if mc["card"] is not None: + # One-sided ranges are legal — an absent bound adds no row (unbounded that side). lo, hi = mc["card"] - h.addConstr(x.sum() >= lo) - h.addConstr(x.sum() <= hi) + if lo is not None: + h.addConstr(x.sum() >= lo) + if hi is not None: + h.addConstr(x.sum() <= hi) for coef, op, val in mc["bounds"]: expr = (x * [float(c) for c in coef]).sum() h.addConstr(expr <= val if op == "max" else expr >= val) diff --git a/tests/test_cardinality_one_sided.py b/tests/test_cardinality_one_sided.py new file mode 100644 index 0000000..8dadbbb --- /dev/null +++ b/tests/test_cardinality_one_sided.py @@ -0,0 +1,229 @@ +"""One-sided cardinality constraints, end to end. + +The natural user statement is often one-sided — "fund at least 20" or "hold at most 24" — +so the schema accepts `min` alone, `max` alone, or both; an absent bound leaves that side +unbounded. These tests pin the whole path: schema, the merged resolver, validation, the +NSGA search encoding, the exact MILP builder, the audit negation, and the server's +constraint formatting — so no layer quietly assumes both bounds are present. +""" + +import pytest + +from engine.models import ( + CardinalityConstraint, + ForceIncludeConstraint, + Objective, + Option, + Problem, + Score, +) +from engine.optimizer import ( + _negate_property, + _parse_constraints, + analyze_infeasibility, + merged_cardinality, + optimize, + validate, +) + + +def _make_problem(**overrides): + """Five-option binary selection, no default cardinality row.""" + names = ["A", "B", "C", "D", "E"] + revenue = {"A": 8, "B": 6, "C": 9, "D": 4, "E": 7} + effort = {"A": 5, "B": 3, "C": 7, "D": 2, "E": 4} + defaults = dict( + objectives=[ + Objective(name="Revenue", direction="maximize"), + Objective(name="Effort", direction="minimize"), + ], + options=[Option(name=n) for n in names], + scores=[Score(option=n, objective=o, value=v) + for o, table in (("Revenue", revenue), ("Effort", effort)) + for n, v in table.items()], + constraints=[], + ) + defaults.update(overrides) + return Problem(**defaults) + + +# ─── Schema ─── + + +class TestSchema: + def test_min_only_is_legal(self): + c = CardinalityConstraint(min=2) + assert c.min == 2 + assert c.max is None + + def test_max_only_is_legal(self): + c = CardinalityConstraint(max=3) + assert c.min is None + assert c.max == 3 + + def test_two_sided_still_works(self): + c = CardinalityConstraint(min=2, max=3) + assert (c.min, c.max) == (2, 3) + + def test_neither_bound_is_rejected(self): + # A row with no bound states no rule — refuse it loudly at the schema. + with pytest.raises(ValueError): + CardinalityConstraint() + + def test_roundtrips_through_dump_and_validate(self): + for c in (CardinalityConstraint(min=2), CardinalityConstraint(max=3)): + again = CardinalityConstraint.model_validate(c.model_dump()) + assert (again.min, again.max) == (c.min, c.max) + + +# ─── Resolver + validation ─── + + +class TestResolution: + def test_merged_min_only(self): + assert merged_cardinality([CardinalityConstraint(min=2)]) == (2, None) + + def test_merged_max_only(self): + assert merged_cardinality([CardinalityConstraint(max=3)]) == (None, 3) + + def test_one_sided_rows_intersect(self): + # A min-only row beside a max-only row combine into the stated box. + rows = [CardinalityConstraint(min=2), CardinalityConstraint(max=4)] + assert merged_cardinality(rows) == (2, 4) + + def test_parse_resolves_absent_sides_to_the_defaults(self): + cp = _parse_constraints(_make_problem(constraints=[CardinalityConstraint(min=2)])) + assert (cp["cardinality_min"], cp["cardinality_max"]) == (2, 5) + cp = _parse_constraints(_make_problem(constraints=[CardinalityConstraint(max=3)])) + assert (cp["cardinality_min"], cp["cardinality_max"]) == (0, 3) + + def test_max_only_keeps_the_search_floor(self): + # A max-only row says nothing about the minimum, so the EA's never-propose-empty + # search default stays in force — exactly as it does with no cardinality row. + cp = _parse_constraints(_make_problem(constraints=[CardinalityConstraint(max=3)]), + search_floor=True) + assert (cp["cardinality_min"], cp["cardinality_max"]) == (1, 3) + + def test_validate_accepts_one_sided(self): + for c in (CardinalityConstraint(min=2), CardinalityConstraint(max=3)): + vr = validate(_make_problem(constraints=[c])) + assert vr.ready is True, [i.message for i in vr.issues] + + def test_validate_still_catches_min_over_available(self): + vr = validate(_make_problem(constraints=[CardinalityConstraint(min=9)])) + assert any("exceeds available options" in i.message and i.severity == "error" + for i in vr.issues) + + def test_validate_still_catches_forced_over_max_only(self): + vr = validate(_make_problem(constraints=[ + CardinalityConstraint(max=2), + *(ForceIncludeConstraint(option=n) for n in "ABC")])) + assert any("exceeds cardinality max (2)" in i.message and i.severity == "error" + for i in vr.issues) + + +# ─── Solve (NSGA) ─── + + +class TestSolve: + def test_solve_respects_max_only(self): + p = _make_problem(constraints=[CardinalityConstraint(max=2)]) + run = optimize(p, mode="fast", seed=42) + assert run.solutions + assert all(len(s.selected_options) <= 2 for s in run.solutions) + + def test_solve_respects_min_only(self): + p = _make_problem(constraints=[CardinalityConstraint(min=4)]) + run = optimize(p, mode="fast", seed=42) + assert run.solutions + assert all(len(s.selected_options) >= 4 for s in run.solutions) + + def test_infeasibility_diagnosis_handles_one_sided(self): + p = _make_problem(constraints=[ + CardinalityConstraint(max=1), + *(ForceIncludeConstraint(option=n) for n in "AB")]) + d = analyze_infeasibility(p) + assert d["binding_constraints"] + + +# ─── Exact path (MILP builder + HiGHS overlay) ─── + + +class TestExactPath: + def test_milp_data_carries_one_sided_range(self): + from solvers._scalarization import _build_milp_data + + assert _build_milp_data( + _make_problem(constraints=[CardinalityConstraint(min=2)]))[-1]["card"] == (2, None) + assert _build_milp_data( + _make_problem(constraints=[CardinalityConstraint(max=3)]))[-1]["card"] == (None, 3) + + def test_highs_respects_max_only(self): + pytest.importorskip("highspy") + p = _make_problem(constraints=[CardinalityConstraint(max=2)]) + run = optimize(p, mode="fast", seed=7, solver="highs") + assert run.solutions + assert all(len(s.selected_options) <= 2 for s in run.solutions) + + def test_highs_respects_min_only(self): + pytest.importorskip("highspy") + p = _make_problem(constraints=[CardinalityConstraint(min=4)]) + run = optimize(p, mode="fast", seed=7, solver="highs") + assert run.solutions + assert all(len(s.selected_options) >= 4 for s in run.solutions) + + +# ─── Audit negation ─── + + +class TestAuditNegation: + def test_min_only_property_negates_to_one_disjunct(self): + p = _make_problem() + disjuncts = _negate_property(p, CardinalityConstraint(min=2)) + assert len(disjuncts) == 1 + [(coef, op, rhs)] = disjuncts[0] + assert (op, rhs) == ("le", 1) + + def test_max_only_property_negates_to_one_disjunct(self): + p = _make_problem() + disjuncts = _negate_property(p, CardinalityConstraint(max=3)) + assert len(disjuncts) == 1 + [(coef, op, rhs)] = disjuncts[0] + assert (op, rhs) == ("ge", 4) + + def test_vacuous_property_is_declined(self): + # min=0 with no max binds nothing; a "holds" on it would certify a tautology. + p = _make_problem() + with pytest.raises(ValueError, match="vacuous"): + _negate_property(p, CardinalityConstraint(min=0)) + + +# ─── Server formatting + binding analytics ─── + + +class TestServerSurface: + def test_format_constraint_one_sided(self): + from mcp_server.server import _format_constraint + + assert _format_constraint(CardinalityConstraint(min=20)) == "select ≥20" + assert _format_constraint(CardinalityConstraint(max=24)) == "select ≤24" + assert _format_constraint(CardinalityConstraint(min=2, max=3)) == "select 2–3" + + def test_merge_note_survives_one_sided_rows(self): + from mcp_server.server import _attach_constraint_merge_note + + p = _make_problem(constraints=[CardinalityConstraint(min=2), + CardinalityConstraint(min=3)]) + result: dict = {} + _attach_constraint_merge_note(result, p) + assert "select ≥3" in result["constraints_merged_note"] + + def test_binding_checks_handle_one_sided(self): + from engine.explorer import _binding_cardinality + from engine.metrics import _check_binding_cardinality + + p = _make_problem(constraints=[CardinalityConstraint(min=4)]) + run = optimize(p, mode="fast", seed=42) + results: list = [] + _check_binding_cardinality(p.constraints[0], run.solutions, results) # no TypeError + _binding_cardinality(p.constraints[0], run.solutions, p.objectives) # no TypeError