Compare commits

..

7 commits

Author SHA1 Message Date
Pagwin
47e21678df
yeet fixed choices from solve 2026-06-11 20:11:56 -04:00
Pagwin
57c2157134
feat: report prints the actually maximized objective
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:30:52 -04:00
Pagwin
98eeb18e3c
feat: build objective from factors dict in product or sum mode 2026-06-11 19:28:20 -04:00
Pagwin
43636d330e
feat: compute resource ceilings only for objective resources
Modify solve() to only compute ceilings (_ceiling solver calls) for
resources that appear in the objective function (where obj_factors[k] != 0).
This avoids unnecessary 20s ceiling solves for resources not contributing
to the maximize criterion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:26:18 -04:00
Pagwin
ead700adf4
feat: solve() accepts objective_factors and objective_mode
Add objective_factors and objective_mode as optional parameters to solve().
Resolve defaults at function entry and validate using existing _validate_objective.
Normalize objective_factors to ensure all required keys (EBSC) are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:22:59 -04:00
Pagwin
8a775dd941
feat: add objective factors/mode defaults and validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:20:50 -04:00
Pagwin
feb5c1cb76
chore: ignore .worktrees directory 2026-06-11 19:17:46 -04:00
2 changed files with 97 additions and 53 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.serena .serena
__pycache__ __pycache__
output.txt output.txt
.worktrees

149
solve.py
View file

