|
| 1 | +# Strategy = Tactics × Orchestration |
| 2 | + |
| 3 | +We build software for RoboCup Small Size League robots — six autonomous robots a side, |
| 4 | +playing soccer, at a scale where "the strategy layer" has to decide, sixty times a second, |
| 5 | +what every robot on the field is doing. This post is about the architecture we landed on |
| 6 | +for that decision, and why splitting it into two orthogonal pieces — *tactics* and |
| 7 | +*orchestration* — turned out to matter more than either piece alone. |
| 8 | + |
| 9 | +## The model |
| 10 | + |
| 11 | +A **`Tactic`** is a small, self-contained unit of coordinated behavior for a *group* of |
| 12 | +robots: a give-and-go, a press-and-contain, a defensive wall. Its interface, in full: |
| 13 | + |
| 14 | +```python |
| 15 | +class Tactic(Protocol[MemT]): |
| 16 | + tag: TacticTag # ATTACK / DEFENSE / MIXED — what role this tactic plays |
| 17 | + |
| 18 | + def initial_mem(self) -> MemT: |
| 19 | + """Fresh state, created whenever this tactic's robot assignment changes.""" |
| 20 | + |
| 21 | + def tick( |
| 22 | + self, game: Game, ctx: TickContext, robot_ids: tuple[RobotId, ...], mem: MemT |
| 23 | + ) -> tuple[dict[RobotId, RobotCommand], MemT]: |
| 24 | + """Compute this tick's commands for `robot_ids`, and the next `mem`.""" |
| 25 | + |
| 26 | + def applicable(self, game: Game) -> bool: |
| 27 | + """Is it sensible for this tactic to *begin* running right now? Default: True.""" |
| 28 | + |
| 29 | + def is_committed(self, game: Game, mem: MemT) -> bool: |
| 30 | + """Must the scheduler leave this tactic's robots alone right now? Default: False.""" |
| 31 | + |
| 32 | + def suggest_next(self, game: Game, mem: MemT) -> Optional[TacticId]: |
| 33 | + """Purely advisory: what should run next, if anything. Default: no opinion.""" |
| 34 | +``` |
| 35 | + |
| 36 | +`tick()` is the only method a tactic *must* implement — everything else defaults to |
| 37 | +permissive, so a minimal tactic is one method long. Handed a game snapshot, a tick context, |
| 38 | +the specific robots it owns *this tick*, and whatever state it carried from last tick, it |
| 39 | +returns commands and its next state. No inheritance required, no base class to extend — |
| 40 | +just an object shaped like this. A tactic doesn't know or care how many other tactics exist, |
| 41 | +what they're doing, or how it ended up with the robots it has. |
| 42 | + |
| 43 | +A **`Strategy`** is the piece that decides *that*. Its core loop, simplified: |
| 44 | + |
| 45 | +```python |
| 46 | +class Strategy: |
| 47 | + def __init__(self, tactics: dict[TacticId, Tactic], partitioner: Partitioner, ...): |
| 48 | + ... |
| 49 | + |
| 50 | + def tick(self, game: Game) -> dict[RobotId, RobotCommand]: |
| 51 | + partition = self._choose_partition(game) # pinned (committed) slots + partitioner's picks |
| 52 | + commands = {} |
| 53 | + for tactic_id, robot_ids in partition.items(): |
| 54 | + slot = self._slot_for(tactic_id) |
| 55 | + slot_commands, slot.mem = slot.tactic.tick(game, self._ctx, robot_ids, slot.mem) |
| 56 | + commands.update(slot_commands) |
| 57 | + return commands |
| 58 | +``` |
| 59 | + |
| 60 | +where a `Partitioner` is a plain function: |
| 61 | + |
| 62 | +```python |
| 63 | +Partitioner = Callable[ |
| 64 | + [Game, frozenset[RobotId], Optional[dict[TacticId, frozenset[RobotId]]], frozenset[TacticId]], |
| 65 | + dict[TacticId, frozenset[RobotId]], |
| 66 | +] |
| 67 | +``` |
| 68 | + |
| 69 | +Every tick, before any tactic runs, the partitioner looks at the game state, the pool of |
| 70 | +*free* robots (excluding any currently `is_committed()`), and the previous partition, and |
| 71 | +decides how to split the free pool across tactic slots. One tactic might get two robots this |
| 72 | +tick, none the next; a new tactic might spin up mid-possession; another might quietly stop |
| 73 | +being handed anyone. The strategy layer runs however many tactics the partition calls for, |
| 74 | +concurrently, each ticking against only the robots it was just assigned. |
| 75 | + |
| 76 | +The split is the whole idea: **a `Tactic` never decides who's on it, and a `Strategy` never |
| 77 | +decides what a group of robots does once assigned to one.** Multiplying those two concerns |
| 78 | +together — a tactic's behavior, times an orchestration decision about group membership — |
| 79 | +is what produces the actual play on the field. Change the orchestration and keep the same |
| 80 | +tactics, and you get a different team shape from the same building blocks. Change a tactic |
| 81 | +and keep the same orchestration, and every formation that uses it inherits the fix at once. |
| 82 | + |
| 83 | +## What falls out of decoupling them |
| 84 | + |
| 85 | + |
| 86 | + |
| 87 | +Read left to right: through `NORMAL_START`, robots 1–2 are running `LeadAndSupportTactic` |
| 88 | +and it has marked itself `is_committed()` — the partitioner never sees them as free, no |
| 89 | +matter what else changes. At t9 a referee restart arrives and triggers a **barrier reset**: |
| 90 | +every slot's commitment and `mem` are cleared unconditionally, for every robot at once, |
| 91 | +including 1–2 — a restart makes the previous tick's in-progress action moot for everyone, |
| 92 | +not just whoever wasn't committed. For the duration of `BALL_PLACEMENT_THEIRS`, |
| 93 | +`RefereeOverride` — not any `Tactic` — owns every outfield robot directly, running the |
| 94 | +restart's own positioning step. Once `NORMAL_START` resumes at t14, the partitioner is |
| 95 | +simply asked again: robots 3–5 reshuffle freely (nothing there was ever committed), and |
| 96 | +there's no "was anything mid-commitment when the restart hit" case to reconcile, because |
| 97 | +the barrier reset already handled that five ticks earlier. |
| 98 | + |
| 99 | +Three things fall out of this for free, without any of them being designed as a special |
| 100 | +case: **regrouping is just the partitioner running again** (two active tactics becoming |
| 101 | +one or three needs no dedicated transition — it's the ordinary consequence of the free pool |
| 102 | +changing tick to tick); **safety needs no locks** (the whole partition is fixed before any |
| 103 | +tactic ticks, so two tactics can never contend for a robot mid-tick, and `is_committed()` |
| 104 | +gives a tactic an absolute, self-declared veto over being reassigned mid-action, cleared |
| 105 | +only on a referee restart); and **tactics compose without coordinating** (a new tactic drops |
| 106 | +in against the same `tick()` shape, with no shared state machine or priority table to update, |
| 107 | +and every existing tactic keeps working unmodified since `applicable()`/`is_committed()` |
| 108 | +default permissively). |
| 109 | + |
| 110 | +## The OS scheduler analogy |
| 111 | + |
| 112 | +If this shape feels familiar, it's on purpose. It's the same split an operating system |
| 113 | +makes between *processes* and the *scheduler* on a multi-core CPU. A process doesn't know |
| 114 | +how many cores exist, which core it's running on, or when it'll be preempted — it just |
| 115 | +computes, given whatever time slice and core it's handed. The scheduler doesn't know or |
| 116 | +care what a process actually does internally; it only ever reasons about which processes |
| 117 | +get which cores, for how long, and under what constraints (priority, affinity, a lock held |
| 118 | +that can't be safely preempted). |
| 119 | + |
| 120 | +A `Tactic` is a process: it runs, given a slice of the shared resource (robots, not cores), |
| 121 | +with no visibility into the scheduling decision that put it there. `Strategy` is the |
| 122 | +scheduler: it never looks inside a tactic's logic, only at the tactic-level metadata it's |
| 123 | +allowed to ask about — `applicable()` (can this be scheduled at all right now), |
| 124 | +`is_committed()` (does it hold something like a lock it can't be preempted out of), and |
| 125 | +`suggest_next()` (a cooperative yield hint, not unlike a process signaling it's about to |
| 126 | +finish). The single-writer partition invariant is exactly a scheduler's guarantee that two |
| 127 | +processes never get handed the same core at once — the concurrency-safety property comes |
| 128 | +from the same place an OS's does: one authority decides the whole allocation before anything |
| 129 | +runs, not from locks negotiated between the things being scheduled. |
| 130 | + |
| 131 | +## Future directions |
| 132 | + |
| 133 | +Two independent axes to push on next, and it's worth being clear they're independent: |
| 134 | +better *tactics* and better *orchestration* are separate improvements, exactly because the |
| 135 | +model keeps them decoupled. |
| 136 | + |
| 137 | +**More tactics** is the straightforward axis — more plays, more set-piece responses, more |
| 138 | +specialized behavior for situations the current roster handles generically. Every new |
| 139 | +tactic is additive: it slots into the existing `Tactic` protocol, and no partitioner has to |
| 140 | +change to accommodate it unless it should specifically prefer the new one. |
| 141 | + |
| 142 | +**Better orchestration** is the more open one. Today's partitioners are hand-written |
| 143 | +if/else logic over game state — auditable, predictable, and easy to reason about, but |
| 144 | +fundamentally a human asserting "in this situation, split the robots this way" rather than |
| 145 | +anything derived or learned. A few directions worth exploring, roughly in order of how much |
| 146 | +they change the model: |
| 147 | + |
| 148 | +- **Fitness vectors.** Instead of (or alongside) hand-written if/else branches, let each |
| 149 | + tactic optionally score how well it thinks it'd do with a given candidate group of robots |
| 150 | + — a named vector (`{"ball_proximity": 0.8, "formation_risk": 0.2}`), not a single opaque |
| 151 | + number, so each criterion stays individually inspectable and testable. The partitioner |
| 152 | + then collapses competing tactics' vectors into a decision via some explicit policy — |
| 153 | + weighted sum, strict lexicographic ordering (defense always wins ties with offense, say), |
| 154 | + or lexicographic-with-tolerance so close scores don't get forced apart by an arbitrary |
| 155 | + priority order. This only matters where tactics are genuinely competing for the same |
| 156 | + marginal robot; it's overkill anywhere the split is already obvious. |
| 157 | +- **Graph-based scheduling.** Tactics already have an optional, purely-advisory |
| 158 | + `suggest_next()` hook — a tactic that knows it's about to finish can name what it thinks |
| 159 | + should run next. Nothing currently *uses* that signal; a partitioner built around chaining |
| 160 | + these hints into an explicit graph (this tactic's likely successors, and theirs) is a |
| 161 | + fundamentally different orchestration shape from today's flat if/else, closer to a |
| 162 | + planner than a classifier. |
| 163 | +- **Learned scheduling.** The far end of the same axis: replace the hand-written or |
| 164 | + hand-scored partitioner with something trained against real match outcomes — a model that |
| 165 | + looks at game state and a candidate partition and predicts how good it is, rather than a |
| 166 | + human asserting it. The action space stays small and enumerable (a handful of robots |
| 167 | + across a bounded set of tactics), which keeps this more tractable than it sounds, but it |
| 168 | + trades away the auditability of "just read the if/else" for something that needs its own |
| 169 | + validation story before anyone trusts it in a real match. |
| 170 | + |
| 171 | +None of these require touching what a `Tactic` looks like at all — every one of them is a |
| 172 | +different `Partitioner`, which is exactly the point of keeping the two sides of this |
| 173 | +multiplication independent in the first place. |
0 commit comments