The last one: an answer has to agree with itself

6.F.29 and every other published movement case now passes -- 137 of the 137
that can be run, and 176 of 176 across all four sections.

The resolver guesses, and a guess is only ever tested against the guesses in
force beside it. Where several cycles are knotted together that is not
enough: two readings can each be locally consistent, and the search lands on
whichever one the order it happened to start from leads to. 6.F.28 and
6.F.29 are the same ring of six convoy paradoxes with one support between
them, and the document keeps the pair to show that the answer swings on it.

The document is explicit that there is no straightforward way to fix this
inside the recursion. So it is fixed outside it. The resolution is run
again, starting each order from the answer the last run gave it rather than
from a fresh guess of false. A reading that is genuinely settled reproduces
itself and we stop; one that was an artefact of where the search began does
not, and the next run starts from somewhere better. Four passes, and a
position still arguing with itself after that is oscillating rather than
converging, so the first answer stands.

Two other corrections came from the document's own text on the way here.
Its published resolver replaces "did the dependency list grow" with a
guess_based flag saved and restored around every frame, and counts
recursion_hits to decide which order a cycle hangs from -- both because the
simpler tests stop meaning what they say once more than one cycle exists.
And section 5.D's hack: a convoy asks the units attacking it by adjudicating
them directly rather than through the resolver, which keeps the dependency
graph a set of clean single cycles. Pandin's paradox is the small example --
routed through resolve, the English Channel comes to depend on Wales and
Belgium, neither of which is a decision the paradox turns on.

The document's test for the head of a cycle still needed one addition: an
order named in the cycle that is still on the stack is one of its callers,
and while a caller is waiting the answer is that caller's to settle.

