Monkey and bananas

A classic planning puzzle: how does the monkey reach the bananas?

monkey-bananas.py · output · proof · check · try it in the playground


The question

A room has three spots: loc1, loc2 and loc3. Bananas hang from the ceiling at loc1, too high to reach. The monkey stands at loc2. A box sits at loc3.

The monkey can walk, push the box while on the ground, climb on and off the box, and grab the bananas when standing on the box beneath them. Finding a sequence of moves that reaches a goal is called planning.

Which sequences of one to five moves end with the monkey holding the bananas?


What we tell eyekind: situations and moves

A situation, or state, is a list [Bananas, Monkey, Box, OnBox, HasBananas]: where the bananas, the monkey and the box are, then 'y' or 'n' (yes or no) for whether the monkey is on the box and whether it holds the bananas.

fact(initial_state(['loc1', 'loc2', 'loc3', 'n', 'n']))
# Any state in which the monkey holds the bananas is a goal.
fact(goal_state([_, _, _, _, 'y']))

# Climbing needs the monkey and box at the same place, and grabbing needs
# all three there with the monkey on the box. Pushing moves monkey and box
# together; walking (go) moves only the monkey. Both need it on the ground.
fact(legal_move([B, M, M, 'n', H], 'climb_on', [B, M, M, 'y', H]))
fact(legal_move([B, M, M, 'y', H], 'climb_off', [B, M, M, 'n', H]))
fact(legal_move([B, B, B, 'y', 'n'], 'grab', [B, B, B, 'y', 'y']))
implied_by(
  legal_move([B, M, M, 'n', H], push(X), [B, X, X, 'n', H]),
  [location(X), not_unify(X, M)],
)
implied_by(
  legal_move([B, M, L, 'n', H], go(X), [B, X, L, 'n', H]),
  [location(X), not_unify(X, M)],
)

fact(location('loc1'))
fact(location('loc2'))
fact(location('loc3'))

What we tell eyekind: searching for plans

implied_by(reaches_goal(Moves), [initial_state(I), goal_state(G), reachable(I, Moves, G)])

fact(reachable(S, [], S))
implied_by(reachable(S1, [M, *Rest], S3), [legal_move(S1, M, S2), reachable(S2, Rest, S3)])

# Plans are tried shortest first: a plan of a given length is a list of that
# many moves still to be chosen. in_range(1, 5, N) yields N = 1, 2, ..., 5.
fact(moves(0, []))
implied_by(moves(N, [_, *Rest]), [gt(N, 0), is_(M, sub(N, 1)), moves(M, Rest)])
implied_by(in_range(Low, High, Low), le(Low, High))
implied_by(
  in_range(Low, High, N),
  [lt(Low, High), is_(Next, add(Low, 1)), in_range(Next, High, N)],
)

implies([in_range(1, 5, N), moves(N, Moves), reaches_goal(Moves)], plan(Moves))

What eyekind concludes

// Conclusions: the facts the program derives, in the order derived.
plan([go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc1'), go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc3'), push('loc1'), 'climb_on', 'grab', 'climb_off'])
plan([go('loc3'), push('loc2'), push('loc1'), 'climb_on', 'grab'])

There is no plan of three moves or fewer. There is one four-move plan: walk to the box, push it under the bananas, climb on, grab. The three five-move plans are variations of it: a detour to loc1 first, an extra climb down at the end, or an extra push via loc2.


The proof, line by line

eyekind --proof writes down how it reached each conclusion. The whole proof has 86 steps; see the full proof. Here are its claims, all its clauses, and the complete chain of 41 steps for the first plan:

// Conclusions: the facts the program derives, in the order derived.
plan([go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc1'), go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc3'), push('loc1'), 'climb_on', 'grab', 'climb_off'])
plan([go('loc3'), push('loc2'), push('loc1'), 'climb_on', 'grab'])

