Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Optimize Maze Generation #856

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft

Conversation

jbdyn
Copy link

@jbdyn jbdyn commented Mar 10, 2025

Hey guys,

analogous to #852, I also tried to use the first version of the optimized chamber finding algorithm in the maze generation as well.

The timings went from several seconds down to less than half a second consistently for script execution.
However, looking at the profiling, only 5% (25 ms) are actually maze generation and the rest of the time was spent in importing:

absolute times
  _     ._   __/__   _ _  _  _ _/_   Recorded: 09:47:34  Samples:  233
 /_//_/// /_\ / //_// / //_'/ //     Duration: 0.480     CPU time: 1.285
/   _/                      v5.0.1

Program: scripts/pelita_createlayout.py

0.479 <module>  pelita_createlayout.py:1
├─ 0.299 <module>  ../__init__.py:1
│  └─ 0.297 <module>  ../game.py:1
│     ├─ 0.183 <module>  ../team.py:1
│     │  └─ 0.181 <module>  networkx/__init__.py:1
│     │        [18 frames hidden]  networkx, importlib
│     ├─ 0.074 <module>  ../viewer.py:1
│     │  ├─ 0.062 <module>  rich/console.py:1
│     │  │     [11 frames hidden]  rich, fractions
│     │  └─ 0.010 <module>  rich/progress.py:1
│     ├─ 0.030 <module>  ../network.py:1
│     │  └─ 0.028 <module>  zmq/__init__.py:1
│     │        [10 frames hidden]  zmq, importlib, enum
│     └─ 0.006 <module>  logging/__init__.py:1
├─ 0.146 <module>  ../maze_generator.py:1
│  └─ 0.146 <module>  numpy/__init__.py:1
│        [30 frames hidden]  numpy, typing
├─ 0.024 main  pelita_createlayout.py:65
│  └─ 0.022 get_new_maze  ../maze_generator.py:381
│     ├─ 0.012 remove_all_chambers  ../maze_generator.py:321
│     │  └─ 0.006 articulation_points  networkx/algorithms/components/biconnected.py:263
│     │     └─ 0.006 _biconnected_dfs  networkx/algorithms/components/biconnected.py:338
│     └─ 0.010 remove_all_dead_ends  ../maze_generator.py:256
│        └─ 0.008 walls_to_graph  ../maze_generator.py:193
└─ 0.008 compile  <built-in>
relative times
  _     ._   __/__   _ _  _  _ _/_   Recorded: 09:47:34  Samples:  233
 /_//_/// /_\ / //_// / //_'/ //     Duration: 0.480     CPU time: 1.285
/   _/                      v5.0.1

Program: scripts/pelita_createlayout.py

100.0% <module>  pelita_createlayout.py:1
├─ 62.4% <module>  ../__init__.py:1
│  └─ 62.0% <module>  ../game.py:1
│     ├─ 38.2% <module>  ../team.py:1
│     │  └─ 37.8% <module>  networkx/__init__.py:1
│     │        [18 frames hidden]  networkx, importlib
│     ├─ 15.4% <module>  ../viewer.py:1
│     │  ├─ 12.9% <module>  rich/console.py:1
│     │  │     [11 frames hidden]  rich, fractions
│     │  └─ 2.1% <module>  rich/progress.py:1
│     ├─ 6.3% <module>  ../network.py:1
│     │  └─ 5.8% <module>  zmq/__init__.py:1
│     │        [10 frames hidden]  zmq, importlib, enum
│     └─ 1.3% <module>  logging/__init__.py:1
├─ 30.5% <module>  ../maze_generator.py:1
│  └─ 30.5% <module>  numpy/__init__.py:1
│        [30 frames hidden]  numpy, typing
├─ 5.0% main  pelita_createlayout.py:65
│  └─ 4.6% get_new_maze  ../maze_generator.py:381
│     ├─ 2.5% remove_all_chambers  ../maze_generator.py:321
│     │  └─ 1.3% articulation_points  networkx/algorithms/components/biconnected.py:263
│     │     └─ 1.3% _biconnected_dfs  networkx/algorithms/components/biconnected.py:338
│     └─ 2.1% remove_all_dead_ends  ../maze_generator.py:256
│        └─ 1.7% walls_to_graph  ../maze_generator.py:193
└─ 1.7% compile  <built-in>

Would that be fast enough to generate mazes on-the-fly?

For now, I think this would not make the layout database obsolete as one still does not have direct control over the number of dead ends and chambers.

@jbdyn
Copy link
Author

jbdyn commented Mar 10, 2025

Related discussion about layout database design: #849

@otizonaizit
Copy link
Member

that is quite impressive. Did you verify that the new algorithm and the old one generate exactly the same maze if started with the same random seed?

@otizonaizit
Copy link
Member

Half a second is too much to be run at every game, but if we don't require to remove dead ends and chambers anymore, the whole thing would be even faster, no? And in that case we could relax our requirements. We could hard code that we want to "trap" a maximum of 33% of food pellets in chambers/dead-ends, and then do our best depending on how many "trapped" tile we have available on the fly.

@otizonaizit
Copy link
Member

that is quite impressive. Did you verify that the new algorithm and the old one generate exactly the same maze if started with the same random seed?

Given the failing test it seems to me that the change also changes the generated maze. As I said, if we relax our requirements we may not need to remove chambers/dead-ends, so the test failure would be irrelevant.

@jbdyn
Copy link
Author

jbdyn commented Mar 10, 2025

