The Repo on GitHub

Explaining the situation to you helps. Then I pick a good first step. The rest is history: Long article, small steps, excellent outcome. Good job, rubber duckie!

Let me see if I can explain, for my own benefit if not yours, what the issue is.

Rubber Duckie
Explaining to someone, even someone who isn’t here, or to one’s favorite rubber duckie, often helps set thoughts in order. This is one of those times.

Because return information from a Bot action isn’t really immediate, we can’t do the obvious thing, which would be something like this:

# state looking
if we have a block in front of us:
    attempt to take the block
    if we got the block:
        state = laden
    else:
        state = looking  # still

Instead we wind up with code like this:

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._state = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

The problem here, well one of the problems here is that at step N, while looking, we see a block and try to take it, and then return from the machine. In step N+1, still in looking, we will note that we have a block and if so, change state. But doing so means that we also skip a turn at doing anything. Right now, that’s somewhat OK because we always try to take a step and that’s all that we would have done anyway. But in principle, we missed a chance for action.

What I believe I want to do is to have the machine have two operational methods, perhaps update and act, where on update we pass in all the info the bot needs and may set the state to a new state, and then act takes place, in the same turn, in the proper state.

As I sit here, I do not see steps from here to there. Machine looks like this:

class Machine:
    def __init__(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired = 10
        self._state = self.walking

    def state(self):
        self.tired -= 1
        self._state()
        return self

    def walking(self):
        if self.tired <= 0:
            self._state = self.laden if self.bot.inventory else self.looking

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._state = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

    def laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._state = self.walking
            return
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

Ah. One thing I know for sure is that we need to pass the bot in on the state command, because if we do not, things like the vision, which we are caching, will not be correct. (We could instead reference thru bot, but we are trying to move to a place where we send in a package of info, not the whole bot.)

Note:
It’s worth noting that part of my problem here is that I have several things going on at once, changing how it works, changing how it’s called, changing what it knows, and even changing the state transitions. It’s no wonder that I’m a bit confused.

One possibility would be to reset way back into the past. I don’t think that’s ideal, because to the extent that my mind knows anything about this, it mostly knows what’s current. So I choose, for now, to move forward.

In the course of explaining this to us, I have come up with a tentative starting plan. First, I’ll pass in the bot on the state command, and update the vision. Then, I think what I’ll do is break out the state method to call update and act, and move things around until that works. That’s going to require breaking each of the state methods, walking, looking, and laden into two methods, one for update and one for act.

In a Slack conversation last night, GeePaw was describing an approach he would take (or would have taken) to this, and it may appear later. We’ll see and I’ll point it out if it does.

OK first get the bot passed in. We are clean and green.

    def state(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired -= 1
        self._state()
        return self

This breaks some tests, which, I suspect, are not expecting to have to provide a vision. In Bot, vision is initialized to None. Let’s see if initing it to an empty list suffices to make the tests run: I think it should. It does not. Undo that even if it’s a good idea, we don’t need another half idea up in this thing.

OK, well past time to look at the tests and see why they are not happy. Ah, good news, it was trivial. When I did the refactoring operation, I mistakenly had the new bot parameter default to self, which meant that all my tests passed themselves as a bot. Changing self to bot about ten times fixed all the tests. We are green.

Commit: Machine state now takes bot parameter to ensure up to date.

Now the plan is to cause the state method above to execute two methods, one to update and one to act.

Let’s rename _state to _action. Green. Commit: rename _state to _action.

Now let’s add another member, _update, which is to be a pointer to the appropriate update method for the state:

class Machine:
    def __init__(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired = 10
        self._update = self.update_walking
        self._action = self.walking

    def state(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired -= 1
        self._update()
        self._action()
        return self

    def update_walking(self):
        pass

So, let me express what I’m up to. I propose that there are two operations for each state, update and action, and that update’s job is to set the two pluggable members, _update and _action to the correct pair of operations, and then action does the logic of the operation, figuring out the commands to be done.

We are green. Let’s stick with the habit of committing on each green, unless, of course, we become concerned that the most recent move was a terrible one. I think we’re OK.

Commit: update always called, currently only does update_walking, pass

I can see two ways to go. One is to put in the other update methods, and change all the state transitions to set both variables. The other would be to elaborate this first one. I think we’ll be better off to get everything in place. Let’s drive the change from these methods:

    def update_walking(self):
        pass

    def walking(self):
        if self.tired <= 0:
            self._action = self.laden if self.bot.inventory else self.looking

wherever we set _action, we need to set the corresponding _update. This code, in walking, should really be in walking_update, but let’s not make two changes at once:

This move is going to break a few things briefly I am OK with it.

First expand the last line there into an actual if/else. PyCharm does that for me:

    def walking(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._action = self.laden
            else:
                self._action = self.looking

This is still green. Commit: open conditional expression.

Now, wishful thinking:

    def walking(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._update = self.update_laden
                self._action = self.laden
            else:
                self._update = self.update_looking
                self._action = self.looking

The sky falls for want of those methods. Add them, trivially:

    def update_walking(self):
        pass

    def update_looking(self):
        pass

    def update_laden(self):
        pass

Green. Commit: all three states support update method.

Now we need to ensure that all the state changes set both members. These two do not:

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._action = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

    def laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._action = self.walking
            return
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

Readily fixed:

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

    def laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking
            return
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

Green. Commit: all states update both update and action member.

Now the thing is to move the update part to the update method, leaving the action part in the other method. I think we’ll want some renaming as well … but this whole thing is only an interim step on the way to class per state.

I am concerned that the upcoming changes may break tests, because when we change state on update, our action will occur in the same turn as the state change, so some of the tests may need to reflect that we don’t skip a turn any more. We’ll find out.

Here’s the first change, from this:

    def update_walking(self):
        pass

    def walking(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._update = self.update_laden
                self._action = self.laden
            else:
                self._update = self.update_looking
                self._action = self.looking

To this:

    def update_walking(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._update = self.update_laden
                self._action = self.laden
            else:
                self._update = self.update_looking
                self._action = self.looking

    def walking(self):
        pass

In the fullness of time, the walking method will return a “step” command, I think, but for now, stepping is outside the machine. Two tests break.

    def test_bot_notices_a_block(self):
        world = World(10, 10)
        bot = Bot(5, 5)
        bot.state.tired = 0
        bot.direction_change_chance = 0
        world.add(bot)
        block = Block(7, 5)
        world.add(block)
        bot.do_something()
        assert bot.state._action == bot.state.looking
        bot.do_something()
        assert bot.has(block)

The first do_something crashes:

    def can_take(self):
>       return self.vision.match_forward_and_one_side('B', '_')
E       AttributeError: 'NoneType' object has no attribute 'match_forward_and_one_side'

Our bot has no vision. Huh. I don’t see how it used to work. Let’s back out that last change. Rollback.

The test runs again. Let’s see if we can figure out how it manages to work.

In this form, our test starts in walking … we could assert that for clarity. The update does nothing and then the action changes state to looking. On the second call to do_something we’ll be in looking state:

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

We’ll call bot.can_take() and not crash. Ah. We will have taken a step. That will cause the world to update our vision. In the new form, we have not taken a step, and will therefore not have a vision.

Let’s put the code back as we want it and fix the test to ensure we have a vision.

Change this to provide a method:

class World:
    def step(self, bot):
        location = self.bots_next_location(bot)
        self.map.attempt_move(bot.id, location)
        bot.vision = self.map.create_vision(bot.location)

Extract method:

    def step(self, bot):
        location = self.bots_next_location(bot)
        self.map.attempt_move(bot.id, location)
        self.set_bot_vision(bot)

    def set_bot_vision(self, bot):
        bot.vision = self.map.create_vision(bot.location)

And in the test:

    def test_bot_notices_a_block(self):
        world = World(10, 10)
        bot = Bot(5, 5)
        bot.state.tired = 0
        bot.direction_change_chance = 0
        world.add(bot)
        block = Block(7, 5)
        world.add(block)
        world.set_bot_vision(bot)
        bot.do_something()
        assert bot.state._action == bot.state.looking
        bot.do_something()
        assert bot.has(block)

The other test just needed a blank vision. I think that should be the default, did that before and rolled it back.

Change this:

class Bot:
    def __init__(self, x, y, direction=Direction.EAST):
        self.world = None
        self.id = None
        self.name = 'R'
        self.location = Location(x, y)
        self.direction = direction
        self.direction_change_chance = 0.2
        self.inventory = []
        self._vision = None
        self.tired = 10
        self.state = Machine(self)

To this:

class Bot:
    def __init__(self, x, y, direction=Direction.EAST):
        self.world = None
        self.id = None
        self.name = 'R'
        self.location = Location(x, y)
        self.direction = direction
        self.direction_change_chance = 0.2
        self.inventory = []
        self.vision = []
        self.tired = 10
        self.state = Machine(self)

Green. Commit: walking properly implements update and action. Renames needed?

Now let’s change the looking state to use update similarly. From this:

    def update_looking(self):
        pass

    def looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking
            return
        if self.bot.can_take():
            self.bot.take()

To this:

    def update_looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking

    def looking(self):
        if self.bot.can_take():
            self.bot.take()

One test breaks, I am hoping with a timing issue because we do the take sooner.

    def test_looking_goes_to_walking_then_laden_if_block_in_inventory(self):
        bot = Bot(5, 5)
        vision_list = [('R', 5, 5)]
        bot.vision = vision_list
        machine = Machine(bot)
        machine._action = machine.looking
        machine.state(bot)
        assert machine._action == machine.looking
        bot.receive(Block(2, 2))
        machine.state(bot)
        assert machine._action == machine.walking
        machine.tired = 0
        machine.state(bot)
        assert machine._action == machine.laden
        bot.receive(Block(2, 2))
        machine.state(bot)
>       assert machine._action == machine.walking
E       assert looking == walking
E        +  where looking = <machine.Machine object at 0x106960140>._action
E        +  and   walking = <machine.Machine object at 0x106960140>.walking

Ah. this test, and all of them, really needs to set both states.

    def test_looking_goes_to_walking_then_laden_if_block_in_inventory(self):
        bot = Bot(5, 5)
        vision_list = [('R', 5, 5)]
        bot.vision = vision_list
        machine = Machine(bot)
        machine._update = machine.update_looking  # <===
        machine._action = machine.looking
        machine.state(bot)
        assert machine._action == machine.looking
        bot.receive(Block(2, 2))
        machine.state(bot)
        assert machine._action == machine.walking
        machine.tired = 0
        machine.state(bot)
        assert machine._action == machine.laden

Green. Commit: looking properly implements update and action.

Now we’ll change this:

    def update_laden(self):
        pass

    def laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking
            return
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

To this:

    def update_laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking

    def laden(self):
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

Two tests fail. I hope they have the same issue as the last one.

First time lucky:

    def test_laden_goes_to_walking_if_no_block_in_inventory(self):
        bot = Bot(5, 5)
        machine = Machine(bot)
        machine._update = machine.update_laden  # <===
        machine._action = machine.laden
        machine.state(bot)
        assert machine._action == machine.walking

And, crossing fingers …

    def test_laden_goes_walkabout_after_drop(self):
        bot = Bot(5, 5)
        machine = Machine(bot)
        entity = Block(3, 3)
        bot.receive(entity)
        vision_list = [('R', 5, 5)]
        bot.vision = vision_list
        bot.direction = Direction.EAST
        machine._update = machine.update_laden  # <===
        machine._action = machine.laden
        machine.state(bot)
        assert bot.has_block()
        assert machine._action == machine.laden
        bot.remove(entity)
        machine.state(bot)
        assert not bot.has_block()
        assert machine._action == machine.walking

Yes! Green. Commit: all three states use update and action properly.

Let’s have a quick look at what we have wrought and do a bit of cleanup.

class Machine:
    def __init__(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired = 10
        self._update = self.update_walking
        self._action = self.walking

    def state(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired -= 1
        self._update()
        self._action()
        return self

    def update_walking(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._update = self.update_laden
                self._action = self.laden
            else:
                self._update = self.update_looking
                self._action = self.looking

    def walking(self):
        pass

    def update_looking(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking

    def looking(self):
        if self.bot.can_take():
            self.bot.take()

    def update_laden(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._update = self.update_walking
            self._action = self.walking

    def laden(self):
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

I think we’d like the names to be more similar. Let’s rename walking, looking and laden to end with action, and the three update ones to end with update instead of begin with it.

class Machine:
    def __init__(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired = 10
        self._update = self.walking_update
        self._action = self.walking_action

    def state(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired -= 1
        self._update()
        self._action()
        return self

    def walking_update(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self._update = self.laden_update
                self._action = self.laden_action
            else:
                self._update = self.looking_update
                self._action = self.looking_action

    def walking_action(self):
        pass

    def looking_update(self):
        if self.bot.inventory:
            self.tired = 5
            self._update = self.walking_update
            self._action = self.walking_action

    def looking_action(self):
        if self.bot.can_take():
            self.bot.take()

    def laden_update(self):
        if self.bot.has_no_block():
            self.tired = 5
            self._update = self.walking_update
            self._action = self.walking_action

    def laden_action(self):
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

Commit: renames.

Now I think we should combine the two-line setting pattern into something less error-prone.

I think we’d like to define the two state members values as explicit pairs.

    def walking_states(self):
        return self.walking_update, self.walking_action

    def looking_states(self):
        return self.looking_update, self.looking_action

    def laden_states(self):
        return self.laden_update, self.laden_action

After I typed in the first one of those, PyCharm knew what I wanted and as soon as I typed the beginning of the other two, it knew exactly what to do. And that’s with the “AI” turned off!

Now I want a setter, and to use it.

    def set_state(self, states):
        self._update, self._action = states

And now use it:

Hmm … this isn’t working. Python doesn’t do what I expected. Roll back.

class Machine:
    def __init__(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired = 10
        self._update = self.walking_update
        self._action = self.walking_action

    def state(self, bot):
        self.bot = bot
        self.vision = bot.vision
        self.tired -= 1
        self._update()
        self._action()
        return self

    def walking_states(self):
        return self.walking_update, self.walking_action

    def looking_states(self):
        return self.looking_update, self.looking_action

    def laden_states(self):
        return self.laden_update, self.laden_action

    def set_states(self, states):
        self._update, self._action = states

    def walking_update(self):
        if self.tired <= 0:
            if self.bot.inventory:
                self.set_states(self.laden_states())
            else:
                self.set_states(self.looking_states())

    def walking_action(self):
        pass

    def looking_update(self):
        if self.bot.inventory:
            self.tired = 5
            self.set_states(self.walking_states())

    def looking_action(self):
        if self.bot.can_take():
            self.bot.take()

    def laden_update(self):
        if self.bot.has_no_block():
            self.tired = 5
            self.set_states(self.walking_states())

    def laden_action(self):
        if self.tired <= 0:
            if self.bot.can_drop():
                block = self.bot.inventory[0]
                self.bot.drop(block)

OK, I like that, we set the states at the same time. That’s as it should be. There are some tests that need the same treatment. I’ll search for accesses to the private members and fix them up. First commit: set states with single call.

Tests fixed. Commit: everyone using set_states.

Reflection / Summary

The point of all this, such as it is, is to get to a class-per-state implementation of the state machine for these ridiculously simple bots. Why? Good question, and the reason is that I want to, and my excuse is that in more complex bots we’ll surely want state machines, and this will serve as an example of how to do it.

Given how uncertain I was coming in, this morning has gone quite well. The initial decision, to break out the internal state method into update and action was a good one, and it was quite easy to put in a basic empty framework, where all the updates were doing nothing. That got the structure in place and allowed moving update logic to the update methods one at a time.

I mentioned GeePaw’s comment. He was talking about using a pair of objects for converting the state machine. Not quite what we did here, but the notion of a “pair” certainly paid off, so he gets some of the credit. I’ll keep it right here for him in case he ever asks for it.

A few tests broke, but the changes were all simple, having to do with initialization and making sure we set both action and update in the tests.

It might have been better to go to a joint setting of both update and action earlier on. That might have discovered the tests that needed updating. To make that happen, since the tests were invading the Machine’s private members, we might have had to rename the private members or searched for accesses. Still, that might have been a bit more forward-looking, but we were not to know that it was a good idea.

We went in small steps and while I did roll back a time or two, it wasn’t a retreat in confusion, just a recognition that I’d made a poor start and would do well to back up and go again. I think the last rollback was probably due to typing () when I shouldn’t have, or not typing it when I should have. I don’t know: I started over and it worked fine. That often happens, which is another good reason for frequent commits: it makes a quick rollback pretty painless.

We’re about 2 1/2 hours in, and we have a dozen commits, so that’s about one every 15 minutes, not bad at all.

I feel that this process has taken longer than it “should” have. I think I expected to get to class per state in one or two sessions and it’s going to be five or six. Fortunately, I didn’t promise anyone how long it would take, because while I am mot smart enough to do it in two days, I am smart enough to know that my guesses on how long things will take are rarely accurate.

We’re in a much better place than we were at the beginning of this session. I apologize for the length of this article but it takes as long as it takes.