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
4 changes: 2 additions & 2 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ 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) |
| | `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, so a floor row sent beside a cap row keeps both; 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. |
| **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. |
| | `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
6 changes: 4 additions & 2 deletions engine/explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1909,8 +1909,10 @@ def solution_quality(problem: Problem, selected_options: list[str], allocations:
"if you expected a spread, add a max_allocation cap or revisit the "
"scores/interactions that let one option dominate",
})
cap = next((c.max for c in problem.constraints or []
if getattr(c, "type", "") == "max_allocation"), 100)
# The APPLIED cap (the tightest row), so the at-a-bound test measures allocations
# against the same edge the solver placed them on.
from .optimizer import merged_max_allocation
cap = merged_max_allocation(problem.constraints) or 100
if n >= 3:
at_bounds = sum(1 for o in problem.options if alloc.get(o.name, 0) in (0, cap))
if at_bounds >= 0.9 * n:
Expand Down
6 changes: 6 additions & 0 deletions engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ 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."""
type: Literal["cardinality"] = "cardinality"
min: int
max: int
Expand Down Expand Up @@ -167,6 +170,9 @@ class GroupLimitConstraint(_Motivated):


class MaxAllocationConstraint(_Motivated):
"""One global cap on every option's allocation percentage. A whole-plan rule, so ONE
row states it: several rows apply as the tightest cap (the minimum), which `validate`
echoes. Per-option floors and caps belong on AllocationBoundConstraint."""
type: Literal["max_allocation"] = "max_allocation"
max: int # maximum allocation percentage for any single option (1-100)

Expand Down
Loading