diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..6ddd4b3 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "wave-function-collapse-ruby" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- ruby + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/bin/benchmark b/bin/benchmark index 260200c..48e5574 100755 --- a/bin/benchmark +++ b/bin/benchmark @@ -3,17 +3,13 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "benchmark" require "json" require "wave_function_collapse" -WIDTH = 20 -HEIGHT = 20 +TILE_PATH = File.expand_path("../assets/map.tsj", __dir__) -srand(WIDTH * HEIGHT) - -json = JSON.load_file!("assets/map.tsj") -tiles = +def build_tiles + json = JSON.load_file(TILE_PATH) json["wangsets"].last["wangtiles"].map do |tile| prob = json["tiles"]&.find { |t| t["id"] == tile["tileid"] }&.fetch("probability") WaveFunctionCollapse::Tile.new( @@ -22,33 +18,139 @@ tiles = probability: prob ) end -times = [] +end + +GC_STAT_KEYS = %i[total_allocated_objects malloc_increase_bytes minor_gc_count major_gc_count].freeze + +def gc_delta(before, after) + GC_STAT_KEYS.each_with_object({}) { |k, h| h[k] = after[k] - before[k] } +end + +def run_once(klass, tiles, w, h) + GC.start + gc_before = GC.stat + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + model = klass.new(tiles, w, h) + model.solve + iters = 1 + until model.complete? + model.iterate + iters += 1 + end + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + gc_after = GC.stat + [elapsed, iters, gc_delta(gc_before, gc_after)] +end + +def median(xs) + s = xs.sort + n = s.length + n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0 +end -puts RUBY_DESCRIPTION unless ENV["CI"] +tiles = build_tiles -times = 10.times.map { |i| - time = Benchmark.realtime { +if ENV["CI"] + # CI contract: 10 runs of 20x20 on the new Model, JSON output for the + # github-action-benchmark workflow. Do not change without updating the workflow. + WIDTH = 20 + HEIGHT = 20 + srand(WIDTH * HEIGHT) + times = 10.times.map { + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) model = WaveFunctionCollapse::Model.new(tiles, WIDTH, HEIGHT) - print "Run ##{i + 1}: Benchmark for Model(grid=#{model.width}x#{model.height} entropy=#{model.max_entropy})… " unless ENV["CI"] model.solve - until model.complete? - model.iterate - end + model.iterate until model.complete? + Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 } - puts "Finished in #{time.round(2)}s" unless ENV["CI"] - time -} - -average = times.sum / times.size -if ENV["CI"] puts JSON.dump([ - { name: "Average time", unit: "Seconds", value: average }, - { name: "Slowest time", unit: "Seconds", value: times.max }, - { name: "Fastest time", unit: "Seconds", value: times.min }, - { name: "P90", unit: "Seconds", value: times.sort[8] } + {name: "Average time", unit: "Seconds", value: times.sum / times.size}, + {name: "Slowest time", unit: "Seconds", value: times.max}, + {name: "Fastest time", unit: "Seconds", value: times.min}, + {name: "P90", unit: "Seconds", value: times.sort[8]} ]) + exit +end + +yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? "on" : "off" +zjit_status = defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled? ? "on" : "off" +puts RUBY_DESCRIPTION +puts "Tiles loaded: #{tiles.size}, YJIT=#{yjit_status}, ZJIT=#{zjit_status}" +puts + +# Accepts either "N" (treated as NxN) or "WxH" entries. +def parse_sizes(str) + str.split(",").map do |tok| + tok = tok.strip + if tok.include?("x") + w, h = tok.split("x", 2).map(&:to_i) + [w, h] + else + n = tok.to_i + [n, n] + end + end +end + +# SIZES overrides both lists. Otherwise each model uses its own *_SIZES var if +# set, or falls back to the other model's setting, or finally its own default. +shared = ENV["SIZES"] +legacy_env = ENV["LEGACY_SIZES"] +new_env = ENV["NEW_SIZES"] +legacy_sizes = parse_sizes(shared || legacy_env || new_env || "10,15,20,25,30") +new_sizes = parse_sizes(shared || new_env || legacy_env || "10,15,20,30,50,64x36,75,100") +runs = Integer(ENV["RUNS"] || "3") +skip_legacy = ARGV.include?("--no-legacy") +show_gc = ENV["GC_STATS"] + +# Header widths match the data printf below so columns align. +# Data: "%-8s %-9s %-7d %9.3fs %9.3fs %12.0f %10d" (+ GC: %12d %14d %8d %8d) +# %9.3fs renders as 10 chars (9 numeric + literal 's'). +if show_gc + printf("%-8s %-9s %-7s %10s %10s %12s %10s %12s %14s %8s %8s\n", + "Model", "Grid", "Cells", "Median", "Best", "Obs/sec", "Iters", + "Alloc/run", "Malloc B/run", "MinorGC", "MajorGC") + printf("%-8s %-9s %-7s %10s %10s %12s %10s %12s %14s %8s %8s\n", + "-----", "----", "-----", "------", "----", "-------", "-----", + "---------", "------------", "-------", "-------") else - puts "Average time: #{average}" - puts "Slowest time: #{times.max}" - puts "Fastest time: #{times.min}" + printf("%-8s %-9s %-7s %10s %10s %12s %10s\n", + "Model", "Grid", "Cells", "Median", "Best", "Obs/sec", "Iters") + printf("%-8s %-9s %-7s %10s %10s %12s %10s\n", + "-----", "----", "-----", "------", "----", "-------", "-----") +end + +[ + [WaveFunctionCollapse::LegacyModel, skip_legacy ? [] : legacy_sizes, "Legacy"], + [WaveFunctionCollapse::Model, new_sizes, "New"] +].each do |klass, sizes, label| + sizes.each do |(w, h)| + cells = w * h + grid = "#{w}x#{h}" + printf("%-8s %-9s %-7d ", label, grid, cells) + $stdout.flush + times = [] + iters_seen = 0 + gc_deltas = [] + runs.times do |r| + srand(cells * 1000 + r + 1) + elapsed, iters, gc = run_once(klass, tiles, w, h) + times << elapsed + iters_seen = iters + gc_deltas << gc + end + med = median(times) + best = times.min + if show_gc + avg = ->(k) { gc_deltas.sum { |g| g[k] } / gc_deltas.size } + printf("%9.3fs %9.3fs %12.0f %10d %12d %14d %8d %8d\n", + med, best, iters_seen / med, iters_seen, + avg.call(:total_allocated_objects), avg.call(:malloc_increase_bytes), + avg.call(:minor_gc_count), avg.call(:major_gc_count)) + else + printf("%9.3fs %9.3fs %12.0f %10d\n", + med, best, iters_seen / med, iters_seen) + end + end + puts end diff --git a/bin/memory_benchmark b/bin/memory_benchmark new file mode 100755 index 0000000..e4ea10b --- /dev/null +++ b/bin/memory_benchmark @@ -0,0 +1,113 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "json" +require "wave_function_collapse" + +TILE_PATH = File.expand_path("../assets/map.tsj", __dir__) + +def build_tiles + json = JSON.load_file(TILE_PATH) + json["wangsets"].last["wangtiles"].map do |tile| + prob = json["tiles"]&.find { |t| t["id"] == tile["tileid"] }&.fetch("probability") + WaveFunctionCollapse::Tile.new( + tileid: tile["tileid"], + wangid: tile["wangid"], + probability: prob + ) + end +end + +GC_KEYS = %i[total_allocated_objects malloc_increase_bytes minor_gc_count major_gc_count].freeze + +def measure + GC.start + before_gc = GC.stat + before_shape = RubyVM.stat[:next_shape_id] + yield + after_gc = GC.stat + after_shape = RubyVM.stat[:next_shape_id] + out = {new_shapes: after_shape - before_shape} + GC_KEYS.each { |k| out[k] = after_gc[k] - before_gc[k] } + out +end + +def median(xs) + s = xs.sort + n = s.length + n.odd? ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2.0 +end + +def p95(xs) + s = xs.sort + s[(s.length * 0.95).ceil - 1] || s.last +end + +WIDTH = Integer(ENV["WIDTH"] || 20) +HEIGHT = Integer(ENV["HEIGHT"] || 20) +RUNS = Integer(ENV["RUNS"] || 20) +STREAM_ROWS = Integer(ENV["STREAM_ROWS"] || 50) + +tiles = build_tiles +yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? "on" : "off" +puts RUBY_DESCRIPTION +puts "Tiles loaded: #{tiles.size}, YJIT=#{yjit_status}" +puts "Grid: #{WIDTH}x#{HEIGHT}, Runs: #{RUNS}, Stream rows: #{STREAM_ROWS}" +puts + +# Section 1: cold solves — full lifecycle per sample. +cold = RUNS.times.map do |i| + srand(WIDTH * 1000 + i + 1) + measure do + model = WaveFunctionCollapse::Model.new(tiles, WIDTH, HEIGHT) + model.solve + model.iterate until model.complete? + end +end + +puts "== Cold solves (Model.new + solve to completion) ==" +GC_KEYS.each do |k| + xs = cold.map { |s| s[k] } + printf(" %-26s median=%-12d p95=%-12d min=%-12d max=%-12d\n", + k.to_s, median(xs), p95(xs), xs.min, xs.max) +end +shape_xs = cold.map { |s| s[:new_shapes] } +printf(" %-26s median=%-12d p95=%-12d min=%-12d max=%-12d\n", + "new_shapes", median(shape_xs), p95(shape_xs), shape_xs.min, shape_xs.max) +puts + +# Section 2: streaming via prepend_empty_row on a single model. +srand(12345) +model = WaveFunctionCollapse::Model.new(tiles, WIDTH, HEIGHT) +model.solve +model.iterate until model.complete? + +stream = STREAM_ROWS.times.map do + measure do + model.prepend_empty_row + model.iterate until model.complete? + end +end + +puts "== Streaming (#{STREAM_ROWS} x prepend_empty_row + solve) ==" +GC_KEYS.each do |k| + xs = stream.map { |s| s[k] } + printf(" %-26s median=%-12d p95=%-12d min=%-12d max=%-12d\n", + k.to_s, median(xs), p95(xs), xs.min, xs.max) +end +shape_xs = stream.map { |s| s[:new_shapes] } +printf(" %-26s median=%-12d p95=%-12d min=%-12d max=%-12d\n", + "new_shapes", median(shape_xs), p95(shape_xs), shape_xs.min, shape_xs.max) +puts + +if ENV["JSON"] + payload = { + config: {width: WIDTH, height: HEIGHT, runs: RUNS, stream_rows: STREAM_ROWS}, + cold: cold, + stream: stream + } + File.write(ENV["JSON"], JSON.dump(payload)) + puts "Wrote raw samples to #{ENV["JSON"]}" +end diff --git a/bin/profile b/bin/profile new file mode 100755 index 0000000..ff4bfd6 --- /dev/null +++ b/bin/profile @@ -0,0 +1,40 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "json" +require "wave_function_collapse" +require "ruby-prof" + +WIDTH = 20 +HEIGHT = 20 + +srand(WIDTH * HEIGHT) + +json = JSON.load_file!("assets/map.tsj") +tiles = + json["wangsets"].last["wangtiles"].map do |tile| + prob = json["tiles"]&.find { |t| t["id"] == tile["tileid"] }&.fetch("probability") + WaveFunctionCollapse::Tile.new( + tileid: tile["tileid"], + wangid: tile["wangid"], + probability: prob + ) + end + +# Profile +profile = RubyProf::Profile.new +profile.start + +model = WaveFunctionCollapse::Model.new(tiles, WIDTH, HEIGHT) +model.solve +until model.complete? + model.iterate +end + +result = profile.stop + +# Print a flat profile to text +printer = RubyProf::FlatPrinter.new(result) +printer.print(STDOUT, min_percent: 1) diff --git a/bin/run b/bin/run index 404fb19..7f09d76 100755 --- a/bin/run +++ b/bin/run @@ -6,7 +6,4 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "bundler/setup" require "wave_function_collapse" -# Disable GC - -GC.disable WaveFunctionCollapse::Window.new.show diff --git a/lib/wave_function_collapse.rb b/lib/wave_function_collapse.rb index a121d59..ea989d5 100644 --- a/lib/wave_function_collapse.rb +++ b/lib/wave_function_collapse.rb @@ -3,7 +3,7 @@ module WaveFunctionCollapse class Error < StandardError; end - autoload :Cell, "wave_function_collapse/cell" + autoload :LegacyModel, "wave_function_collapse/legacy_model" autoload :Model, "wave_function_collapse/model" autoload :Tile, "wave_function_collapse/tile" autoload :Window, "wave_function_collapse/window" diff --git a/lib/wave_function_collapse/cell.rb b/lib/wave_function_collapse/cell.rb deleted file mode 100644 index 5dc6c4f..0000000 --- a/lib/wave_function_collapse/cell.rb +++ /dev/null @@ -1,52 +0,0 @@ -module WaveFunctionCollapse - class Cell < BasicObject - @@cellid = 0 - attr_reader :tiles, :cellid - attr_accessor :collapsed, :entropy, :x, :y - alias_method :collapsed?, :collapsed - - def initialize(x, y, tiles) - @cellid = @@cellid - @collapsed = tiles.size == 1 - @entropy = tiles.size - @tiles = tiles - @neighbors = {} - @x = x - @y = y - @@cellid = @@cellid.succ - end - - def ==(other) - @cellid == other.cellid - end - - def tiles=(new_tiles) - @tiles = new_tiles - update - end - - def update - @entropy = @tiles.size - @collapsed = @entropy == 1 - end - - def tile - @tiles[0] if @collapsed - end - - def collapse - self.tiles = [@tiles.max_by { |t| ::Kernel.rand**(1.0 / t.probability) }] - end - - def neighbors(model) - @neighbors[model.width * y + x] ||= begin - up = model.cell_at(@x, @y + 1) if @y < model.height - 1 - down = model.cell_at(@x, @y - 1) if @y.positive? - right = model.cell_at(@x + 1, @y) if @x < model.width - 1 - left = model.cell_at(@x - 1, @y) if @x.positive? - - {up: up, down: down, right: right, left: left} - end - end - end -end diff --git a/lib/wave_function_collapse/legacy_model.rb b/lib/wave_function_collapse/legacy_model.rb new file mode 100644 index 0000000..ec6a3e1 --- /dev/null +++ b/lib/wave_function_collapse/legacy_model.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +module WaveFunctionCollapse + # Snapshot of the original Model and Cell implementation, preserved verbatim + # so the benchmark can measure the speedup of the rewrite. Not used at runtime + # by the Window or the default `WaveFunctionCollapse::Model`. + class LegacyCell < BasicObject + @@cellid = 0 + attr_reader :tiles, :cellid + attr_accessor :collapsed, :entropy, :x, :y + alias_method :collapsed?, :collapsed + + def initialize(x, y, tiles) + @cellid = @@cellid + @collapsed = tiles.size == 1 + @entropy = tiles.size + @tiles = tiles + @neighbors = {} + @x = x + @y = y + @@cellid = @@cellid.succ + end + + def ==(other) + @cellid == other.cellid + end + + def tiles=(new_tiles) + @tiles = new_tiles + update + end + + def update + @entropy = @tiles.size + @collapsed = @entropy == 1 + end + + def tile + @tiles[0] if @collapsed + end + + def collapse + self.tiles = [@tiles.max_by { |t| ::Kernel.rand**(1.0 / t.probability) }] + end + + def neighbors(model) + @neighbors[model.width * y + x] ||= begin + up = model.cell_at(@x, @y + 1) if @y < model.height - 1 + down = model.cell_at(@x, @y - 1) if @y.positive? + right = model.cell_at(@x + 1, @y) if @x < model.width - 1 + left = model.cell_at(@x - 1, @y) if @x.positive? + + {up: up, down: down, right: right, left: left} + end + end + end + + class LegacyModel + DIRECTION_TO_INDEXES = { + up: [7, 0, 1], + right: [1, 2, 3], + down: [5, 4, 3], + left: [7, 6, 5] + }.freeze + + OPPOSITE_OF = { + up: :down, + right: :left, + down: :up, + left: :right + }.freeze + + attr_reader :tiles, :width, :height, :cells, :max_entropy + + def initialize(tiles, width, height) + @tiles = tiles + @width = width.to_i + @height = height.to_i + @cells = [] + @height.times { |y| @width.times { |x| @cells << LegacyCell.new(x, y, @tiles.shuffle) } } + @uncollapsed_cells = @cells.reject(&:collapsed) + @max_entropy = @tiles.length + end + + def cell_at(x, y) + @cells[@width * y + x] + end + + def complete? + @uncollapsed_cells.empty? + end + + def percent + ((@width * @height) - @uncollapsed_cells.length.to_f) / (@width * @height) * 100 + end + + def solve + cell = random_cell + process_cell(cell) + generate_grid + end + + def iterate + return false if @uncollapsed_cells.empty? + + next_cell = find_lowest_entropy + return false unless next_cell + + process_cell(next_cell) + generate_grid + end + + def prepend_empty_row + @cells = @cells.drop(@width) + @cells.each { |cell| cell.y -= 1 } + x = 0 + while x < @width + new_cell = LegacyCell.new(x, @height - 1, @tiles) + @cells << new_cell + @uncollapsed_cells << new_cell + x = x.succ + end + @width.times { |x| + evaluate_neighbor(cell_at(x, @height - 2), :up) + } + end + + def random_cell + @uncollapsed_cells.sample + end + + def generate_grid + x = 0 + result = [] + + while x < @width + rx = result[x] = [] + y = 0 + + while y < @height + rx[y] = cell_at(x, y).tile + y = y.succ + end + x = x.succ + end + + result + end + + def process_cell(cell) + cell.collapse + @uncollapsed_cells.delete(cell) + return if @uncollapsed_cells.empty? + + propagate(cell) + end + + def propagate(source_cell) + evaluate_neighbor(source_cell, :up) + evaluate_neighbor(source_cell, :right) + evaluate_neighbor(source_cell, :down) + evaluate_neighbor(source_cell, :left) + end + + def evaluate_neighbor(source_cell, evaluation_direction) + neighbor_cell = source_cell.neighbors(self)[evaluation_direction] || return + return if neighbor_cell.collapsed + + original_tile_count = neighbor_cell.tiles.length + opposite_direction = OPPOSITE_OF[evaluation_direction] + + valid_edges = {} + source_cell.tiles.each do |source_tile| + valid_edges[source_tile.__send__(evaluation_direction)] = true + end + + neighbor_tiles = neighbor_cell.tiles + new_tiles = [] + i = 0 + ntc = neighbor_tiles.length + while i < ntc + tile = neighbor_tiles[i] + new_tiles << tile if valid_edges[tile.__send__(opposite_direction)] + i = i.succ + end + + neighbor_cell.tiles = new_tiles unless new_tiles.empty? + @uncollapsed_cells.delete(neighbor_cell) if neighbor_cell.collapsed + + propagate(neighbor_cell) if neighbor_cell.tiles.length != original_tile_count + end + + def find_lowest_entropy + ucg = @uncollapsed_cells + i = 0 + l = ucg.length + min_e = ucg[0].entropy + acc = [] + while i < l + cc = ucg[i] + next i = i.succ if !cc + + ce = cc.entropy + if ce < min_e + min_e = ce + acc.clear + acc << i + elsif ce == min_e + acc << i + end + + i = i.succ + end + ucg[acc.sample] + end + end +end diff --git a/lib/wave_function_collapse/model.rb b/lib/wave_function_collapse/model.rb index a24b790..dbb7f10 100644 --- a/lib/wave_function_collapse/model.rb +++ b/lib/wave_function_collapse/model.rb @@ -1,166 +1,833 @@ +# frozen_string_literal: true + module WaveFunctionCollapse + # Wave Function Collapse — chunked-Fixnum wave + AC-4 compatible counter. + # + # Each cell's domain is split across `@chunk_count` parallel Fixnum arrays + # (`@wave_chunks[ch][c]`), each chunk holding up to `WAVE_CHUNK_BITS` tiles. + # Keeping every chunk a Fixnum lets the hot propagation loop AND a wave + # chunk with a precomputed `@propagator_chunks[d][t][ch]` mask and iterate + # the resulting set bits — no Bignum allocations on the inner path. + # Supporter counts live in a flat byte buffer (`@compatible`); when a count + # hits zero the tile is banned at that cell, which kicks off iterative + # propagation through an explicit stack. Entropy is maintained incrementally + # per cell. class Model - MAX_ITERATIONS = 5_000 + DX = [0, 1, 0, -1].freeze + DY = [1, 0, -1, 0].freeze + OPP = [2, 3, 0, 1].freeze - DIRECTION_TO_INDEXES = { - up: [7, 0, 1], - right: [1, 2, 3], - down: [5, 4, 3], - left: [7, 6, 5] - }.freeze + # Bits per wave chunk. MRI's Fixnum holds 62 unsigned bits before + # promoting to Bignum, so 62 keeps every wave/propagator chunk in + # tagged-integer land and every `&`/`^`/`& -m`/`bit_length` op cheap. + WAVE_CHUNK_BITS = 62 - OPPOSITE_OF = { - up: :down, - right: :left, - down: :up, - left: :right - }.freeze + # Upper bound on consecutive contradiction restarts before + # `observe_and_propagate` gives up. Solvable tilesets almost always + # succeed in one or two attempts; inherently-broken inputs would + # otherwise loop forever. + MAX_RESTARTS = 100 - attr_reader :tiles, :width, :height, :cells, :max_entropy + attr_reader :tiles, :width, :height, :max_entropy, :generation def initialize(tiles, width, height) @tiles = tiles @width = width.to_i @height = height.to_i - @cells = [] - @height.times { |y| @width.times { |x| @cells << Cell.new(x, y, @tiles.shuffle) } } - @uncollapsed_cells = @cells.reject(&:collapsed) - @max_entropy = @tiles.length - end + @num_tiles = tiles.length + @max_entropy = @num_tiles + @cells_count = @width * @height + @generation = 0 + @chunk_count = (@num_tiles + WAVE_CHUNK_BITS - 1) / WAVE_CHUNK_BITS - def cell_at(x, y) - @cells[@width * y + x] + build_propagator + build_initial_state + setup_wave_state + + # All long-lived precomputed data (propagator, neighbours, bit + # tables, compatible template + fill sentinel, weights) is frozen + # and lives for the model's lifetime. Compact once now so it + # settles into old gen and doesn't fragment the heap as solves + # churn young-gen objects. Skipped for tiny grids where compaction + # cost outweighs the win. + ::GC.compact if @cells_count >= 400 end def complete? - @uncollapsed_cells.empty? + @uncollapsed_count == 0 && !@contradiction end def percent - ((@width * @height) - @uncollapsed_cells.length.to_f) / (@width * @height) * 100 + (@cells_count - @uncollapsed_count).to_f / @cells_count * 100 + end + + def entropy_at(x, y) + @remaining[y * @width + x] + end + + # Tileset asset id (Integer) at (x, y), or nil if uncollapsed. Lighter + # than `tile_at` for hot draw paths that only need the asset id. + def tile_id_at(x, y) + t = @chosen_tile[y * @width + x] + t < 0 ? nil : @tiles[t].tileid end def solve - cell = random_cell - process_cell(cell) - generate_grid + observe_and_propagate + true end def iterate - return false if @uncollapsed_cells.empty? - - next_cell = find_lowest_entropy - return false unless next_cell + return false if complete? + observe_and_propagate + true + end - process_cell(next_cell) + # Returns a 2-D array indexed [x][y] of tiles (nil for uncollapsed cells). + # Called on demand by the renderer; not by iterate/solve. + def grid generate_grid end def prepend_empty_row - @cells = @cells.drop(@width) - @cells.each { |cell| cell.y -= 1 } - x = 0 - while x < @width - new_cell = Cell.new(x, @height - 1, @tiles) - @cells << new_cell - @uncollapsed_cells << new_cell - x = x.succ + w = @width + n = @cells_count + shift_count = n - w + + # Don't carry a leftover flag from an earlier failed run into the + # new pass — and reset the propagation stacks too, since they may + # still hold entries from a contradiction that returned early. + @contradiction = false + @prop_cells.clear + @prop_tiles.clear + + # Shift state down in place: drop bottom row (cells 0..w-1), fill + # the new top row with default values. Copying low-to-high is safe + # because each source index (i + w) is greater than its destination. + ch = 0 + while ch < @chunk_count + shift_uniform!(@wave_chunks[ch], shift_count, @full_chunk_masks[ch]) + ch += 1 end - @width.times { |x| - evaluate_neighbor(cell_at(x, @height - 2), :up) - } - end + shift_uniform!(@remaining, shift_count, @num_tiles) + shift_uniform!(@sum_w, shift_count, @initial_sum_w) + shift_uniform!(@sum_w_log_w, shift_count, @initial_sum_w_log_w) + # When the tileset has a single tile every cell is born collapsed, + # so the new row's chosen_tile must point at tile 0 rather than the + # generic "uncollapsed" sentinel — mirroring the t_max==1 branch in + # setup_wave_state. Without this, complete? returns true (because + # remaining[c] == 1) while grid returns nil for the whole new row. + shift_uniform!(@chosen_tile, shift_count, (@num_tiles == 1) ? 0 : -1) - def random_cell - @uncollapsed_cells.sample + noise = @noise + entropy_noise = @entropy_noise + initial_entropy = @initial_entropy + i = 0 + # Carry both the noise and the merged `entropy + noise` value down + # so existing rows keep their evolved entropies, then mint fresh + # noise (and corresponding initial entropy_noise) for the new top + # rows. + while i < shift_count + noise[i] = noise[i + w] + entropy_noise[i] = entropy_noise[i + w] + i += 1 + end + while i < n + nz = ::Kernel.rand * 1e-6 + noise[i] = nz + entropy_noise[i] = initial_entropy + nz + i += 1 + end + + @uncollapsed_count = 0 + c = 0 + while c < n + @uncollapsed_count += 1 if @remaining[c] > 1 + c += 1 + end + + rebuild_compatible_from_wave + orphan_ban_pass + propagate + + if @contradiction + # The new row can't be reconciled with the row below it. The + # wave is now half-mutated — if we returned anyway, the next + # `iterate` would observe `@contradiction == true`, call + # `setup_wave_state`, and silently wipe every streamed row. + # Reset to a clean blank state and tell the caller the prepend + # failed so it can decide what to do. + setup_wave_state + return false + end + @generation += 1 + true end + # Returns a 2-D array indexed [x][y] of tiles, or nil for uncollapsed cells. def generate_grid + result = ::Array.new(@width) x = 0 - result = [] - while x < @width - rx = result[x] = [] + col = result[x] = ::Array.new(@height) y = 0 - while y < @height - rx[y] = cell_at(x, y).tile - y = y.succ + col[y] = tile_at(x, y) + y += 1 end - x = x.succ + x += 1 end - result end - def process_cell(cell) - cell.collapse - @uncollapsed_cells.delete(cell) - return if @uncollapsed_cells.empty? + private + + # ---- one-time precomputation ------------------------------------------------ + + def build_propagator + tiles = @tiles + t_max = @num_tiles + chunk_count = @chunk_count + + # Canonical integer ID per unique edge signature (Array of 3 ints). + edge_id = {} + ups = ::Array.new(t_max) + rights = ::Array.new(t_max) + downs = ::Array.new(t_max) + lefts = ::Array.new(t_max) + t = 0 + while t < t_max + tile = tiles[t] + ups[t] = (edge_id[tile.up] ||= edge_id.size) + rights[t] = (edge_id[tile.right] ||= edge_id.size) + downs[t] = (edge_id[tile.down] ||= edge_id.size) + lefts[t] = (edge_id[tile.left] ||= edge_id.size) + t += 1 + end + + # Edge per (tile, direction). Index by direction id: 0=up,1=right,2=down,3=left. + edges_per_dir = [ups, rights, downs, lefts] + + # propagator_chunks[d][a][ch] = Fixnum mask of tiles in chunk `ch` + # such that match(a, d, b) — i.e. tile a's edge in dir d equals + # tile b's edge in opposite(d). Also keep `propagator_counts[d][a]` + # = popcount of all chunks (initial supporter count for an interior + # cell). Bignum `propagator[d][a]` is built once for + # `rebuild_compatible_from_wave` (cold path) only. + propagator = ::Array.new(4) { ::Array.new(t_max, 0) } + propagator_chunks = ::Array.new(4) { ::Array.new(t_max) { ::Array.new(chunk_count, 0) } } + propagator_counts = ::Array.new(4) { ::Array.new(t_max, 0) } + + d = 0 + while d < 4 + opp_d = OPP[d] + my_edges = edges_per_dir[d] + opp_edges = edges_per_dir[opp_d] + a = 0 + while a < t_max + my_edge = my_edges[a] + mask = 0 + count = 0 + chunks = propagator_chunks[d][a] + b = 0 + while b < t_max + if opp_edges[b] == my_edge + mask |= (1 << b) + ch = b / WAVE_CHUNK_BITS + chunks[ch] |= (1 << (b - ch * WAVE_CHUNK_BITS)) + count += 1 + end + b += 1 + end + propagator[d][a] = mask + propagator_counts[d][a] = count + chunks.freeze + a += 1 + end + propagator[d].freeze + propagator_chunks[d].each(&:freeze) + propagator_chunks[d].freeze + propagator_counts[d].freeze + d += 1 + end + + @propagator = propagator.freeze + @propagator_chunks = propagator_chunks.freeze + @propagator_counts = propagator_counts.freeze + + # Supporter counts live in a byte buffer (`@compatible`), so any + # propagator count above 255 would silently wrap modulo 256 at + # build time and corrupt the AC-4 invariants — `complete?` can + # even start returning true while the wave is actually contradicted. + # Reject these tilesets up front rather than producing wrong output. + d = 0 + while d < 4 + a = 0 + while a < t_max + if propagator_counts[d][a] > 255 + ::Kernel.raise( + ::WaveFunctionCollapse::Error, + "tile #{a} has #{propagator_counts[d][a]} compatible " \ + "neighbours in direction #{d}; the byte-packed supporter " \ + "counter only fits 0..255" + ) + end + a += 1 + end + d += 1 + end + + # Weights + weights = ::Array.new(t_max) + weights_log_weights = ::Array.new(t_max) + sum_w = 0.0 + sum_w_log_w = 0.0 + t = 0 + while t < t_max + w = tiles[t].probability.to_f + weights[t] = w + # `w * Math.log(w)` is NaN for w == 0 (since 0 * -Infinity = NaN), + # which would then propagate into every cell's entropy and make + # `find_lowest_entropy_cell` return nothing forever. The limit + # lim_{w→0} w*log(w) is 0, so use that. + wlogw = (w == 0.0) ? 0.0 : w * ::Math.log(w) + weights_log_weights[t] = wlogw + sum_w += w + sum_w_log_w += wlogw + t += 1 + end + @weights = weights.freeze + @weights_log_weights = weights_log_weights.freeze + @initial_sum_w = sum_w + @initial_sum_w_log_w = sum_w_log_w + @initial_entropy = ::Math.log(sum_w) - sum_w_log_w / sum_w + + # Per-tile chunk index + Fixnum bit-within-chunk mask. Used by `ban` + # and `observe`, where iteration is by absolute tile index. + @chunk_of = ::Array.new(t_max) { |i| i / WAVE_CHUNK_BITS }.freeze + @bit_in_chunk = ::Array.new(t_max) { |i| 1 << (i - (i / WAVE_CHUNK_BITS) * WAVE_CHUNK_BITS) }.freeze + + # Full-domain mask per chunk. The last chunk only has `t_max % + # WAVE_CHUNK_BITS` tiles; everything else is 62 bits. + @full_chunk_masks = ::Array.new(chunk_count) { |ch| + bits = t_max - ch * WAVE_CHUNK_BITS + bits = WAVE_CHUNK_BITS if bits > WAVE_CHUNK_BITS + (1 << bits) - 1 + }.freeze - propagate(cell) end - def propagate(source_cell) - evaluate_neighbor(source_cell, :up) - evaluate_neighbor(source_cell, :right) - evaluate_neighbor(source_cell, :down) - evaluate_neighbor(source_cell, :left) + def build_initial_state + n = @cells_count + # Stack buffers reused across propagations. + @prop_cells = [] + @prop_tiles = [] + # Pre-allocate per-cell state arrays once; setup_wave_state resets + # them in place via Array#fill (no per-restart allocations). + @wave_chunks = ::Array.new(@chunk_count) { ::Array.new(n) } + @remaining = ::Array.new(n) + @sum_w = ::Array.new(n) + @sum_w_log_w = ::Array.new(n) + # `entropy + noise` for find_lowest_entropy_cell. Maintained + # eagerly on every ban so the lowest-entropy scan reads a single + # array. Noise (jitter for tie-breaking) is baked in once at setup + # and stays constant per cell for the run, so the addition only + # has to happen when the entropy itself changes. + @entropy_noise = ::Array.new(n) + @noise = ::Array.new(n) + @chosen_tile = ::Array.new(n) + build_neighbours + build_initial_compatible_template + # Supporter-count storage. Split per direction so the inner + # propagation loop can index by a flat `(c * t_max + t)` integer + # (no `<<2`, no `+ opp_d`). Each is pre-allocated and reset in + # place via Array#replace on every restart — no per-restart + # allocations. + @compatible_per_dir = ::Array.new(4) { ::Array.new(@cells_count * @num_tiles) } + # Same arrays in OPP order so the propagation loop can do a single + # array lookup per direction (`compatible_per_opp[d]`) rather than + # `compatible_per_dir[OPP[d]]` — two Array#[] reads per direction. + @compatible_per_opp = OPP.map { |opp_d| @compatible_per_dir[opp_d] }.freeze end - def evaluate_neighbor(source_cell, evaluation_direction) - neighbor_cell = source_cell.neighbors(self)[evaluation_direction] || return - return if neighbor_cell.collapsed + # Precompute the neighbour cell index for every (cell, direction) pair, + # stored flat at `@neighbours[c * 4 + d]`. Missing neighbours (off-grid) + # are encoded as -1. Replaces per-iteration `c % w`, `c / w`, bounds + # checks, and `ny * w + nx` in every hot loop that walks neighbours. + def build_neighbours + n = @cells_count + w = @width + h = @height + neighbours = ::Array.new(n * 4) + c = 0 + while c < n + cx = c % w + cy = c / w + d = 0 + while d < 4 + nx = cx + DX[d] + ny = cy + DY[d] + neighbours[c * 4 + d] = if nx >= 0 && nx < w && ny >= 0 && ny < h + ny * w + nx + else + -1 + end + d += 1 + end + c += 1 + end + @neighbours = neighbours.freeze + end - original_tile_count = neighbor_cell.tiles.length - opposite_direction = OPPOSITE_OF[evaluation_direction] + # ---- per-run state (resettable on contradiction/restart) --------------------- - # Build set of valid edges from source cell - valid_edges = {} - source_cell.tiles.each do |source_tile| - valid_edges[source_tile.__send__(evaluation_direction)] = true - end + def setup_wave_state + n = @cells_count + t_max = @num_tiles - # Filter neighbor tiles that have matching edges - neighbor_tiles = neighbor_cell.tiles - new_tiles = [] + # Reset per-cell state in place — buffers are pre-allocated in + # build_initial_state, so contradiction restarts don't churn the GC. + ch = 0 + while ch < @chunk_count + @wave_chunks[ch].fill(@full_chunk_masks[ch]) + ch += 1 + end + @remaining.fill(t_max) + @sum_w.fill(@initial_sum_w) + @sum_w_log_w.fill(@initial_sum_w_log_w) + @chosen_tile.fill(-1) + noise = @noise + entropy_noise = @entropy_noise + initial_entropy = @initial_entropy i = 0 - ntc = neighbor_tiles.length - while i < ntc - tile = neighbor_tiles[i] - new_tiles << tile if valid_edges[tile.__send__(opposite_direction)] - i = i.succ + while i < n + nz = ::Kernel.rand * 1e-6 + noise[i] = nz + entropy_noise[i] = initial_entropy + nz + i += 1 + end + # When the tileset has a single tile every cell is born collapsed, + # so `complete?` must report true immediately. Fill must come after + # the generic `-1` fill above so we overwrite, not the other way. + if t_max == 1 + @chosen_tile.fill(0) + @uncollapsed_count = 0 + else + @uncollapsed_count = n + end + @contradiction = false + @prop_cells.clear + @prop_tiles.clear + + d = 0 + while d < 4 + @compatible_per_dir[d].replace(@initial_compatible_per_dir[d]) + d += 1 end + orphan_ban_pass + propagate + @generation += 1 + end - neighbor_cell.tiles = new_tiles unless new_tiles.empty? - @uncollapsed_cells.delete(neighbor_cell) if neighbor_cell.collapsed + # The initial supporter-count arrays are fully determined by the tileset + # and grid dimensions, so build them once and `replace` per run instead + # of repeating the border-patch pass on every contradiction restart. + # One Array per direction; index by `c * t_max + t`. + def build_initial_compatible_template + n = @cells_count + t_max = @num_tiles + neighbours = @neighbours + propagator_counts = @propagator_counts - # if the number of tiles changed, we need to evaluate current cell's neighbors now - propagate(neighbor_cell) if neighbor_cell.tiles.length != original_tile_count + @initial_compatible_per_dir = ::Array.new(4) do |d| + counts = propagator_counts[d] + arr = ::Array.new(n * t_max) + c = 0 + while c < n + base = c * t_max + if neighbours[c * 4 + d] < 0 + # Missing neighbour in this direction: sentinel 255 so + # `orphan_ban_pass` can't see a zero and incorrectly ban + # tiles at the boundary. + t = 0 + while t < t_max + arr[base + t] = 255 + t += 1 + end + else + t = 0 + while t < t_max + arr[base + t] = counts[t] + t += 1 + end + end + c += 1 + end + arr.freeze + end + @initial_compatible_per_dir.freeze + # Sentinel 255-filled array reused by rebuild_compatible_from_wave + # to clear arrays in place without allocating an intermediate. + @compatible_fill = ::Array.new(n * t_max, 255).freeze end - def find_lowest_entropy - ucg = @uncollapsed_cells - i = 0 - l = ucg.length - min_e = ucg[0].entropy - acc = [] - while i < l - cc = ucg[i] - next i = i.succ if !cc - - ce = cc.entropy - if ce < min_e - min_e = ce - acc.clear - acc << i - elsif ce == min_e - acc << i + def rebuild_compatible_from_wave + n = @cells_count + t_max = @num_tiles + neighbours = @neighbours + propagator_chunks = @propagator_chunks + wave_chunks = @wave_chunks + chunk_count = @chunk_count + compatible_per_dir = @compatible_per_dir + + d = 0 + while d < 4 + compatible_per_dir[d].replace(@compatible_fill) + d += 1 + end + + c = 0 + while c < n + d = 0 + while d < 4 + nc = neighbours[c * 4 + d] + if nc >= 0 + compat_d = compatible_per_dir[d] + base_c = c * t_max + t = 0 + while t < t_max + cnt = 0 + ch = 0 + while ch < chunk_count + cnt += popcount(propagator_chunks[d][t][ch] & wave_chunks[ch][nc]) + ch += 1 + end + cnt = 255 if cnt > 255 + compat_d[base_c + t] = cnt + t += 1 + end + end + d += 1 end + c += 1 + end + end + + def orphan_ban_pass + n = @cells_count + t_max = @num_tiles + compatible_per_dir = @compatible_per_dir + compat_d0 = compatible_per_dir[0] + compat_d1 = compatible_per_dir[1] + compat_d2 = compatible_per_dir[2] + compat_d3 = compatible_per_dir[3] + wave_chunks = @wave_chunks + chunk_count = @chunk_count + + c = 0 + while c < n + base_c = c * t_max + ch = 0 + while ch < chunk_count + v = wave_chunks[ch][c] + tile_offset = ch * WAVE_CHUNK_BITS + while v != 0 + lowest = v & -v + t = tile_offset + lowest.bit_length - 1 + base = base_c + t + if compat_d0[base] == 0 || + compat_d1[base] == 0 || + compat_d2[base] == 0 || + compat_d3[base] == 0 + ban(c, t) + end + v ^= lowest + end + ch += 1 + end + c += 1 + end + end + + # ---- core observe / ban / propagate ----------------------------------------- + + def observe_and_propagate + restarts = 0 + loop do + c = find_lowest_entropy_cell + return false unless c + + observe(c) + propagate + + if @contradiction + restarts += 1 + if restarts > MAX_RESTARTS + ::Kernel.raise( + ::WaveFunctionCollapse::Error, + "exceeded #{MAX_RESTARTS} consecutive contradiction " \ + "restarts; the tileset may be inherently unsolvable on " \ + "this grid" + ) + end + # Restart: rebuild wave state and try again. + setup_wave_state + next + end + @generation += 1 + return true + end + end + + def find_lowest_entropy_cell + n = @cells_count + remaining = @remaining + entropy_noise = @entropy_noise + best_c = -1 + best_e = ::Float::INFINITY + c = 0 + while c < n + if remaining[c] > 1 + e = entropy_noise[c] + if e < best_e + best_e = e + best_c = c + end + end + c += 1 + end + best_c < 0 ? nil : best_c + end + + def observe(c) + total = @sum_w[c] + r = ::Kernel.rand * total + + weights = @weights + wave_chunks = @wave_chunks + chunk_count = @chunk_count + + chosen = -1 + ch = 0 + while ch < chunk_count + v = wave_chunks[ch][c] + tile_offset = ch * WAVE_CHUNK_BITS + while v != 0 + lowest = v & -v + t = tile_offset + lowest.bit_length - 1 + r -= weights[t] + if r <= 0 + chosen = t + break + end + v ^= lowest + end + break if chosen >= 0 + ch += 1 + end + + if chosen < 0 + # Floating-point edge: pick the highest set bit across chunks. + ch = chunk_count - 1 + while ch >= 0 + v = wave_chunks[ch][c] + if v != 0 + chosen = ch * WAVE_CHUNK_BITS + v.bit_length - 1 + break + end + ch -= 1 + end + end + + # Ban every other tile at this cell. Snapshot chunks before iterating + # because `ban` mutates them in place — we want to ban every tile that + # was alive *before* this observation, not the shrinking set. + ch = 0 + while ch < chunk_count + v = wave_chunks[ch][c] + tile_offset = ch * WAVE_CHUNK_BITS + while v != 0 + lowest = v & -v + t = tile_offset + lowest.bit_length - 1 + if t != chosen + ban(c, t) + return if @contradiction + end + v ^= lowest + end + ch += 1 + end + end + + def ban(c, t) + ch = @chunk_of[t] + b = @bit_in_chunk[t] + wave_ch = @wave_chunks[ch] + v = wave_ch[c] + return if (v & b) == 0 + + v ^= b + wave_ch[c] = v + @remaining[c] -= 1 + + w = @weights[t] + wlogw = @weights_log_weights[t] + @sum_w[c] -= w + @sum_w_log_w[c] -= wlogw + + r = @remaining[c] + if r == 0 + @contradiction = true + return + end + + s = @sum_w[c] + @entropy_noise[c] = ::Math.log(s) - @sum_w_log_w[c] / s + @noise[c] + + if r == 1 + @uncollapsed_count -= 1 + @chosen_tile[c] = find_single_tile(c) + end + + @prop_cells.push(c) + @prop_tiles.push(t) + end + + # Locate the single remaining tile across `@wave_chunks` for cell `c`. + # Only called when `@remaining[c]` just dropped to 1, so exactly one + # chunk has a non-zero entry with a single set bit. + def find_single_tile(c) + ch = @chunk_count - 1 + while ch >= 0 + v = @wave_chunks[ch][c] + return ch * WAVE_CHUNK_BITS + v.bit_length - 1 if v != 0 + ch -= 1 + end + -1 + end + + def propagate + prop_cells = @prop_cells + prop_tiles = @prop_tiles + propagator_chunks = @propagator_chunks + compatible_per_opp = @compatible_per_opp + wave_chunks = @wave_chunks + neighbours = @neighbours + t_max = @num_tiles + remaining = @remaining + sum_w = @sum_w + sum_w_log_w = @sum_w_log_w + entropy_noise = @entropy_noise + noise = @noise + weights = @weights + weights_log_weights = @weights_log_weights + chosen_tile = @chosen_tile + chunk_count = @chunk_count + chunk_bits = WAVE_CHUNK_BITS + + until prop_cells.empty? + return if @contradiction + t = prop_tiles.pop + c = prop_cells.pop + c4 = c * 4 + + d = 0 + while d < 4 + nc = neighbours[c4 + d] + if nc >= 0 + # Single Array indexed by `nc * t_max + tp` for this + # direction's supporter counts. Avoids the `<<2` + `+opp_d` + # arithmetic the interleaved-byte-buffer layout required. + compat = compatible_per_opp[d] + nc_base = nc * t_max + prop_dt = propagator_chunks[d][t] + + ch = 0 + while ch < chunk_count + prop_mask = prop_dt[ch] + if prop_mask != 0 + wave_ch = wave_chunks[ch] + wnc = wave_ch[nc] + # Intersection: tiles that are both still alive at the + # neighbour and compatible with originating tile t in + # direction d. Iterating set bits of `m` walks exactly + # the tiles that need a supporter-count decrement. + m = wnc & prop_mask + tile_offset = ch * chunk_bits + nc_base_ch = nc_base + tile_offset + while m != 0 + lowest = m & -m + bit_pos = lowest.bit_length - 1 + idx = nc_base_ch + bit_pos + count = compat[idx] - 1 + compat[idx] = count + if count == 0 + # Inlined fast-path of `ban(nc, tp)`. We know the bit + # is set in this chunk (from the intersection), so + # subtracting the single-bit `lowest` flips it off. + wnc -= lowest + wave_ch[nc] = wnc + tp = tile_offset + bit_pos + new_remaining = remaining[nc] - 1 + remaining[nc] = new_remaining + sum_w[nc] -= weights[tp] + sum_w_log_w[nc] -= weights_log_weights[tp] + if new_remaining == 0 + @contradiction = true + return + end + s = sum_w[nc] + entropy_noise[nc] = ::Math.log(s) - sum_w_log_w[nc] / s + noise[nc] + if new_remaining == 1 + @uncollapsed_count -= 1 + chosen_tile[nc] = find_single_tile(nc) + end + prop_cells.push(nc) + prop_tiles.push(tp) + end + # `m -= lowest` clears the lowest set bit (the bit we + # just processed) without touching the others — same + # effect as `m ^= lowest` but consistently faster in + # the interpreter on this branch. + m -= lowest + end + end + ch += 1 + end + end + d += 1 + end + end + end + + # ---- helpers ---------------------------------------------------------------- + + def tile_at(x, y) + t = @chosen_tile[y * @width + x] + t < 0 ? nil : @tiles[t] + end + + def shift_uniform!(arr, shift_count, fill_value) + w = @width + i = 0 + while i < shift_count + arr[i] = arr[i + w] + i += 1 + end + n = arr.length + while i < n + arr[i] = fill_value + i += 1 + end + end - i = i.succ + def popcount(x) + c = 0 + while x > 0 + c += 1 if (x & 1) != 0 + x >>= 1 end - ucg[acc.sample] + c end end end diff --git a/lib/wave_function_collapse/tile.rb b/lib/wave_function_collapse/tile.rb index a50d6c4..736e918 100644 --- a/lib/wave_function_collapse/tile.rb +++ b/lib/wave_function_collapse/tile.rb @@ -1,14 +1,26 @@ +# frozen_string_literal: true + module WaveFunctionCollapse - class Tile < BasicObject + class Tile attr_reader :tileid, :probability, :up, :right, :down, :left + # Tilesets typically share edge signatures across many tiles, so intern + # the 3-element edge arrays in a class-level cache keyed by the triple + # itself. Collapses 4-per-tile array allocations to one per unique + # signature. Array#hash is value-based, so consumers that key off these + # arrays (build_propagator's edge_id dedup) keep working unchanged. + def self.intern_edge(a, b, c) + key = [a, b, c] + (@edges ||= {})[key] ||= key.freeze + end + def initialize(tileid:, wangid:, probability: 1.0) @tileid = tileid @probability = probability || 1.0 - @up = wangid.values_at(7, 0, 1).hash - @right = wangid.values_at(1, 2, 3).hash - @down = wangid.values_at(5, 4, 3).hash - @left = wangid.values_at(7, 6, 5).hash + @up = Tile.intern_edge(wangid[7], wangid[0], wangid[1]) + @right = Tile.intern_edge(wangid[1], wangid[2], wangid[3]) + @down = Tile.intern_edge(wangid[5], wangid[4], wangid[3]) + @left = Tile.intern_edge(wangid[7], wangid[6], wangid[5]) end end end diff --git a/lib/wave_function_collapse/window.rb b/lib/wave_function_collapse/window.rb index d09166a..27c9437 100644 --- a/lib/wave_function_collapse/window.rb +++ b/lib/wave_function_collapse/window.rb @@ -5,8 +5,29 @@ module WaveFunctionCollapse class Window < Gosu::Window WIDTH = 1280 HEIGHT = 720 + # Rolling window for per-iteration timing stats. Bounded so that + # sorting for P90/P99 each frame stays O(TIMES_CAPACITY log N) instead + # of drifting up with run length. + TIMES_CAPACITY = 240 + # Per-`update` budget (seconds) for running model iterations. With + # update_interval lowered below, `update` is effectively called as + # fast as it returns, so this controls how long we batch model work + # before yielding back for a possible redraw. + ITERATE_BUDGET = 0.014 + # Minimum gap (seconds) between consecutive map redraws while + # generation is in progress. ~30 Hz is plenty for watching the wave + # collapse, and keeps the macro rebuild from eating into iterate + # time. + DRAW_INTERVAL = 1.0 / 30 + # Gosu's main loop sleeps until the next update_interval boundary. + # Default is ~16.6 ms (60 Hz); lowering it removes that idle gap so + # we can spend the wall-clock time iterating instead. The draw rate + # is throttled separately via `needs_redraw?`. + UPDATE_INTERVAL_MS = 2 + def initialize super(WIDTH, HEIGHT) + self.update_interval = UPDATE_INTERVAL_MS self.caption = "Wave Function Collapse in Ruby" @font = Gosu::Font.new(14) @small_font = Gosu::Font.new(12) @@ -16,9 +37,17 @@ def initialize @tiles = Gosu::Image.load_tiles("assets/#{@map_json["image"]}", @tile_width, @tile_height, tileable: true) @times = [] @paused = false + @show_entropy = true + @last_iterates_per_frame = 0 @labels = [] + # Pre-build the 256 entropy overlay colors so the per-cell text draw + # doesn't allocate a Gosu::Color each call. + @entropy_colors = Array.new(256) { |i| Gosu::Color.new(160, i, 255 - i, 0) } @model = nil - @map = nil + @map_macro = nil + @last_rendered_generation = -1 + @force_redraw = true + @last_drawn_at = 0.0 @started_at = nil @finished_at = nil defaults @@ -26,25 +55,56 @@ def initialize def defaults @model = Model.new(build_tiles, WIDTH.div(@tile_width), HEIGHT.div(@tile_height)) - @map = nil @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) @finished_at = nil + @map_macro = nil + @last_rendered_generation = -1 + @force_redraw = true + @last_iterates_per_frame = 0 + @last_drawn_at = 0.0 end def update - @labels = [] - @map = @model.solve if @map.nil? - return if @paused + return if @model.complete? + + frame_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + iters = 0 + until @model.complete? + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @model.iterate + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + @times << elapsed + @times.shift if @times.size > TIMES_CAPACITY + iters += 1 + break if (Process.clock_gettime(Process::CLOCK_MONOTONIC) - frame_start) >= ITERATE_BUDGET + end + @last_iterates_per_frame = iters + end - unless @model.complete? - time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @map = @model.iterate - @times << Process.clock_gettime(Process::CLOCK_MONOTONIC) - time_start + def needs_redraw? + return true if @force_redraw + return false if @paused + if @model.complete? + return @model.generation != @last_rendered_generation end + # Throttle redraws while generating so the macro rebuild doesn't + # contend with iterate for CPU. The first draw of a new state and + # any externally-forced redraw still go through. + (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_drawn_at) >= DRAW_INTERVAL end def draw + @last_drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @force_redraw = false + @labels.clear + + if @model.generation != @last_rendered_generation + rebuild_map_macro + @last_rendered_generation = @model.generation + end + @map_macro&.draw(0, 0, ZOrder::MAP) + if @model.complete? @finished_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) time = @finished_at - @started_at @@ -53,23 +113,30 @@ def draw else time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at add_label("Generating #{@model.width}x#{@model.height}. Elapsed #{"%02.2f" % time}s. #{"%02.2f" % @model.percent}% complete.") - add_label("Press P to pause/unpause, R to restart.") + add_label("Press P to pause/unpause, R to restart, E to toggle entropy overlay.") end + + add_label("Iterates/frame: #{@last_iterates_per_frame} | Entropy overlay: #{@show_entropy ? "ON" : "OFF"} (E)") + if (last_time = @times.last) mss = last_time * 1000 color = (mss > 16) ? Gosu::Color::RED : Gosu::Color::GREEN add_label("Last iteration: #{"%03.2f" % mss}ms", color) end - draw_map - average_time_mss = (@times.sum / @times.size.to_f) * 1000 - add_label("AVG(mss)=#{"%03.2f" % average_time_mss}ms", (average_time_mss > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) + unless @times.empty? + sorted = @times.sort + avg_mss = (@times.sum / @times.size.to_f) * 1000 + add_label("AVG(mss)=#{"%03.2f" % avg_mss}ms", (avg_mss > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) - p90_time = @times.sort[(@times.size * 0.9).to_i] * 1000 - add_label("P90(mss)=#{"%03.2f" % p90_time}ms", (p90_time > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) + p90_mss = sorted[(sorted.size * 0.9).to_i] * 1000 + add_label("P90(mss)=#{"%03.2f" % p90_mss}ms", (p90_mss > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) - p99_time = @times.sort[(@times.size * 0.99).to_i] * 1000 - add_label("P99(mss)=#{"%03.2f" % p99_time}ms", (p99_time > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) + p99_mss = sorted[(sorted.size * 0.99).to_i] * 1000 + add_label("P99(mss)=#{"%03.2f" % p99_mss}ms", (p99_mss > 16) ? Gosu::Color::RED : Gosu::Color::GREEN) + end + + add_label("FPS: #{Gosu.fps}") if @paused @font.draw_text_rel("Paused", WIDTH / 2, HEIGHT / 2, ZOrder::UI, 0.5, 0.5) @@ -82,18 +149,29 @@ def button_down(id) case id when Gosu::KB_R puts "Restarting..." + @times = [] defaults when Gosu::KB_A if @model.complete? puts "Adding empty row..." @times = [] @model.prepend_empty_row + @finished_at = nil + @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @force_redraw = true end when Gosu::KB_P @paused = !@paused + @force_redraw = true + when Gosu::KB_E + @show_entropy = !@show_entropy + # Invalidate the cached macro so the toggle takes effect immediately. + @last_rendered_generation = -1 + @force_redraw = true when Gosu::KB_S puts "Solving..." @model.solve + @force_redraw = true end end @@ -117,28 +195,50 @@ def draw_labels @small_font.draw_text_rel(version_label, WIDTH - 4, HEIGHT - 2, ZOrder::UI, 1.0, 1.0, 1, 1, Gosu::Color::GRAY) end - def draw_map - @map.each_with_index do |column, x| - column.reverse.each_with_index do |tile, y| - inverted_y = (y - @model.height + 1).abs - - entropy = @model.cell_at(x, inverted_y).entropy - - if entropy > 1 - percent_entropy = (entropy.to_f / @model.max_entropy * 255).round - color = Gosu::Color.new(160, percent_entropy, 255 - percent_entropy, 0) - @small_font.draw_text_rel( - entropy, - x * @tile_width + (@tile_width / 2), - y * @tile_height + (@tile_height / 2), - ZOrder::MAP, 0.5, 0.5, 1, 1, color - ) - end + # Re-record the full grid into a Gosu macro so subsequent frames replay + # it as one batched draw instead of per-cell Ruby calls. Called only + # when @model.generation advances (i.e. iterate ran), and we also lazily + # skip the costly entropy overlay unless the user has toggled it on. + def rebuild_map_macro + model = @model + width = model.width + height = model.height + tw = @tile_width + th = @tile_height + tiles = @tiles + show_entropy = @show_entropy + max_entropy = model.max_entropy.to_f + small_font = @small_font + entropy_colors = @entropy_colors - next unless tile + @map_macro = record(WIDTH, HEIGHT) do + x = 0 + while x < width + screen_x = x * tw + y = 0 + while y < height + screen_y = (height - 1 - y) * th - image = @tiles[tile.tileid] - image.draw(x * @tile_width, y * @tile_height, ZOrder::MAP) + tile_id = model.tile_id_at(x, y) + if tile_id + tiles[tile_id].draw(screen_x, screen_y, 0) + elsif show_entropy + entropy = model.entropy_at(x, y) + if entropy > 1 + percent_entropy = (entropy / max_entropy * 255).to_i + percent_entropy = 255 if percent_entropy > 255 + percent_entropy = 0 if percent_entropy < 0 + small_font.draw_text_rel( + entropy, + screen_x + (tw / 2), + screen_y + (th / 2), + 0, 0.5, 0.5, 1, 1, entropy_colors[percent_entropy] + ) + end + end + y += 1 + end + x += 1 end end end diff --git a/test/test_model.rb b/test/test_model.rb index 96f503e..e918c8a 100644 --- a/test/test_model.rb +++ b/test/test_model.rb @@ -13,9 +13,8 @@ def test_initialize assert_equal 320, model.width assert_equal 240, model.height - assert_equal 320 * 240, model.cells.size assert_equal 3, model.max_entropy - assert_equal 0, model.percent + assert_equal 0.0, model.percent refute model.complete? assert model.solve assert model.iterate @@ -37,20 +36,92 @@ def test_prepend_empty_row model.prepend_empty_row - assert_equal 4, model.cells.size - assert_equal 1, model.cells[0].entropy - assert_equal 1, model.cells[1].entropy - assert_predicate model.cells[0], :collapsed? - assert_predicate model.cells[0], :collapsed? - assert_equal 3, model.cells[2].entropy - assert_equal 3, model.cells[3].entropy - refute_predicate model.cells[2], :collapsed? - refute_predicate model.cells[3], :collapsed? + assert_equal 1, model.entropy_at(0, 0) + assert_equal 1, model.entropy_at(1, 0) + assert_equal 3, model.entropy_at(0, 1) + assert_equal 3, model.entropy_at(1, 1) assert_equal 2, model.width assert_equal 2, model.height - assert_equal 2 * 2, model.cells.size assert_equal 3, model.max_entropy - assert_equal 50, model.percent + assert_equal 50.0, model.percent + end + + def test_prepend_empty_row_3x3 + tiles = [ + Tile.new(tileid: 0, wangid: [0, 0, 0, 0, 0, 0, 0, 0]), + Tile.new(tileid: 1, wangid: [0, 0, 0, 0, 0, 0, 0, 0]), + Tile.new(tileid: 2, wangid: [0, 0, 0, 0, 0, 0, 0, 0]) + ] + model = Model.new(tiles, 3, 3) + model.iterate until model.complete? + assert model.complete? + + model.prepend_empty_row + + # Bottom two rows were the bottom two of the prior 3x3 (collapsed). + # Top row is the freshly-inserted empty row (entropy == max_entropy == 3). + assert_equal 1, model.entropy_at(0, 0) + assert_equal 1, model.entropy_at(1, 0) + assert_equal 1, model.entropy_at(2, 0) + assert_equal 1, model.entropy_at(0, 1) + assert_equal 1, model.entropy_at(1, 1) + assert_equal 1, model.entropy_at(2, 1) + assert_equal 3, model.entropy_at(0, 2) + assert_equal 3, model.entropy_at(1, 2) + assert_equal 3, model.entropy_at(2, 2) + end + + def test_prepend_empty_row_fills_chosen_tile_for_single_tile_set + # With a single-tile tileset every cell is already collapsed, so the + # new row inserted by prepend_empty_row must materialise tile 0 in + # `grid` — not return nil because chosen_tile is still -1. + tiles = [Tile.new(tileid: 42, wangid: [0, 0, 0, 0, 0, 0, 0, 0])] + model = Model.new(tiles, 2, 2) + model.prepend_empty_row + model.grid.each { |col| col.each { |t| assert_equal 42, t.tileid } } + end + + def test_rejects_tileset_that_overflows_supporter_byte + # Supporter counts are stored as bytes (0..255). 256 mutually-compatible + # tiles would wrap and silently corrupt the wave; reject up front. + tiles = 256.times.map { |i| Tile.new(tileid: i, wangid: [0, 0, 0, 0, 0, 0, 0, 0]) } + assert_raises(WaveFunctionCollapse::Error) { Model.new(tiles, 2, 1) } + end + + def test_observe_and_propagate_caps_consecutive_restarts + # If the tileset is inherently unsolvable but orphan_ban_pass leaves + # some cells with multiple candidates, the solver would otherwise + # cycle observe → contradiction → setup_wave_state forever. Force + # that condition by overriding setup_wave_state to always end in a + # contradiction state, and verify the cap fires instead of hanging. + klass = Class.new(Model) do + def setup_wave_state + super + @contradiction = true + end + end + tiles = [ + Tile.new(tileid: 0, wangid: [0, 0, 0, 0, 0, 0, 0, 0]), + Tile.new(tileid: 1, wangid: [0, 0, 0, 0, 0, 0, 0, 0]) + ] + model = klass.new(tiles, 2, 2) + assert_raises(WaveFunctionCollapse::Error) do + model.iterate until model.complete? + end + end + + def test_zero_probability_tile_does_not_poison_entropy + # `w * Math.log(w)` is NaN for w == 0; if that leaks into the entropy + # table, `find_lowest_entropy_cell` never picks a cell and the solve + # loop spins forever. Regression for the bug found by /pr-bug-hunt. + tiles = [ + Tile.new(tileid: 0, wangid: [0, 0, 0, 0, 0, 0, 0, 0], probability: 1.0), + Tile.new(tileid: 1, wangid: [0, 0, 0, 0, 0, 0, 0, 0], probability: 0.0) + ] + model = Model.new(tiles, 2, 2) + model.iterate until model.complete? + assert model.complete? + model.grid.each { |col| col.each { |t| refute_nil t } } end end diff --git a/test/test_tile.rb b/test/test_tile.rb new file mode 100644 index 0000000..06b62d4 --- /dev/null +++ b/test/test_tile.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestTile < Minitest::Test + def test_intern_edge_does_not_collide_for_wide_wang_ids + # Earlier the cache key was packed as `(a << 16) | (b << 8) | c`, which + # silently collided once any component exceeded 255. Regression: the + # interning cache must distinguish triples with components ≥ 256. + e1 = Tile.intern_edge(1, 0, 256) + e2 = Tile.intern_edge(1, 1, 0) + assert_equal [1, 0, 256], e1 + assert_equal [1, 1, 0], e2 + refute_equal e1, e2 + end + + def test_intern_edge_returns_same_object_for_equal_triples + e1 = Tile.intern_edge(3, 4, 5) + e2 = Tile.intern_edge(3, 4, 5) + assert_same e1, e2 + end +end