Rulebooks are a bit like switch statements... and yet different

@Zed 's contribution to this recent post sent me down a rabbithole, thinking about classical switch statements, the mechanics of Inform 7’s rulebooks and thereby the structure of action-processing.

Herewith, the fruits of my musings and investigations, including some new (to me) discoveries:

In the canonical switch statement, a given index value is matched in turn against a sequence of code-blocks, each starting with a ‘match clause’, until the index value matches against a match clause, after which the associated code block is executed. What happens next depends on the implementation.

Some implementations of switch manifest a behaviour called ‘case fall-though’ whereby after one code block of the statement has been matched and executed, the code goes on to consider in turn any other code blocks that also match the index variable. e.g.

x=3;
switch (x) {
	1 to 3: print "low number-";
	3: print "three";
}

which would, due to case fall-through, output low number-three, whereas without case fall-through the statement terminates after the first matching code block is executed and the output is low number-.

I6 switch statements do not exhibit case fall-through, so the second code block will never execute.

In implementations which do exhibit case fall-through, a means is usually provided to explicitly exit the switch statement after executing a given code block- thus avoiding case fall-through- often by a statement such as break:

x=3;
switch (x) {
	1 to 3: print "low number-"; break;
	3: print "three";
}

Similarly, in implementations where case fall-through is not the default, it can sometimes be forced by a statement such as continue:

x=3;
switch (x) {
	1 to 3: print "low number-"; continue;
	3: print "three";
}

Inform rulebooks are interesting in that they can be declared individually to default either to case fall-through or not:

Failing is a nothing based rulebook with default failure.
Succeeding is a nothing based rulebook with default success.
Equivocating is a nothing based rulebook with default no outcome.

In Inform, a rule (akin to a code block in a switch statement) has one of 3 fundamental outcomes- success, failure, or no outcome. Hidden underneath this is a more basic distinction: a rule ends with case fall-through, equivalent to ‘no outcome’, (Inform also calls this ‘making no decision’) or no case fall-through, equivalent to failure/success.

Ultimately, an Inform rule is an I6 routine, and an Inform rulebook is itself also an I6 routine largely comprising a sequence of (potential) calls to the routines representing the rules within it. Unlike a switch statement, the compiler takes its own view of the sequence in which rules are listed in a rulebook (which can be crudely summarised as most-specific-first) but the Inform author can override some or all of the compiler’s choices. To emulate a traditional switch statement in which the code-block order is explicitly fixed by forward sequencing in the source code, the author can explicitly declare each rule in turn as ‘Last’ in the rulebook:

Announcing is a number based rulebook with default no outcome.
Last announcing a number which is at most 1: say "un".
Last announcing a number which is at most 2: say "deux".
Last announcing a number which is at most 3: say "trois"

In this trivial example, that ensures that ‘follow announcing for 1’ results in

un
deux
trois

in the correct order.

The basic dichotomy between fall-through and no fall-through is expressed in how a rule returns to the I6 rulebook that called it. ‘no outcome’, and therefore fall-through, is indicated by returning false (e.g. in I6 rfalse or return 0). success/failure, and therefore no fall-through, is indicated by returning true (e.g. in I6 rtrue or return 1, or in fact return <any non-zero number>).

When returning true, whether the rule ended in success or failure is logged separately by first calling one of RuleSucceeds() or RuleFails(), which routine logs the constant RS_SUCCESS or RS_FAILURE in the first element of an array called latest_rule_result.

If a rulebook has run its course without any rule making a decision (which can be because it has no rules, i.e. it is an empty rulebook, or no rule matched, or every matched rule made no decision (i.e. returned false)) then the rulebook itself logs the outcome RS_NEITHER in the first element of latest_rule_result.

When a rulebook is declared with default failure or success, then unless individual rules are explictly coded otherwise, each of its rules will log RS_SUCCESS or RS_FAILURE then return true, i.e. the rulebook will by default not exhibit case fall-through. As in a switch code block, case fall-through can be explicitly enforced by ending the rule with the statement make no decision:

Announcing is a number based rulebook with default success.
First announcing a number which is at most 3: say "low number-"; make no decision;
Announcing 3: say "three".

The make no decision at the end of the first rule enforces case fall-through, ensuring the Announcing rulebook will go on to consider the second rule even though Announcing was declared with default success- and therefore no default case fall-through.

