Graph theory in Dialog

As I continue to experiment with Dialog’s capabilities, I’m trying to have it generate a classic Knights and Knaves puzzle. In these puzzles, there are a number of people, all of whom are either knights (always tell the truth) or knaves (always lie). The people make statements about each other, and you have to figure out who’s a knight and who’s a knave.

In the simplest version of the puzzle, every statement boils down to either “[person] is a knight” (an affirmation) or “[person] is a knave” (an accusation). If you affirm someone, then either you’re both knights or you’re both knaves; if you accuse someone, then one of you is a knight and the other is a knave.

This means we can represent the puzzle as a graph, where the vertices are people, and the edges are affirmations or accusations. If the graph is connected, then the puzzle is solvable[1]. So, you can generate a puzzle that’s guaranteed to be solvable by:

  • Randomly assigning the knights and knaves
  • Making a complete graph (a graph where every vertex is linked to every other vertex)
  • Removing as many edges as you like, as long as the graph remains connected

This sounds fun. So let’s do it!

The problem is…I don’t know how to represent the graph in Dialog. Fundamentally I need a symmetric various-to-various relation that I can modify on the fly, and this is something that Inform provides by default (“stating relates various people to each other”), but Dialog doesn’t.

How would you model this in Dialog? The best I can think of right now is to give each person a list of other people they’re connected to, but iterating through that many lists feels like a nightmare to implement.


  1. Though the solution isn’t quite unique: swapping every knight to a knave and every knave to a knight produces an equally valid solution. This means you need to provide some additional piece of information to have a unique solution, something that’s neither an accusation nor an affirmation. ↩︎

1 Like

I don’t know Dialog, but the simplest way to represent this is as an NxN two-dimensional matrix of booleans. (Or a simple array with N-squared entries, if you want to do a bit of math to find entry (x,y).) Set all flags true to begin with, then clear both (x,y) and (y,x) to represent removing an edge between x and y.

You could do this much in I6 or BASIC for that matter.

Checking whether a graph is connected is a minor programming challenge. (I have done this in BASIC, as it happens. Searching for the shortest route through the puzzle book Maze…)

Once that’s in the toolbox, you can delete edges at random while ensuring that the graph stays connected. This won’t be efficient, though. (It would have bogged my old Apple 2 right down.) Really what you want is to find a loop and then remove one edge from the loop, and keep doing that until there are no more loops. This is a few more programming challenges. :)

1 Like

That is, unfortunately, one of Dialog’s main weaknesses. There are no random-access arrays, only linked lists you have to iterate over in O(n) time. (Nor are there bitwise operators that I could use to fake it with a list of integers.)

It’s extremely efficient at doing various graph theory things…if those graphs are defined at compile-time. As soon as you have to mess with the details at runtime, it all comes apart.

Oh, whoops. Sorry.

I’d say “time for assembly” but I guess Dialog isn’t big on that either.

Unfortunately not. It tries not to let low-level VM details leak into the high-level language, so the compilation goes through an intermediate representation before being converted into VM code.

(Amusingly, this does mean that the intermediate representation has several extremely specific instructions, like “draw a progress bar of this width”, for whatever The Impossible Bottle needed at the time. There’s no other way to do it without having access to low-level I/O features.)

I love Dialog, but there are some things it’s just extremely bad at, and I’m increasingly tempted to add a “trapdoor” to the intermediate representation that lets me embed an implementation in JavaScript (for the web backend) and Z-code (for the Z-machine backend) when I really need some specific feature. It would limit forward compatibility, but I don’t really foresee there being any backends beyond those two and maybe Glulx in the near future, and this would let people write libraries to extend the language’s capabilities without changing the compiler every time.

2 Likes

Well, I think this works! At least for relatively small values of N. (“N” in the following means the total number of people in the puzzle.)

First, let’s define our parameters. I’m making them global variables so we can tweak them later, but they should always be constant within a puzzle.

(global variable (total number of people 3))
(global variable (number of knaves 2))
(number of knights $Knights)
	(total number of people $Total)
	(number of knaves $Knaves)
	($Total minus $Knaves into $Knights)
(total number of people squared $Square)
	(total number of people $Total)
	($Total times $Total into $Square)
(global variable (number of statements 4))

Since we can’t make multidimensional arrays, we fake it with a big list. If X is making a statement about Y, then we’ll store a 1 at X×N+Y. Otherwise, we’ll store a 0 there.

Note that looking up a value in this list is O(N²), which is not great. We’ll want to keep N fairly small.

(complete graph $G)
	(total number of people squared $N)
	($N instances of 1 into $G)

(0 instances of $ into [])
($N instances of $Value into [$Value|$Tail])
	($N minus 1 into $Nm1)
	($Nm1 instances of $Value into $Tail)

(convert $In to index $Out) %% Convenience predicate for coordinate conversion
	(if) ($In = [$X $Y]) (then) %% Coordinates are 0-indexed to make the math easier
		(total number of people $N)
		($X times $N into $Tmp)
		($Tmp plus $Y into $Tmp2)
		($Tmp2 plus 1 into $Out) %% Add 1 because lists in Dialog are 1-indexed
	(else)
		($In = $Out)
	(endif)

We could make the coordinate conversion a bit more efficient by passing X and Y separately instead of using a list, but I’m more worried about readability than efficiency here.

