Hello, loves!

Again with the weird hours! Elaborating an idea from a more civilized hour yesterday. Tout le monde déteste l’IA.

Yesterday, the code for the QuestGiverDenizen included this definition of its state machine:

        self.transitions=dict(
            seeking=dict(
                has_item=dict(to='satisfied', hook=self.giving_action),
                no_item=dict(to='seeking', hook=self.seeking_action),
            ),
            satisfied=dict(
                has_item=dict(to='satisfied', hook=self.satisfied_action),
                no_item=dict(to='satisfied', hook=self.satisfied_action),
            ),
        )

I liked that and still do, but it’s not as communicative as one could imagine. So yesterday afternoon I did a spike that makes this test pass:

    def test_package(self):
        state = Machine(
            seeking = State(
                has_item = Event(to='satisfied', hook=self.hook_1),
                no_item = Event(to='seeking', hook=self.hook_2)
            ),
            satisfied = State(
                has_item = Event(to='satisfied', hook=self.hook_1),
                no_item = Event(to='satisfied', hook=self.hook_2)
            )
        )
        assert state.seeking.has_item.to == 'satisfied'
        assert state.satisfied.has_item.to == 'satisfied'

One way of coding that—I tried several—is this one:

class Dotter:
    def __init__(self, **kwargs):
        self.info = dict()
        for k, v in kwargs.items():
            self.info[k] = v

    def __getattr__(self, item):
        return self.info[item]

class Machine(Dotter):
    pass
class State(Dotter):
    pass
class Event(Dotter):
    pass

The base class, Dotter, has a constructor that accepts only keyword-value pairs, and just puts each pair into an internal dictionary, info. The dunder method __getattr__ is called whenever a Dotter instance is sent any dotted message, such as my_dotter.foo. __getattr__ sees item ‘foo’ and looks it up in info, returning whatever value was stored there. (If ‘foo’ is not present, Python will raise KeyError.)

The whole point of that is to allow us to say, for example, state.satisfied rather than state['satisfied']. When this idea is fully fleshed out, the state machine will be made up of a Machine instance containing State instances containing Event instances containing to and hook or whatever we may finally call those fields.

Let’s put this into play, still on a spike basis, in QuestGiverDenizen. I fully expect to commit, but perhaps not right away: we’ll see what we see and decide soon.

Change QDC to use the new classes:

class QuestGiverDenizen:
    def __init__(self, *, name,
                 knowledge,):
        self.name = name
        self.knowledge = knowledge
        self.state= 'seeking'
        self.transitions=Machine(
            seeking=State(
                has_item=Event(to='satisfied', hook=self.giving_action),
                no_item=Event(to='seeking', hook=self.seeking_action),
            ),
            satisfied=State(
                has_item=Event(to='satisfied', hook=self.satisfied_action),
                no_item=Event(to='satisfied', hook=self.satisfied_action),
            ),
        )

All the tests fail, no surprise. Se need to change this:

class QuestGiverDenizen:
    def event(self, event, interactor):
        transition = self.transitions[self.state][event]
        transition['hook'](interactor)
        self.state = transition['to']

To this:

    def event(self, event, interactor):
        transition = self.transitions[self.state][event]
        transition.hook(interactor)
        self.state = transition.to

And we need to add to Dotter:

class Dotter:
    def __getitem__(self, item):
        return self.info[item]

That dunder method is the one that lets the class accept subscripting, as in the first line of event above. I had forgotten that we needed that, blithely kind of assuming that we could say something like self.transitions.self.state.event which of course we cannot.

For the predictable names in the Event instance, we can use . but not in the machine-dependent names such as has_item or ‘seeking’. They will work, but we have no way to express them other than with the square brackets.

Disappointed and a Bit Derailed

It was already clear that we couldn’t just live with those three Dotter subclasses, I think, but I had thought they’d hold up better than they have done. Only Event really needs to be a Dotter. Let’s redo the classes:

class Event:
    def __init__(self, *, to:str, hook):
        self.to = to
        self.hook = hook

class State:
    def __init__(self, **kwargs):
        self.info:dict[str, Event] = dict()
        for k, v in kwargs.items():
            self.info[k] = v

    def __getitem__(self, item:str) -> Event:
        return self.info[item]

class Machine:
    def __init__(self, **kwargs):
        self.info:dict[str,State] = dict()
        for k, v in kwargs.items():
            self.info[k] = v

    def __getitem__(self, item:str) -> State:
        return self.info[item]

Tests are green. I note some duplication but not going to chase it now. Test the bee. Works fine.

I think it would be most excellent if we could declare what states were to exist, what events, and check them automatically.

Let’s write a test:

    def test_validate_states(self):
        Machine.states = ['seeking', 'satisfied']
        with pytest.raises(Exception):
            machine = Machine(
                seeking = State(
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='seeking', hook=self.hook_2)
                ),
                satiated = State(
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='satisfied', hook=self.hook_2)
                )
            )

Here we declare that we expect states ‘seeking’ and ‘satisfied’ and provide, instead, ‘satiated’. We expect an exception, I haven’t decided which one. Test fails, of course. We add:

class Machine:
    states = []
    def __init__(self, **kwargs):
        self.info:dict[str,State] = dict()
        for k, v in kwargs.items():
            if self.states and k not in self.states:
                raise KeyError(f'{k} must be in {self.states}')
            else:
                self.info[k] = v

This works, but leaves the class variable set. Won’t do. What if we were to provide states as the first parameter?

That works OK with Machine, looks like this:

    def test_validate_states(self):
        with pytest.raises(KeyError):
            machine = Machine(states = ('seeking', 'satisfied'),
                seeking = State(
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='seeking', hook=self.hook_2)
                ),
                satiated = State(
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='satisfied', hook=self.hook_2)
                )
            )

But we don’t want to do the same thing with Event, because we’d have to repeat it:

    def test_validate_states(self):
        with pytest.raises(KeyError):
            machine = Machine(states = ('seeking', 'satisfied'),
                seeking = State(events=('has_item', 'no_item'),
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='seeking', hook=self.hook_2)
                ),
                satiated = State(events=('has_item', 'no_item'),
                    has_item = Event(to='satisfied', hook=self.hook_1),
                    no_item = Event(to='satisfied', hook=self.hook_2)
                )
            )

I do not like that. Ugly, repetitive, says the same thing over and over, and it’s unattractive to the eye.

We could use the state variable idea and clear them after the machine is created. Could we somehow store the event list in Machine? Would making nested classes improve things?

What about something like this:

    Machine.declare(
        states = ('seeking', 'satisfied'),
        events='has_item', 'no_item')
    machine = Machine(
        seeking = State(
            has_item = Event(to='satisfied', hook=self.hook_1),
            no_item = Event(to='seeking', hook=self.hook_2)
        ),
        satiated = State(
            has_item = Event(to='satisfied', hook=self.hook_1),
            no_item = Event(to='satisfied', hook=self.hook_2)
        )
    )

And we’d undeclare at the end of creating the instance. But if that, why not just this:

    machine = Machine(
        states = ('seeking', 'satisfied'),
        events = ('has_item', 'no_item'),
        seeking = State(
            has_item = Event(to='satisfied', hook=self.hook_1),
            no_item = Event(to='seeking', hook=self.hook_2)
        ),
        satiated = State(
            has_item = Event(to='satisfied', hook=self.hook_1),
            no_item = Event(to='satisfied', hook=self.hook_2)
        )
    )

I’m not sure how we could check the events. Maybe we wait until the end of the machine constructor and check the whole structure, since we know what it’s supposed to look like?

Might try that. For now, I’m going to try for a bit more sleep. Consider this an interim report. See you next time!