When a rulebook is declared with default no outcome, then unless individual rules are explictly coded otherwise each of its rules will leave latest_rule_result undisturbed and return false, i.e. the rulebook will by default exhibit case fall-through. The rulebook itself sets latest_rule_result to RS_NEITHER after each rule that returns false.

As in a switch code block, case fall-through can be explicitly blocked by writing a rule ending with the statement rule succeeds or rule fails:

Announcing is a number based rulebook with default no outcome.
First announcing a number which is at most 3: say "low number-"; rule succeeds;
Announcing 3: say "three".

The rule succeeds at the end of the first rule blocks case fall-through, ensuring the Announcing rulebook will not go on to consider the second rule even though Announcing was declared with default no outcome- and therefore default case fall-through.

To improve readability, Inform provides some phrases meaning the same as make no decision:

continue the action;
continue the activity;

Although obviously conceived for use within action or activity rules, they can (if somewhat pointlessly) be used in any rule with equivalent meaning to make no decision.

Inform also provides

stop the action;
... instead;        (or, equivalently, instead...;)

(NB ‘instead…’ refers to a phrase such as ‘Before drinking the yoghurt: instead try eating the yoghurt.’ rather than an Instead rule starting with, for example, ‘Instead of eating…’)

‘stop the action’ and ‘…instead’ are both at first sight a little odd. Reading the documentation, you might easily go away with the impression that these both mean the same as rule fails, but that’s not the case. Both of these mean ‘stop the rulebook immediately, but with an outcome in keeping with the last decision a previous rule or rulebook made’. The outcome of that last decision could be any of ‘failure’, ‘success’ or ‘no outcome’. To understand this, consider that both of these phrases cause the current rule to immediately return true but, crucially, and unlike ‘rule succeeds’ and ‘rule fails’, don’t modify latest_rule_result before doing so. Consequently, the calling rulebook will also immediately terminate, with whatever result was previously stored in latest_rule_result. Often this result won’t be important, but in situations where it’s of significance whether a rulebook terminates with success, failure or no outcome this behaviour can either be helpful or lead to results the author did not anticipate.

A case in point are the action-processing rulebooks. First some background. Attached here is a spreadsheet illustrating a simplified schema of how action-processing rules are organised.

ActionProcessingRulesAndOutcomes.pdf (533.4 KB)

Each of the rulebooks for the 6 stages within action-processing is declared with its own default outcome: BEFORE, CHECK, CARRY OUT and REPORT are declared with default no outcome, INSTEAD with default failure and AFTER with default success. These default outcomes can as usual be overridden within rules by the specific phrases ‘rule succeeds’, ‘rule fails’ or ‘make no decision’- the last of these also known as ‘continue the action’.

There is however a further nuance arising from how these rulebooks are called from their overarching rulebooks- the Action-processing rules (for BEFORE and INSTEAD) and the Specific Action-processing Rules (for CHECK, CARRY OUT, AFTER and REPORT).