Notably, though, we don’t want to start with a complete graph—statements like “Alice says Alice is a knight” are completely worthless! So we remove the diagonal elements from it.

(remove diagonals from $In into $Out)
	(total number of people $Total)
	(remove $Total diagonals from $In into $Out)
(remove 0 diagonals from $In into $In) %% We're done
(remove $N diagonals from $In into $Out)
	($N minus 1 into $Nm1)
	(convert [$Nm1 $Nm1] to index $Index)
	(change entry $Index of $In to 0 into $Tmp)
	(remove $Nm1 diagonals from $Tmp into $Out)

To modify the list, we’ll use these predicates. (The standard library also provides these as “nth” and “replace nth” but I didn’t know about the latter until I had already written these, and they have more readable names besides.)

(change entry 1 of [$|$Tail] to $Value into [$Value|$Tail])
(change entry $N of [$Head|$Tail] to $Value into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(change entry $Nm1 of $Tail to $Value into $NewTail)

(entry 1 of [$Head | $] into $Head)
(entry $N of [$ | $Tail] into $Value)
	($N minus 1 into $Nm1)
	(entry $Nm1 of $Tail into $Value)

Now, since we’re using indices instead of objects for our knights and knaves, we need a new way to iterate over them. This is what I’m currently using.

(have $N count up from $Low to $High)
		($N = $Low)
	(or)
		($Low plus 1 into $Lp1)
		~($Lp1 > $High)
		*(have $N count up from $Lp1 to $High)

(person index $N)
	(total number of people $Total)
	($Total minus 1 into $Tm1)
	*(have $N count up from 0 to $Tm1)

Note that our person indices start at zero rather than one! Not a great decision, honestly.

Now, we’re storing this as a directed graph, where “Alice makes a statement about Bob” is separate from “Bob makes a statement about Alice”. But when we check if it’s connected, we want to treat it as an undirected graph, where those two are equivalent. So we have a different way of looking up values for that!

(connection between $X and $Y in $G)
	(if) ~(fully bound $Y) (then) %% Make sure it iterates over the right range if it's unbound
		*(person index $Y)
		(internal connection between $X and $Y in $G)
	(else)
		(internal connection between $X and $Y in $G)
	(endif)

(internal connection between $X and $Y in $G)
		(convert [$X $Y] to index $Idx1)
		(entry $Idx1 of $G into 1)
	(or)
		(convert [$Y $X] to index $Idx2)
		(entry $Idx2 of $G into 1)

Note that, if $Y is unbound, we iterate over all person-indices, not over all objects or numbers.

To determine if the graph is connected, we just do a floodfill. If the flood touches every person, the graph is connected.

($List contains all people)
	(length of $List into $N)
	(total number of people $N)

(flood $ from $LastGen) %% End recursion in success when we've hit everything
	($LastGen contains all people)

(flood $G from $LastGen)
	(collect $NextStep)
		*($LastStep is one of $LastGen)
		*(connection between $LastStep and $NextStep in $G)
	(into $NextSteps)
	(fully bound $NextSteps)
	(append $LastGen $NextSteps $NextGenRaw)
	(remove duplicates $NextGenRaw $NextGen)
	(length of $LastGen into $Old)
	(length of $NextGen into $New)
	~($Old = $New) %% If we've made no progress, fail
	(flood $G from $NextGen)

(connected $G)
	(flood $G from [0]) %% Arbitrary starting point

That’s not the only condition, though: for the sake of having an interesting puzzle, we also want everyone to make at least one statement. We’ll need a quick utility predicate for this:

(drop 0 from $List into $List)
(drop $N from [$|$OldTail] into $NewTail)
	($N minus 1 into $Nm1)
	(drop $Nm1 from $OldTail into $NewTail)

Now we can check if someone’s row in the matrix is completely empty.

($Person gives no testimony in $G)
	(total number of people $Total)
	($Total times $Person into $Prefix)
	%% Now $Prefix is the number of entries to discard before our person's row
	(drop $Prefix from $G into $Tmp)
	%% And $Total is the number of entries in this person's row
	(take $Total from $Tmp into $Row)
	%% Now we need to check if $Row contains anything
	(accumulate $Entry)
		*($Entry is one of $Row)
	(into 0) %% If it contains nothing at all, then they give no testimony

Why do we design the predicate this way (checking for no testimony instead of for any testimony)? Well, because Dialog’s multiqueries make it easy to test if a predicate is true for any thing, but very hard to test if a predicate is true for all things.

(someone gives no testimony in $G)
	*(person index $Person)
	($Person gives no testimony in $G)

We use an access predicate to flip it around to work the intuitive way, though.

@(valid testimony $G)
	~(someone gives no testimony in $G)

Now, we want to randomly discard connections from the graph until we have the appropriate number of statements. We’re specifically not trying to detect loops or anything—if we were, generating a tree is significantly easier than what we’re doing! (All we’d have to do is give each node a random parent.) But having loops is useful, puzzle-wise: it gives the player a way to confirm their deductions by adding some redundancy.

(count connections from $G into $Count)
	(accumulate $Conn)
		*($Conn is one of $G)
	(into $Count)

To randomly drop a connection, first we need to determine all ways to drop a connection.

(remove one connection from [0 | $OldRest] into [0 | $NewRest])
	*(remove one connection from $OldRest into $NewRest)
(remove one connection from [1 | $OldRest] into [0 | $OldRest])
(remove one connection from [1 | $OldRest] into [1 | $NewRest])
	*(remove one connection from $OldRest into $NewRest)

Now we pick one of them at random—specifically, one that leaves the graph connected and valid. This takes O(N⁴) space, which is really not good.

(randomly remove one connection from $G into $G2)
	(collect $New)
		*(remove one connection from $G into $New)
		(connected $New)
		(valid testimony $New)
	(into $Options)
	(randomly select $G2 from $Options)

Do this over and over until we have the number of connections we want.

(trim $G to $N connections into $G)
	(count connections from $G into $N)
(trim $G to $N connections into $G2)
	(randomly remove one connection from $G into $Tmp)
	(trim $Tmp to $N connections into $G2)

And putting it all together:

(generate puzzle $G)
	(complete graph $Start)
	(remove diagonals from $Start into $Next)
	(number of statements $N)
	(trim $Next to $N connections into $G)

To choose our knaves, we need another little utility predicate.

(select knaves into $Knaves)
	(collect $Index)
		*(person index $Index)
	(into $Options)
	(number of knaves $N)
	(randomly select $N from $Options without replacement into $Knaves)

(randomly select 0 from $ without replacement into [])
(randomly select $N from $List without replacement into [$Chosen|$MoreChosen])
	(randomly select $Chosen from $List)
	(split $List by $Chosen into $Left and $Right)
	(append $Left $Right $Shrunken)
	($N minus 1 into $Nm1)
	(randomly select $Nm1 from $Shrunken without replacement into $MoreChosen)

Now all we have to do is print the statements!

(name for index $Index)
	($Index plus 1 into $Ip1) %% Our person indices go from zero, Dialog lists go from one
	(entry $Ip1 of [Alice Bob Claire Dave Emma Fred Gwen Harry Isabelle Jake Kyra Liam Mary Nick Olivia Pedro] into $Name)
	(uppercase) $Name

If they’re both of the same type—knight on knight, knave on knave—it’s an affirmation. Otherwise, it’s an accusation. I feel like there should really be a more elegant way to XOR two conditions in Dialog, but if there is, I don’t know it.

(statement from $Source about $Target with knaves $Knaves)
	(if) ($Source is one of $Knaves) (then)
		($SourceGuilty = 1)
	(else)
		($SourceGuilty = 0)
	(endif)
	
	(if) ($Target is one of $Knaves) (then)
		($TargetGuilty = 1)
	(else)
		($TargetGuilty = 0)
	(endif)
	
	(if) ($SourceGuilty = $TargetGuilty) (then)
		($Source affirms $Target)
	(else)
		($Source accuses $Target)
	(endif)

An “affirmation” is saying someone’s a knight; an “accusation” is saying someone’s a knave.

($Source affirms $Target)
	(name for index $Source) says: "(no space)(name for index $Target) is innocent!"
($Source accuses $Target)
	(name for index $Source) says: "(no space)(name for index $Target) is guilty!"

Now we can just present all the statements in a list. But we need one more caveat: if the number of knights is equal to the number of knaves, then the solution is not unique! Affirmations and accusations tell us whether people are the same type or different types, but neither of them actually tells us whether someone is a knight or a knave.

So in this case, we add one more piece of evidence. Since I’ve been playing Ace Attorney recently, I’ve (minimally) flavored this as suspects on trial.

(present puzzle $G with knaves $Knaves)
	(total number of people $Total)
	(number of knaves $Guilty)
	$Total people have been accused of a crime! $Guilty of them (if) ($Guilty = 1) (then) is (else) are (endif) actually guilty. The innocent will tell the truth, the guilty will lie.
	(exhaust) {
		*(person index $Source)
		*(person index $Target)
		(convert [$Source $Target] to index $Index)
		(entry $Index of $G into 1)
		(line) (statement from $Source about $Target with knaves $Knaves)
	}
	(if) (number of knights $N) (number of knaves $N) (then) %% This is the only case where there's a legitimate ambiguity
		(par) You also have conclusive evidence that (name for index 0) is
		(if) (0 is one of $Knaves) (then) guilty (else) innocent (endif)
		.
	(endif)

Finally, we add a very minimal user interface to let people generate puzzles. (Really the minimum number of connections is N-1, not N, which I didn’t realize until I already posted this, but that’s an easy change.)

(program entry point)
	How many knights should there be in the puzzle?
	(get number $Knights)
	(par) How many knaves should there be in the puzzle?
	(get number $Knaves)
	(now) (number of knaves $Knaves)
	($Knights plus $Knaves into $Total)
	(now) (total number of people $Total)
	(par) How many statements should there be?
	(get number $Evidence)
	(if) ($Evidence < $Total) (then) Error: evidence cannot be < knights + knaves! (fail) (endif) %% Avoid a crash if this happens
	(now) (number of statements $Evidence)
	(generate puzzle $G)
	(select knaves into $KnaveList)
	(par) (present puzzle $G with knaves $KnaveList)
	(par) Press ENTER to see the solution.
	(get input $)
	Knaves:
	(exhaust) {
		*($Knave is one of $KnaveList)
		(name for index $Knave)
	}

(get number $N)
	*(repeat forever)
	> (get input $Words)
	{
		($Words = [$N]) (number $N)
	(or)
		That is not a number. (line) (fail)
	}

And while we’re not using the standard library, we do need some of the list-manipulation predicates from it, so I’ve copied those here.

(interface (remove duplicates $<Input $>Output))

(remove duplicates [] [])
	(just)
(remove duplicates [$Head | $MoreIn] $MoreOut)
	($Head is one of $MoreIn)
	(just)
	(remove duplicates $MoreIn $MoreOut)
(remove duplicates [$Head | $MoreIn] [$Head | $MoreOut])
	(remove duplicates $MoreIn $MoreOut)

(interface (length of $List into $>Number))

(length of [] into 0)
(length of [$ | $More] into $Np1)
	(length of $More into $N)
	($N plus 1 into $Np1)

(interface (nth $List $<Index $Element))

(nth [$Head | $] 1 $Head)
(nth [$ | $Tail] $N $Result)
	($N minus 1 into $Nm1)
	(nth $Tail $Nm1 $Result)

(interface (randomly select $>Element from $<List))

(randomly select $Element from $List)
	(length of $List into $N)
	(random from 1 to $N into $Index)
	(nth $List $Index $Element)

(take 0 from $ into [])
(take $N from [$Head | $MoreIn] into [$Head | $MoreOut])
	($N minus 1 into $Nm1)
	(take $Nm1 from $MoreIn into $MoreOut)

Now you can play the whole thing in dgdebug. You can’t, unfortunately, play it in the Z-machine—at least for more than a trivial number of suspects. It just uses too much memory. I’m not sure if that’s a problem that can be solved; Dialog list-manipulation is just too slow and inefficient. But there are definitely some ways to cut down the O(N⁴) memory usage, like storing the index of the removed connection instead of the entire modified graph when choosing one at random (which would take it down to O(N²)). If you want to use this in an actual game, I recommend doing that.

knaves.dg (10.7 KB)

Run with dgdebug knaves.dg, no library necessary. And if you want to try the puzzles, here are some example outputs:

3 people have been accused of a crime! 1 of them is actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Bob is guilty!”
Alice says: “Claire is innocent!”
Bob says: “Alice is guilty!”
Claire says: “Alice is innocent!”

Knaves: |_|_Bob_|_|

5 people have been accused of a crime! 2 of them are actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Dave is innocent!”
Bob says: “Claire is innocent!”
Claire says: “Alice is guilty!”
Claire says: “Dave is guilty!”
Dave says: “Claire is guilty!”
Dave says: “Emma is innocent!”
Emma says: “Bob is guilty!”
Knaves: Bob Claire

5 people have been accused of a crime! 2 of them are actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Bob is guilty!”
Alice says: “Dave is guilty!”
Bob says: “Alice is guilty!”
Claire says: “Dave is guilty!”
Dave says: “Claire is guilty!”
Dave says: “Emma is guilty!”
Emma says: “Claire is innocent!”

Knaves: Dave Bob

6 people have been accused of a crime! 3 of them are actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Dave is innocent!”
Bob says: “Emma is guilty!”
Claire says: “Fred is innocent!”
Dave says: “Claire is guilty!”
Dave says: “Fred is guilty!”
Emma says: “Claire is guilty!”
Emma says: “Fred is guilty!”
Fred says: “Emma is guilty!”

You also have conclusive evidence that Alice is guilty.

Knaves: Dave Alice Emma

2 Likes

I was about to try to work this one out in my brain, but I could tell from the short width of the blurred out spoiler tag that the answer was the guy with the shortest name :smile:

-Wade

Oops! Cleaned that up a bit.

This seems to be sticking in my mind tonight, so I tried to find a way to fit it into the Z-machine. The current “randomly remove a connection” code runs in O(n²) time but O(n⁴) space; here’s a version that runs in O(n⁴) time but O(n²) space. Such are the tradeoffs of computer science!

($List has connection at position $N)
	*($List has connection at position $N counting from 1) %% The "counting from" ensures all our indices are on the same scale
([1 | $] has connection at position $N counting from $N)
([$ | $Tail] has connection at position $N counting from $Current)
	($Current plus 1 into $Cp1)
	*($Tail has connection at position $N counting from $Cp1)

(randomly remove one connection from $G into $G2)
	(collect $Index)
		*($G has connection at position $Index)
		(change entry $Index of $G to 0 into $New)
		(connected $New)
		(valid testimony $New)
	(into $Options)
	(randomly select $Choice from $Options)
	(change entry $Choice of $G to 0 into $G2)

With this, it’s possible to fit it into the Z-machine, as long as you stick to a reasonable number of people. Where “reasonable” in this case means “no more than seven, and even then it takes a few seconds on modern hardware”.

7 people have been accused of a crime! 3 of them are actually guilty. The
innocent will tell the truth, the guilty will lie.
Alice says: “Claire is innocent!”
Alice says: “Dave is innocent!”
Bob says: “Dave is guilty!”
Claire says: “Bob is guilty!”
Claire says: “Fred is guilty!”
Dave says: “Bob is guilty!”
Emma says: “Fred is guilty!”
Fred says: “Gwen is innocent!”
Gwen says: “Alice is guilty!”
Knaves: Fred Bob Gwen

Since this is a RAM limitation, I’m pretty sure this would not cause problems for fitting the rest of your game code in—as long as you don’t run this while any parse results are on the heap. (Those are the most memory-intensive thing in the standard library.) But if you wanted to use this in an actual game, probably best to generate the graphs ahead of time, then have the Dialog code choose one from a dozen or so options at runtime. Players will never know the difference!

Anyway, I’m going to call this project done for the moment. Enjoy! And see what O(n⁴) time looks like on modern hardware by putting in (e.g.) 4 knights, 3 knaves, 10 statements, and watching your computer struggle!

knaves.dg (12.5 KB)
knaves.z8 (28.5 KB)
knaves.z8.html.zip (383.7 KB)

4 Likes

This is neat!

I spent a little time playing with it this morning… leaving some thoughts here in case they’re correct and useful.

  • The complete graph is size n^2, but the minimal solution as you noted is size n-1, and “interesting” puzzles are probably much closer in size to the minimum one.
  • Graph algorithms generally have an easier time tracking connected components when adding edges than removing edges.
  • Generally speaking adjacency lists (for every node, enumerating precisely which nodes they’re connected to) are more efficient than the full connectivity matrix when graphs are “sparse”… and interesting puzzles are generally sparse.

So for the above reasons I’m interested in trying approaches that start from nothing and add edges, instead of starting with the complete graph and paring it down… at minimum it should involve fewer steps, and it allows for slightly more clever algorithms that should speed things up further.

I have started on the code for this but have to actually go do some more practical things… if I get some time tonight I’ll see if I can finish and post it.

3 Likes

Oh, of course, that makes a lot of sense!

I’m now looking into generating a random tree for the initial state—it looks like Prüfer sequences make those surprisingly easy to generate—and then adding edges to that. In that case, we could dispense with the connectivity testing altogether; the key requirement would just be that every person makes at least one statement, but that could be done by first adding random edges to any person who’s not making a statement, then adding any remaining edges to any person at random.

So something like:

  • Generate a random Prüfer sequence
  • Convert it to a random tree
  • When creating the tree, have the newly-connected node always be the one making the statement
  • For each person not making a statement (there should be exactly one: the root of the tree), add an edge to a random other node
  • Add remaining edges at random (choose two random nodes without replacement and connect them)

That should be a lot better in terms of memory!

3 Likes

Oh, yeah, this is much better. It easily handles 20 people on the Z-machine without a problem, with no noticeable lag. So let’s take this from the top!

Once again, our global variables. These could just as well be constants, really, except that for the demo I want the player to supply them.

(global variable (total number of people 5))
(global variable (number of knaves 2))
(number of knights $Knights)
	(total number of people $Total)
	(number of knaves $Knaves)
	($Total minus $Knaves into $Knights)
(global variable (number of statements 8))

Now, once again, we want to build a graph with [total number of people] vertices, with the following properties:

  • [number of statements] directed edges
  • every vertex has at least one edge originating from it
  • the graph is connected, when edge directions are ignored

But this time we’re going to come at it from the opposite direction: we’re going to generate a minimal connected graph (that is, a spanning tree), then add random edges to it, instead of starting with a maximal connected graph (a complete graph) and removing random edges.

Since the graphs are going to be sparse, now, we can use adjacency lists instead of an adjacency matrix. In other words, our graph representation will be a list of lists of edges, with one list for each vertex.

(empty graph $G)
	(total number of people $N)
	($N instances of [] into $G)

(0 instances of $ into [])
($N instances of $Value into [$Value|$Tail])
	($N minus 1 into $Nm1)
	($Nm1 instances of $Value into $Tail)

As before, I find the way the library named these predicates deeply unintuitive, so I’m using my own.

(change entry 1 of [$|$Tail] to $Value into [$Value|$Tail])
(change entry $N of [$Head|$Tail] to $Value into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(change entry $Nm1 of $Tail to $Value into $NewTail)

(entry 1 of [$Head | $] into $Head)
(entry $N of [$ | $Tail] into $Value)
	($N minus 1 into $Nm1)
	(entry $Nm1 of $Tail into $Value)

And some more utilities in the same vein, since Dialog doesn’t have “modify in place” as a core concept.

(increment entry 1 of [$Old|$Tail] into [$New|$Tail])
	($Old plus 1 into $New)
(increment entry $N of [$Head|$Tail] into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(increment entry $Nm1 of $Tail into $NewTail)

(decrement entry 1 of [$Old|$Tail] into [$New|$Tail])
	($Old minus 1 into $New)
(decrement entry $N of [$Head|$Tail] into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(decrement entry $Nm1 of $Tail into $NewTail)

(prepend $Value to entry 1 of [$Head|$Tail] into [ [$Value|$Head] | $Tail])
(prepend $Value to entry $N of [$Head|$Tail] into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(prepend $Value to entry $Nm1 of $Tail into $NewTail)

(delete $Value from entry 1 of [$Old|$Tail] into [$New|$Tail])
	(delete $Value from $Old into $New)
(delete $Value from entry $N of [$Head|$Tail] into [$Head|$NewTail])
	($N minus 1 into $Nm1)
	(delete $Value from entry $Nm1 of $Tail into $NewTail)

%% Remove the first instance of a value from a list
(delete $Value from [$Value|$Tail] into $Tail)
(delete $Value from [$Head|$Tail] into [$Head|$NewTail])
	(delete $Value from $Tail into $NewTail)

Now, how are we going to generate a random spanning tree? Turns out there’s a standard algorithm for that! Specifically, there’s a bijection between Prüfer sequences (a sequence of N-2 numbers, all between 1 and N), and undirected labelled trees (with N vertices). So all we have to do is generate a random Prüfer sequence, and we can turn it into a random tree!

(0 random values between $ and $ into [])
($N random values between $Low and $High into [$Random|$Tail])
	(random from $Low to $High into $Random)
	($N minus 1 into $Nm1)
	($Nm1 random values between $Low and $High into $Tail)

(random Prüfer sequence $Prüfer)
	(total number of people $N)
	($N minus 2 into $Length)
	($Length random values between 1 and $N into $Prüfer)

Fun fact: Dialog allows non-ASCII characters in its identifiers, probably because it was invented by a guy named Åkesson. (Though it doesn’t have full Unicode compatibility, because Swedish still uses the Latin alphabet.)

Now, we just implement the algorithm from Wikipedia to turn a Prüfer sequence into a tree. The hardest part is that this algorithm wants a mutable array, storing a “degree” for each vertex, and that doesn’t work very well with Dialog’s paradigm. But, we can make it work.

(convert Prüfer sequence $Prüfer into tree $G)
	(total number of people $N)
	(empty graph $Empty)
	($N instances of 1 into $InitialDegrees)
	(update degrees $InitialDegrees from sequence $Prüfer into $Degrees)
	
	(use Prüfer sequence $Prüfer on $Empty and $Degrees to make $NewTree and $NewDegrees)
	(use degrees $NewDegrees to add final connection to $NewTree to make $G)

The first sub-component is populating the $Degrees list.

(update degrees $Degrees from sequence [] into $Degrees)
(update degrees $Degrees from sequence [$Head|$Tail] into $Updated)
	(increment entry $Head of $Degrees into $Intermediate)
	(update degrees $Intermediate from sequence $Tail into $Updated)

Conveniently, the algorithm only iterates over the Prüfer sequence once (and never does any random access), so we can make that a straightforward recursive predicate.

(use Prüfer sequence [] on $Tree and $Degrees to make $Tree and $Degrees)
(use Prüfer sequence [$I|$Tail] on $OldTree and $OldDegrees to make $NewTree and $NewDegrees)
	(first index of 1 in $OldDegrees is $J)
	(decrement entry $I of $OldDegrees into $Tmp)
	(decrement entry $J of $Tmp into $Degrees)
	(prepend $J to entry $I of $OldTree into $Tree)
	(use Prüfer sequence $Tail on $Tree and $Degrees to make $NewTree and $NewDegrees)

And once the sequence is complete, find the last two nodes that have non-zero degree values, and link them together.

(use degrees $Degrees to add final connection to $OldTree to make $NewTree)
	(first index of 1 in $Degrees is $U)
	(decrement entry $U of $Degrees into $NewDegrees)
	(first index of 1 in $NewDegrees is $V)
	(prepend $V to entry $U of $OldTree into $NewTree)

This “first index of value” predicate turns out to be a bit clunky. Since memory is our most insurmountable limit, and basically everything is implemented with recursion, I want to do tail recursion as much as possible. (Tail recursion is when the recursive call is the last query in a predicate; when this happens, it doesn’t use any extra stack space, so it’s just as efficient as iterating over the list with a classic for-loop would be.)

Which means designing it like this:

%% This predicate is implemented a bit awkwardly to ensure all recursion is in tail position
%% The more elegant way to do it (without the "counting from") requires addition after the recursive call
(first index of $Value in [$Value|$] counting from $N is $N)
(first index of $Value in [$|$Tail] counting from $N is $Result)
	($N plus 1 into $Np1)
	(first index of $Value in $Tail counting from $Np1 is $Result)

%% So we have a convenience predicate for the standard case
(first index of $Value in $List is $N)
	(first index of $Value in $List counting from 1 is $N)

Now we’ve generated a random tree…with the edges pointing in arbitrary directions. That’s going to be a problem, since we want every knight and knave to make at least one statement. (If you don’t have that requirement, though, you don’t have to worry about this next part. The puzzle is perfectly solvable even if some people are only spoken about, and never speak up themselves.)

So our next goal is to ensure every vertex has at least one outgoing edge. We iterate over the vertices, and if any of them have no outgoing edges:

  • Check if another vertex has two or more outgoing edges, and one of them points to us. If so, flip that edge around.
  • Otherwise, add a new edge from this vertex to a random other vertex.

We also need to keep track of how many new edges we added, for later, which means the same sort of clunkiness as before to ensure recursion is always in tail position.

(ensure testimony from $Tree into $G by adding $E edges)
	(total number of people $N)
	(improve node $N of $Tree into $G by adding $E edges counting from 0)

(improve node $I of $Tree into $G by adding $ENew edges counting from $EOld) %% $EOld is how many we already added; we need to pass it in for tail recursion purposes
	(total number of people $N)
	(if) (entry $I of $Tree into $List) ~(empty $List) (then) %% No need to add anything, there's already an edge here
		($ETmp = $EOld)
		($New = $Tree)
	(elseif) *(have $J count up from 1 to $N) (node $J of $Tree has redundant edge to $I) (then) %% We can flip an edge
		(delete $I from entry $J of $Tree into $Tmp)
		(prepend $J to entry $I of $Tmp into $New)
		($ETmp = $EOld)
	(else) %% We have to insert a new edge
		%% We want to pick a random entry that's NOT this node
		(random from 1 to $N excluding $I into $J)
		(prepend $J to entry $I of $Tree into $New)
		($EOld plus 1 into $ETmp)
	(endif)
	
	%% Now, potentially recurse (counting down because it's a bit easier that way)
	(if) ($I > 1) (then)
		($I minus 1 into $Next)
		(improve node $Next of $New into $G by adding $ENew edges counting from $ETmp)
	(else)
		($G = $New)
		($ENew = $ETmp)
	(endif)

We count down instead of counting up because the code is a bit shorter that way, and it doesn’t really matter.

This requires two simple utilities:

(node $J of $Tree has redundant edge to $I)
	(entry $J of $Tree into $List)
	(length of $List into $Length)
	($Length > 1)
	($I is one of $List)

(random from $Low to $High excluding $Exclude into $Result)
	($High minus 1 into $NewHigh)
	(random from $Low to $NewHigh into $Random) %% Generate a random number in [Low..High-1]
	(if) ($Random < $Exclude) (then) %% If it's less than Exclude, use it directly
		($Result = $Random)
	(else) %% Otherwise, bump it up by 1 to skip over Exclude
		($Random plus 1 into $Result)
	(endif)

This last utility predicate may be useful elsewhere too! Sadly, though, this is the only place we get to use it in this example.

So now we have a puzzle that’s guaranteed to be solvable, where every knight and knave makes at least one statement. Mission complete, right? Well, not quite. We have that global variable at the top specifying how many statements there should be. If we’re not at that number yet, add random edges until we are.

(add 0 random edges to $G into $G)
(add $K random edges to $In into $Out)
	(total number of people $N)
	(random from 1 to $N into $I)
	(entry $I of $In into $Existing)
	(collect $Choice)
		*(have $Choice count up from 1 to $N)
	(into $RawChoices)
	(remove from $RawChoices matching [$I | $Existing] into $Choices)
	(randomly select $J from $Choices)
	(prepend $J to entry $I of $In into $Tmp)
	($K minus 1 into $Km1)
	(add $Km1 random edges to $Tmp into $Out)

We have to use “randomly select” with an explicit list of choices here, because our “random excluding” predicate only lets us exclude one number. Here, we want to exclude the vertex itself, and any vertices it already has connections to, since two statements saying exactly the same thing aren’t much of a puzzle.

Putting these two steps together, we go from a tree to a graph:

(flesh out $Tree into $G)
	(number of statements $Goal)
	(total number of people $N)
	($N minus 1 into $Edges)
	
	(ensure testimony from $Tree into $Tmp by adding $E edges)
	(log) {Improved: $Tmp}
	(if) ($Goal minus $Edges into $Add) ($Add minus $E into $Remaining) (then)
		(add $Remaining random edges to $Tmp into $G)
	(else)
		($G = $Tmp)
	(endif)

And wrap up the whole thing!

(generate random puzzle $G)
	(random Prüfer sequence $Prüfer)
	(log) { Sequence: $Prüfer }
	(convert Prüfer sequence $Prüfer into tree $Tree)
	(log) { Tree: $Tree }
	(flesh out $Tree into $G)
	(log) { G: $G }

Those “log” statements are for debugging; they’ll display in dgdebug but not in the Z-machine. Feel free to comment them out or remove them if they get annoying.

The rest of the code is exactly the same as before, to randomly choose some knaves and present the puzzle. I did expand the “name for index” predicate, though, to handle an arbitrary number of people.

(name for index $Index) %% Automatically generated with regexes
	(if) ($Index = 1) (then)
		Alice
 	(elseif) ($Index = 2) (then)
		Bob
 	(elseif) ($Index = 3) (then)
		Claire
 	(elseif) ($Index = 4) (then)
		Dave
 	(elseif) ($Index = 5) (then)
		Emma
 	(elseif) ($Index = 6) (then)
		Fred
 	(elseif) ($Index = 7) (then)
		Gwen
 	(elseif) ($Index = 8) (then)
		Harry
 	(elseif) ($Index = 9) (then)
		Isabelle
 	(elseif) ($Index = 10) (then)
		Jake
 	(elseif) ($Index = 11) (then)
		Kyra
 	(elseif) ($Index = 12) (then)
		Liam
 	(elseif) ($Index = 13) (then)
		Mary
 	(elseif) ($Index = 14) (then)
		Nick
 	(elseif) ($Index = 15) (then)
		Olivia
 	(elseif) ($Index = 16) (then)
		Pedro
 	(elseif) ($Index = 17) (then)
		Qilin
 	(elseif) ($Index = 18) (then)
		Randall
 	(elseif) ($Index = 19) (then)
		Samantha
 	(elseif) ($Index = 20) (then)
		Thomas
 	(elseif) ($Index = 21) (then)
		Ursula
 	(elseif) ($Index = 22) (then)
		Victor
 	(elseif) ($Index = 23) (then)
		Wynn
 	(elseif) ($Index = 24) (then)
		Xavier
 	(elseif) ($Index = 25) (then)
		Yasmine
 	(elseif) ($Index = 26) (then)
		Zen
	(else)
		Person \#(no space)$Index
	(endif)

And voilà! Have some big puzzles! These were generated on the Z-machine, taking no noticeable time.

16 people have been accused of a crime! 8 of them are actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Pedro is guilty!”
Alice says: “Fred is innocent!”
Bob says: “Nick is guilty!”
Claire says: “Gwen is guilty!”
Dave says: “Kyra is innocent!”
Emma says: “Pedro is guilty!”
Emma says: “Mary is innocent!”
Fred says: “Emma is innocent!”
Fred says: “Jake is guilty!”
Gwen says: “Bob is innocent!”
Harry says: “Gwen is guilty!”
Isabelle says: “Emma is guilty!”
Jake says: “Dave is innocent!”
Kyra says: “Isabelle is innocent!”
Liam says: “Kyra is guilty!”
Mary says: “Jake is guilty!”
Nick says: “Emma is innocent!”
Nick says: “Gwen is guilty!”
Olivia says: “Emma is guilty!”
Pedro says: “Nick is guilty!”
You also have conclusive evidence that Alice is guilty.
Knaves: Emma Harry Fred Nick Alice Mary Claire Liam

27 people have been accused of a crime! 13 of them are actually guilty. The innocent will tell the truth, the guilty will lie.
Alice says: “Xavier is innocent!”
Bob says: “Randall is innocent!”
Claire says: “Gwen is guilty!”
Dave says: “Person #27 is guilty!”
Emma says: “Pedro is guilty!”
Fred says: “Wynn is innocent!”
Gwen says: “Victor is guilty!”
Harry says: “Alice is guilty!”
Isabelle says: “Emma is innocent!”
Jake says: “Fred is innocent!”
Kyra says: “Thomas is guilty!”
Kyra says: “Harry is innocent!”
Liam says: “Samantha is guilty!”
Mary says: “Isabelle is innocent!”
Nick says: “Olivia is innocent!”
Olivia says: “Nick is innocent!”
Pedro says: “Yasmine is innocent!”
Qilin says: “Person #27 is innocent!”
Randall says: “Mary is guilty!”
Samantha says: “Jake is innocent!”
Thomas says: “Olivia is innocent!”
Ursula says: “Nick is innocent!”
Victor says: “Ursula is guilty!”
Wynn says: “Randall is innocent!”
Wynn says: “Gwen is guilty!”
Xavier says: “Zen is guilty!”
Yasmine says: “Xavier is guilty!”
Zen says: “Person #27 is guilty!”
Zen says: “Kyra is innocent!”
Person #27 says: “Samantha is guilty!”
Knaves: Alice Mary Ursula Nick Qilin Thomas Emma Liam Gwen Person #27 Isabelle Olivia Xavier

It still runs out of memory around 40 people, but I think if you’re generating 40-person knights-and-knaves puzzles, hitting the Z-machine RAM limit is to be expected.

knaves2.dg (14.0 KB)
knaves2.z8 (29.6 KB)

3 Likes

I presume that some amount of the limitations here are because of the design goal that Dialog games should be more-or-less playable on a Commodore 64, particularly when delivered in the native format.

So I’m mildly surprised that the .aa version crashes with a much lower number of participants than the .z8 on Ozmoo.

1 Like

Yeah, I don’t actually know what benefits the Å-machine has over the Z-machine on a C64, except that the code size is significantly smaller—Dialog compiled for the Z-machine basically includes a routine for each Å-machine instruction, while on the Å-machine all that code is in the interpreter.

Did you specify the maximum heap size when compiling the .aa file? This needs a big primary heap more than anything else, to hold the lists that comprise the graph. For the Z8 I used the largest the compiler would allow, 8192 words.

1 Like

Did not, am caveman. What values should I set for -H, -A, -L ?

(setting all to 8192 caused the interpreter to crash shortly after initial load, presumably too much memory being allocated. Just -H 8192 would crash on bigger scenarios showing that 500 aux words were used, suggesting that is the issue. -H 8192 -A 4096 let me run a scenario of 24/8/33 without incident.)

-H 8192 was the only one I tried, I never saw it run out of auxiliary words. I wonder what the difference is?

It hits it pretty quickly:

(this is with just -H 8192, other settings left default)

Huh! I wonder why it’s using the auxiliary heap so much more on the Å-machine? In my Z-machine experiments it’s basically untouched.

I confess I actually have no idea what the difference between main heap, auxiliary heap, and long-term heap is, in the Dialog back-end. It doesn’t seem to be documented anywhere, and I’m not confident enough in my understanding of the compiler code to figure it out from there. Does anyone happen to know?

I know from experience that allocating too much heap size increases the likelihood of crashes with z-machine interpreters. I suspect memory region overlaps between heap area and others.

Disclaimer: This is speculation, I know nothing about z-machine memory model and how dialog compiler treats it.

FWIW, that’s what does in the Z-machine version (Ozmoo) sometimes over here as well:

1 Like