dws-city-res-solve/docs/superpowers/plans/2026-06-11-configurable-objective.md
Pagwin 652126acf9
plan: configurable maximize criteria implementation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:13:36 -04:00

11 KiB

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:

# 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
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.pysolve() signature (~line 981) and top of the function body

  • Step 1: Extend the signature

Add two keyword args to solve() (after resource_constraints=None):

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:

    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
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:

    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):

    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:

        print(f"(resource ceilings used: E<={capE}  B<={capB}  S<={capS})")

with:

        caps_str = "  ".join(f"{k}<={v}" for k, v in caps.items())
        print(f"(resource ceilings used: {caps_str})")
  • Step 3: Commit
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:

    # ======================================================================
    # 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

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:

    baron_deposits=None,
    obj_factors=None,
    obj_mode=None,
):
  • Step 2: Replace the FINAL print

Replace:

    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:

    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,:

        baron_deposits,
        obj_factors=obj_factors,
        obj_mode=objective_mode,
    )
  • Step 4: Commit
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 = ...).