Documentation
¶
Overview ¶
Package bot plays TwixT. One alpha-beta search backs four effort tiers and the hint feature; the tiers differ in the guards they search under — how long they may think, how deep they may look, how many nodes they may visit — in how many candidate moves they keep, and in how much of the evaluation they are allowed to see.
A tier names an effort ceiling, not an attained strength. What a search actually reaches inside those guards depends on the position, the board size and the machine, and more effort is not a promise of a better move in every position. The search is also selective — it looks at a shortlist of holes per node and places pegs with their links taken automatically — so nothing here is a solver for the full game.
Limits and NewWithLimits replace a tier's guards with the caller's own, which is how a measurement asks for work rather than for wall-clock time, and StatsOf reports what the last search actually spent. ProgressOf reports how far a search has got while it is still running, and can be read from another goroutine without waiting for it.
Analyze reads one position outside a game and outside the Bot interface: one bounded search, the candidates it kept with what it established about each, and the explanation a hint carries. Hint is that same analysis under the hint's own guards.
Index ¶
Constants ¶
const MaxDepth = maxSearchPly - 1
MaxDepth is the deepest search Limits may ask for. The search keeps one working position per ply and deepens forced lines past their nominal depth, so the last ply it can hold is reserved for those extensions.
const NoChain = -1
NoChain is the Dist of a side with no route between its borders left in the graph this file builds: an opposing peg or an opposing link seals every walk. It is a distinct value rather than a large number so that a caller cannot mistake it for a peg count.
It is a reading of this graph and not a verdict on the game. The graph refuses to travel between two occupied holes with no link between them, because no placement can join them — but a turn under the printed rules may join them by hand, so a side reading NoChain here can still have a legal winning turn. TestHintDoesNotCallAPlacementDeadlockADraw holds a committed position where both sides read NoChain and one of them wins at once by placing a peg and joining a pair of its own. Nothing built on this value may say the game is decided; see AnalysisPolicy.
Variables ¶
var ErrNoMove = errors.New("bot: no legal placement available")
ErrNoMove reports a position where the bot has nowhere to play.
var ErrStagedTurn = errors.New("bot: the turn in progress has uncommitted edits: commit or abort it first")
ErrStagedTurn reports a position whose turn in progress carries uncommitted edits: a peg placed but not committed, or a link added or withdrawn by hand.
The search plays and takes back trial moves on the game it is given, and taking a move back restores the position from before the turn, so a staged edit handed to the engine would be searched as though it were part of the position and then thrown away. The bot refuses instead of doing either: the caller commits or aborts the turn first. This is checked once per request, not per node.
Functions ¶
func TierNames ¶
func TierNames() []string
TierNames returns tier names in increasing configured effort order.
func TierSummary ¶
TierSummary returns the one-line description of a named tier, for help text and shell completion. An unknown name returns the empty string.
Types ¶
type AnalysisCandidate ¶ added in v0.7.0
type AnalysisCandidate struct {
Move game.Point
Score int
Bound ScoreBound
}
AnalysisCandidate is one root placement together with what the search established about it.
type AnalysisOptions ¶ added in v0.7.0
type AnalysisOptions struct {
// Limits replace the guards the analysis searches under. A zero field
// keeps the analysis default, which is the hint's: the highest-effort
// levers the package has under a two-second guard.
Limits Limits
// OnComplete, when set, is called once for every deepening iteration that
// finished, in order. It is not called for an iteration that was cut
// short, for the ordering fallback that answers a search no iteration
// finished, or for a win taken without searching: none of those is a
// finished iteration.
//
// It runs synchronously inside the search, on the goroutine that called
// Analyze, so blocking in it spends the search's own budget; and the game
// it was called about is the search's working position, so it must not be
// edited from here. The snapshot it is given is its own, and changing it
// cannot change the search. Analyze does not keep the function after it
// returns.
OnComplete func(AnalysisProgress)
}
AnalysisOptions configures one Analyze call.
type AnalysisPolicy ¶ added in v0.3.3
type AnalysisPolicy struct {
// contains filtered or unexported fields
}
AnalysisPolicy identifies the move restrictions and heuristic scope of advice. The zero value is unstated, not unrestricted. Hint carries the policy so its consumers can distinguish a placement-only recommendation from full-rule analysis. Exact terminal claims additionally require an engine-verified result.
func PlacementOnlyPolicy ¶ added in v0.3.3
func PlacementOnlyPolicy() AnalysisPolicy
PlacementOnlyPolicy is the policy every search in this package runs under: it places one peg a turn and keeps every link that placement offers, and it looks at a shortlist of holes rather than at every legal continuation.
A turn under the printed rules may do more than that — join two pegs already down, take one of the mover's own links back, take the swap — and none of it is searched. So what comes back is a steer under those restrictions and never a statement about legal play: a side this evaluation reads as having no route left can still have a legal winning turn, because a link it refuses to travel along is a link a player may add by hand.
func (AnalysisPolicy) Label ¶ added in v0.3.3
func (p AnalysisPolicy) Label() string
Label is the badge for a status line with no room for a sentence.
func (AnalysisPolicy) String ¶ added in v0.3.3
func (p AnalysisPolicy) String() string
String is the compact form, for a log line or a machine-readable field.
func (AnalysisPolicy) Summary ¶ added in v0.3.3
func (p AnalysisPolicy) Summary() string
Summary is the sentence a reader gets. It names the badge inside itself, so a surface with room for one line of policy and no more still shows which policy it is.
type AnalysisProgress ¶ added in v0.7.0
type AnalysisProgress struct {
// Recommended is the best move of this iteration.
Recommended game.Point
// Candidates is this iteration's root list, best first, and belongs to the
// observer: editing it cannot reach the search.
Candidates []AnalysisCandidate
// Stats is the work spent up to this iteration. StopReason is empty
// because the search has not stopped.
Stats SearchStats
}
AnalysisProgress is what one finished deepening iteration reached.
type AnalysisResult ¶ added in v0.7.0
type AnalysisResult struct {
// Result is the result of the position analysed, and never the result of
// the position the recommendation would produce.
Result game.Result
// Policy is the restriction the analysis ran under.
Policy AnalysisPolicy
// Before is the decomposition of the analysed position for the side to
// move. On a finished game that is the side the game was left with: a
// connection win does not hand the turn over.
Before Terms
// After is the decomposition once the recommended move is played, nil when
// there is no recommendation.
After *Terms
// Recommended is the hole to play, nil for a finished game.
Recommended *game.Point
// Candidates are the root placements the search kept, best first. It is
// empty for a finished game.
Candidates []AnalysisCandidate
// Reason is the code of the explanation — "advance", "only-defence",
// "deadlock" and so on. It is empty for a finished game, which gets no
// explanation at all.
Reason string
// Headline and Detail are the explanation in words, empty alongside an
// empty Reason. They state only what the decomposition measured.
Headline, Detail string
// Highlight lists the holes the explanation refers to, the recommended one
// first. It is empty for a finished game.
Highlight []game.Point
// Stats is what the search spent, nil for a finished game because no
// search ran.
Stats *SearchStats
}
AnalysisResult is one bounded reading of one position: what to play, what else was considered and on what evidence, the evaluation on either side of the move, and the explanation derived from those numbers.
Everything in it belongs to the caller. The slices and the pointed-to values are copies, not views into the searcher's scratch.
func Analyze ¶ added in v0.7.0
func Analyze(ctx context.Context, g *game.Game, opts AnalysisOptions) (AnalysisResult, error)
Analyze returns an owned position analysis under the same placement-only policy as Hint, with Max levers and a two-second default search guard. Scores and bounds describe the selective search, not optimal full-rule play; terminal facts come from the rules engine.
Time, node and context stops return the last completed iteration, or an unscored ordering fallback. Stats describes search work only: Nodes excludes root/tactical probes, and Evaluations excludes post-search explanation. Limits are checked at work boundaries, not as a hard real-time deadline.
Invalid limits, nil input, staged turns and nonterminal positions with no placement return errors. A terminal position returns its result and terms without a recommendation. The search temporarily makes and unmakes moves; callers must not read or mutate g concurrently.
type Bot ¶
type Bot interface {
Tier() Tier
// Move returns the hole to play. It never returns an illegal move.
//
// A deadline or a cancellation on ctx cuts the search short and Move
// returns the best move it had reached, which is how a per-move budget is
// enforced; it is not an error, and even an already-cancelled context
// yields a legal move chosen by the ordering heuristic. An error means the
// position cannot be searched: the game is over, there is nowhere left to
// play, or the turn in progress has uncommitted edits.
Move(ctx context.Context, g *game.Game) (game.Point, error)
// Hint explains the move the bot would consider best for the side to move.
// A hint is always computed with the highest-effort settings the package
// has, whatever tier is playing, so that a beginner-tier game does not also
// give beginner-tier advice.
Hint(ctx context.Context, g *game.Game) (Hint, error)
}
Bot plays one side of a game.
A Bot carries the working state of its search and is not safe for concurrent use: give each game its own Bot.
func New ¶
New returns a bot of the given tier. A tier that does not exist gives the beginner rather than an error, so a caller that has not validated its input still plays; NewWithLimits refuses it instead.
The seed decides the beginner tier's choice among near-best moves, and nothing else: the other tiers play the best move their search found. That move is a function of the seed and the position for as long as the search runs to completion. The beginner and intermediate tiers stop at their depth ceilings, which they reach well inside their budgets, so in practice they do run to completion; the pro and max tiers carry ceilings their budgets need not be long enough to reach, and any tier can be cut short by a deadline or a cancellation on the caller's context. A search that was cut short answers from the work it had finished, so reproducibility follows the completed work rather than the tier — see Limits for how a caller asks for work it can count on rather than for wall-clock time.
func NewWithLimits ¶ added in v0.3.0
NewWithLimits returns a bot of the given tier searching under the given limits. Everything else about the tier — its candidate widths, its evaluation, its table, its sampling — is untouched, so the bot is the tier playing under a different guard rather than a different evaluation policy.
Unlike New it refuses a tier that does not exist. A caller passing explicit limits is configuring a measurement rather than starting a game, and would rather hear about the mistake than quietly measure the beginner.
type Hint ¶
type Hint struct {
Move game.Point
// Headline is one short line: what to play.
Headline string
// Detail is one to three short sentences saying why, in terms of the
// numbers the search actually measured.
Detail string
// Highlight lists the holes the explanation refers to, for the board to
// mark.
Highlight []game.Point
// Policy is the restriction the advice was produced under. Every hint this
// package returns carries PlacementOnlyPolicy; the zero value states
// nothing, which is what a stub or scripted bot leaves behind.
Policy AnalysisPolicy
}
Hint is a recommended move together with an explanation derived from the search that found it.
type Limits ¶ added in v0.3.0
type Limits struct {
// Nodes is the most nodes the search may visit across all its iterations.
// Zero keeps the tier's own ceiling, which for every tier is none.
Nodes int64
// Depth is the deepest iteration the search may run. Zero keeps the tier's
// own ceiling, and MaxDepth is the most the search can hold.
Depth int
// Time is a search-time guard, checked at work boundaries rather than a
// hard real-time deadline. Zero keeps the tier's own budget.
Time time.Duration
}
Limits replace the effort guards a tier searches under. A zero field keeps the tier's own value, so a caller states only what it means to change. A negative one is refused rather than ignored: it can only be a mistake, and silently searching under the tier's own guard instead would make a measurement report work it never bounded.
Nodes and Depth bound work rather than time, and work is what a caller can hold constant between machines. That only holds as far as the work bound is the guard that actually binds: the search always carries a wall-clock guard as well, a deadline or cancellation on the caller's context always cuts it short, and whichever guard comes first is the one that ends the search. So a caller after a search it can reproduce sets Nodes or Depth, lifts Time far out of the way — Time: time.Hour — passes no deadline it expects to hit, and checks StatsOf: a StopReason of "nodes" or "depth" says the work bound ended the search and the answer is reproducible, while "time" or "canceled" says it did not and the answer is whatever that machine had reached by then.
type ScoreBound ¶ added in v0.7.0
type ScoreBound string
ScoreBound says what a candidate's score is: a measurement, a bound, or nothing at all. The root measures the move it ends up recommending and asks every later move only whether it beats that one, so most alternatives come back bounded from above and nothing further about them is known.
const ( // BoundExact is a score the search measured inside a window that // contained it. BoundExact ScoreBound = "exact" // BoundUpper is a ceiling: the move was shown not to beat the best found, // and may be worth far less than the number says. BoundUpper ScoreBound = "upper" // BoundLower is a floor. Nothing in this package produces one; it is // named so that a consumer can render the case if one ever appears, and a // score must never be labelled with it for having been searched or chosen. BoundLower ScoreBound = "lower" // BoundUnscored marks a candidate no search scored: either no iteration // finished and the list is the ordering heuristic's, or the root could not // play the move at all. Its Score is zero because there is no number, not // because zero was measured. BoundUnscored ScoreBound = "unscored" )
type SearchProgress ¶ added in v0.8.0
type SearchProgress struct {
// Generation counts the Move searches this engine has started, so a
// reader can tell a new search from the one it looked at last. Zero means
// none has started yet.
Generation uint64
// Started is when that search began.
Started time.Time
// Running is true from the moment the search starts until Move returns.
Running bool
// Completed says at least one deepening iteration finished, or a winning
// hole was taken without searching. Depth, Recommended and Stats mean
// something only when it is true: before then the only answer the search
// has is the ordering heuristic's guess, and that is not a search.
Completed bool
// Depth is the deepest completed iteration, and zero for a win taken
// without searching.
Depth int
// Recommended is the best move of the last completed iteration. It is the
// search's own choice, before the beginner tier samples among the
// alternatives, so it need not be the move Move returns on that tier.
Recommended game.Point
// Stats is the work as of the last completed iteration while Running is
// true, with an empty StopReason because the search has not stopped. Once
// Running is false it is what StatsOf reports for the same search.
Stats SearchStats
}
SearchProgress is an immutable snapshot of an engine's current or last Move search.
It exists for a screen that wants to show what the engine is doing while it thinks. Stats cannot answer that: it describes a search that has already happened, and a caller that serialises its bot behind the Move lock would wait out the whole search to read it. A snapshot is published whole at the start of a search, at the end of every deepening iteration that finished, and when Move returns, and it is never edited after that, so reading one costs a load and a copy and never waits on the search.
What it reports is completed work only. An iteration still running has scored some root moves at the new depth and the rest at the old one, so its best move and its counters describe nothing in particular; a snapshot keeps showing the last iteration that finished until the next one does.
func ProgressOf ¶ added in v0.8.0
func ProgressOf(b Bot) SearchProgress
ProgressOf reports b's latest search progress without blocking and without starting any work, or the zero SearchProgress for a bot that does not report it.
Like StatsOf it is optional: an implementation that reports progress offers Progress() SearchProgress, which is what this asks for. Unlike StatsOf it is meant to be read from another goroutine while Move is running, so an implementation has to answer without waiting for the search.
type SearchStats ¶ added in v0.3.0
type SearchStats struct {
// Nodes is how many positions the search visited below the root, summed
// over every iteration of its deepening loop.
Nodes int64
// Evaluations is how many positions it loaded and scored, root and threat
// probes included, so it is normally larger than Nodes.
Evaluations int64
// Depth is the deepest completed iteration. Zero includes immediate wins
// and searches interrupted before their first iteration completed.
Depth int
// Elapsed is how long the search took.
Elapsed time.Duration
// StopReason names what ended it: "immediate" for a winning hole taken
// without searching, "depth" for every allowed iteration finished,
// "decided" for a forced result inside the selective search tree (not a
// proof about every legal TwixT continuation), "time" for the budget or the
// caller's deadline, "nodes" for the node ceiling, "canceled" for a
// cancelled context, "no-move" for a position with nowhere to play, and
// "error" for a search that could not restore the position it was given.
// The first three mean the search ended on its own terms and Depth is what
// it reached; the rest mean it was cut short and Depth is the last
// iteration it had completed.
StopReason string
}
SearchStats is what a search actually spent. The search records it as it goes, so reading it costs nothing and starts nothing: it describes a search that has already happened, and is the zero value before the first one.
func StatsOf ¶ added in v0.3.0
func StatsOf(b Bot) SearchStats
StatsOf reports what a bot's last Move search spent, or the zero SearchStats for a bot that does not keep the count.
Keeping it is optional on purpose. Bot is the contract the screens and the tests depend on, and a stub or a scripted opponent has no search to report; an implementation that does report offers Stats() SearchStats, which is what this asks for. It never runs a search itself, so a caller may read it after every move without changing what the bot does or how long it takes.
type Terms ¶
type Terms struct {
// Dist is the cost of the side's cheapest chain still open to it: the pegs
// it would place along that chain if nothing interfered. Zero means the
// chain is complete; NoChain means the evaluation found no route left.
Dist int
// OppDist is the same count for the opponent.
OppDist int
// Bottlenecks counts the holes every one of the side's cheapest chains has
// to run through, so a single opposing peg would set the plan back. Zero
// means every step of the plan has an alternative.
Bottlenecks int
// OppBottlenecks is the same count for the opponent.
OppBottlenecks int
// Ground is how much more of the board the side can reach cheaply than the
// opponent, in parts per thousand of the usable holes. It is negative when
// the opponent has the wider reach.
Ground int
}
Terms is the decomposition of a static evaluation, in the units the hint feature talks about. Every score the search produces at a leaf is a function of these numbers and nothing else, which is what makes a derived explanation checkable.
The whole decomposition is placement-only, which is the restriction AnalysisPolicy states: every count here reads the board as it stands and assumes each side plays one peg a turn and keeps the links that peg offers. None of these numbers is a bound on legal play in either direction — the opponent moves in between, and a turn may also join or withdraw a link — so they are a steer and not a proof. The weights below are what the tournament measured and are not changed by that: what changes is only what may be claimed about them.