@ -36,30 +36,7 @@ INITIAL = (30, 30, 30, 30)
# #
# None for nothing # None for nothing
# FIXED_CHOICES = None # FIXED_CHOICES = None
FIXED_CHOICES = { FIXED_CHOICES = {"actions": {}}
"actions": {
(0, 1): "renovate_h",
(1, 1): "renovate_h",
(2, 1): "renovate_h",
(3, 1): "overwork",
(0, 2): "collect",
(1, 2): "collect",
(2, 2): "overwork",
(3, 2): "upgrade_a",
(0, 3): "collect",
(1, 3): "collect",
(2, 3): "upgrade_a",
(3, 3): "overwork",
(0, 4): "collect",
(1, 4): "collect",
(2, 4): "overwork",
(3, 4): "upgrade_b",
(0, 5): "collect",
(1, 5): "collect",
(2, 5): "upgrade_b",
(3, 5): "overwork",
}
}
# Resource constraints: list of callables that receive a dict with keys E, B, S, C # Resource constraints: list of callables that receive a dict with keys E, B, S, C
# mapping to resource variables indexed by step. Each callable returns a constraint # mapping to resource variables indexed by step. Each callable returns a constraint
@ -67,6 +44,33 @@ FIXED_CHOICES = {
# lambda res: res["E"][3] >= 50 # Ensure at least 50 E at step 3 # lambda res: res["E"][3] >= 50 # Ensure at least 50 E at step 3
RESOURCE_CONSTRAINTS = [] RESOURCE_CONSTRAINTS = []
# 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")
# Arrival schedule. Key = step (1..5), value = list of city types that arrive # Arrival schedule. Key = step (1..5), value = list of city types that arrive
# at the START of that step. Types: 'H' Hub, 'F' Foundry, 'M' Metropolis, 'N' Monument. # at the START of that step. Types: 'H' Hub, 'F' Foundry, 'M' Metropolis, 'N' Monument.
# Arriving cities act that same step. # Arriving cities act that same step.
@ -133,7 +137,7 @@ AGENT_AVAILABILITY = {
"metallurgist": [], "metallurgist": [],
"builder": [], "builder": [],
"courier": [], "courier": [],
"planner": [], "planner": [1, 2, 3, 4, 5],
"fence": [], "fence": [],
"foreman": [], "foreman": [],
"industrialist": [], "industrialist": [],
@ -989,8 +993,18 @@ def solve(
verbose=True, verbose=True,
fixed_choices=FIXED_CHOICES, fixed_choices=FIXED_CHOICES,
resource_constraints=None, resource_constraints=None,
objective_factors=None,
objective_mode=None,
): ):
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"}
# ---- build the city list ----------------------------------------- # ---- build the city list -----------------------------------------
# cities: list[tuple[int, dict]] where the dict has keys "type" and # cities: list[tuple[int, dict]] where the dict has keys "type" and
# "adjacent_to". TODO: adjacency unused; for Industrialist agent. # "adjacent_to". TODO: adjacency unused; for Industrialist agent.
@ -1546,36 +1560,37 @@ def solve(
else max_res else max_res
) )
capE = _ceiling(finalE) finals = {"E": finalE, "B": finalB, "S": finalS, "C": C[NUM_STEPS + 1]}
capB = _ceiling(finalB)
capS = _ceiling(finalS) # Ceilings only for resources that appear in the objective: each
m.Add(finalE <= capE) # ceiling solve costs up to 20s, and only objective resources need
m.Add(finalB <= capB) # bounds (product mode) / benefit from the redundant cap constraint.
m.Add(finalS <= capS) caps = {}
for k, var in finals.items():
if obj_factors[k] != 0:
caps[k] = _ceiling(var)
m.Add(var <= caps[k])
# ====================================================================== # ======================================================================
# OBJECTIVE IS SET HERE # OBJECTIVE IS SET HERE
# ====================================================================== # ======================================================================
def Eprod(v): if objective_mode == "sum":
return v * v # Linear: CP-SAT takes weighted sums (negative weights included)
# directly, no auxiliary variables needed.
def Bprod(v): m.Maximize(sum(f * finals[k] for k, f in obj_factors.items() if f))
return v else:
# Product: maximize prod(finals[k] ** obj_factors[k]). Expand the
def Sprod(v): # exponents into a flat factor list and fold pairwise, carrying a
return v * v # running upper bound from the Phase-1 caps.
factor_keys = [k for k, f in obj_factors.items() for _ in range(f)]
prodEE = m.NewIntVar(0, Eprod(capE), "prodEE") obj = finals[factor_keys[0]]
m.AddMultiplicationEquality(prodEE, [finalE, finalE]) bound = caps[factor_keys[0]]
prodSS = m.NewIntVar(0, Sprod(capS), "prodSS") for k in factor_keys[1:]:
m.AddMultiplicationEquality(prodSS, [finalS, finalS]) bound *= caps[k]
prodBB = m.NewIntVar(0, Bprod(capB), "prodBB") nxt = m.NewIntVar(0, bound, "")
m.AddMultiplicationEquality(prodBB, [finalB]) m.AddMultiplicationEquality(nxt, [obj, finals[k]])
prodEB = m.NewIntVar(0, Eprod(capE) * Bprod(capB), "prodEB") obj = nxt
m.AddMultiplicationEquality(prodEB, [prodEE, prodBB]) m.Maximize(obj)
obj = m.NewIntVar(0, Eprod(capE) * Bprod(capB) * Sprod(capS), "obj")
m.AddMultiplicationEquality(obj, [prodEB, prodSS])
m.Maximize(obj)
# ---- Phase 2: solve the product to optimality ---- # ---- Phase 2: solve the product to optimality ----
solver = cp_model.CpSolver() solver = cp_model.CpSolver()
@ -1593,7 +1608,8 @@ def solve(
status = solver.Solve(m) status = solver.Solve(m)
if verbose: if verbose:
print(f"(resource ceilings used: E<={capE} B<={capB} S<={capS})") caps_str = " ".join(f"{k}<={v}" for k, v in caps.items())
print(f"(resource ceilings used: {caps_str})")
_report( _report(
solver, solver,
status, status,
@ -1644,6 +1660,8 @@ def solve(
capital_spent, capital_spent,
fence_deposits, fence_deposits,
baron_deposits, baron_deposits,
obj_factors=obj_factors,
obj_mode=objective_mode,
) )
return solver, status return solver, status
@ -1698,6 +1716,8 @@ def _report(
capital_spent=None, capital_spent=None,
fence_deposits=None, fence_deposits=None,
baron_deposits=None, baron_deposits=None,
obj_factors=None,
obj_mode=None,
): ):
print("status:", solver.StatusName(status)) print("status:", solver.StatusName(status))
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
@ -1852,9 +1872,32 @@ def _report(
) )
fe, fb, fs = solver.Value(finalE), solver.Value(finalB), solver.Value(finalS) 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( print(
f"\nFINAL E={fe / 10:.1f} B={fb / 10:.1f} S={fs / 10:.1f} " 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}" f"{obj_str} sum = {(fe + fb + fs) / 10:.1f}"
) )