Skip to content

Commit d2540f3

Browse files
committed
docs: add blog post on the Strategy = Tactics x Orchestration model
Explains the Tactic/Strategy split, the single-writer partition invariant, an OS-scheduler analogy, and open orchestration directions (fitness vectors, graph-based scheduling, learned scheduling).
1 parent 39257ee commit d2540f3

2 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Loading
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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+
![Timeline: robots 1–2 held by a committed LeadAndSupportTactic straight through a barrier reset into BALL_PLACEMENT_THEIRS, while robots 3–5 freely reshuffle across the reset and again once normal play resumes.](img/referee_restart_timeline.svg)
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

Comments
 (0)