(small print There’s an anomaly here- despite being called under the umbrella of the Specific Action-processing Rules, After is not a specific action-processing rulebook, in that (like Before and Instead) it is monolithic- there is not a separate rulebook compiled for each action. At the I6 level, Before, Instead and After consist of a series of code segments within a single routine delineated by if (action == ##Take) {...} else if (action == ##Insert) {...}... whereas e.g. the Carry out rulebooks are a group of distinct rulebooks called Carry out taking, Carry out inserting, ... each represented by a discrete I6 routine)

The stage rules for BEFORE, INSTEAD CHECK and AFTER abide by the result of their respective rulebooks, and therefore when the latter return true, indicating a decision has been made, also immediately stop action-processing with the same outcome as their rulebook. CARRY OUT and REPORT stage rules however just follow their respective rulebooks, meaning that when the latter return they ignore whether they returned true or false and make their own choice as to what to do next.
In practice both CARRY OUT AND REPORT stage rules always return false, i.e. they make no decision, and the Specific Action-processing rules move on to the next stage.

For this reason, it’s not possible to cause an action to terminate from a CARRY OUT rule. We can end a ‘Carry out…’ rule with ‘rule fails’, ‘rule succeeds’ ‘stop the action’ or ‘…instead’, and in each case the Carry out rulebook will terminate, but (via the Carry out stage rule returning false) the Specific Action-processing rulebook ignores the reason for the Carry out rulebook ending and rolls on to the After stage regardless. Similarly, in the case of the REPORT stage rule- the action-processing rules always roll on to the final (unnamed) Specific Action-processing rule, which logs the action as a success in latest_rule_result, thereby ensuring that if none of BEFORE, INSTEAD, CHECK or AFTER made a decision (and thereby halted action-processing at an earlier stage), by default the action ends in success.

Inform keeps a log of which actions have (on at least one occasion) succeeded, so that it can process conditions such as ‘if we have eaten the toadstool’. To work this trick, it takes note whenever the action-processing rulebooks return with success, this being defined by the contents of latest_rule_result when the action-processing rules are complete.

As an example of how the slightly odd behaviour of ‘stop the action’ and ‘…instead’ can be useful, consider the following:

The player holds an edible thing called a yoghurt.
Before drinking the yoghurt: try eating the yoghurt instead.

Let’s trace what happens as the Before rule runs. With action ‘the player drinking the yoghurt’, the Before rule fires and fully executes a secondary action- ‘the player eating the yoghurt’- before returning to processing the ‘the player drinking the yoghurt’ action and specifically to the ‘…instead.’ at the end of the Before rule. At this point, latest_rule_result is holding the outcome of ‘the player eating the yoghurt’ (which ‘…instead’ does not alter) but ‘…instead’ immediately terminates the ‘the player drinking the yoghurt’ action at the Before stage.

Now, when at the end of action-processing Inform comes to examine the outcome of the ‘the player drinking the yoghurt’, its outcome simply reflects whatever was the outcome of ‘the player eating the yoghurt’- and is logged as such. This means that if the yoghurt was successfully eaten, subsequently both ‘if we have drunk the yoghurt’ and ‘if we have eaten the yoghurt’ will be true; likewise if something prevented the yoghurt being eaten, both ‘we have drunk the yoghurt’ and ‘we have eaten the yoghurt’ will be false.

Consequently ‘Before drinking the yoghurt: try eating the yoghurt instead.’ effectively means ‘make the outcome of drinking the yoghurt whatever was the outcome of trying to eat the yoghurt’, and this will often be what we want.
If we want a specific, potentially different outcome for ‘the player drinking the yoghurt’ we can write, for example:

The player holds an edible thing called a yoghurt.
Before drinking the yoghurt: try eating the yoghurt; rule fails.

after which ‘the player drinking the yoghurt’ ends in failure and therefore ‘if we have drunk the yoghurt’ will not become true, regardless of the outcome of trying to eat the yoghurt. (Of course, in the unlikely event that it has already become true by some other mechanism, it will remain so, because ‘if we have drunk the yoghurt’ really means ‘if the action ‘an actor drinking the yoghurt’ has ever ended in success’. )

Note that how the Before rule ends will not affect whether ‘if we have eaten the yoghurt’ becomes true. That decision was taken separately during processing of the action ‘the player trying to eat the yoghurt’, before it returned to the end of the Before rule.

Let’s examine another common scenario:

Before drinking the yoghurt: say "It's too thick to drink!" instead.

In this instance, ‘…instead’ will terminate the action with latest_rule_result set to ‘no outcome’. This will usually achieve what we want because- ‘no outcome’ not being ‘success’- it means that ‘if we have drunk the yoghurt’ will not become true. We can be confident of this because the Action-processing rulebook sets latest_rule_result to ‘no outcome’ immediately before the Before stage rule calls the Before rulebook. Indeed, it is a general feature of rulebooks that they do this between every rule, whether or not the rule actually ran
Perhaps unhelpfully, they don’t do so before calling the first rule in the rulebook, which means when the first rule in a rulebook runs the contents of latest_rule_result is undefined. Consequently, it’s not so easy to make assumptions about what outcome that first rule (or a rulebook called by it) will return after a ‘stop the action’ or `…instead’. Fortunately the Before stage rule is not the first rule in the Action-processing rules (it is preceded by the ‘announce items from multiple object lists rule’ and the ‘set pronouns from items from multiple object lists rule’).

It’s worth noting here that declaring a rulebook with default success, failure or no outcome makes no difference to the compilation or behaviour of the rulebook itself. It will always clear latest_rule_result to ‘no outcome’ if a rule it calls returns false (implying ‘no outcome’). The difference is that (in the absence of an explicitly coded outcome), each rule within that rulebook will be compiled to return to the rulebook with the declared default of ‘success’, ‘failure’ or ‘no outcome’.

Things get a bit stickier if we write something like this:

Before eating the toadstool: follow the eating inedible items rules; stop the action.

Now, when the Before rule reaches ‘stop the action’, latest_rule_result will hold the outcome of the ‘eating inedible items’ rules, and that outcome will therefore also become the outcome of the ‘eating the toadstool’ action, which may or may not be what we want. This may come as a surprise to some authors, who assumed after reading the documentation that ‘stop the action’ was equivalent to ‘rule fails’ and therefore did not consider the possibility that using ‘stop the action’ or ‘…instead’ at the end of a Before rule might nevertheless end up with Inform judging the action to have succeeded.

It might be less of a surprise to know that a Before rule ending with ‘rule succeeds’ causes Inform to judge the action to have succeeded- although it’s less intuitive to realise that the same is true of an Instead rule ending with ‘rule succeeds’.

Similarly, on one level it’s surprising to realise that an action can still be judged to have failed after running through Before, Instead, Check, Carry out and After stages, if the terminating After rule ends in ‘rule fails’, or under some circumstances (analagous to the discussion on Before rules), with ‘stop the action’ or ‘…instead’. For example:

After eating the toadstool: follow the vomiting rules; stop the action.

If the vomiting rules end in failure, then the After rule ends in failure and therefore the action ends in failure and ‘If we have eaten the toadstool’ does not become true

Ending the After rules in failure might conceivably be useful where we want to reverse the success of an action at a late stage. In the unlikely event we want to do this when the action has gone as far as reaching the Report stage (perhaps because we very much want the Report rules to run), since the report stage rule itself can never make a decision on success or failure of an action we would have to insert a new rule into the Specific action-processing rules after the report stage rule, pre-empting the (unnamed) final rule that normally guarantees that an action that has reached the Report stage ends in success.

A specific action-processing rule (this is the end in failure after vomiting rule):
	if we are eating the toadstool:
		if a 1 in 2 chance succeeds:
			say "Suddenly, you start to feel very strange; shortly thereafter, following a paroxysm of retching, you bring up the remains of the toadstool.";
			rule fails;
		otherwise:
			rule succeeds.
The end in failure after vomiting rule is listed after the report stage rule in the specific action-processing rules.

A slightly more invasive solution would be to modify the report stage rule itself, so that it abides by rather than simply follows the specific Report rulebook (thereby allowing us to ‘fail’ an action in a Report rule) but this might have widespread side-effects- we would need to be sure that no other Report rule (and there are many) inadvertently ended an action in failure.

7 Likes

From the documentation on action-processing in the Standard Rules source

Specific action-processing has a rather complicated range of outcomes: it must succeed or fail, according to whether the action either reaches the carry out rules or is converted into another action which does, but also ensure that in the event of failure, the exact rule causing the failure is recorded in the “reason the action failed” variable.

Failing in an after rule would mean re-defining failure, so I reported that an action failing due to an after rule failing is a bug.

Also, that was really great – thank you!

1 Like

… or a feature! :grinning:

It’s clearly a bug as you say that if persuasion succeeds when ‘asking <someone> to try <some action>’ , and therefore ‘asking <someone> to try <some action>’ succeeds, Inform logs that <some action> has succeeded, regardless of whether it did or not.

Maybe a slightly better model for understanding rulebooks comes from systems programming or, alternately, from a discrete.event simulation.

Essentially a process starts with an event (or an interrupt). In Inform, an event/interrupt might simply be the user typing the return key, or it might be the result of handling another event (for example, the end of a begin action rulebook.

The second part is the event handler, in this case, the rulebook, a series of rules to process to respond to the event. In some cases, for example, the check action rulebooks, one must go through all the rules unless told otherwise (for example “stop the action”). In other cases, like the after action rulebook, it normally works more like the “switch” statement - first applicable handler wins by default, the exception being when the handler encounters “continue the action”…