// Clauses: the program clauses the steps cite, by clause number.
clause(1, implied_by(reaches_goal(Moves), [initial_state(I), goal_state(G), reachable(I, Moves, G)]))
clause(2, fact(reachable(S, [], S)))
clause(3, implied_by(reachable(S1, [M, ...Rest], S3), [legal_move(S1, M, S2), reachable(S2, Rest, S3)]))
clause(4, fact(initial_state(['loc1', 'loc2', 'loc3', 'n', 'n'])))
clause(5, fact(goal_state([_0, _1, _2, _3, 'y'])))
clause(6, fact(legal_move([B, M, M, 'n', H], 'climb_on', [B, M, M, 'y', H])))
clause(7, fact(legal_move([B, M, M, 'y', H], 'climb_off', [B, M, M, 'n', H])))
clause(8, fact(legal_move([B, B, B, 'y', 'n'], 'grab', [B, B, B, 'y', 'y'])))
clause(9, implied_by(legal_move([B, M, M, 'n', H], push(X), [B, X, X, 'n', H]), [location(X), not_unify(X, M)]))
clause(10, implied_by(legal_move([B, M, L, 'n', H], go(X), [B, X, L, 'n', H]), [location(X), not_unify(X, M)]))
clause(11, fact(location('loc1')))
clause(12, fact(location('loc2')))
clause(13, fact(location('loc3')))
clause(14, fact(moves(0, [])))
clause(15, implied_by(moves(N, [_0, ...Rest]), [gt(N, 0), is(M, sub(N, 1)), moves(M, Rest)]))
clause(16, implied_by(in_range(Low, High, Low), le(Low, High)))
clause(17, implied_by(in_range(Low, High, N), [lt(Low, High), is(Next, add(Low, 1)), in_range(Next, High, N)]))
clause(18, implies([in_range(1, 5, N), moves(N, Moves), reaches_goal(Moves)], plan(Moves)))