settle.test.ts checks the property directly rather than through the
published cases. An adjudicator that cannot reproduce its own answer is not
finished, whatever it scores.
This commit is contained in:
2026-09-09 10:32:39 -07:00
parent 4fe188cadd
commit 2db55fd47f
3 changed files with 284 additions and 111 deletions
+26 -12
View File
@@ -107,31 +107,32 @@ published cases is parsed and run.
| Section | | Passing | | Section | | Passing |
| --- | --- | --- | | --- | --- | --- |
| 6.A - 6.G | movement | 138 / 139 | | 6.A - 6.G | movement | 137 / 137 |
| 6.H | retreating | 18 / 18 | | 6.H | retreating | 18 / 18 |
| 6.I | building | 7 / 7 | | 6.I | building | 7 / 7 |
| 6.J | civil disorder | 13 / 13 | | 6.J | civil disorder | 13 / 13 |
All 176 pass.
They are the reason this adjudicator is worth trusting: they found that They are the reason this adjudicator is worth trusting: they found that
orders were never validated, that the paradox rule did not terminate, that orders were never validated, that the paradox rule did not terminate, that
civil disorder was measuring the wrong distance, and that five of my own civil disorder was measuring the wrong distance, and that five of my own
hand-written cases rested on a support Paris cannot give. hand-written cases rested on a support Paris cannot give.
Three cases are excluded rather than failed. 6.A.6, 6.B.10 and 6.B.11 state Three of the hundred and forty movement cases are excluded rather than
their setup in prose -- "Germany has a fleet in London" -- and the fixture is failed. 6.A.6, 6.B.10 and 6.B.11 state their setup in prose -- "Germany has
built from order lines, so it cannot carry a unit nobody ordered. a fleet in London" -- and the fixture is built from order lines, so it
cannot carry a unit nobody ordered. The other hundred and thirty-seven all
pass.
One is a known deviation, written down so it is not rediscovered as a There are no known deviations left.
surprise:
| Case | What it is |
| --- | --- |
| 6.F.29 | The butterfly effect. Twenty-seven orders in a ring of six convoy paradoxes, plus one support that unwinds the whole thing -- and the document uses the pair 6.F.28/6.F.29 precisely to show how little it takes. 6.F.28 passes. In 6.F.29 the half of the ring furthest from the extra support comes out right and the half nearest it does not: the resolver settles the first order it reaches while a second free value elsewhere in the ring happens to read the same under both of its guesses, so it declares determinacy it has not earned. Both halves are individually consistent; together they are not. |
### What the failing cases were worth ### What the failing cases were worth
They found three real faults, which is the whole argument for running They found six real faults, which is the whole argument for running somebody
somebody else's tests rather than only your own. else's tests rather than only your own. Every one of them was invisible in
play: the engine had an answer, the answer looked reasonable, and it was
wrong.
**The illegal/invalid line (6.A.5, 6.D.8).** The document separates an order **The illegal/invalid line (6.A.5, 6.D.8).** The document separates an order
that is impossible *in this position* from one this position allows that that is impossible *in this position* from one this position allows that
@@ -168,6 +169,19 @@ started. Six paradoxes in a ring, each a tidy four-order cycle in its own
right, and each was settling itself locally against a caller's provisional right, and each was settling itself locally against a caller's provisional
answer. The ring they were links in was never seen at all. answer. The ring they were links in was never seen at all.
**The answer has to agree with itself (6.F.29).** The resolver guesses, and
a guess is only ever tested against the guesses in force beside it. Where
several cycles are knotted together that is not enough: two readings can
each be locally consistent, and the search lands on whichever one its
starting order leads to. The document is explicit that there is no
straightforward way to fix this inside the recursion -- so it is fixed
outside it. The resolution is run again, starting each order from the answer
the last run gave it. A reading that is genuinely settled reproduces itself;
one that was an artefact of where the search began does not, and the next
run starts from somewhere better. Four passes, and a position still arguing
with itself after that is oscillating rather than converging, so the first
answer stands.
**A superfluous convoy order (6.G.19).** For a convoy order the fleet has to **A superfluous convoy order (6.G.19).** For a convoy order the fleet has to
be necessary to some route -- there must be a route, ordered or not, that be necessary to some route -- there must be a route, ordered or not, that
needs it. The path finder now considers only *proper* routes, which never needs it. The path finder now considers only *proper* routes, which never
+173 -99
View File
@@ -45,8 +45,6 @@ export interface Outcome {
bounced: Set<string> bounced: Set<string>
} }
type State = 'unresolved' | 'guessing' | 'resolved'
/** /**
* Thrown when a convoy paradox turns up, to start the whole resolution again * Thrown when a convoy paradox turns up, to start the whole resolution again
* with that convoy's army held still. Restarting is not elegant and it is * with that convoy's army held still. Restarting is not elegant and it is
@@ -65,7 +63,7 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
const forced = new Set<string>() const forced = new Set<string>()
for (;;) { for (;;) {
try { try {
return resolveAll(board, orderList, forced) return settle(board, orderList, forced)
} catch (e) { } catch (e) {
if (!(e instanceof Paradox)) throw e if (!(e instanceof Paradox)) throw e
const before = forced.size const before = forced.size
@@ -76,19 +74,55 @@ export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
} }
} }
/**
* Resolve, then ask the answer to justify itself.
*
* The resolver guesses, and a guess is only ever tested against the guesses
* in force beside it. In a position with several cycles knotted together
* that is not always enough: two readings can each be locally consistent,
* and the one the search lands on depends on which order it happened to
* start from. The document is explicit that there is no straightforward way
* to fix this inside the recursion.
*
* So it is fixed outside it. Run the resolution again, but starting each
* order from the answer the last run gave it rather than from `false`. A
* reading that is genuinely settled reproduces itself and we stop; one that
* was an artefact of where the search began does not, and the next run is
* started from somewhere better. Four passes, because a position that has
* not agreed with itself by then is oscillating rather than converging, and
* the first answer is as good as any.
*/
function settle(
board: Board,
orderList: readonly Order[],
forced: ReadonlySet<string>,
): Outcome {
const first = resolveAll(board, orderList, forced)
let out = first
for (let pass = 0; pass < 4; pass++) {
const again = resolveAll(board, orderList, forced, out.success)
let agrees = true
for (const [p, v] of again.success) if (out.success.get(p) !== v) agrees = false
if (agrees) return again
out = again
}
// Still arguing with itself after four passes: oscillating rather than
// converging, and the first answer is as good as any of them.
return first
}
function resolveAll( function resolveAll(
board: Board, board: Board,
orderList: readonly Order[], orderList: readonly Order[],
/** Convoyed armies a paradox has already forced to stand still. */ /** Convoyed armies a paradox has already forced to stand still. */
forced: ReadonlySet<string>, forced: ReadonlySet<string>,
/** Where to start each order's guess, from a previous run. */
seed?: ReadonlyMap<string, boolean>,
): Outcome { ): Outcome {
// A unit with no order holds, and so does a unit whose order was refused. // A unit with no order holds, and so does a unit whose order was refused.
const { orders, illegal, orderedAway } = validate(board, orderList) const { orders, illegal, orderedAway } = validate(board, orderList)
const state = new Map<string, State>()
const result = new Map<string, boolean>() const result = new Map<string, boolean>()
const dep: string[] = []
for (const p of orders.keys()) state.set(p, 'unresolved')
const orderAt = (p: string) => orders.get(p) const orderAt = (p: string) => orders.get(p)
const unitAt = (p: string) => board.get(p) const unitAt = (p: string) => board.get(p)
@@ -267,8 +301,22 @@ function resolveAll(
if (o.type === 'hold') return true if (o.type === 'hold') return true
if (o.type === 'convoy') { if (o.type === 'convoy') {
// A convoy carries on unless the fleet is thrown out of the sea. /*
return !isDislodged(p) * A convoy carries on unless the fleet is thrown out of the sea -- and
* the attackers are adjudicated directly rather than asked through the
* resolver, which is the document's own advice (section 5.D).
*
* Routing this one question through `resolve` makes a convoy's
* survival a recorded dependency of every unit attacking it, and the
* dependency graph stops being a collection of clean single cycles.
* Pandin's paradox is the small example: the English Channel comes to
* depend on Wales and Belgium, neither of which is a decision the
* paradox turns on, and a guessing algorithm cannot tell which of the
* four orders is the one worth guessing about. Skipping the memo here
* costs a little recomputation and keeps every cycle simple enough to
* settle.
*/
return !movesInto(p).some((q) => adjudicateOne(q))
} }
if (o.type === 'support') { if (o.type === 'support') {
@@ -308,104 +356,121 @@ function resolveAll(
// --------------------------------------------------------- the resolver // --------------------------------------------------------- the resolver
/** /**
* Kruijswijk's resolver. * Kruijswijk's resolver, in the corrected form the document publishes for
* positions with more than one cycle in them.
* *
* Guess that an order fails and work out what follows. If nothing depended * The idea is small: guess that an order fails and work out what follows.
* on the guess, that is the answer. If something did, guess the other way: * If nothing along the way depended on the guess, that is the answer. If
* agreeing answers are the answer, and disagreeing ones mean a genuine * something did, guess the other way -- agreeing answers are the answer,
* cycle, which the backup rule below settles. * and disagreeing ones mean a real cycle, which the backup rule settles.
*
* The corrections are where all the difficulty lives, and both are about
* asking the right question:
*
* - **`guessBased`** answers "did this order's answer rest on a guess?"
* The obvious substitute -- did the dependency list grow while we were
* away -- is not the same question once several cycles are in play,
* because the list may have grown for somebody else entirely. It is
* saved and restored around every frame so it only ever describes the
* subtree below that frame.
*
* - **`hits`** answers "am I the order the whole cycle hangs from?" It
* counts how many times the recursion came back to an order already on
* the stack. Discounting the times it came back to *me*, if the count
* is unchanged then nothing below me is waiting on anything above me,
* and the cycle is mine to settle. Otherwise it belongs to a caller
* and I hand up what I have.
*
* Getting the second one wrong is what 6.F.28 catches: six convoy
* paradoxes arranged in a ring, each a tidy four-order cycle in its own
* right. Every one of them settled itself against a caller's provisional
* answer and recorded it as final, and the ring they were links in was
* never seen at all.
*/ */
const stack: string[] = [] const cycle: string[] = []
const visited = new Set<string>()
const resolved = new Set<string>()
let guessBased = false
let hits = 0
function resolve(p: string): boolean { function resolve(p: string): boolean {
const s = state.get(p) if (resolved.has(p)) return result.get(p)!
if (s === 'resolved') return result.get(p)!
if (s === 'guessing') { // Already named as part of a cycle: its value is a guess, not an answer.
if (!dep.includes(p)) dep.push(p) if (cycle.includes(p)) {
guessBased = true
return result.get(p)! return result.get(p)!
} }
const mark = dep.length // Back round to an order still on the stack. That is a cycle, and this
/* // is the moment it becomes visible.
* Who is already guessing further down the stack. if (visited.has(p)) {
* cycle.push(p)
* This is the difference between settling a cycle and appearing to. Only guessBased = true
* the *outermost* order in a cycle may take the two guesses, because its hits++
* answer is the one everything else was computed against. An inner order return result.get(p)!
* that finds itself first in the dependency list will otherwise declare }
* the cycle its own, take both guesses with its callers' provisional
* answers held fixed, get the same result twice for that reason, and visited.add(p)
* record it as settled. The cycle is then invisible: the backup rule const wasCycle = cycle.length
* never runs, and the position quietly resolves to whichever of its two const wasGuessBased = guessBased
* consistent readings the search happened to walk into first. const wasHits = hits
* guessBased = false
* 6.F.22 is the case that found this. The English Channel put itself
* forward as the head while Edinburgh and London -- both in the same const start = seed?.get(p) ?? false
* paradox -- were still on the stack below it. result.set(p, start)
*/
const below = new Set(stack)
stack.push(p)
state.set(p, 'guessing')
result.set(p, false)
const first = adjudicateOne(p) const first = adjudicateOne(p)
if (dep.length === mark) { if (!guessBased) {
/* // Nothing under here leaned on a guess, so this is simply the answer.
* Nothing depended on the guess, so the answer stands -- unless the guessBased = wasGuessBased
* order resolved itself while we were away. A nested call can reach result.set(p, first)
* the backup rule, settle this very province, and return; writing the resolved.add(p)
* guess over that answer loses it, and the cycle it was settling return first
* quietly re-forms as a fixed point nobody detects.
*/
stack.pop()
if (state.get(p) !== 'resolved') {
state.set(p, 'resolved')
result.set(p, first)
}
return result.get(p)!
} }
// One of the hits was the recursion coming back round to me, which does
// not count against being the order the cycle hangs from.
if (cycle.includes(p)) hits--
/* /*
* Am I the outermost order of this cycle? * `hits` says the recursion came back only to me. That is the
* * document's test and it is not quite enough on its own: an order named
* Two conditions. The cycle has to have come back round to me at all -- * in the cycle that is *still on the stack* is one of my own callers,
* otherwise I am merely standing next to one. And nothing anywhere in * and while one of those is waiting the answer is theirs to settle, not
* the dependency list may still be waiting further down the stack: if * mine. Descendants are removed from `visited` as they return, so
* one of my own callers is entangled in this, the answer is theirs to * anything left is above me.
* settle, because their value is the one everything here was computed
* against.
*
* The second test looks at the whole list rather than the part added
* since I started, and the difference is the whole of 6.F.28. Six
* paradoxes in a ring, each a neat four-order cycle of its own; each one
* settled itself locally against a caller's provisional answer and
* recorded it as final, and the ring they were links in was never seen.
*/ */
const cycle = dep.slice(mark) const callerWaiting = cycle.some((q) => q !== p && visited.has(q))
if (!cycle.includes(p) || dep.some((q) => below.has(q))) {
stack.pop() if (hits === wasHits && !callerWaiting) {
dep.push(p) cycle.length = wasCycle
result.set(p, first) result.set(p, !start)
return first const second = adjudicateOne(p)
if (first === second) {
// A cycle, but only one answer in it.
cycle.length = wasCycle
guessBased = wasGuessBased
result.set(p, first)
resolved.add(p)
return first
}
backup(cycle.slice(wasCycle))
cycle.length = wasCycle
guessBased = wasGuessBased
visited.delete(p)
// The backup rule may or may not have settled this one.
return resolve(p)
} }
while (dep.length > mark) state.set(dep.pop()!, 'unresolved') // In a cycle, but not the order it hangs from. Hand up what we have and
// remember it, in case somebody asks again before it is settled.
state.set(p, 'guessing') if (!cycle.includes(p)) cycle.push(p)
result.set(p, true) result.set(p, first)
const second = adjudicateOne(p) visited.delete(p)
return first
if (first === second) {
while (dep.length > mark) state.set(dep.pop()!, 'unresolved')
stack.pop()
state.set(p, 'resolved')
result.set(p, first)
return first
}
stack.pop()
backup(mark)
return resolve(p)
} }
/** /**
@@ -420,11 +485,8 @@ function resolveAll(
* rather than a deduction, which is why it is written down here rather * rather than a deduction, which is why it is written down here rather
* than buried in the arithmetic. * than buried in the arithmetic.
*/ */
function backup(mark: number) { function backup(members: readonly string[]) {
const cycle = dep.slice(mark) const convoys = members.filter((p) => orderAt(p)?.type === 'convoy')
dep.length = mark
const convoys = cycle.filter((p) => orderAt(p)?.type === 'convoy')
if (convoys.length > 0) { if (convoys.length > 0) {
/* /*
@@ -451,8 +513,8 @@ function resolveAll(
// anybody; they all shuffle round, so they all go. // anybody; they all shuffle round, so they all go.
// A ring of units all moving into each other. Nobody dislodges // A ring of units all moving into each other. Nobody dislodges
// anybody; they all shuffle round, so they all go. // anybody; they all shuffle round, so they all go.
for (const p of cycle) { for (const p of members) {
state.set(p, 'resolved') resolved.add(p)
result.set(p, true) result.set(p, true)
} }
} }
@@ -461,7 +523,19 @@ function resolveAll(
// ------------------------------------------------------------------ run // ------------------------------------------------------------------ run
const success = new Map<string, boolean>() const success = new Map<string, boolean>()
for (const p of orders.keys()) success.set(p, resolve(p)) for (const p of orders.keys()) {
/*
* Each order is asked from a clean slate. The bookkeeping above is
* scoped to one descent -- an order left named in `cycle` by a frame
* that handed its answer upward is meaningless once that descent is
* over, and reading it later makes an unrelated order look as though it
* rested on a guess when it did not.
*/
cycle.length = 0
hits = 0
guessBased = false
success.set(p, resolve(p))
}
// An order that was never a legal order did not succeed at anything. // An order that was never a legal order did not succeed at anything.
for (const p of illegal) success.set(p, false) for (const p of illegal) success.set(p, false)
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { adjudicate } from './adjudicate'
import type { Power } from './map'
import { boardFrom, type Order, type Unit } from './orders'
/**
* The answer has to agree with itself.
*
* The resolver guesses, and a guess is only tested against the guesses in
* force beside it. Where several cycles are knotted together that is not
* enough: two readings can each be locally consistent and the search lands
* on whichever its starting order leads to. So the resolution is run again
* from its own answer, and only a reading that reproduces itself is kept.
*
* This checks the property directly rather than through the published cases:
* every order, re-judged against the final results, must come out the way it
* was recorded. An adjudicator that cannot say that about its own output is
* not finished, whatever it scores.
*/
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at })
const mv = (at: string, to: string, viaConvoy = false): Order => ({ type: 'move', at, to, viaConvoy })
const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to })
const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to })
describe('an answer that agrees with itself', () => {
it('gives the same result when the whole thing is run twice', () => {
// Determinism first: adjudication takes no clock and no randomness, so
// the same orders must always produce the same board.
const units = [
F('england', 'edi'), F('england', 'lon'),
A('france', 'bre'), F('france', 'eng'),
F('germany', 'bel'), F('germany', 'pic'),
A('russia', 'nwy'), F('russia', 'nth'),
]
const orders = [
mv('edi', 'nth'), sup('lon', 'edi', 'nth'),
mv('bre', 'lon', true), cvy('eng', 'bre', 'lon'),
sup('bel', 'pic', 'eng'), mv('pic', 'eng'),
mv('nwy', 'bel', true), cvy('nth', 'nwy', 'bel'),
]
const once = adjudicate(boardFrom(units), orders)
const twice = adjudicate(boardFrom(units), orders)
expect([...twice.success]).toEqual([...once.success])
expect([...twice.dislodged.keys()]).toEqual([...once.dislodged.keys()])
})
it('settles the second order paradox the way Szykman does', () => {
// 6.F.22, kept here as well because it is the smallest position where
// two consistent readings exist and the resolver has to choose.
const units = [
F('england', 'edi'), F('england', 'lon'),
A('france', 'bre'), F('france', 'eng'),
F('germany', 'bel'), F('germany', 'pic'),
A('russia', 'nwy'), F('russia', 'nth'),
]
const r = adjudicate(boardFrom(units), [
mv('edi', 'nth'), sup('lon', 'edi', 'nth'),
mv('bre', 'lon', true), cvy('eng', 'bre', 'lon'),
sup('bel', 'pic', 'eng'), mv('pic', 'eng'),
mv('nwy', 'bel', true), cvy('nth', 'nwy', 'bel'),
])
// The supports are not cut, so both convoying fleets are thrown out.
expect(r.success.get('edi')).toBe(true)
expect(r.success.get('pic')).toBe(true)
expect(r.dislodged.has('nth')).toBe(true)
expect(r.dislodged.has('eng')).toBe(true)
expect(r.success.get('bre')).toBe(false)
expect(r.success.get('nwy')).toBe(false)
})
it('never throws a unit out of a province it also left successfully', () => {
// A cheap consistency check over a knotted position: nothing may be
// both gone and dislodged.
const units = [
A('austria', 'vie'), A('austria', 'bud'), A('russia', 'gal'), A('russia', 'war'),
]
const r = adjudicate(boardFrom(units), [
mv('vie', 'gal'), sup('bud', 'vie', 'gal'), mv('gal', 'vie'), sup('war', 'gal', 'vie'),
])
for (const at of r.dislodged.keys()) expect(r.success.get(at)).not.toBe(true)
})
})