Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 3 additions & 2 deletions engine/explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions engine/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,17 +570,18 @@ 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",
"constraint": f"cardinality ≤ {constraint.max}",
"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",
Expand Down
19 changes: 15 additions & 4 deletions engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
64 changes: 49 additions & 15 deletions engine/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,19 +325,35 @@ 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.
"""
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]:
Expand Down Expand Up @@ -546,18 +562,21 @@ 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}).",
))
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 "
Expand All @@ -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}).",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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":
Expand Down
11 changes: 7 additions & 4 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = (
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion skills/problem_framing/references/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <int>, "max": <int>} (whole-plan: ONE row states the selection count several rows apply intersected, and an empty intersection is a validation error)
{"type": "cardinality", "min": <int>, "max": <int>} (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": "<name>"}
{"type": "force_exclude", "option": "<name>"}
{"type": "objective_bound", "objective": "<name>", "operator": "min"|"max", "value": <float>}
Expand Down
2 changes: 1 addition & 1 deletion solvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
4 changes: 3 additions & 1 deletion solvers/_scalarization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
Loading