From gtmvp-gtm-agents
Reusable constraint-solver templates for GTMVP agents that produce provably-optimal recommendations. Eight templates — five Z3 (linear-allocation, knapsack, max-min-distance, set-cover, scheduling-with-deps) for solver-z3, one PySAT MaxSAT (maxsat-claim-synthesis) for solver-maxsat in /gtm-audit D1, one MiniZinc assignment-with-diversity for solver-mzn in /content-calendar E2, and one Z3 quantifier-alternation (manual skolemization, predicates 4a/4b/4c — market-share defensibility, feature-coverage moat, channel-economics resilience) for solver-z3 in /war-game E1. Mandates labeling, fresh-model, timeout, UNSAT-explanation, and piecewise-linear conventions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/gtmvp-gtm-agents:solver-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill is the contract every solver-using agent and command consults before building a Z3 model. The goal is to keep model construction **template-driven, not prose-driven**, so encodings are reproducible across runs and across agents.
This skill is the contract every solver-using agent and command consults before building a Z3 model. The goal is to keep model construction template-driven, not prose-driven, so encodings are reproducible across runs and across agents.
The solver-z3 MCP server takes Python z3 code as items (add_item, replace_item, delete_item), not raw SMT-LIB. Every model ends with export_solution(solver=..., variables=...). The templates below are copy-paste skeletons — fill the brand-specific slots, never re-author the structure.
from z3 import *
from mcp_solver.z3 import export_solution
Always put this as add_item(0, ...). Every other item assumes these are in scope.
clear_model so prior session state doesn't contaminate. End with clear_model unless the same agent is mid-conversation re-solving incrementally.solver.assert_and_track(constraint_expr, "label_string") instead of solver.add(...) whenever the constraint might cause infeasibility. Labels become the user-facing reason a recommendation can't be made. Use solver.set(unsat_core=True) once before any assert_and_track call. Reserve plain solver.add(...) for constraints that are tautologically satisfiable (domain bounds, sum identities).mcp__solver-z3__solve_model with timeout=10000. Treat timeout as "constraints too tight" and surface the active constraints as relaxation candidates.sqrt or log. For diminishing-returns objectives, use 5 breakpoints with logarithmic spacing — pattern below.Task sub-agents simultaneously invoke solver-z3. Either serialize solver-using stages, or restrict solver use to the final synthesis stage.export_solution(satisfiable=False, variables=variables_dict). Without this call, the solver result is invisible to the command flow.Use cases. A1 channel-mix optimization. Any "split budget B across N options each with a score and a min-spend threshold" problem.
Slots to fill.
N — number of options (e.g. 28 channels)option_ids — stable string IDs from a taxonomy (e.g. agent_seo_onpage_001)scores[i] — per-option quality score (1–10 typical)min_viable[i] — minimum spend below which the option is considered inactivemax_useful[i] — upper bound on useful spend per optiontotal_budget — founder-supplied capmax_concentration_pct — single-option cap as % of totaldependencies — list of (child_idx, parent_idx) pairs; child can be active only if parent iscompounding_indices — option indices marked as compoundingfast_feedback_indices — option indices marked as fast-feedbackteam_size — operators available; each runs ≤ 3 active optionsTemplate code (split across add_item calls — typically 6–8 items).
# Item 1: brand data (slots filled here from the agent flow)
option_ids = [...] # e.g. ["agent_seo_onpage_001", ...]
scores = [...] # 1–10 per option
min_viable = [...] # dollar floor per option
max_useful = [...] # dollar ceiling per option
total_budget = 50000.0
max_concentration_pct = 0.40
dependencies = [(7, 0), (12, 3)] # (child_idx, parent_idx)
compounding_indices = [0, 3, 9, 15]
fast_feedback_indices = [11, 19, 22]
team_size = 1
N = len(option_ids)
# Item 2: variables
spend = [Real(f"spend_{i}") for i in range(N)]
active = [Bool(f"active_{i}") for i in range(N)]
opt = Optimize()
opt.set("timeout", 10000)
# Item 3: domain + activation linkage (untracked — tautologically OK)
for i in range(N):
opt.add(spend[i] >= 0)
opt.add(spend[i] <= max_useful[i])
opt.add(active[i] == (spend[i] >= min_viable[i]))
# Item 4: labeled hard constraints — these are the ones whose UNSAT reasons surface to the user
# (Optimize() doesn't expose unsat_core directly; use a parallel Solver() for the feasibility precheck.
# Here we use plain add() because Optimize will return inf-objective on infeasibility — the
# feasibility precheck Solver below catches UNSAT and labels it.)
opt.add(Sum(spend) <= total_budget)
for i in range(N):
opt.add(spend[i] <= max_concentration_pct * total_budget)
for child, parent in dependencies:
opt.add(Implies(active[child], active[parent]))
opt.add(Sum([If(a, 1, 0) for a in active]) <= team_size * 3)
opt.add(Sum([If(active[i], 1, 0) for i in compounding_indices]) >= 1)
opt.add(Sum([If(active[i], 1, 0) for i in fast_feedback_indices]) >= 1)
# Item 5: piecewise-linear sqrt of spend (diminishing returns)
# 5 breakpoints log-spaced from min_viable to max_useful per channel.
# Encoded as Real auxiliary var per channel with piecewise linear constraints.
def pwl_sqrt(spend_var, low, high):
# 5 breakpoints; aux is a Real that equals sqrt(spend_var) at each breakpoint
# and linearly interpolates between them.
bps = [low * (high / low) ** (k / 4) for k in range(5)] if low > 0 else \
[k * high / 4 for k in range(5)]
sqrts = [b ** 0.5 for b in bps]
aux = Real(f"sqrt_{spend_var}")
# Below first breakpoint: clamp to sqrts[0]
opt.add(Implies(spend_var <= bps[0], aux == sqrts[0]))
# Between breakpoints: linear
for k in range(4):
slope = (sqrts[k+1] - sqrts[k]) / (bps[k+1] - bps[k]) if bps[k+1] != bps[k] else 0
opt.add(Implies(And(spend_var > bps[k], spend_var <= bps[k+1]),
aux == sqrts[k] + slope * (spend_var - bps[k])))
# Above last breakpoint: clamp to sqrts[-1] (no further useful spend)
opt.add(Implies(spend_var > bps[-1], aux == sqrts[-1]))
return aux
returns = [pwl_sqrt(spend[i], max(min_viable[i], 1.0), max_useful[i]) for i in range(N)]
# Item 6: objective + solve
opt.maximize(Sum([scores[i] * returns[i] for i in range(N)]))
variables = {}
for i, oid in enumerate(option_ids):
variables[f"spend_{oid}"] = spend[i]
variables[f"active_{oid}"] = active[i]
# Item 7: feasibility precheck with labeled constraints (separate Solver for unsat-core support)
feas = Solver()
feas.set(unsat_core=True)
# Re-add only the hard structural constraints (skip objective and pwl)
feas.add(spend[i] >= 0 for i in range(N)) # domain
feas.assert_and_track(Sum(spend) <= total_budget, "budget_cap")
for child, parent in dependencies:
feas.assert_and_track(Implies(active[child], active[parent]),
f"dep_{option_ids[child]}_needs_{option_ids[parent]}")
feas.assert_and_track(Sum([If(a, 1, 0) for a in active]) <= team_size * 3, "team_capacity")
feas.assert_and_track(Sum([If(active[i], 1, 0) for i in compounding_indices]) >= 1, "min_compounding")
feas.assert_and_track(Sum([If(active[i], 1, 0) for i in fast_feedback_indices]) >= 1, "min_fast_feedback")
if feas.check() == unsat:
core = feas.unsat_core()
print(f"Infeasible: {[str(c) for c in core]}")
export_solution(satisfiable=False, variables=variables)
else:
result = opt.check()
if result == sat:
export_solution(solver=opt, variables=variables, objective=opt.objectives()[0])
else:
export_solution(satisfiable=False, variables=variables)
Output parsing. Each spend_<option_id> in the solution is a Real-valued dollar amount; threshold against min_viable[i] to determine active_<option_id> truth (or read it directly). Total allocation = Σ spend[i] (≤ budget). Sensitivity is computed by re-solving with total_budget += delta and diffing the active set.
Common pitfalls.
opt.set("timeout", 10000) — hard problems can hang.solver.add(...) instead of assert_and_track on constraints that might be infeasible — UNSAT then returns opaque "unsat" with no labels.pwl_sqrt function — breakpoint policy then drifts across runs.Use cases. B1 SWOT priorities under founder hours/week. Any "pick a subset maximizing total weighted score subject to a single capacity constraint + categorical coverage requirements."
Slots to fill.
N — number of itemsitem_ids — stable identifiersvalues[i] — score per item (any non-negative scalar)costs[i] — capacity cost per item (hours, dollars, slots)total_capacity — founder-supplied budgetcategories[i] — list of category tags per itemmust_cover_categories — categories that need at least one selected itemmax_concurrent — optional cap on total items (k_max)Template code.
# Item 1: data
item_ids = [...] # e.g. SWOT priority IDs
values = [...] # weighted SWOT-coverage scores
costs = [...] # hours/week per priority
total_capacity = 20.0 # founder hours/week
categories = [[...], ...] # per item, list of tag strings
must_cover_categories = ["critical_threat_1", "critical_threat_2"]
max_concurrent = 3
N = len(item_ids)
# Item 2: variables + solver
picked = [Bool(f"picked_{i}") for i in range(N)]
opt = Optimize()
opt.set("timeout", 10000)
# Item 3: capacity + count
opt.add(Sum([If(picked[i], costs[i], 0) for i in range(N)]) <= total_capacity)
if max_concurrent is not None:
opt.add(Sum([If(p, 1, 0) for p in picked]) <= max_concurrent)
# Item 4: category coverage (separate feasibility-checked block in a real run)
for cat in must_cover_categories:
covering = [picked[i] for i in range(N) if cat in categories[i]]
if covering:
opt.add(Or(covering)) # at least one selected item carries this tag
# Item 5: objective + export
opt.maximize(Sum([If(picked[i], values[i], 0) for i in range(N)]))
variables = {f"picked_{iid}": picked[i] for i, iid in enumerate(item_ids)}
if opt.check() == sat:
export_solution(solver=opt, variables=variables, objective=opt.objectives()[0])
else:
export_solution(satisfiable=False, variables=variables)
Output parsing. Each picked_<item_id> is Bool — true means selected. Total cost = Σ costs[i] for picked[i]. Total value = objective.
Use cases. A2 positioning whitespace verification. Find a point in N-dimensional space that maximizes its minimum distance to any of M competitor points, subject to per-dimension envelope constraints.
Slots to fill.
D — number of positioning dimensions (typically 4–6)dim_names — labels (e.g. ["price_tier", "audience_sophistication", "feature_depth", "channel_fit", "defensibility_commitment"])competitor_points — list of D-tuples (one per competitor)envelope_lows[d], envelope_highs[d] — founder defensibility envelope per dimensiondim_weights[d] — relative importance per dimension (default 1.0)Template code.
# Item 1: data
dim_names = [...] # length D
competitor_points = [...] # list of D-tuples
envelope_lows = [...] # per dim
envelope_highs = [...] # per dim
dim_weights = [1.0] * len(dim_names)
D = len(dim_names)
M = len(competitor_points)
# Item 2: variables — the proposed positioning vector
pos = [Real(f"pos_{dim_names[d]}") for d in range(D)]
min_dist = Real("min_dist") # auxiliary: minimum distance to any competitor
opt = Optimize()
opt.set("timeout", 10000)
# Item 3: envelope constraints
for d in range(D):
opt.add(pos[d] >= envelope_lows[d])
opt.add(pos[d] <= envelope_highs[d])
# Item 4: distance lower bound per competitor (Manhattan distance — sum of |pos[d] - comp[d]|)
# Z3 handles abs via If; sum is exact.
def manhattan(a_vec, b_vec):
return Sum([dim_weights[d] * If(a_vec[d] >= b_vec[d],
a_vec[d] - b_vec[d],
b_vec[d] - a_vec[d])
for d in range(D)])
for j, comp in enumerate(competitor_points):
opt.add(min_dist <= manhattan(pos, comp))
# Item 5: objective + export
opt.maximize(min_dist)
variables = {f"pos_{dim_names[d]}": pos[d] for d in range(D)}
variables["min_dist"] = min_dist
if opt.check() == sat:
export_solution(solver=opt, variables=variables, objective=opt.objectives()[0])
else:
export_solution(satisfiable=False, variables=variables)
Output parsing. Position vector = pos_<dim> values. min_dist is the guaranteed separation from any competitor. Per-competitor distances are computed post-hoc by evaluating manhattan(pos_solution, comp) in Python after extracting the model.
Why Manhattan, not Euclidean. Z3 doesn't natively handle non-linear arithmetic well for Real. Manhattan stays linear, solves fast, and is interpretable ("you differ from competitor X by 1.5 on price and 0.8 on audience"). For Euclidean, use Z3's nlsat tactic but expect 10x slower solves.
Use cases. A3 competitor-map cluster cover (pick K representatives covering all feature dimensions). C1 Porter's response packaging (cheapest set of strategic responses addressing all forces above threshold).
Slots to fill.
N — number of candidate items (competitors, responses)M — number of dimensions/forces to covercoverage[i] — list of dimension indices that item i coverscosts[i] — cost of selecting item i (uniform = 1 if just counting items)target_size — if "pick exactly K", set this; else Nonemin_coverage_per_dim — usually 1 (each dim must be covered by ≥ 1 selected item)Template code.
# Item 1: data
item_ids = [...] # competitor or response IDs
dim_names = [...]
coverage = [[...], ...] # per item: list of dim indices it covers
costs = [1] * len(item_ids) # uniform cost = picking K of N
target_size = 5 # set to None for unconstrained pick
min_coverage_per_dim = 1
N = len(item_ids)
M = len(dim_names)
# Item 2: variables + solver
selected = [Bool(f"sel_{i}") for i in range(N)]
opt = Optimize()
opt.set("timeout", 10000)
# Item 3: coverage constraints — each dim must be hit ≥ min_coverage_per_dim times
for d in range(M):
covers_d = [selected[i] for i in range(N) if d in coverage[i]]
if covers_d:
opt.add(Sum([If(c, 1, 0) for c in covers_d]) >= min_coverage_per_dim)
# else: dim has no coverers — infeasible by data; surface as warning before solving
# Item 4: optional size target
if target_size is not None:
opt.add(Sum([If(s, 1, 0) for s in selected]) == target_size)
# Item 5: minimize cost
opt.minimize(Sum([If(selected[i], costs[i], 0) for i in range(N)]))
variables = {f"sel_{iid}": selected[i] for i, iid in enumerate(item_ids)}
if opt.check() == sat:
export_solution(solver=opt, variables=variables, objective=opt.objectives()[0])
else:
export_solution(satisfiable=False, variables=variables)
Output parsing. Selected items = those whose sel_<id> is true. Coverage map = post-hoc compute "which selected items cover which dim" by intersecting coverage[i] with selected indices.
Use cases. C2 TAM/SAM/SOM horizon planning with prerequisite DAG.
Slots to fill.
N — number of initiativesinit_ids — stable identifiersvalues[i] — strategic value of initiative i (revenue, positioning impact)effort[i] — capacity cost (FTE-months, hours, dollars)horizons — list of horizon names (e.g. ["0_3mo", "3_12mo", "12mo_plus"])horizon_capacity[h] — capacity available in each horizondependencies — list of (downstream_idx, upstream_idx) pairs; downstream's horizon must be ≥ upstream'sTemplate code.
# Item 1: data
init_ids = [...]
values = [...]
effort = [...]
horizons = ["0_3mo", "3_12mo", "12mo_plus"]
horizon_capacity = [40, 120, 360] # FTE-months per horizon
dependencies = [(3, 0), (7, 3)] # (downstream, upstream)
N = len(init_ids)
H = len(horizons)
# Item 2: variables — horizon assignment per initiative (0..H-1, plus -1 = unscheduled)
assigned = [Int(f"horizon_{i}") for i in range(N)]
scheduled = [Bool(f"scheduled_{i}") for i in range(N)]
opt = Optimize()
opt.set("timeout", 15000) # scheduling is harder; allow more time
# Item 3: domain
for i in range(N):
opt.add(assigned[i] >= -1)
opt.add(assigned[i] < H)
opt.add(scheduled[i] == (assigned[i] >= 0))
# Item 4: horizon capacity
for h in range(H):
opt.add(Sum([If(assigned[i] == h, effort[i], 0) for i in range(N)]) <= horizon_capacity[h])
# Item 5: dependency ordering — downstream horizon ≥ upstream horizon (and both scheduled)
for down, up in dependencies:
opt.add(Implies(scheduled[down],
And(scheduled[up], assigned[down] >= assigned[up])))
# Item 6: maximize total scheduled value
opt.maximize(Sum([If(scheduled[i], values[i], 0) for i in range(N)]))
variables = {f"horizon_{iid}": assigned[i] for i, iid in enumerate(init_ids)}
if opt.check() == sat:
export_solution(solver=opt, variables=variables, objective=opt.objectives()[0])
else:
export_solution(satisfiable=False, variables=variables)
Output parsing. Each horizon_<init_id> is an Int — -1 means dropped from plan, 0..H-1 is the horizon index. Critical path = the longest dependency chain among scheduled items.
/gtm-audit)Use cases. Selecting the max-weight consistent subset of atomic GTM recommendations from across all agents when some pairs contradict each other. Uses the solver-maxsat MCP server (PySAT RC2 algorithm), NOT solver-z3.
Solver. mcp__solver-maxsat__* tools. Register solver-maxsat → mcp-solver-maxsat.exe in ~/.claude.json before invoking.
Model shape.
[-i, -j] — cannot both appear in the output[i] with weight = round(claim.weight × claim.confidence × 10) — prefer including high-confidence, high-importance claimsSlots to fill.
claims — list of dicts, each with id (claimId string), weight (1–10), confidence (0.0–1.0)incompatible_pairs — list of [i, j] 0-indexed pairs from incompatibleWithClaimIds edgesTemplate code (two add_item calls).
# Item 1: claim data (filled from collected stage outputs)
claims = [
{"id": "analytics_agent.insight_001", "weight": 8, "confidence": 0.85},
{"id": "ppc_agent.keyword_001", "weight": 7, "confidence": 0.90},
# ... all claims with claimId + weight + confidence
]
# Pairs are 0-indexed (claim A at index i, claim B at index j)
incompatible_pairs = [
[0, 3], # analytics_agent.insight_001 incompatible with competitor_mapper_agent.strategy_001
]
# Item 2: WCNF model + RC2 solver + export
wcnf = WCNF()
# Hard clauses: incompatible pairs cannot both be selected
for pair in incompatible_pairs:
wcnf.append([-(pair[0]+1), -(pair[1]+1)])
# Soft clauses: prefer each claim to be included, weight = importance × confidence
for idx, claim in enumerate(claims):
w = max(1, round(claim["weight"] * claim["confidence"] * 10))
wcnf.append([idx+1], weight=w)
# Solve with RC2 MaxSAT optimizer
with RC2(wcnf) as rc2:
model = rc2.compute()
if model is not None:
true_vars = set(v for v in model if v > 0)
selected = sorted([i for i in range(len(claims)) if (i+1) in true_vars])
dropped = sorted([i for i in range(len(claims)) if (i+1) not in true_vars])
result = {
"satisfiable": True,
"status": "optimal",
"selected_claim_ids": [claims[i]["id"] for i in selected],
"dropped_claim_ids": [claims[i]["id"] for i in dropped],
"total_weight": sum(max(1, round(claims[i]["weight"] * claims[i]["confidence"] * 10))
for i in selected),
}
export_solution(result)
else:
export_solution({
"satisfiable": False,
"status": "unsatisfiable",
"selected_claim_ids": [],
"dropped_claim_ids": [c["id"] for c in claims],
})
Output parsing.
selected_claim_ids — the max-weight consistent set; include these in the synthesis.dropped_claim_ids — excluded by solver. Each dropped claim's reason: "incompatible with higher-weight claim [X]" — compute this post-hoc by intersecting incompatible_pairs for the dropped claim with the selected set.total_weight — the objective value achieved by the solver.Claim collection protocol (upstream of the solver call).
Scan every stage's recommendations[] array. Accept a claim into the MaxSAT input only if ALL four fields are present and valid: claimId, atomicClaim, weight (1–10), confidence (0.0–1.0). Skip any recommendation missing any field — do not default. Collect incompatibleWithClaimIds edges; build incompatible_pairs by mapping claim IDs to their 0-based indices.
Important difference from z3 templates.
export_solution(result_dict) — pass the dict directly, not solver=... (RC2 object is already consumed by .compute()).clear_model at the end — the with RC2(wcnf) as rc2 context manager cleans up the solver.mcp__solver-maxsat__solve_model takes timeout in milliseconds like z3 (pass timeout=10000).WCNF(), RC2, and solver.compute() are all present in the code — all three are required or solve_model returns an error before executing.Common pitfalls.
export_solution(data=rc2_solver) instead of the result dict — RC2's .model attribute is only meaningful before the with block exits. Extract the model inside the with block.incompatibleWithClaimIds field is directional — both directions are already declared if the schema was followed. De-duplicate before encoding.budget_cap not c1. dep_seo_keyword_needs_seo_onpage not dep_3_0.print() of variable values. export_solution handles serialization. print() is for status only ("Solution found", "Property verified").result == unsat, call export_solution(satisfiable=False, variables=...) — the command flow depends on the export envelope.add_item(0, very_long_code) call. The MCP server rejects items above a size threshold and the validation feedback per-item is lost. Split into 5–8 logical items.export_solution. Always return variables to the outer scope or pass them through a variables dict.solver.add(5 + y == 10) raises 'int' object has no attribute 'as_ast'. Wrap with Int/Real constructors: x = Int('x'); solver.add(x == 5); solver.add(x + y == 10).solver.add(...) on constraints that can drive UNSAT. Use solver.assert_and_track(constraint, "label") so the unsat core has a human-readable explanation.opt.set("timeout", 10000). Hard scheduling/cover problems can hang for minutes. Always set a timeout. Treat timeout as functional infeasibility — surface as "constraints too tight, suggest relaxing: [active hard constraints]."/content-calendar)Use cases. E2 content calendar planning. Any "assign K options to D × P slots with per-slot fit, per-window diversity, per-option min/max coverage, and a weighted objective" problem. Generalizes to staffing rosters, ad-creative rotation, and any timetabling variant where global cardinality + diversity constraints dominate.
Solver. mcp__solver-mzn__* tools (MiniZinc). Register solver-mzn → mcp-solver-mzn.exe in ~/.claude.json before invoking. Requires minizinc Python bindings + the MiniZinc binary on PATH (see reference_minizinc.md in user memory). MiniZinc is the right tool here because all_different, global cardinality, and pairwise-different-within-window are first-class — encoding them in z3 would be quadratic in clauses and 10–100× slower.
Why MiniZinc, not Z3. Z3 handles mixed Bool/Int/Real well but suffers on combinatorial assignment problems. MiniZinc's global constraints (all_different, count, cumulative) compile to specialized propagators in the underlying CP solver (Gecode, Chuffed). For D=14, P=7, K=5, the MiniZinc model solves in seconds; the same encoded as raw z3 bool clauses takes minutes.
Slots to fill.
D — calendar length in days (typical 7, 14, 28)P — number of distribution platformsK — number of content pillarsplatform_names[p] — labels (string array, MiniZinc identifier-safe)pillar_names[k] — labels (string array, MiniZinc identifier-safe)cadence[p] — exact number of posts on platform p across the D dayspillar_fit[0..K, 1..P] — 0/1 matrix; row 0 is the no-post sentinel (all 1s); rows 1..K are real pillar/platform fitpillar_min[k] — minimum total appearances of pillar k across the whole calendarpillar_max[k] — maximum total appearancesmin_gap[p] — minimum days between same-pillar posts on platform ppillar_weight[k] — 1–10 importance score; higher = preferentially scheduledTemplate code (split across add_item calls — 4 items).
% Item 0: includes, sizes, and brand-slot data
include "globals.mzn";
int: D = 14;
int: P = 7;
int: K = 5;
array[1..P] of string: platform_names = ["LinkedIn", "X", "Instagram", "YouTube", "TikTok", "Email", "Blog"];
array[1..K] of string: pillar_names = ["pillar_1", "pillar_2", "pillar_3", "pillar_4", "pillar_5"];
array[1..P] of int: cadence = [4, 7, 3, 1, 2, 2, 1];
% pillar_fit indexed 0..K x 1..P; row 0 is the no-post sentinel (all 1s)
% Rows 1..K are the real pillar-platform fit values.
array[0..K, 1..P] of 0..1: pillar_fit = array2d(0..K, 1..P, [
1, 1, 1, 1, 1, 1, 1, % row 0: sentinel
1, 1, 1, 1, 1, 1, 1, % pillar 1
1, 1, 0, 0, 1, 1, 1, % pillar 2
1, 1, 1, 1, 1, 0, 1, % pillar 3
1, 1, 1, 0, 0, 1, 1, % pillar 4
0, 0, 1, 1, 1, 0, 0 % pillar 5
]);
array[1..K] of int: pillar_min = [2, 1, 2, 1, 1];
array[1..K] of int: pillar_max = [6, 4, 5, 4, 3];
array[1..P] of int: min_gap = [3, 2, 3, 7, 3, 5, 7];
array[1..K] of int: pillar_weight = [5, 4, 3, 2, 1];
% Item 1: decision variables
% x[d,p] = 0 means no post on (day d, platform p). 1..K = assigned pillar id.
array[1..D, 1..P] of var 0..K: x;
% Item 2: all constraints
% Cadence: exactly cadence[p] non-zero entries per platform column
constraint forall(p in 1..P)(
sum(d in 1..D)(bool2int(x[d,p] != 0)) = cadence[p]
);
% Fit: row 0 of pillar_fit is all 1s (sentinel), so this auto-allows x[d,p] = 0.
% For x[d,p] = k > 0, requires pillar_fit[k,p] = 1.
constraint forall(d in 1..D, p in 1..P)(
pillar_fit[x[d,p], p] = 1
);
% Diversity: no same non-zero pillar within (min_gap[p]-1) days on the same platform.
% Window is d1+1..d1+min_gap[p]-1 — meaning two same-pillar posts must be at least
% min_gap[p] days apart.
constraint forall(p in 1..P, d1 in 1..D-1, d2 in d1+1..min(D, d1+min_gap[p]-1))(
x[d1,p] = 0 \/ x[d2,p] = 0 \/ x[d1,p] != x[d2,p]
);
% Pillar coverage bounds: each pillar appears between pillar_min[k] and pillar_max[k] times
constraint forall(k in 1..K)(
pillar_min[k] <= sum(d in 1..D, p in 1..P)(bool2int(x[d,p] = k))
);
constraint forall(k in 1..K)(
sum(d in 1..D, p in 1..P)(bool2int(x[d,p] = k)) <= pillar_max[k]
);
% Item 3: objective + output
solve maximize sum(d in 1..D, p in 1..P, k in 1..K)(
bool2int(x[d,p] = k) * pillar_weight[k]
);
% Output is CSV-shaped for easy parsing by the command flow.
% One line per non-zero slot: "day,platform,pillar"
output [
show(d) ++ "," ++ platform_names[p] ++ "," ++
(if fix(x[d,p]) = 0 then "-" else pillar_names[fix(x[d,p])] endif) ++ "\n"
| d in 1..D, p in 1..P where fix(x[d,p]) != 0
];
Calling sequence.
clear_model
→ add_item(0, ...item 0 above with brand slots filled...)
→ add_item(1, ...item 1 verbatim...)
→ add_item(2, ...item 2 verbatim...)
→ add_item(3, ...item 3 verbatim...)
→ solve_model(timeout=30)
→ clear_model
Solve output shape.
{
"status": "error",
"satisfiable": true,
"solution": {
"objective": 70,
"x": [[0,1,0,0,0,0,0], [0,0,0,0,0,0,0], ...14 rows of 7 ints...]
},
"objective": 70,
"optimal": false,
"success": true
}
Output parsing rules.
success: true AND satisfiable: true = the schedule is usable. Read solution.x as a 14×7 matrix.status: 'error' alongside success: true is a known solver-mzn quirk — ignore the status field, trust success + satisfiable. The solver returns this when satisficing (a feasible solution found) but not proven optimal within the timeout.optimal: true means proven-optimal. optimal: false means satisficing (best found so far). For calendar planning, satisficing within 30s is normally acceptable; rerun with longer timeout if the founder wants proven-optimal.x[d-1][p-1]: value 0 = no post; value k ∈ 1..K = the assigned pillar index. Map back to pillar_names[k] and platform_names[p] for human-readable output.Infeasibility diagnosis. If satisfiable: false, the founder's constraints can't all be met. The two most common causes:
sum(cadence) < sum(pillar_min) — too few posts to hit the minimum-pillar floor. Raise cadence or lower pillar_min.pillar_fit is too restrictive — a pillar has fit=1 on only one platform whose cadence is below pillar_min for that pillar. Loosen fit or raise that platform's cadence.Pre-check these in the command flow BEFORE building the model, and surface the gap to the founder.
Common pitfalls.
pillar_fit at index 0. Without it, the indexing-by-variable pillar_fit[x[d,p], p] triggers an out-of-bounds error when x[d,p]=0. The row-0-all-1s pattern lets the same constraint handle both "no post" and "real fit check" uniformly.d2 in d1+1..d1+min_gap[p]-1 means "min_gap days between posts" — if min_gap[p] = 3, two same-pillar posts cannot be on consecutive days or 2 days apart, but 3+ days is fine. Off-by-one here either over- or under-constrains the schedule by a factor of 2.clear_model at start and end. MiniZinc parses items in order; a stale parameter declaration from a prior run silently shadows the new one./war-game)Use cases. E1 competitor war-gaming. Any "∃ my_moves ∀ their_responses : success_predicate(my_moves, their_responses)" problem — i.e. find a move I can make such that for every competitor counter-move, my position still wins. Three predicates ship under this template, one per dominant demo question: 4a (market-share defensibility — "is my pricing move share-durable?"), 4b (feature-coverage moat — "is my feature lead durable?"), and 4c (channel-economics resilience — "does my channel bet survive a counter-investment?"). The skolemization scaffold stays identical across all three — only the per-combo success predicate changes.
Solver. mcp__solver-z3__* (NOT solver-maxsat, NOT solver-mzn). Z3's ground SAT path is used after manual skolemization — the universal quantifier is enumerated in Python over the Cartesian product of competitor responses, NOT encoded via Z3.ForAll. This is the §8c mitigation from the E1 scoping doc: it preserves full unsat-core explainability that Z3's native quantifier handling cannot give.
Why manual skolemization over ForAll. Native Z3 ForAll has limited unsat-core support and can hang on non-trivial formulas. Enumerating combos in Python and asserting them one-at-a-time via assert_and_track(..., label) recovers explainability (each combo becomes a labeled constraint, so UNSAT cores name the exact kill scenario), keeps the solver on its fast ground-SAT path, and makes "why didn't this move work?" answerable in plain English. Trade-off: combos blow up as R^N_competitors. v1 caps at 3–5 competitors × 3–5 responses each (max ~3125 combos — well within solver headroom).
Cross-predicate summary.
| Predicate | Best for | Founder data lift | SMT difficulty |
|---|---|---|---|
| 4a Market-share defensibility | Established markets, pricing moves | Moderate — needs share baseline + sensitivity table | Low (linear arithmetic) |
| 4b Feature-coverage moat | Product-led companies, feature-launch decisions | Light — needs feature matrix only | Lowest (Bool-only) |
| 4c Channel-economics resilience | Paid-acquisition-dependent companies | Heavy — needs CAC elasticity per channel | Medium (linear with sensitivity coefficients) |
All three predicates share the same skolemization scaffold (item 0 imports, exactly-one move pick via PbEq, combo enumeration via itertools.product, per-move durability second pass on SAT, labeled kill-scenario extraction on UNSAT). The subsections below differ only in (a) the slot data, (b) the per-combo success-predicate body, and (c) the kill-scenario label encoding.
Question it answers. "If I make my move and lock in a market-share level, can any combination of competitor responses dislodge me below my floor?"
Variables required.
my_moves: List[str] — candidate move IDs (the existential decision)my_share_after_move: List[float] — projected share after each move (0..1)their_response_impact: List[List[float]] — competitor × response signed share-shift (positive = they take share from me)floor_threshold: float — minimum acceptable sharePredicate body.
my_share - total_impact >= floor_threshold
# where my_share = Sum([If(move_vars[i], my_share_after_move[i], 0.0) for i in range(N_my_moves)])
# where total_impact = sum(their_response_impact[k][combo[k]] for k in range(N_competitors)) # Python sum, combo fixed
Critical scoping notes.
Sample template code.
# Item 0: imports + slot data
from z3 import Solver, Bool, If, Sum, PbEq, sat, is_true
from itertools import product
from mcp_solver.z3 import export_solution
my_moves = ["raise_price", "freemium", "no_change"]
my_share_after_move = [0.32, 0.50, 0.30]
their_response_impact = [
# Comp A: r0=aggressive cut (+0.08 against me), r1=match (+0.03), r2=no change (0)
[0.08, 0.03, 0.00],
# Comp B: r0=aggressive (+0.05), r1=match (+0.02), r2=no change (0)
[0.05, 0.02, 0.00],
]
floor_threshold = 0.25
# Item 1: decision vars + per-combo skolemized constraints
N_my_moves = len(my_moves)
N_competitors = len(their_response_impact)
N_responses = len(their_response_impact[0])
solver = Solver()
solver.set(unsat_core=True)
move_vars = [Bool(f"choose_{m}") for m in my_moves]
solver.add(PbEq([(m, 1) for m in move_vars], 1))
my_share = Sum([If(move_vars[i], my_share_after_move[i], 0.0)
for i in range(N_my_moves)])
combos = list(product(range(N_responses), repeat=N_competitors))
for combo_idx, combo in enumerate(combos):
total_impact = sum(their_response_impact[k][combo[k]]
for k in range(N_competitors))
label = f"4a_combo_{combo_idx}_resp_{'_'.join(str(c) for c in combo)}_impact_{total_impact:.3f}"
solver.assert_and_track(my_share - total_impact >= floor_threshold, label)
# Item 2: solve + per-move durability second pass + export
variables = {f"choose_{m}": move_vars[i] for i, m in enumerate(my_moves)}
result = solver.check()
if result == sat:
durable = []
kill = {}
for i, name in enumerate(my_moves):
s2 = Solver()
s2.set(unsat_core=True)
s2.add(move_vars[i])
s2.add(PbEq([(m, 1) for m in move_vars], 1))
for combo_idx, combo in enumerate(combos):
ti = sum(their_response_impact[k][combo[k]]
for k in range(N_competitors))
s2.assert_and_track(my_share - ti >= floor_threshold, f"c{combo_idx}")
if s2.check() == sat:
durable.append(name)
else:
core = list(s2.unsat_core())
kill[name] = str(core[0]) if core else "no core"
export_solution({
"status": "sat", "winning_moves": durable,
"kill_scenarios": kill, "total_combos_checked": len(combos),
})
else:
core = solver.unsat_core()
export_solution({
"status": "unsat", "winning_moves": [],
"kill_scenarios": {str(c): "blocking" for c in list(core)[:5]},
"total_combos_checked": len(combos),
})
Output parsing.
status: "sat" + non-empty winning_moves — at least one move defends share at floor_threshold against every realistic competitor counter. Rank by other criteria (revenue impact, brand positioning) outside the solver.status: "sat" + empty winning_moves — solver found a model but no individual move was durable when pinned. Widen the move set or lower floor_threshold.status: "unsat" — market is structurally indefensible at the chosen floor. The kill label 4a_combo_<idx>_resp_<r1>_<r2>_..._impact_<value> names the exact response combination that breaks the floor and its total impact value.Question it answers. "After I ship feature set F, will my coverage of buyer-evaluated dimensions stay ≥ lead_margin ahead of every competitor's worst-case response?"
Variables required.
my_moves: List[str] — candidate move IDs (the existential decision)my_baseline: List[int] — 0/1 per dimension, the brand's current feature coveragemy_move_deltas: List[List[int]] — per-move 0/1 vector adding coverage (rows = moves, cols = dims)their_baseline: List[List[int]] — per-competitor 0/1 coverage vectortheir_response_deltas: List[List[List[int]]] — competitor × response × dimension 0/1 deltasdim_weights: List[int] — 1–10 typical, buyer importance per dimensionlead_margin: int — required weighted-score lead my_score must maintain over the worst-case competitor maxPredicate body.
my_score >= their_max + lead_margin
# where my_score = Σ_d (my_baseline OR (Σ_m move_var[m] * my_move_deltas[m][d])) * dim_weights[d]
# where their_max = max over k of Σ_d (their_baseline[k][d] OR their_response_deltas[k][combo[k]][d]) * dim_weights[d]
Critical scoping notes.
Sample template code.
# Item 0: imports + slot data
from z3 import Solver, Bool, If, Sum, PbEq, sat, is_true
from itertools import product
from mcp_solver.z3 import export_solution
my_moves = ["coaching", "mobile", "warehouse", "no_op"]
my_baseline = [1, 1, 1, 0, 0, 0]
my_move_deltas = [
[0, 0, 0, 1, 0, 0], # coaching: +dim 3
[0, 0, 0, 0, 1, 0], # mobile: +dim 4 (architecturally locked, high weight)
[0, 0, 0, 0, 0, 1], # warehouse:+dim 5
[0, 0, 0, 0, 0, 0], # no_op: nothing
]
their_baseline = [
[1, 1, 1, 0, 0, 0], # Comp A
[1, 1, 0, 0, 0, 0], # Comp B
[1, 0, 1, 0, 0, 0], # Comp C
]
their_response_deltas = [
[[0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0]],
]
dim_weights = [5, 5, 5, 5, 15, 5]
lead_margin = 3
# Item 1: decision vars + my_score expression
N_my_moves = len(my_moves)
N_dims = len(dim_weights)
N_competitors = len(their_baseline)
N_responses = len(their_response_deltas[0])
solver = Solver()
solver.set(unsat_core=True)
move_vars = [Bool(f"choose_{m}") for m in my_moves]
solver.add(PbEq([(m, 1) for m in move_vars], 1)) # pick exactly one move
score = 0
for d in range(N_dims):
if my_baseline[d] == 1:
score = score + dim_weights[d]
else:
covered = Sum([If(move_vars[m], my_move_deltas[m][d], 0)
for m in range(N_my_moves)])
score = score + dim_weights[d] * covered
my_score = score
# Item 2: combo enumeration + per-combo labeled constraints (the skolemization)
combos = list(product(range(N_responses), repeat=N_competitors))
for combo_idx, combo in enumerate(combos):
their_scores = []
for k in range(N_competitors):
cov = [1 if (their_baseline[k][d] == 1 or
their_response_deltas[k][combo[k]][d] == 1) else 0
for d in range(N_dims)]
their_scores.append(sum(dim_weights[d] * cov[d] for d in range(N_dims)))
their_max = max(their_scores)
label = f"combo_{combo_idx}_resp_{'_'.join(str(c) for c in combo)}_their_max_{their_max}"
solver.assert_and_track(my_score >= their_max + lead_margin, label)
# Item 3: solve + per-move durability second pass + export
variables = {f"choose_{m}": move_vars[i] for i, m in enumerate(my_moves)}
result = solver.check()
if result == sat:
durable = []
kill = {}
for i, name in enumerate(my_moves):
s2 = Solver()
s2.set(unsat_core=True)
s2.add(move_vars[i])
s2.add(PbEq([(m, 1) for m in move_vars], 1))
for combo_idx, combo in enumerate(combos):
their_max = max(
sum(dim_weights[d] * (1 if (their_baseline[k][d] == 1 or
their_response_deltas[k][combo[k]][d] == 1) else 0)
for d in range(N_dims))
for k in range(N_competitors)
)
s2.assert_and_track(my_score >= their_max + lead_margin, f"c{combo_idx}")
if s2.check() == sat:
durable.append(name)
else:
core = list(s2.unsat_core())
kill[name] = str(core[0]) if core else "no core"
export_solution({
"status": "sat",
"winning_moves": durable,
"kill_scenarios": kill,
"total_combos_checked": len(combos),
})
else:
core = solver.unsat_core()
export_solution({
"status": "unsat",
"winning_moves": [],
"kill_scenarios": {str(c): "blocking" for c in list(core)[:5]},
"total_combos_checked": len(combos),
})
Per-move durability second pass. After the initial SAT check returns some winning move, the template re-solves once per candidate move with that move pinned ON. This produces (a) the full list of durable moves, not just one solver pick, and (b) for every non-durable move, a 1-line kill scenario extracted from the first label in its unsat core. The kill label encodes the competitor responses that beat the move (combo_<idx>_resp_<r1>_<r2>_..._their_max_<score>), so the founder sees exactly which competitor counter-move closes the gap.
Output parsing.
status: "sat" + non-empty winning_moves — at least one move survives every competitor counter-move within lead_margin. Present winning_moves as the durable list; rank by other criteria (cost, strategic fit) outside the solver.status: "sat" + empty winning_moves — solver found a model only because the existential pick wasn't pinned during the durability pass. Surface kill_scenarios to the founder; widen the move set or relax lead_margin.status: "unsat" — no move dominates against the full counter-move space. The kill_scenarios dict names the first ~5 blocking combos from the unsat core. Treat as "this competitive frame is unwinnable at current lead_margin — relax the margin, expand my_moves, or accept parity."total_combos_checked — sanity check the combo space size (R^N_competitors). If it exceeds ~3000, sample combos instead of enumerating (see pitfall 2).Question it answers. "If I invest $X/mo into channel C, will my blended CAC stay under target_cac across the worst-case counter-investment by competitors?"
Variables required.
my_moves: List[str] — discrete channel-investment options (the existential decision)my_cac_after_move: List[float] — my CAC under move i without competitor countercompetitor_counter_impact: List[List[List[float]]] — move × competitor × response signed CAC lift (positive = my CAC rises when I make move i AND competitor k plays response r)target_cac: float — LTV-derived ceilingPredicate body. Note: impact is per-(move, competitor, response) — competitors can counter different moves differently (LinkedIn counter is cheap, content counter is hard).
my_cac_for_combo <= target_cac
# where my_cac_for_combo = Sum([If(move_vars[i],
# my_cac_after_move[i] + sum(competitor_counter_impact[i][k][combo[k]]
# for k in range(N_competitors)),
# 0.0)
# for i in range(N_my_moves)])
Critical scoping notes.
Sample template code.
# Item 0: imports + slot data
from z3 import Solver, Bool, If, Sum, PbEq, sat, is_true
from itertools import product
from mcp_solver.z3 import export_solution
my_moves = ["linkedin_30k", "content_30k", "no_invest"]
my_cac_after_move = [80, 60, 100] # content has lower base CAC
competitor_counter_impact = [
# Move 0 (linkedin_30k) — competitors can counter-bid LinkedIn hard
[
[30, 20, 5], # Comp A: r0=full counter, r1=partial, r2=no counter
[25, 15, 5], # Comp B
],
# Move 1 (content_30k) — competitors can't easily counter content compounding
[
[8, 5, 2],
[6, 4, 2],
],
# Move 2 (no_invest) — small CAC drift regardless
[
[5, 3, 1],
[4, 2, 1],
],
]
target_cac = 85
# Item 1: decision vars + per-combo skolemized constraints
N_my_moves = len(my_moves)
N_competitors = len(competitor_counter_impact[0])
N_responses = len(competitor_counter_impact[0][0])
solver = Solver()
solver.set(unsat_core=True)
move_vars = [Bool(f"choose_{m}") for m in my_moves]
solver.add(PbEq([(m, 1) for m in move_vars], 1))
combos = list(product(range(N_responses), repeat=N_competitors))
for combo_idx, combo in enumerate(combos):
my_cac_for_combo = Sum([
If(move_vars[i],
my_cac_after_move[i] + sum(
competitor_counter_impact[i][k][combo[k]]
for k in range(N_competitors)),
0.0)
for i in range(N_my_moves)
])
worst_lift = max(
sum(competitor_counter_impact[i][k][combo[k]] for k in range(N_competitors))
for i in range(N_my_moves)
)
label = f"4c_combo_{combo_idx}_resp_{'_'.join(str(c) for c in combo)}_worst_lift_{worst_lift:.2f}"
solver.assert_and_track(my_cac_for_combo <= target_cac, label)
# Item 2: solve + per-move durability second pass + export
variables = {f"choose_{m}": move_vars[i] for i, m in enumerate(my_moves)}
result = solver.check()
if result == sat:
durable = []
kill = {}
for i, name in enumerate(my_moves):
s2 = Solver()
s2.set(unsat_core=True)
s2.add(move_vars[i])
s2.add(PbEq([(m, 1) for m in move_vars], 1))
for combo_idx, combo in enumerate(combos):
cac_combo = Sum([
If(move_vars[j],
my_cac_after_move[j] + sum(
competitor_counter_impact[j][k][combo[k]]
for k in range(N_competitors)),
0.0)
for j in range(N_my_moves)
])
s2.assert_and_track(cac_combo <= target_cac, f"c{combo_idx}")
if s2.check() == sat:
durable.append(name)
else:
core = list(s2.unsat_core())
kill[name] = str(core[0]) if core else "no core"
export_solution({
"status": "sat", "winning_moves": durable,
"kill_scenarios": kill, "total_combos_checked": len(combos),
})
else:
core = solver.unsat_core()
export_solution({
"status": "unsat", "winning_moves": [],
"kill_scenarios": {str(c): "blocking" for c in list(core)[:5]},
"total_combos_checked": len(combos),
})
Output parsing.
status: "sat" + non-empty winning_moves — at least one channel move keeps blended CAC under target_cac against every modeled counter-investment. The kill label 4c_combo_<idx>_resp_<r1>_<r2>_..._worst_lift_<value> names the worst-case CAC lift for that combo.status: "sat" + empty winning_moves — solver picked a move but no individual move was durable when pinned. Channel options are too narrow — broaden the move set or relax target_cac.status: "unsat" — the market is structurally dominated for channel-economics-driven growth. Per scoping doc §7 scenario E1-3, this is the most strategically valuable output — a math-backed exit signal. Narrate as "consider exiting the channel or shifting to a channel where competitors cannot counter-spend (e.g. content, community, partnerships)."clear_model
→ add_item(0, ...slot data per predicate...)
→ add_item(1, ...decision vars + skolemized constraints...)
→ add_item(2, ...solve + durability pass + export...) # for 4a/4c
OR
→ add_item(2, ...combo enumeration + constraints...) # for 4b (4-item structure)
→ add_item(3, ...solve + durability pass + export...) # for 4b only
→ solve_model(timeout=10000)
→ clear_model
Common pitfalls.
ForAll(...) instead of skolemizing. Loses unsat-core explainability — UNSAT then returns opaque "unsat" with no per-combo label, so the founder gets no "you lost because competitor X did Y" output. The whole point of this template is the named kill scenario; ForAll defeats it.R^N_competitors — at 4 competitors × 5 responses you have 625 combos (fine); at 6 × 6 you have 46,656 (slow but tractable); at 8 × 8 you have 16M (intractable). Cap at ~3000 combos. Beyond that, sample combos uniformly (e.g. 2000 random combos drawn without replacement) and document that the result is "robust against sampled counter-moves" rather than "robust against all counter-moves."my_share * my_growth), Z3's quantifier elimination degrades sharply. Pre-compute one side as a Python constant before constraint construction.solver.set(unsat_core=True) before any assert_and_track. Without it, the unsat core is empty even on UNSAT — the kill scenarios silently disappear and the template returns "unsat with no core." Always set this immediately after constructing the Solver().gtm-output-schemas §8 (Solver Conventions) for the runtime conventions every command must follow when invoking the solver.marketing-channel-scoring for how the linear-allocation template is wired into /channel-score.competitor-discovery-cot for how set-cover is wired into /competitor-map candidate selection.swot-analysis (Phase B1) for the knapsack template integration.porters-five-forces (Phase C1) for the set-cover variant on response packaging.tam-sam-som-horizons (Phase C2) for scheduling-with-deps./gtm-audit (Phase D1) for the maxsat-claim-synthesis template wired into the synthesis stage. Uses solver-maxsat MCP server, not solver-z3./content-calendar (Phase E2) for the assignment-with-diversity template wired into multi-platform editorial planning. Uses solver-mzn MCP server, not solver-z3./war-game (Phase E1) for the quantifier-alternation template wired into competitor war-gaming. Uses solver-z3 MCP server with manual skolemization of the universal quantifier.Patterns added or modified here are MAJOR version bumps for the skill — they change the contract every solver-using agent depends on.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub gtmvp/gtmvp-gtm-agents --plugin gtmvp-gtm-agents