Did you verify that the new algorithm and the old one generate exactly the same maze if started with the same random seed?

No, but for that I would need to align pelita_createlayout.py and maze_generator.get_new_maze() as the first one takes a seed kwarg and the second one does not, but instead a rng object.
Have not looked into this yet.

Half a second is too much to be run at every game

Okay, but most of this time is importing pelita modules which are not required at all in the maze generation.
This is due to the root __init__.py.

Given the failing test it seems to me that the change also changes the generated maze.

The tests fail because I removed find_chamber and introduced a new find_chambers (note the s).

@otizonaizit
Copy link
Member

Half a second is too much to be run at every game, but if we don't require to remove dead ends and chambers anymore, the whole thing would be even faster, no? And in that case we could relax our requirements. We could hard code that we want to "trap" a maximum of 33% of food pellets in chambers/dead-ends, and then do our best depending on how many "trapped" tile we have available on the fly.

@Debilski : what do you think of this approach? I would try together with @jbdyn to implement it on Wednesday in a monster PR. Plan:

  • maze generation on the fly
  • configurable proportion of "trapped" food, intended as a best effort: if there are not enough trapped tiles, then less food will be trapped than requested
  • remove the functionality to remove chambers/dead-ends (could be kept somewhere commented out so taht we don't have to re-invent the wheel in the future

@Debilski
Copy link
Member

@jbdyn Could you rebase this branch on the updated main? This will make comparisons easier.

@Debilski
Copy link
Member

Let me see if I can fix #854 before Wednesday as this is probably useful for testing (although shell redirection will already do the job well).

Can we be certain though that the same maze will be generated everywhere? And even if this is the case, I see a few UX problems:

  • Currently, we have 1000 mazes and it is always clear which one is used.
  • This makes it less overwhelming for the students than having billions of mazes and they can pick any maze and easily discuss it.
  • They can also easily copy and paste it and maybe modify it to test something specific. A maze is always in a file (or in a string).
  • If we want to change the 1000 mazes, we can simply recreate new ones and ship them.

Cons in the new approach:

  • Live-generated mazes can only have a seed as an id. The seed must be short enough to be useful.
  • If students want to modify a maze, they will have to pipe it into a file or use special Python code to read and save it.
  • If we want to change which maze is generated from --seed 1, we have to hard-code a salt value into Pelita (which is used to generate the real seed) and change that in code.

Not unsolvable and maybe not too bad (I dislike the salt here, though. Maybe you have a better idea.) but I wanted to note these things.

@jbdyn
Copy link
Author

jbdyn commented Mar 10, 2025

Could you rebase this branch on the updated main? This will make comparisons easier.

@Debilski Done.

@otizonaizit
Copy link
Member

otizonaizit commented Mar 10, 2025

Can we be certain though that the same maze will be generated everywhere? And even if this is the case, I see a few UX problems:

  • Currently, we have 1000 mazes and it is always clear which one is used.

  • This makes it less overwhelming for the students than having billions of mazes and they can pick any maze and easily discuss it.

in my experience everyone has always assumed that the mazes are generated at run time. The team that asked about it was very surprised when I said that they are indeed pre-generated and that was the moment when they chose to hardcode behavior dependent on the layout name. We decided to have 1000 mazes to make it impossible to do this. Having mazes generated at run time will not surprise anyone, as far as my experience tells me.

  • They can also easily copy and paste it and maybe modify it to test something specific. A maze is always in a file (or in a string).

But that is still possible:

pelita --seed XXX --dump-layout /tmp/my.layout

With --seed you replay on the same maze with the same food. With --dump-layout you save to a string, modify the layout at will and reload later with

pelita --layout /tmp/my.layout

Given that my.layout will contain the food, you will be able to play on exactly the situation you want to have. You have full control.

  • If we want to change the 1000 mazes, we can simply recreate new ones and ship them.

But hey, there's not need to do it if maze generation is fast.

Cons in the new approach:

  • Live-generated mazes can only have a seed as an id. The seed must be short enough to be useful.

You lost me here. Why does a maze need an id?

  • If students want to modify a maze, they will have to pipe it into a file or use special Python code to read and save it.

See the --dump-layout option above

  • If we want to change which maze is generated from --seed 1, we have to hard-code a salt value into Pelita (which is used to generate the real seed) and change that in code.

I am even more lost here. Why do we care about --seed 1?

@Debilski
Copy link
Member

in my experience everyone has always assumed that the mazes are generated at run time. The team that asked about it was very surprised when I said that they are indeed pre-generated and that was the moment when they chose to hardcode behavior dependent on the layout name. We decided to have 1000 mazes to make it impossible to do this. Having mazes generated at run time will not surprise anyone, as far as my experience tells me.

Survivorship bias? Maybe teams that were not confused about the layouts never asked? 🙃

The problem I am describing boils down to: How do two separate groups in a team communicate which layout they should use for testing.

The first group notices a problem and tells the other group that they should compare it with their own implementation or whatever.

Currently: The first group has the UI open, sees the layout name there and communicates this to the second group.

With auto-generated layouts, they must scroll back to find the seed on the command line (buried in lines of debugging output). And then they must recite something like 20 numbers to the other group so that they can use this as a seed to have the same layout. (And this assumes that this is actually stable between different computers.)

My suggested solution here was to generate a short id from the main seed that is given to the layout generator. This id would be shown in the UI and could be used for communication. (The second group would for example use --layout 123 to access the layout that the first group sees.) The drawback here is that all layouts with nice seeds are then fixed forever (or until the maze-generating algorithm changes), hence my suggestion to add a salt. But I am not a fan of this idea either.

Obviously people can save and send around layout files, but this makes things quite a bit more involved compared to what we had before.


Some thinking later:

What we could do is suggest to the teams that they pre-generate their own set of mazes for themselves in their group repo and whenever pelita is run with pelita --layout ./folder, it will draw a random layout from that folder. Then they can decide on their own naming scheme.

Potential breakage, though: There will be this one team who does this and generates only three mazes and then losing because they made some hard-coded assumptions about these mazes.

@Debilski
Copy link
Member

I start to like the pregen idea. If we had a command that does this automatically, we could tell the teams to use it on the first day or so:

> pelita-gen-layout --count 1000 ./layout_folder
Generating 1000 layouts in ./layout_folder. Re-run with --seed 1244546245
.............done.

And the layout_folder then contains ./layout_folder/0000.layout or something like that.

@otizonaizit
Copy link
Member

otizonaizit commented Mar 10, 2025

Really, I have never ever seen anyone being obsessed about replaying on the same maze. When this happened, it was always connected to reusing the very same seed, i.e. replaying the very same game. I have seen seeds saved in files and communicated through git. Never layout names. Even in the last TU course, a group had a collection of seeds to explore because of problems with the corresponding games. Having the same layout but a different seed seems to me a very exotic debugging configuration (which is of course still achievable, just not by using an id shortcut).

If there is a very particular maze where something absurd happens, then you reuse the seed and use --dump-layout to replay with different seeds, but again, I have a hard time imagining when this may be relevant.

@Debilski
Copy link
Member

I have seen seeds saved in files and communicated through git. Never layout names.

That’s my point, though. There is no need to write down a layout name or put it into git, they would just shout it over the table.

But I was just listing the differences that I see. If you think they can be neglected then I am fine with that.

@@ -202,7 +201,7 @@ def _add_wall(maze, ngaps, vertical, rng):
_add_wall(sub_maze, max(1, ngaps // 2), not vertical, rng=rng)


def maze_to_graph(maze):
def walls_to_graph(maze):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could make it a bit more explicit (in name, docs and/or type annotation) which functions take a numpy maze and which take a list of wall positions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, yes, definitely. This is at the moment a moving target, we are changing and adapting the code a lot. It is really a big WIP. I'll ask here for review as soon as the code has stabilized a bit. There are all sorts of horrors in it at the moment :-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought so. I wish Github had a feature that made these comments private until the review is ready. I wrote it so I won’t forget. :)

@jbdyn
Copy link
Author

jbdyn commented Mar 14, 2025

I checked the half-maze generation for the maze-based (i.e. numpy-based) create_half_maze and graph-based generate_half_maze functions.

Very nice: They produce the exact same result on the same seed. 🥳

check_seeds.py
from hashlib import sha256

from pelita.layout import layout_as_str
from pelita.maze_generator import (
    create_half_maze,
    empty_graph,
    empty_maze,
    generate_half_maze,
    maze_to_str,
)

RUNS = 1000
W = 32
H = 16
GAPS = H // 2
SEED = 1

for i in range(RUNS):
    seed = i
    graph, outer_walls = empty_graph(W, H)
    walls = generate_half_maze(graph, outer_walls, GAPS, rng=seed)

    # remove trailing newline `\n` character
    layout_graph = layout_as_str(walls=walls).strip()

    maze = empty_maze(H, W)
    create_half_maze(maze, GAPS, rng=seed)
    layout_maze = maze_to_str(maze)

    if layout_graph != layout_maze:
        print(sha256(layout_graph.encode()).hexdigest())
        print(layout_graph)
        print()
        print(sha256(layout_maze.encode()).hexdigest())
        print(layout_maze)
        raise RuntimeError("layouts are not the same")

Regarding the timings:

I ran with a 1000 runs, so don't get scared by the numbers. create_half_maze (0.329s) is 4 times faster than generate_half_maze (1.182s) without init and printing. With init and printing, the maze-based script (0.375s) is 10 times faster than the graph-based script (3.829s).

For a single run, both create_half_maze and generate_half_maze are well below the import times and were not captured by pyinstrument.

$ # with RUNS = 1
$ time python time_maze.py

real	0m0,236s
user	0m0,816s
sys	0m0,022s
$ # with RUNS = 1
$ time python time_graph.py

real	0m0,245s
user	0m0,877s
sys	0m0,025s
time_maze.py
from hashlib import sha256

from pelita.maze_generator import create_half_maze, empty_maze, maze_to_str

RUNS = 1000
H = 16
W = 32
GAPS = H // 2
SEED = 1

for i in range(RUNS):
    maze = empty_maze(H, W)
    create_half_maze(maze, GAPS, rng=SEED)
    layout = maze_to_str(maze)

print(sha256(layout.encode()).hexdigest())
print(layout)
profile time_maze.py (1000 runs)

  _     ._   __/__   _ _  _  _ _/_   Recorded: 10:17:13  Samples:  629
 /_//_/// /_\ / //_// / //_'/ //     Duration: 0.638     CPU time: 1.448
/   _/                      v5.0.1

Program: time_maze.py

0.638 <module>  time_maze.py:1
├─ 0.329 create_half_maze  maze_generator.py:99
│  ├─ 0.304 _add_wall  maze_generator.py:176
│  │  ├─ 0.280 _add_wall  maze_generator.py:176
│  │  │  ├─ 0.242 _add_wall  maze_generator.py:176
│  │  │  │  ├─ 0.198 _add_wall  maze_generator.py:176
│  │  │  │  │  ├─ 0.123 _add_wall  maze_generator.py:176
│  │  │  │  │  │  ├─ 0.053 _add_wall  maze_generator.py:176
│  │  │  │  │  │  │  ├─ 0.023 _add_wall  maze_generator.py:176
│  │  │  │  │  │  │  │  ├─ 0.012 Random.randint  random.py:336
│  │  │  │  │  │  │  │  │  └─ 0.009 Random.randrange  random.py:295
│  │  │  │  │  │  │  │  └─ 0.011 [self]  maze_generator.py
│  │  │  │  │  │  │  ├─ 0.011 Random.randint  random.py:336
│  │  │  │  │  │  │  │     [2 frames hidden]  random
│  │  │  │  │  │  │  ├─ 0.009 _add_wall_at  maze_generator.py:134
│  │  │  │  │  │  │  └─ 0.008 [self]  maze_generator.py
│  │  │  │  │  │  ├─ 0.031 _add_wall_at  maze_generator.py:134
│  │  │  │  │  │  │  ├─ 0.018 Random.shuffle  random.py:354
│  │  │  │  │  │  │  │     [2 frames hidden]  random
│  │  │  │  │  │  │  └─ 0.011 [self]  maze_generator.py
│  │  │  │  │  │  ├─ 0.025 Random.randint  random.py:336
│  │  │  │  │  │  │     [3 frames hidden]  random
│  │  │  │  │  │  └─ 0.014 [self]  maze_generator.py
│  │  │  │  │  ├─ 0.031 _add_wall_at  maze_generator.py:134
│  │  │  │  │  │  ├─ 0.017 Random.shuffle  random.py:354
│  │  │  │  │  │  │     [2 frames hidden]  random
│  │  │  │  │  │  └─ 0.010 [self]  maze_generator.py
│  │  │  │  │  ├─ 0.026 Random.randint  random.py:336
│  │  │  │  │  │     [3 frames hidden]  random
│  │  │  │  │  └─ 0.016 [self]  maze_generator.py
│  │  │  │  ├─ 0.030 _add_wall_at  maze_generator.py:134
│  │  │  │  │  ├─ 0.015 [self]  maze_generator.py
│  │  │  │  │  └─ 0.012 Random.shuffle  random.py:354
│  │  │  │  └─ 0.007 [self]  maze_generator.py
│  │  │  ├─ 0.022 _add_wall_at  maze_generator.py:134
│  │  │  │  └─ 0.014 Random.shuffle  random.py:354
│  │  │  │     └─ 0.009 Random._randbelow_with_getrandbits  random.py:245
│  │  │  └─ 0.009 Random.randint  random.py:336
│  │  │     └─ 0.009 Random.randrange  random.py:295
│  │  └─ 0.017 _add_wall_at  maze_generator.py:134
│  │     └─ 0.014 Random.shuffle  random.py:354
│  │        └─ 0.009 Random._randbelow_with_getrandbits  random.py:245
│  ├─ 0.009 default_rng  base_utils.py:3
│  │  └─ 0.007 Random.__init__  random.py:119
│  └─ 0.008 Random.shuffle  random.py:354
├─ 0.158 <module>  __init__.py:1
│  └─ 0.158 <module>  game.py:1
│     ├─ 0.096 <module>  team.py:1
│     │  └─ 0.096 <module>  networkx/__init__.py:1
│     │        [7 frames hidden]  networkx
│     ├─ 0.040 <module>  viewer.py:1
│     │  └─ 0.033 <module>  rich/console.py:1
│     │        [4 frames hidden]  rich
│     └─ 0.016 <module>  network.py:1
│        └─ 0.014 <module>  zmq/__init__.py:1
│              [4 frames hidden]  zmq, importlib
├─ 0.094 <module>  maze_generator.py:1
│  └─ 0.094 <module>  numpy/__init__.py:1
│        [23 frames hidden]  numpy, typing
└─ 0.046 maze_to_str  maze_generator.py:67
   └─ 0.044 maze_to_bytes  maze_generator.py:61
      └─ 0.040 bytes.join  <built-in>
time_graph.py
from hashlib import sha256

from pelita.layout import layout_as_str
from pelita.maze_generator import empty_graph, generate_half_maze

RUNS = 1000
W = 32
H = 16
GAPS = H // 2
SEED = 1

for i in range(RUNS):
    graph, outer_walls = empty_graph(W, H)
    walls = generate_half_maze(graph, outer_walls, GAPS, rng=SEED)

    # remove trailing newline `\n` character
    layout = layout_as_str(walls=walls).strip()

print(sha256(layout.encode()).hexdigest())
print(layout)
profile time_graph.py (1000 runs)

  _     ._   __/__   _ _  _  _ _/_   Recorded: 10:17:08  Samples:  3754
 /_//_/// /_\ / //_// / //_'/ //     Duration: 4.092     CPU time: 4.890
/   _/                      v5.0.1

Program: time_graph.py

4.091 <module>  time_graph.py:1
├─ 2.439 empty_graph  maze_generator.py:562
│  ├─ 2.348 argmap_grid_2d_graph_1  <class 'networkx.utils.decorators.argmap'> compilation 4:1
│  │     [11 frames hidden]  networkx, <class 'networkx, <built-in>
│  │        1.808 Graph.add_edges_from  networkx/classes/graph.py:975
│  │        └─ 1.218 [self]  networkx/classes/graph.py
│  └─ 0.079 get_ring  maze_generator.py:585
├─ 1.182 generate_half_maze  maze_generator.py:772
│  ├─ 1.026 generate_walls  maze_generator.py:822
│  │  ├─ 0.833 generate_walls  maze_generator.py:822
│  │  │  ├─ 0.730 generate_walls  maze_generator.py:822
│  │  │  │  ├─ 0.508 generate_walls  maze_generator.py:822
│  │  │  │  │  ├─ 0.346 generate_walls  maze_generator.py:822
│  │  │  │  │  │  ├─ 0.178 generate_wall_at  maze_generator.py:707
│  │  │  │  │  │  │  ├─ 0.070 transpose  maze_generator.py:613
│  │  │  │  │  │  │  └─ 0.062 [self]  maze_generator.py
│  │  │  │  │  │  └─ 0.116 generate_walls  maze_generator.py:822
│  │  │  │  │  │     └─ 0.061 generate_wall_at  maze_generator.py:707
│  │  │  │  │  └─ 0.108 generate_wall_at  maze_generator.py:707
│  │  │  │  └─ 0.173 generate_wall_at  maze_generator.py:707
│  │  │  │     ├─ 0.065 [self]  maze_generator.py
│  │  │  │     └─ 0.055 transpose  maze_generator.py:613
│  │  │  └─ 0.074 generate_wall_at  maze_generator.py:707
│  │  └─ 0.164 generate_wall_at  maze_generator.py:707
│  │     ├─ 0.079 [self]  maze_generator.py
│  │     └─ 0.060 transpose  maze_generator.py:613
│  └─ 0.087 generate_wall_at  maze_generator.py:707
├─ 0.208 layout_as_str  layout.py:270
│  ├─ 0.130 [self]  layout.py
│  └─ 0.071 StringIO.write  <built-in>
├─ 0.153 <module>  __init__.py:1
│  └─ 0.153 <module>  game.py:1
│     └─ 0.093 <module>  team.py:1
│        └─ 0.093 <module>  networkx/__init__.py:1
│           └─ 0.052 <module>  networkx/algorithms/__init__.py:1
└─ 0.093 <module>  maze_generator.py:1
   └─ 0.092 <module>  numpy/__init__.py:1
         [2 frames hidden]  numpy

@jbdyn
Copy link
Author

jbdyn commented Mar 14, 2025

Minor detail: generate_half_maze is actually expecting a list of nodes as its first argument, but in time_graph.py I passed a nx.Graph object. It seems to work either way, and profiling shows no different results.

@jbdyn
Copy link
Author

jbdyn commented Mar 14, 2025

We should rename generate_half_maze to generate_walls though and include flipping the walls.
The current generate_walls would perhaps be renamed to generate_inner_walls as well.

@jbdyn
Copy link
Author

jbdyn commented Mar 14, 2025

For the record, the algorithm generating the inner walls is actually called binary space partitioning.

@Debilski
Copy link
Member

Great work!

@otizonaizit
Copy link
Member

otizonaizit commented Mar 18, 2025

I have refactored the graph-based maze generation completely, adding comments everywhere to make it hopefully more understandable to the future reader. I have added also tests to verify that we can reproduce exactly the numpy-based generated mazes.

The profiling timings are promising (times are for 2000 mazes of normal size):

  • graph-based: 5.5s
  • numpy with dead ends (no layout parsing): 0.9s
  • numpy with dead ends (with layout parsing): 1.2s
  • numpy no dead ends (no layout parsing): 26.9s
  • numpy no dead ends (with layout parsing): 27.1s

So, yes, we are almost 5x slower than the numpy (with layout parsing) with a maze with dead ends. But the timing for the graph-based include the time spent to distribute the food with a fixed proportion in and out of dead-ends. If we wanted to do the same with the old numpy based maze, we would be facing the slower timings. In this sense we could say that we are 5x faster ;-)
Also, the fairer comparison would be numpy with dead ends + layout parsing vs only the maze generation without food distribution. In this case the graph-based takes 0.47s for 2000 mazes, so it is indeed twice faster than numpy.

And now the code is much more readable.

If we decide to drop numpy compatibility some parts can be refactored even further to make it even more readable. It doesn't work right now because refactoring would imply changing the usage of the random generator so that we wouldn't generate the same maze as numpy with the same seed.

@otizonaizit
Copy link
Member

It is still WIP, but it is now possible to start looking at it ;-)

@otizonaizit
Copy link
Member

Integration in pelita CLI is still missing completely.

@Debilski
Copy link
Member

I have refactored the graph-based maze generation completely, adding comments everywhere to make it hopefully more understandable to the future reader.

The inline comments are certainly helpful but as a future reader, I would suggest adding comments that explain the purpose of a function as well. :)

