plan: configurable maximize criteria implementation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d18e533559
commit
652126acf9
1 changed files with 311 additions and 0 deletions
311
docs/superpowers/plans/2026-06-11-configurable-objective.md
Normal file
311
docs/superpowers/plans/2026-06-11-configurable-objective.md
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
# Configurable Maximize Criteria Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the maximize criteria a parameter of `solve()`: a factors dict (E/B/S/C) plus a "product"/"sum" mode, defaulting to the current hardcoded `E²·B·S²` product.
|
||||||
|
|
||||||
|
**Architecture:** All changes live in `solve.py`. Module-level defaults (`OBJECTIVE_FACTORS`, `OBJECTIVE_MODE`) follow the existing `INITIAL`/`FIXED_CHOICES` pattern; `solve()` resolves `None` args to them (same convention as `resource_constraints`). The hand-chained `AddMultiplicationEquality` objective block is replaced by a generic fold (product mode) or a single linear `Maximize` (sum mode). Phase-1 ceilings are computed only for resources with a nonzero factor. `_report` prints the expression that was actually maximized.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.13, Google OR-Tools CP-SAT (`cp_model`).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-11-configurable-objective-design.md`
|
||||||
|
|
||||||
|
**Testing:** Per user instruction, no automated tests — the user validates manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Module-level defaults and validation helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `solve.py` (PARAMETERS section, after `RESOURCE_CONSTRAINTS` at ~line 68)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the defaults and `_validate_objective`**
|
||||||
|
|
||||||
|
Insert after the `RESOURCE_CONSTRAINTS = []` line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Maximize criteria for solve(). Factor = exponent in "product" mode,
|
||||||
|
# weight in "sum" mode. Keys: E, B, S, C; missing keys = 0 (resource
|
||||||
|
# excluded from the objective — it is NOT forced to zero). Negative
|
||||||
|
# factors are allowed only in "sum" mode (a negative exponent would mean
|
||||||
|
# division, which integer CP-SAT cannot express).
|
||||||
|
OBJECTIVE_FACTORS = {"E": 2, "B": 1, "S": 2}
|
||||||
|
OBJECTIVE_MODE = "product" # "product" or "sum"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_objective(factors, mode):
|
||||||
|
if mode not in ("product", "sum"):
|
||||||
|
raise ValueError(f"objective_mode must be 'product' or 'sum', got {mode!r}")
|
||||||
|
unknown = set(factors) - {"E", "B", "S", "C"}
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown objective_factors keys: {sorted(unknown)}")
|
||||||
|
for k, v in factors.items():
|
||||||
|
if not isinstance(v, int) or isinstance(v, bool):
|
||||||
|
raise ValueError(f"objective factor {k}={v!r} must be an int")
|
||||||
|
if mode == "product" and v < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"objective factor {k}={v} is negative; negative exponents "
|
||||||
|
"are not expressible in product mode"
|
||||||
|
)
|
||||||
|
if not any(factors.values()):
|
||||||
|
raise ValueError("objective_factors needs at least one nonzero factor")
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `isinstance(v, bool)` check exists because `bool` is a subclass of `int` in Python; `True` as a factor is almost certainly a caller bug.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add solve.py
|
||||||
|
git commit -m "feat: add objective factors/mode defaults and validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Accept and resolve the new `solve()` arguments
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `solve.py` — `solve()` signature (~line 981) and top of the function body
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the signature**
|
||||||
|
|
||||||
|
Add two keyword args to `solve()` (after `resource_constraints=None`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def solve(
|
||||||
|
*,
|
||||||
|
initial=INITIAL,
|
||||||
|
arrivals=ARRIVALS,
|
||||||
|
max_res=MAX_RES,
|
||||||
|
max_vat=MAX_VAT,
|
||||||
|
time_limit=60.0,
|
||||||
|
num_workers=8,
|
||||||
|
verbose=True,
|
||||||
|
fixed_choices=FIXED_CHOICES,
|
||||||
|
resource_constraints=None,
|
||||||
|
objective_factors=None,
|
||||||
|
objective_mode=None,
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Resolve defaults and validate, first thing in the body**
|
||||||
|
|
||||||
|
Insert at the very top of the function body, before the `# ---- build the city list` block, so bad input fails before any model construction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if objective_factors is None:
|
||||||
|
objective_factors = OBJECTIVE_FACTORS
|
||||||
|
if objective_mode is None:
|
||||||
|
objective_mode = OBJECTIVE_MODE
|
||||||
|
_validate_objective(objective_factors, objective_mode)
|
||||||
|
# Normalized copy: every key present, missing keys = 0.
|
||||||
|
obj_factors = {k: objective_factors.get(k, 0) for k in "EBSC"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add solve.py
|
||||||
|
git commit -m "feat: solve() accepts objective_factors and objective_mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Compute Phase-1 ceilings only for resources in the objective
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `solve.py` — Phase-1 block (~lines 1536-1554, `def _ceiling` through the three `m.Add(final* <= cap*)` lines) and the verbose ceilings print (~line 1596)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the fixed capE/capB/capS computation with a caps dict**
|
||||||
|
|
||||||
|
The existing line `finalE, finalB, finalS = E[NUM_STEPS + 1], ...` stays. Just below it, replace this block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
capE = _ceiling(finalE)
|
||||||
|
capB = _ceiling(finalB)
|
||||||
|
capS = _ceiling(finalS)
|
||||||
|
m.Add(finalE <= capE)
|
||||||
|
m.Add(finalB <= capB)
|
||||||
|
m.Add(finalS <= capS)
|
||||||
|
```
|
||||||
|
|
||||||
|
with (keeping `def _ceiling(var):` as is, above it):
|
||||||
|
|
||||||
|
```python
|
||||||
|
finals = {"E": finalE, "B": finalB, "S": finalS, "C": C[NUM_STEPS + 1]}
|
||||||
|
|
||||||
|
# Ceilings only for resources that appear in the objective: each
|
||||||
|
# ceiling solve costs up to 20s, and only objective resources need
|
||||||
|
# bounds (product mode) / benefit from the redundant cap constraint.
|
||||||
|
caps = {}
|
||||||
|
for k, var in finals.items():
|
||||||
|
if obj_factors[k] != 0:
|
||||||
|
caps[k] = _ceiling(var)
|
||||||
|
m.Add(var <= caps[k])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the verbose ceilings print**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
print(f"(resource ceilings used: E<={capE} B<={capB} S<={capS})")
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
caps_str = " ".join(f"{k}<={v}" for k, v in caps.items())
|
||||||
|
print(f"(resource ceilings used: {caps_str})")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add solve.py
|
||||||
|
git commit -m "feat: compute resource ceilings only for objective resources"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Generic objective builder (product fold / linear sum)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `solve.py` — the "OBJECTIVE IS SET HERE" block (~lines 1556-1578)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the hardcoded objective block**
|
||||||
|
|
||||||
|
Delete the whole block from `def Eprod(v):` through `m.Maximize(obj)` (including `Eprod`/`Bprod`/`Sprod` and the `prodEE`/`prodSS`/`prodBB`/`prodEB`/`obj` variables) and replace with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ======================================================================
|
||||||
|
# OBJECTIVE IS SET HERE
|
||||||
|
# ======================================================================
|
||||||
|
if objective_mode == "sum":
|
||||||
|
# Linear: CP-SAT takes weighted sums (negative weights included)
|
||||||
|
# directly, no auxiliary variables needed.
|
||||||
|
m.Maximize(sum(f * finals[k] for k, f in obj_factors.items() if f))
|
||||||
|
else:
|
||||||
|
# Product: maximize prod(finals[k] ** obj_factors[k]). Expand the
|
||||||
|
# exponents into a flat factor list and fold pairwise, carrying a
|
||||||
|
# running upper bound from the Phase-1 caps.
|
||||||
|
factor_keys = [k for k, f in obj_factors.items() for _ in range(f)]
|
||||||
|
obj = finals[factor_keys[0]]
|
||||||
|
bound = caps[factor_keys[0]]
|
||||||
|
for k in factor_keys[1:]:
|
||||||
|
bound *= caps[k]
|
||||||
|
nxt = m.NewIntVar(0, bound, "")
|
||||||
|
m.AddMultiplicationEquality(nxt, [obj, finals[k]])
|
||||||
|
obj = nxt
|
||||||
|
m.Maximize(obj)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for the implementer:
|
||||||
|
- `factor_keys` for the default `{"E": 2, "B": 1, "S": 2}` is `["E", "E", "B", "S", "S"]`, reproducing the old `E²·B·S²` objective.
|
||||||
|
- A single-factor product (e.g. `{"E": 1}`) skips the loop entirely and maximizes `finalE` directly — that's correct.
|
||||||
|
- Every key in `factor_keys` has a nonzero factor, so `caps[k]` always exists (Task 3 computed caps for exactly those keys).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add solve.py
|
||||||
|
git commit -m "feat: build objective from factors dict in product or sum mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Report the actually-maximized objective
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `solve.py` — `_report()` signature (~line 1651) and its FINAL print (~lines 1854-1858); the `_report(...)` call inside `solve()` (~line 1597)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add objective params to `_report`'s signature**
|
||||||
|
|
||||||
|
Append two keyword params after `baron_deposits=None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
baron_deposits=None,
|
||||||
|
obj_factors=None,
|
||||||
|
obj_mode=None,
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the FINAL print**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS)
|
||||||
|
print(
|
||||||
|
f"\nFINAL E={fe / 10:.1f} B={fb / 10:.1f} S={fs / 10:.1f} "
|
||||||
|
f"product(scaled) = {fe * fb * fs / 1000} sum = {(fe + fb + fs) / 10:.1f}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS)
|
||||||
|
vals = {"E": fe, "B": fb, "S": fs, "C": solver.Value(C[NUM_STEPS + 1])}
|
||||||
|
if obj_factors is None:
|
||||||
|
# Legacy fallback: old hardcoded E*B*S display.
|
||||||
|
obj_str = f"product(scaled) = {fe * fb * fs / 1000}"
|
||||||
|
elif obj_mode == "sum":
|
||||||
|
terms = [
|
||||||
|
f"{f}{k}" if abs(f) != 1 else (k if f > 0 else f"-{k}")
|
||||||
|
for k, f in obj_factors.items()
|
||||||
|
if f
|
||||||
|
]
|
||||||
|
expr = " + ".join(terms).replace("+ -", "- ")
|
||||||
|
raw = sum(f * vals[k] for k, f in obj_factors.items())
|
||||||
|
obj_str = f"objective {expr} = {raw / 10:.1f}"
|
||||||
|
else:
|
||||||
|
expr = "*".join(
|
||||||
|
k if f == 1 else f"{k}^{f}" for k, f in obj_factors.items() if f
|
||||||
|
)
|
||||||
|
raw, n = 1, 0
|
||||||
|
for k, f in obj_factors.items():
|
||||||
|
raw *= vals[k] ** f
|
||||||
|
n += f
|
||||||
|
# Resource values are x10-scaled, so descale by 10^(sum of exponents).
|
||||||
|
obj_str = f"objective {expr} = {raw / 10**n}"
|
||||||
|
print(
|
||||||
|
f"\nFINAL E={fe / 10:.1f} B={fb / 10:.1f} S={fs / 10:.1f} "
|
||||||
|
f"{obj_str} sum = {(fe + fb + fs) / 10:.1f}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Display examples: product `{"E": 2, "B": 1, "S": 2}` → `objective E^2*B*S^2 = ...` descaled by `10^5`; sum `{"E": 2, "B": 1, "S": -1}` → `objective 2E + B - S = ...` descaled by 10.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pass the objective through from `solve()`**
|
||||||
|
|
||||||
|
At the `_report(...)` call inside `solve()`, append two keyword arguments after `baron_deposits,`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
baron_deposits,
|
||||||
|
obj_factors=obj_factors,
|
||||||
|
obj_mode=objective_mode,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add solve.py
|
||||||
|
git commit -m "feat: report prints the actually maximized objective"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Smoke check (user validation)
|
||||||
|
|
||||||
|
Per user instruction there are no automated tests. A quick way to confirm the default path is byte-for-byte behavior-compatible:
|
||||||
|
|
||||||
|
- [ ] **Step 1: Syntax check**
|
||||||
|
|
||||||
|
Run: `uv run python -c "import solve"`
|
||||||
|
Expected: no output, exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Hand off to user**
|
||||||
|
|
||||||
|
The user validates solver behavior themselves (e.g. `uv run python solve.py` should produce the same plan/objective as before the change, now printing `objective E^2*B*S^2 = ...`).
|
||||||
Loading…
Reference in a new issue