// Steps: each goal, what justifies it (a clause, a builtin, a control, or an
// absence or collection taken on trust), the bindings and the goals it uses.
step(plan([go('loc3'), push('loc1'), 'climb_on', 'grab']), clause(18), {N: 4, Moves: [go('loc3'), push('loc1'), 'climb_on', 'grab']}, [in_range(1, 5, 4), moves(4, [go('loc3'), push('loc1'), 'climb_on', 'grab']), reaches_goal([go('loc3'), push('loc1'), 'climb_on', 'grab'])])
step(in_range(1, 5, 4), clause(17), {Low: 1, High: 5, N: 4, Next: 2}, [lt(1, 5), is(2, add(1, 1)), in_range(2, 5, 4)])
step(lt(1, 5), 'builtin', {}, [])
step(is(2, add(1, 1)), 'builtin', {}, [])
step(in_range(2, 5, 4), clause(17), {Low: 2, High: 5, N: 4, Next: 3}, [lt(2, 5), is(3, add(2, 1)), in_range(3, 5, 4)])
step(lt(2, 5), 'builtin', {}, [])
step(is(3, add(2, 1)), 'builtin', {}, [])
step(in_range(3, 5, 4), clause(17), {Low: 3, High: 5, N: 4, Next: 4}, [lt(3, 5), is(4, add(3, 1)), in_range(4, 5, 4)])
step(lt(3, 5), 'builtin', {}, [])
step(is(4, add(3, 1)), 'builtin', {}, [])
step(in_range(4, 5, 4), clause(16), {Low: 4, High: 5}, [le(4, 5)])
step(le(4, 5), 'builtin', {}, [])
step(moves(4, [go('loc3'), push('loc1'), 'climb_on', 'grab']), clause(15), {N: 4, _0: go('loc3'), Rest: [push('loc1'), 'climb_on', 'grab'], M: 3}, [gt(4, 0), is(3, sub(4, 1)), moves(3, [push('loc1'), 'climb_on', 'grab'])])
step(gt(4, 0), 'builtin', {}, [])
step(is(3, sub(4, 1)), 'builtin', {}, [])
step(moves(3, [push('loc1'), 'climb_on', 'grab']), clause(15), {N: 3, _0: push('loc1'), Rest: ['climb_on', 'grab'], M: 2}, [gt(3, 0), is(2, sub(3, 1)), moves(2, ['climb_on', 'grab'])])
step(gt(3, 0), 'builtin', {}, [])
step(is(2, sub(3, 1)), 'builtin', {}, [])
step(moves(2, ['climb_on', 'grab']), clause(15), {N: 2, _0: 'climb_on', Rest: ['grab'], M: 1}, [gt(2, 0), is(1, sub(2, 1)), moves(1, ['grab'])])
step(gt(2, 0), 'builtin', {}, [])
step(is(1, sub(2, 1)), 'builtin', {}, [])
step(moves(1, ['grab']), clause(15), {N: 1, _0: 'grab', Rest: [], M: 0}, [gt(1, 0), is(0, sub(1, 1)), moves(0, [])])
step(gt(1, 0), 'builtin', {}, [])
step(is(0, sub(1, 1)), 'builtin', {}, [])
step(moves(0, []), clause(14), {}, [])
step(reaches_goal([go('loc3'), push('loc1'), 'climb_on', 'grab']), clause(1), {Moves: [go('loc3'), push('loc1'), 'climb_on', 'grab'], I: ['loc1', 'loc2', 'loc3', 'n', 'n'], G: ['loc1', 'loc1', 'loc1', 'y', 'y']}, [initial_state(['loc1', 'loc2', 'loc3', 'n', 'n']), goal_state(['loc1', 'loc1', 'loc1', 'y', 'y']), reachable(['loc1', 'loc2', 'loc3', 'n', 'n'], [go('loc3'), push('loc1'), 'climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y'])])
step(initial_state(['loc1', 'loc2', 'loc3', 'n', 'n']), clause(4), {}, [])
step(goal_state(['loc1', 'loc1', 'loc1', 'y', 'y']), clause(5), {_0: 'loc1', _1: 'loc1', _2: 'loc1', _3: 'y'}, [])
step(reachable(['loc1', 'loc2', 'loc3', 'n', 'n'], [go('loc3'), push('loc1'), 'climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(3), {S1: ['loc1', 'loc2', 'loc3', 'n', 'n'], M: go('loc3'), Rest: [push('loc1'), 'climb_on', 'grab'], S3: ['loc1', 'loc1', 'loc1', 'y', 'y'], S2: ['loc1', 'loc3', 'loc3', 'n', 'n']}, [legal_move(['loc1', 'loc2', 'loc3', 'n', 'n'], go('loc3'), ['loc1', 'loc3', 'loc3', 'n', 'n']), reachable(['loc1', 'loc3', 'loc3', 'n', 'n'], [push('loc1'), 'climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y'])])
step(legal_move(['loc1', 'loc2', 'loc3', 'n', 'n'], go('loc3'), ['loc1', 'loc3', 'loc3', 'n', 'n']), clause(10), {B: 'loc1', M: 'loc2', L: 'loc3', H: 'n', X: 'loc3'}, [location('loc3'), not_unify('loc3', 'loc2')])
step(location('loc3'), clause(13), {}, [])
step(not_unify('loc3', 'loc2'), 'builtin', {}, [])
step(reachable(['loc1', 'loc3', 'loc3', 'n', 'n'], [push('loc1'), 'climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(3), {S1: ['loc1', 'loc3', 'loc3', 'n', 'n'], M: push('loc1'), Rest: ['climb_on', 'grab'], S3: ['loc1', 'loc1', 'loc1', 'y', 'y'], S2: ['loc1', 'loc1', 'loc1', 'n', 'n']}, [legal_move(['loc1', 'loc3', 'loc3', 'n', 'n'], push('loc1'), ['loc1', 'loc1', 'loc1', 'n', 'n']), reachable(['loc1', 'loc1', 'loc1', 'n', 'n'], ['climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y'])])
step(legal_move(['loc1', 'loc3', 'loc3', 'n', 'n'], push('loc1'), ['loc1', 'loc1', 'loc1', 'n', 'n']), clause(9), {B: 'loc1', M: 'loc3', H: 'n', X: 'loc1'}, [location('loc1'), not_unify('loc1', 'loc3')])
step(location('loc1'), clause(11), {}, [])
step(not_unify('loc1', 'loc3'), 'builtin', {}, [])
step(reachable(['loc1', 'loc1', 'loc1', 'n', 'n'], ['climb_on', 'grab'], ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(3), {S1: ['loc1', 'loc1', 'loc1', 'n', 'n'], M: 'climb_on', Rest: ['grab'], S3: ['loc1', 'loc1', 'loc1', 'y', 'y'], S2: ['loc1', 'loc1', 'loc1', 'y', 'n']}, [legal_move(['loc1', 'loc1', 'loc1', 'n', 'n'], 'climb_on', ['loc1', 'loc1', 'loc1', 'y', 'n']), reachable(['loc1', 'loc1', 'loc1', 'y', 'n'], ['grab'], ['loc1', 'loc1', 'loc1', 'y', 'y'])])
step(legal_move(['loc1', 'loc1', 'loc1', 'n', 'n'], 'climb_on', ['loc1', 'loc1', 'loc1', 'y', 'n']), clause(6), {B: 'loc1', M: 'loc1', H: 'n'}, [])
step(reachable(['loc1', 'loc1', 'loc1', 'y', 'n'], ['grab'], ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(3), {S1: ['loc1', 'loc1', 'loc1', 'y', 'n'], M: 'grab', Rest: [], S3: ['loc1', 'loc1', 'loc1', 'y', 'y'], S2: ['loc1', 'loc1', 'loc1', 'y', 'y']}, [legal_move(['loc1', 'loc1', 'loc1', 'y', 'n'], 'grab', ['loc1', 'loc1', 'loc1', 'y', 'y']), reachable(['loc1', 'loc1', 'loc1', 'y', 'y'], [], ['loc1', 'loc1', 'loc1', 'y', 'y'])])
step(legal_move(['loc1', 'loc1', 'loc1', 'y', 'n'], 'grab', ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(8), {B: 'loc1'}, [])
step(reachable(['loc1', 'loc1', 'loc1', 'y', 'y'], [], ['loc1', 'loc1', 'loc1', 'y', 'y']), clause(2), {S: ['loc1', 'loc1', 'loc1', 'y', 'y']}, [])
// … 45 more steps, 86 in total, prove the three five-move plans. They reuse
// the steps above wherever they can, such as the legal moves already shown.

It has three parts:

The 41 steps for the first plan say:

Steps 29, 33, 37 and 39, by rule 3, link these moves together: each says from this state, these remaining moves reach the goal, because the first of them is legal and the rest reach the goal from the state it leads to.


Checked: what the report says

A proof is only worth something if someone else can check it. eyekind -c generates the proof and checks it against the program; --check-proof checks a saved proof. The checker never reasons by itself: it only confirms each step against the program. This is the complete report:

// Checked answers: these follow from the input program.
plan([go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc1'), go('loc3'), push('loc1'), 'climb_on', 'grab'])
plan([go('loc3'), push('loc1'), 'climb_on', 'grab', 'climb_off'])
plan([go('loc3'), push('loc2'), push('loc1'), 'climb_on', 'grab'])

// C1-C8: resolution, acyclicity, justification, coverage,
// re-decision, boundary consistency, relevance and witness integrity.
condition('C1', 'resolution', 'ok', 61)
condition('C2', 'well_founded', 'ok', 86)
condition('C3', 'justification', 'ok', 86)
condition('C4', 'coverage', 'ok', 127)
condition('C5', 're_decision', 'ok', 25)
condition('C6', 'boundary_consistency', 'ok', 0)
condition('C7', 'relevance', 'ok', 90)
condition('C8', 'witness_integrity', 'ok', 0)

// Proof statistics: recorded steps and how they were checked.
steps(86)
verified(61)
recomputed(25)
composed(0)
trusted(0)
claims(4)

// This verdict applies to the claims above; it does not establish completeness.
verdict('checked')

Each condition line names a check, says 'ok', and counts what it looked at:

The statistics: 86 steps, 61 verified against the program, 25 recomputed, none taken on trust, 4 claims. The verdict is checked: each of the four plans really works. As the last comment says, this confirms the claims; it does not by itself show that there are no other plans of up to five moves.


Try it

eyekind examples/monkey-bananas.py            # the answers
eyekind --proof examples/monkey-bananas.py    # answers with their proof

Or open it in the playground. Change in_range(1, 5, N) to in_range(1, 4, N) to allow at most four moves: only the shortest plan remains. Or start the monkey next to the box, by changing the initial state to ['loc1', 'loc3', 'loc3', 'n', 'n']: the shortest plan is now three moves, [push('loc1'), 'climb_on', 'grab'], and there are 18 plans in all.


Takeaway

A plan is a chain of small, checkable moves. eyekind finds the plans and records why each move was allowed, so you can follow the monkey step by step.