For example:

def generate_walls(partition, walls, ngaps, vertical, rng=None):

Why does it take walls as an argument? Isn’t this supposed to be creating these walls? (I think for what I think it does it should also have a different name that does not include ‘generate’. More like place_walls or partition_maze. But you decide, I am only 80% sure of what it does.)

Concerning future additions to the food algorithm: Right now the whole thing is (more or less) only concerned with the absolute number of chamber tiles, right? Will it become much more costly once we might decide to distinguish here between chambers, tunnels and possibly also the distance to an entrance?

@Debilski
Copy link
Member

Integration in pelita CLI is still missing completely.

If we do live-integration into Pelita, we might want to have different error types for input errors like odd number of tiles for x and ‘could not place enough food. please try again with a new seed’ (or do the latter one automatically).

@otizonaizit
Copy link
Member

I have refactored the graph-based maze generation completely, adding comments everywhere to make it hopefully more understandable to the future reader.

The inline comments are certainly helpful but as a future reader, I would suggest adding comments that explain the purpose of a function as well. :)

yes, yes, it's in my TODO.

For example:

def generate_walls(partition, walls, ngaps, vertical, rng=None):

Why does it take walls as an argument? Isn’t this supposed to be creating these walls? (I think for what I think it does it should also have a different name that does not include ‘generate’. More like place_walls or partition_maze. But you decide, I am only 80% sure of what it does.)

Agreed.

Concerning future additions to the food algorithm: Right now the whole thing is (more or less) only concerned with the absolute number of chamber tiles, right? Will it become much more costly once we might decide to distinguish here between chambers, tunnels and possibly also the distance to an entrance?

It will be a bit slower, but not dramatically so. The food distribution is super fast. Most of the time is spent in finding the biconnected components. If we go back to distinguish tunnels and chambers it will be maybe twice as slow. But still, we are talking about about 3s over 2000 mazes, so nothing that the user will notice while using pelita.

@otizonaizit
Copy link
Member

by working on only the left half of the maze for all graph operations I could cut the execution time by almost half, so now to generate 2000 mazes with detection of chambers and distribution of food according to configuration we are down to 3.5s.

Until here the whole thing is generating exactly the same mazes we were generating with numpy when using the same random seed, and I have added tests to verify that this is the case.

Now the question: if we allow for a divergence from numpy we could simplify the code even further (we are talking about 20 lines less), which would help the future reader a lot. The problem is that it may be tricky to verify that the changes are really only efficiency/cosmetics and not really breaking the algorithm. Would you be satisfied if I use as a measure the number of tiles in chambers @Debilski @jbdyn ?

I could generate 100_000 mazes with the old algorithm, 100_000 mazes with the modified one, and then count how many mazes have N tiles in chambers and check that the histograms are compatible? Or can you come up with a better metric that does not require to write huge amount of new code?

@otizonaizit
Copy link
Member

Or is it better to move this to a new PR?

@Debilski
Copy link
Member

I guess I’d say if it looks like a maze it does not matter if it is strictly compatible with the old version or not.

What is the difference to the numpy version, though?

@otizonaizit
Copy link
Member

I guess I’d say if it looks like a maze it does not matter if it is strictly compatible with the old version or not.

What is the difference to the numpy version, though?

What do you mean? By refactoring the code I will use the random generator in a different way, for example, instead of doing this:

    ngaps = max(1, ngaps)                                                        
    wall_pos = list(range(max_length))                                           
    rng.shuffle(wall_pos)                                                        
    gaps_pos = wall_pos[:ngaps]                                                  
    for gap in gaps_pos:                                                 
    if vertical:                                                             
        wall.discard((pos, ymin+gap))                                        
    else:                                                                    
        wall.discard((xmin+gap, pos))                                        

I will do something like this (untested):

    gaps = rng.sample(wall, k=max(1, ngaps))                                           
    for gap in gaps:                                                           
         wall.remove(gap)

which is nicer and more readable. Unfortunately the selected gap positions will be different in the two cases, because rng.shuffle and rng.sample consume the random number generator in a different way (I tested it).

@otizonaizit
Copy link
Member

    gaps = rng.sample(wall, k=max(1, ngaps))                                           
    for gap in gaps:                                                           
         wall.remove(gap)

I also have the hard-core version (untested):

    wall = wall.difference(rng.sample(wall, k=max(1, ngaps)))

which is maybe a little too terse ;-)

@Debilski
Copy link
Member

Maybe you could rewrite the numpy first to also use sample (on a list(range(len(...))), then regenerate all test mazes and then work again on the non-numpy version and see if this matches with the then-new mazes? Or are the versions also incompatible in the number of items in the list etc?

@jbdyn
Copy link
Author

jbdyn commented Mar 20, 2025

Would you be satisfied if I use as a measure the number of tiles in chambers @Debilski @jbdyn ?

I suspect that this measure won't be helpful. Since it is about deciding whether the mazes are satisfactory or not, I would have just eyeballed it. We already experienced that our intuition is quite good about a "good" - or better, a "bad" - maze. 😏
Here, we can be just artists and free to adapt the code to make the mazes look "satisfactory", regardless of the previous state.

Or is it better to move this to a new PR?

Just push it here. It is still about optimizing the maze generation. 🙂

Maybe you could rewrite the numpy first

I feel that this would be too much.

Personally, I don't see any difference between

rng.shuffle(wall)                                                        
gaps = wall[:ngaps]

and

gaps = rng.sample(wall, k=ngaps)

So, IMO just go for the rewrite. 👍
Having mazes from before to compare by eye with the new ones is nonetheless a good idea.

@jbdyn
Copy link
Author

jbdyn commented Mar 20, 2025

by working on only the left half of the maze for all graph operations I could cut the execution time by almost half, so now to generate 2000 mazes with detection of chambers and distribution of food according to configuration we are down to 3.5s.

Really cool. I like how this evolves. 😁

But, did you check this:

################
#              #
#          #   #
#      #####   #
#   #####      #
#   #          #
#              #
################

or cut in half:

########  ########
#                #
#            #   #
#      #  ####   #
#   ####  #      #
#   #...         #
#   ....         #
########  ########

Before, when we searched in this layout for chambers only on one half, it would wrongly detect the dotted region as chamber.

@jbdyn
Copy link
Author

jbdyn commented Mar 20, 2025

Personally, I don't see any difference between

rng.shuffle(wall)                                                        
gaps = wall[:ngaps]

and

gaps = rng.sample(wall, k=ngaps)

For reference, the corresponding routines from Pythons random module.
Let x be our population to be shuffled or sampled.

rng.shuffle

for i in reversed(range(1, len(x))):
    # pick an element in x[: i + 1] with which to exchange x[i]
    j = randbelow(i + 1)
    x[i], x[j] = x[j], x[i]

rng.sample

n = len(x)
result = [None] * k
pool = list(x)
for i in range(k):
    j = randbelow(n - i)
    result[i] = pool[j]
    pool[j] = pool[n - i - 1]  # move non-selected item into vacancy

I generated some walls:

shuffled

╶╴╶╴████╶╴██
██╶╴╶╴╶╴████
████╶╴██╶╴╶╴
╶╴██████╶╴╶╴
████╶╴╶╴██╶╴
╶╴╶╴╶╴██████
╶╴██╶╴██╶╴██
██╶╴╶╴╶╴████
██████╶╴╶╴╶╴
╶╴╶╴╶╴██████
████╶╴╶╴██╶╴
██████╶╴╶╴╶╴
████╶╴╶╴██╶╴
██╶╴╶╴╶╴████
╶╴╶╴██╶╴████
██╶╴████╶╴╶╴
██╶╴╶╴╶╴████
██╶╴╶╴████╶╴
██╶╴╶╴██╶╴██
╶╴██╶╴╶╴████
██╶╴██╶╴██╶╴
╶╴╶╴██████╶╴
╶╴╶╴██████╶╴
██╶╴╶╴╶╴████
╶╴╶╴████╶╴██
╶╴████╶╴╶╴██
╶╴██╶╴██╶╴██
╶╴████╶╴╶╴██
██╶╴██╶╴╶╴██
╶╴╶╴██╶╴████
╶╴██╶╴██╶╴██
╶╴████╶╴╶╴██
╶╴██╶╴████╶╴
██████╶╴╶╴╶╴
██╶╴╶╴╶╴████
██╶╴╶╴██╶╴██
██╶╴████╶╴╶╴
╶╴████╶╴██╶╴
██╶╴██╶╴██╶╴
██╶╴████╶╴╶╴
╶╴████╶╴╶╴██
████╶╴██╶╴╶╴
╶╴╶╴██╶╴████
██╶╴╶╴██╶╴██
██╶╴╶╴╶╴████
████╶╴██╶╴╶╴
╶╴╶╴████╶╴██
██╶╴████╶╴╶╴
╶╴╶╴██╶╴████
╶╴██╶╴╶╴████

sampled

██████╶╴╶╴╶╴
████╶╴╶╴╶╴██
██╶╴██╶╴╶╴██
████╶╴██╶╴╶╴
╶╴████╶╴╶╴██
██╶╴╶╴████╶╴
██╶╴██╶╴╶╴██
╶╴╶╴████╶╴██
██╶╴╶╴╶╴████
██╶╴╶╴████╶╴
╶╴╶╴██╶╴████
╶╴████╶╴╶╴██
╶╴██╶╴╶╴████
╶╴╶╴████╶╴██
██████╶╴╶╴╶╴
██╶╴██╶╴██╶╴
████╶╴╶╴╶╴██
██████╶╴╶╴╶╴
██╶╴╶╴██╶╴██
████╶╴██╶╴╶╴
╶╴████╶╴██╶╴
████╶╴╶╴██╶╴
╶╴╶╴██╶╴████
╶╴╶╴██████╶╴
██╶╴████╶╴╶╴
╶╴██╶╴╶╴████
╶╴╶╴██╶╴████
██╶╴╶╴╶╴████
████╶╴██╶╴╶╴
██╶╴╶╴████╶╴
██████╶╴╶╴╶╴
██╶╴╶╴████╶╴
████╶╴╶╴██╶╴
╶╴██╶╴██╶╴██
╶╴████╶╴██╶╴
████╶╴╶╴╶╴██
████╶╴╶╴╶╴██
██╶╴╶╴████╶╴
██╶╴████╶╴╶╴
██████╶╴╶╴╶╴
██╶╴██╶╴╶╴██
████╶╴╶╴╶╴██
╶╴██████╶╴╶╴
╶╴██╶╴╶╴████
████╶╴╶╴╶╴██
╶╴╶╴████╶╴██
╶╴████╶╴╶╴██
██╶╴╶╴╶╴████
████╶╴██╶╴╶╴
██╶╴╶╴████╶╴

sample_walls.py
from random import Random

NWALLS = 6
NGAPS = NWALLS // 2

rng = Random()


def get_walls(gaps):
    walls = ["██"] * NWALLS
    for gap in gaps:
        walls[gap] = "╶╴"
    return "".join(walls)


with open("shuffled.md", "w+") as fshuffle, open("sampled.md", "w+") as fsample:
    for i in range(50):
        shuffle_x = list(range(NWALLS))
        sample_x = list(range(NWALLS))

        rng.shuffle(shuffle_x)
        shuffled_gaps = shuffle_x[:NGAPS]
        sampled_gaps = rng.sample(sample_x, k=NGAPS)

        print(f"`{get_walls(shuffled_gaps)}`", file=fshuffle)
        print(f"`{get_walls(sampled_gaps)}`", file=fsample)

@otizonaizit
Copy link
Member

######## ########

#

# ####

#### #

#...

....

######## ########

I tested it this way: generate 1000 mazes with the code that works on full mazes, including distribution of food. Generate 1000 mazes with the code that works on half mazes. Verify that the food is distributed in exactly the same way. The problem we had does not happen anymore because I cut in half + 1 column of the right border. I think.
But I can test this layout and see if it works.

@otizonaizit
Copy link
Member

I tested it this way: generate 1000 mazes with the code that works on full mazes, including distribution of food. Generate 1000 mazes with the code that works on half mazes. Verify that the food is distributed in exactly the same way. The problem we had does not happen anymore because I cut in half + 1 column of the right border. I think. But I can test this layout and see if it works.

I tested it again. The way I implemented it, it always works because the right side of the maze has no walls except for the external ones. This way any wall that maybe located at the left border can be always walked around going to the right side. The example you posted correctly detects no chambers if you delete all the walls on the right side.
I added a comment in the code so that we don't forget this.

@jbdyn
Copy link
Author

jbdyn commented Mar 21, 2025

it always works because the right side of the maze has no walls except for the external ones

Ouh, that's brilliant! Super neat that this works now with half the effort. 🤩

@jbdyn
Copy link
Author

jbdyn commented Mar 22, 2025

It just hit me: shuffle and sample are indeed equivalent, and I can prove it:

This is the definition of the routine in rng.sample, as stated above.

n = len(x)
result = [None] * k
pool = list(x)
for i in range(k):
    j = randbelow(n - i)
    result[i] = pool[j]
    pool[j] = pool[n - i - 1]

Now lets rewrite this for k = n, so that we have one variable less to care about.

n = len(x)
result = [None] * n  # <--
pool = list(x)
for i in range(n):  # <--
    j = randbelow(n - i)
    result[i] = pool[j]
    pool[j] = pool[n - i - 1]

Next, we redefine i -> n - i - 1. That also means that the interval [0, n - 1) now becomes [n - 1, 0), or equivalently (n, 1]. So, i is now running from n - 1 to 1.

n = len(x)
result = [None] * n
pool = list(x)
for i in reversed(range(1, n)):  # <-- i in [1, n), i.e. reversed (n, 1]
    j = randbelow(n - (n - i - 1))  # <--
    result[n - i - 1] = pool[j]  # <--
    pool[j] = pool[n - (n - i - 1) - 1]  # <--

which becomes

n = len(x)
result = [None] * n
pool = list(x)
for i in reversed(range(1, n)):  # <-- i in [1, n), i.e. reversed (n, 1]
    j = randbelow(i + 1)  # <--
    result[n - i - 1] = pool[j]  # <--
    pool[j] = pool[i]  # <--

Now let's rearrange the code a bit:

n = len(x)
result = [None] * n
pool = list(x)
for i in reversed(range(1, n)):
    j = randbelow(i + 1)
    result[n - i - 1], pool[j] = pool[j], pool[i]  # <--

The reasoning why result[n - i - 1] is equivalent to result[i] is, that we just write the elements into result in reverse. The order does not matter.

n = len(x)
result = [None] * n
pool = list(x)
for i in reversed(range(1, n)):
    j = randbelow(i + 1)
    result[i], pool[j] = pool[j], pool[i]  # <--

Since we know by using randbelow that 0 <= j < i + 1, we also know that the elements result[i + 1] and pool[i + 1] will never be touched again in the loop steps afterwards (we are going from n to 1), so we can also just use x instead of result and pool:

n = len(x)
for i in reversed(range(1, n)):
    j = randbelow(i + 1)
    x[i], x[j] = x[j], x[i]  # <--

Et voilà, we have our definition of rng.shuffle, also as stated above.
The loops are doing the same thing, and since they do it for every step equivalently, it also holds when we assign n --> k with k <= n again.

Following my script, I wondered whether the sampled gaps are equal to the reverse of shuffled gaps when using the same seed:

SEED = 1
rng = Random(SEED)

rng.shuffle(wall)
shuffled_gaps = wall[:ngaps]
sampled_gaps = rng.sample(wall, k=ngaps)

sampled_gaps == shuffled_gaps[::-1]

but it seems to make a difference whether the code runs through [0, n - 1) or [n - 1, 0).
I suspect that the randbelow is the reason for that, since it is not symmetric to our index redefinition i --> n - i - 1. If we had an equivalent randabove, maybe it would work out.

But yeah @otizonaizit, I hope to have removed any of your doubts about rewriting the code from

ngaps = max(1, ngaps)                                                        
wall_pos = list(range(max_length))                                           
rng.shuffle(wall_pos)                                                        
gaps_pos = wall_pos[:ngaps]                                                  
for gap in gaps_pos:                                                 
if vertical:                                                             
    wall.discard((pos, ymin+gap))                                        
else:                                                                    
    wall.discard((xmin+gap, pos))                                        

to

gaps = rng.sample(wall, k=max(1, ngaps))                                           
for gap in gaps:                                                           
     wall.remove(gap)

The problem is that it may be tricky to verify that the changes are really only efficiency/cosmetics and not really breaking the algorithm.

I proved that it does not break the algorithm. 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants