zer0-image-generator 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Port of the settings layer of lib/zer0_image_generator/preview_generator.py:
4
+ # the Settings value object, resolve_settings' CLI>env>config>DEFAULTS
5
+ # precedence chain, and the per-file override machinery (author + collection
6
+ # style blocks). The Python file stays the oracle;
7
+ # test/ruby/test_settings.rb differential-tests resolve_settings against it.
8
+
9
+ require_relative "config"
10
+
11
+ module Zer0ImageGenerator
12
+ # Fully-resolved run settings (before per-file author overrides).
13
+ #
14
+ # Ports the Python `@dataclass Settings`. Field names and defaults match the
15
+ # oracle one-for-one so `to_h` diffs cleanly against `dataclasses.asdict`.
16
+ class Settings
17
+ ATTRIBUTES = %i[
18
+ provider model size quality style style_modifiers output_dir
19
+ assets_prefix auto_prefix enabled collections collections_dir
20
+ collection_styles front_matter_key authors_file prompt_engine
21
+ review_engine claude_model claude_effort dry_run verbose force
22
+ list_only parallel batch file collection enhance enhance_prompt
23
+ enhance_model enhance_quality enhance_fidelity enhance_format
24
+ rasterizer provider_explicit
25
+ ].freeze
26
+
27
+ attr_accessor(*ATTRIBUTES)
28
+
29
+ def initialize(**overrides)
30
+ @provider = DEFAULTS["provider"]
31
+ @model = DEFAULTS["model"]
32
+ @size = DEFAULTS["size"]
33
+ @quality = DEFAULTS["quality"]
34
+ @style = DEFAULTS["style"]
35
+ @style_modifiers = DEFAULTS["style_modifiers"]
36
+ @output_dir = DEFAULTS["output_dir"]
37
+ @assets_prefix = DEFAULTS["assets_prefix"]
38
+ @auto_prefix = DEFAULTS["auto_prefix"]
39
+ @enabled = true
40
+ # Fresh copies so two Settings never share the same mutable default
41
+ # (mirrors the dataclass field(default_factory=...)).
42
+ @collections = DEFAULTS["collections"].dup
43
+ @collections_dir = DEFAULTS["collections_dir"]
44
+ @collection_styles = {}
45
+ @front_matter_key = DEFAULTS["front_matter_key"]
46
+ @authors_file = DEFAULTS["authors_file"]
47
+ @prompt_engine = DEFAULTS["prompt_engine"]
48
+ @review_engine = DEFAULTS["review_engine"]
49
+ @claude_model = DEFAULTS["claude_model"]
50
+ @claude_effort = DEFAULTS["claude_effort"]
51
+
52
+ @dry_run = false
53
+ @verbose = false
54
+ @force = false
55
+ @list_only = false
56
+ @parallel = 4
57
+ @batch = 0
58
+
59
+ @file = ""
60
+ @collection = ""
61
+
62
+ @enhance = false
63
+ @enhance_prompt = ""
64
+ @enhance_model = ENHANCE_DEFAULTS["model"]
65
+ @enhance_quality = ENHANCE_DEFAULTS["quality"]
66
+ @enhance_fidelity = ENHANCE_DEFAULTS["fidelity"]
67
+ @enhance_format = ENHANCE_DEFAULTS["format"]
68
+
69
+ @rasterizer = "auto"
70
+ @provider_explicit = false
71
+
72
+ overrides.each { |key, value| public_send("#{key}=", value) }
73
+ end
74
+
75
+ def to_h
76
+ ATTRIBUTES.each_with_object({}) { |attr, acc| acc[attr] = public_send(attr) }
77
+ end
78
+
79
+ # Port of dataclasses.replace(settings, **changes): a copy with `changes`
80
+ # applied. Accepts string or symbol keys (OVERRIDE_KEYS are strings).
81
+ def dup_with(changes)
82
+ copy = dup
83
+ copy.instance_variable_set(:@collections, @collections.dup)
84
+ copy.instance_variable_set(:@collection_styles, @collection_styles.dup)
85
+ changes.each { |key, value| copy.public_send("#{key}=", value) }
86
+ copy
87
+ end
88
+
89
+ def ==(other)
90
+ other.is_a?(Settings) && to_h == other.to_h
91
+ end
92
+ end
93
+
94
+ # Mirrors the argparse.Namespace resolve_settings consumes. A CLI builds one
95
+ # from parsed flags; a Rails controller builds one from request params. Field
96
+ # names and defaults match build_arg_parser's `dest=` / `default=`.
97
+ #
98
+ # Build with a Hash (string or symbol keys); `Args.build` applies argparse
99
+ # defaults for anything omitted and reproduces parse_args' post-processing
100
+ # (the historical rule that --enhance-prompt / -fidelity / -format imply
101
+ # --enhance) so a Rails caller behaves identically to the CLI.
102
+ class Args
103
+ DEFAULTS = {
104
+ dry_run: false, verbose: false, file: nil, collection: nil,
105
+ provider: nil, model: nil, output_dir: nil, force: false,
106
+ list_missing: false, parallel: nil, enhance: false,
107
+ enhance_prompt: nil, enhance_model: nil, enhance_quality: nil,
108
+ enhance_fidelity: nil, enhance_format: nil, prompt_engine: nil,
109
+ review: nil, rasterizer: nil, style: nil, assets_prefix: nil,
110
+ no_auto_prefix: false, collections_dir: nil, front_matter_key: nil,
111
+ authors_file: nil, batch: 0, log_file: nil, rate_limit: nil
112
+ }.freeze
113
+
114
+ attr_accessor(*DEFAULTS.keys)
115
+
116
+ def self.build(source = {})
117
+ return source if source.is_a?(Args)
118
+
119
+ normalized = {}
120
+ (source.respond_to?(:to_h) ? source.to_h : source).each do |key, value|
121
+ normalized[key.to_sym] = value
122
+ end
123
+ new(normalized).tap(&:apply_enhance_implication!)
124
+ end
125
+
126
+ def initialize(overrides = {})
127
+ DEFAULTS.each { |key, value| instance_variable_set("@#{key}", value) }
128
+ overrides.each do |key, value|
129
+ sym = key.to_sym
130
+ # Unknown keys would silently vanish; surface them instead of masking a
131
+ # caller typo (e.g. :front_matter vs :front_matter_key).
132
+ raise ArgumentError, "unknown Args field: #{key}" unless DEFAULTS.key?(sym)
133
+
134
+ instance_variable_set("@#{sym}", value)
135
+ end
136
+ end
137
+
138
+ # parse_args post-processing: these three flags imply --enhance, but
139
+ # --enhance-model deliberately does not (historical behavior).
140
+ def apply_enhance_implication!
141
+ @enhance = true if @enhance_prompt || @enhance_fidelity || @enhance_format
142
+ self
143
+ end
144
+ end
145
+
146
+ module_function
147
+
148
+ # Settings fields an override block may replace per file (see constants.rb's
149
+ # OVERRIDE_KEYS). Keep only recognized keys with non-empty stringified values.
150
+ def _filter_override_block(block)
151
+ return {} unless block.is_a?(Hash)
152
+
153
+ result = {}
154
+ block.each do |key, value|
155
+ next unless OVERRIDE_KEYS.include?(key)
156
+ next if value.nil?
157
+
158
+ # python_str, not to_s: a boolean override value must stringify as
159
+ # "True"/"False" (CPython str()) to stay byte-identical to the oracle.
160
+ stripped = python_str(value).strip
161
+ result[key] = stripped unless stripped.empty?
162
+ end
163
+ result
164
+ end
165
+
166
+ # `preview:` override block for an author key. `author:` may be a list or
167
+ # mapping in some posts — only a plain string key can index authors.yml.
168
+ def author_preview_overrides(authors, author_key)
169
+ return {} unless author_key.is_a?(String) && !author_key.empty?
170
+
171
+ author = authors[author_key]
172
+ return {} unless author.is_a?(Hash)
173
+
174
+ _filter_override_block(author["preview"])
175
+ end
176
+
177
+ # The file's collection, from the nearest `_<name>` ancestor directory
178
+ # (Jekyll's collection layout) — "" for files outside any collection.
179
+ def collection_of(path)
180
+ Pathname.new(path.to_s).expand_path.parent.ascend do |ancestor|
181
+ name = ancestor.basename.to_s
182
+ return name[1..] if name.start_with?("_") && name.length > 1
183
+ end
184
+ ""
185
+ end
186
+
187
+ # The file's section — the directory immediately INSIDE its collection
188
+ # (`pages/_posts/hacks/x.md` -> 'hacks') — or "" when the collection holds its
189
+ # files flat. Sites that fold several kinds of writing into one collection
190
+ # carry the editorial distinction here, and it is the strongest single signal
191
+ # for what a banner should look like.
192
+ def section_of(path)
193
+ parts = Pathname.new(path.to_s).expand_path.to_s.split(File::SEPARATOR)
194
+ parts.each_with_index do |name, i|
195
+ next unless name.start_with?("_") && name.length > 1
196
+
197
+ # parts[i + 1] is a directory only when something follows it.
198
+ return i + 2 < parts.length ? parts[i + 1] : ""
199
+ end
200
+ ""
201
+ end
202
+
203
+ # The page context the local renderer derives its art from.
204
+ def article_art_context(content_file, slug)
205
+ {
206
+ "slug" => slug,
207
+ "section" => (s = section_of(content_file.path)).empty? ? collection_of(content_file.path) : s,
208
+ "title" => content_file.title.to_s,
209
+ "description" => content_file.description.to_s,
210
+ "categories" => content_file.categories.to_s,
211
+ "body" => content_file.content.to_s,
212
+ }
213
+ end
214
+
215
+ # `collection_styles:` override block for a collection — same keys as author
216
+ # overrides, so each collection can carry its own visual theme.
217
+ def collection_preview_overrides(collection_styles, collection)
218
+ return {} if collection.nil? || collection.empty? || !collection_styles.is_a?(Hash)
219
+
220
+ _filter_override_block(collection_styles[collection])
221
+ end
222
+
223
+ # Merge CLI > env > _config.yml `preview_images:` > DEFAULTS into a Settings.
224
+ # `config` is the site's FULL _config.yml Hash — the block is extracted here,
225
+ # and two top-level Jekyll keys are honored as fallbacks: collections_dir and
226
+ # (for `collections: auto`) the collections: map.
227
+ #
228
+ # `args` is an Args (or any Hash/Struct Args.build accepts).
229
+ def resolve_settings(args, config)
230
+ args = Args.build(args)
231
+
232
+ site = config["preview_images"]
233
+ site = {} unless site.is_a?(Hash)
234
+
235
+ cfg = lambda do |key, default|
236
+ value = site[key]
237
+ value.nil? ? default : value
238
+ end
239
+
240
+ # env only wins when the CLI value is nil AND the env value is non-empty.
241
+ pick = lambda do |cli_value, env_name, cfg_key|
242
+ return cli_value unless cli_value.nil?
243
+
244
+ env_value = ENV[env_name]
245
+ return env_value if !env_value.nil? && !env_value.empty?
246
+
247
+ python_str(cfg.call(cfg_key, DEFAULTS[cfg_key]))
248
+ end
249
+
250
+ # collections_dir: block override > Jekyll's own top-level key > default.
251
+ collections_dir = cfg.call(
252
+ "collections_dir",
253
+ python_first_truthy(config["collections_dir"], DEFAULTS["collections_dir"])
254
+ )
255
+ collections_dir = args.collections_dir unless args.collections_dir.nil?
256
+
257
+ collections = cfg.call("collections", DEFAULTS["collections"])
258
+ if collections == "auto" || (collections.is_a?(Array) && collections.include?("auto"))
259
+ # Discover from Jekyll's own collections: map; posts always exists.
260
+ declared = config["collections"]
261
+ names = declared.is_a?(Hash) ? declared.keys : []
262
+ collections = ["posts"] + names.reject { |n| n == "posts" }
263
+ end
264
+ collections = DEFAULTS["collections"].dup unless collections.is_a?(Array) && !collections.empty?
265
+
266
+ parallel_env = ENV.fetch("MAX_PARALLEL", "")
267
+ parallel = if !args.parallel.nil?
268
+ args.parallel
269
+ elsif parallel_env.match?(/\A[0-9]+\z/)
270
+ Integer(parallel_env, 10)
271
+ else
272
+ 4
273
+ end
274
+
275
+ collection_styles = cfg.call("collection_styles", {})
276
+ collection_styles = {} unless collection_styles.is_a?(Hash)
277
+
278
+ Settings.new(
279
+ provider: pick.call(args.provider, "AI_PROVIDER", "provider"),
280
+ model: pick.call(args.model, "IMAGE_MODEL", "model"),
281
+ size: pick.call(nil, "IMAGE_SIZE", "size"),
282
+ quality: pick.call(nil, "IMAGE_QUALITY", "quality"),
283
+ style: pick.call(args.style, "IMAGE_STYLE", "style"),
284
+ style_modifiers: pick.call(nil, "IMAGE_STYLE_MODIFIERS", "style_modifiers"),
285
+ output_dir: pick.call(args.output_dir, "OUTPUT_DIR", "output_dir"),
286
+ assets_prefix: (
287
+ args.assets_prefix.nil? ? python_str(cfg.call("assets_prefix", DEFAULTS["assets_prefix"])) : args.assets_prefix
288
+ ),
289
+ auto_prefix: (
290
+ args.no_auto_prefix ? false : python_bool(cfg.call("auto_prefix", DEFAULTS["auto_prefix"]))
291
+ ),
292
+ enabled: python_bool(cfg.call("enabled", true)),
293
+ collections: collections.map { |c| python_str(c) },
294
+ collection_styles: collection_styles,
295
+ collections_dir: python_str(python_first_truthy(collections_dir, "")).strip.gsub(%r{\A/+|/+\z}, ""),
296
+ front_matter_key: python_first_truthy(
297
+ args.front_matter_key,
298
+ python_str(cfg.call("front_matter_key", DEFAULTS["front_matter_key"])).strip,
299
+ DEFAULTS["front_matter_key"]
300
+ ),
301
+ authors_file: (
302
+ if args.authors_file.nil?
303
+ python_str(python_first_truthy(cfg.call("authors_file", DEFAULTS["authors_file"]), ""))
304
+ else
305
+ args.authors_file
306
+ end
307
+ ),
308
+ prompt_engine: pick.call(args.prompt_engine, "PROMPT_ENGINE", "prompt_engine"),
309
+ review_engine: pick.call(args.review, "REVIEW_ENGINE", "review_engine"),
310
+ claude_model: python_str(python_first_truthy(cfg.call("claude_model", ""), "")),
311
+ claude_effort: python_str(python_first_truthy(cfg.call("claude_effort", DEFAULTS["claude_effort"]), "")),
312
+ dry_run: (args.dry_run ? true : _env_flag("DRY_RUN")),
313
+ verbose: (args.verbose ? true : _env_flag("VERBOSE")),
314
+ force: (args.force ? true : _env_flag("FORCE")),
315
+ list_only: (args.list_missing ? true : _env_flag("LIST_ONLY")),
316
+ parallel: parallel,
317
+ batch: python_first_truthy(args.batch, 0),
318
+ # These mirror bare `args.x or default` chains in the oracle (no str()
319
+ # wrapper), so values pass through uncoerced exactly as Python leaves them.
320
+ file: python_first_truthy(args.file, ""),
321
+ collection: python_first_truthy(args.collection, ""),
322
+ enhance: (args.enhance ? true : _env_flag("ENHANCE")),
323
+ enhance_prompt: python_first_truthy(args.enhance_prompt, ""),
324
+ enhance_model: python_first_truthy(args.enhance_model, ENV["ENHANCE_MODEL"], ENHANCE_DEFAULTS["model"]),
325
+ enhance_quality: python_first_truthy(args.enhance_quality, ENV["ENHANCE_QUALITY"], ENHANCE_DEFAULTS["quality"]),
326
+ enhance_fidelity: python_first_truthy(args.enhance_fidelity, ENV["ENHANCE_FIDELITY"], ENHANCE_DEFAULTS["fidelity"]),
327
+ enhance_format: python_first_truthy(args.enhance_format, ENV["ENHANCE_FORMAT"], ENHANCE_DEFAULTS["format"]),
328
+ rasterizer: pick.call(args.rasterizer, "IMAGE_RASTERIZER", "rasterizer"),
329
+ provider_explicit: !args.provider.nil? || python_bool(ENV["AI_PROVIDER"])
330
+ )
331
+ end
332
+
333
+ # Per-file settings copy with override blocks applied in order — later blocks
334
+ # win (pass collection styles first, author overrides on top). Returns the
335
+ # same object when nothing overrides, matching dataclasses.replace's identity
336
+ # short-circuit in the oracle.
337
+ def apply_preview_overrides(settings, *override_blocks)
338
+ merged = {}
339
+ override_blocks.each { |block| merged.update(block || {}) }
340
+ return settings if merged.empty?
341
+
342
+ changes = {}
343
+ OVERRIDE_KEYS.each do |key|
344
+ changes[key] = merged.key?(key) ? merged[key] : settings.public_send(key)
345
+ end
346
+ settings.dup_with(changes)
347
+ end
348
+
349
+ # The per-file settings: global → collection_styles[collection] → author
350
+ # preview: block (most specific wins). `cf` is any object exposing `path` and
351
+ # `author` (the ContentFile from the front-matter slice).
352
+ def file_preview_settings(settings, cf, authors)
353
+ coll_overrides = collection_preview_overrides(settings.collection_styles, collection_of(cf.path))
354
+ author_overrides = author_preview_overrides(authors, cf.author)
355
+ result = apply_preview_overrides(settings, coll_overrides, author_overrides)
356
+ info(" ↳ Collection '#{collection_of(cf.path)}' preview style applied") unless coll_overrides.empty?
357
+ unless author_overrides.empty?
358
+ info(" ↳ Author '#{cf.author}' preview overrides applied (#{settings.authors_file})")
359
+ end
360
+ result
361
+ end
362
+
363
+ # Model-name prefix → provider family, or nil. FIRST matching prefix wins, in
364
+ # MODEL_FAMILIES insertion order (see constants.rb).
365
+ def model_family(model)
366
+ m = (model || "").to_s.strip.downcase
367
+ MODEL_FAMILIES.each { |prefix, family| return family if m.start_with?(prefix) }
368
+ nil
369
+ end
370
+
371
+ # Configured model when it belongs to the provider's family, else the provider
372
+ # default (with a warning) — never emit a guaranteed-400 request. `provider`
373
+ # is any object exposing `name` and `default_model` (the Provider slice).
374
+ def effective_model(settings, provider)
375
+ model = (settings.model || "").to_s.strip
376
+ return provider.default_model if model.empty?
377
+
378
+ family = model_family(model)
379
+ if !family.nil? && family != provider.name
380
+ warn("Model '#{model}' belongs to the '#{family}' family; using " \
381
+ "#{provider.name} default '#{provider.default_model}' instead")
382
+ return provider.default_model
383
+ end
384
+ model
385
+ end
386
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zer0ImageGenerator
4
+ # Per-run counters. Mirrors the Python @dataclass Stats: worker threads share
5
+ # one instance and call inc(name), so the mutation must be guarded (Python
6
+ # relied on its own threading.Lock here, not just the GIL — inc reads then
7
+ # writes, which is not atomic).
8
+ class Stats
9
+ ATTRS = %i[processed generated enhanced skipped errors].freeze
10
+
11
+ attr_accessor(*ATTRS)
12
+
13
+ def initialize(processed: 0, generated: 0, enhanced: 0, skipped: 0, errors: 0)
14
+ @processed = processed
15
+ @generated = generated
16
+ @enhanced = enhanced
17
+ @skipped = skipped
18
+ @errors = errors
19
+ @lock = Mutex.new
20
+ end
21
+
22
+ def inc(name)
23
+ key = name.to_sym
24
+ # Guard against a typo'd counter name silently doing nothing — the
25
+ # Python setattr would have raised too (AttributeError on a slotless
26
+ # write is not swallowed anywhere in the engine).
27
+ raise ArgumentError, "unknown stat: #{name}" unless ATTRS.include?(key)
28
+
29
+ @lock.synchronize { instance_variable_set("@#{key}", instance_variable_get("@#{key}") + 1) }
30
+ nil
31
+ end
32
+ end
33
+
34
+ # The engine's `_interrupted` global as an object, so the Runner can be
35
+ # handed a fresh one per run (a Rails worker cancels one job without
36
+ # poisoning the next). trip() is what _signal_handler does; tripped? is the
37
+ # `if _interrupted:` guard sprinkled through the run loop.
38
+ #
39
+ # NOT named `Interrupt`: that is a Ruby core class (the SIGINT exception), and
40
+ # this module is meant to be `include`d by a Rails host — the collision would
41
+ # shadow the core class for that host.
42
+ #
43
+ # Deliberately lock-free: trip() is called from a signal-trap context, where
44
+ # acquiring a Mutex raises ThreadError on modern Ruby. A single boolean
45
+ # write/read is safe under MRI's GVL, which is exactly what the Python global
46
+ # relied on too.
47
+ class InterruptFlag
48
+ def initialize
49
+ @flag = false
50
+ end
51
+
52
+ def trip
53
+ @flag = true
54
+ nil
55
+ end
56
+
57
+ def tripped?
58
+ @flag
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zer0ImageGenerator
4
+ module Support
5
+ # A bit-exact reimplementation of CPython's `random.Random`.
6
+ #
7
+ # Why this exists: the SVG generators ported from `tools/svg/` derive every
8
+ # coordinate from a seeded stream, and the sample artwork committed under
9
+ # `docs/samples/` was produced by CPython's Mersenne Twister. Ruby ships
10
+ # MT19937 too, but seeds it differently — `Random.new(7)` and
11
+ # `random.Random(7)` diverge on the first draw — so a port built on Ruby's
12
+ # own RNG would silently re-skin every existing piece.
13
+ #
14
+ # Reimplementing CPython's seeding (`init_by_array`) and its derived
15
+ # methods (`random`, `getrandbits`, `_randbelow`, `randrange`, `choice`,
16
+ # `uniform`) keeps the ported generators byte-identical to their Python
17
+ # originals, which `test/test_py_random.rb` and the golden-SVG tests pin.
18
+ #
19
+ # Only the surface the generators actually use is implemented; anything
20
+ # else would be untested weight.
21
+ class PyRandom
22
+ N = 624
23
+ M = 397
24
+ MATRIX_A = 0x9908b0df
25
+ UPPER_MASK = 0x80000000
26
+ LOWER_MASK = 0x7fffffff
27
+ MASK32 = 0xffffffff
28
+
29
+ # CPython divides `random()`'s 53 bits as 26 high + 27 low.
30
+ TWO_POW_26 = 67_108_864.0
31
+ TWO_POW_53 = 9_007_199_254_740_992.0
32
+
33
+ def initialize(seed = 0)
34
+ @mt = Array.new(N, 0)
35
+ @mti = N + 1
36
+ seed!(seed)
37
+ end
38
+
39
+ # CPython's random_seed(): integers are seeded by absolute value, split
40
+ # little-endian into 32-bit words, through init_by_array.
41
+ def seed!(seed)
42
+ n = seed.to_i.abs
43
+ key = []
44
+ if n.zero?
45
+ key << 0
46
+ else
47
+ while n.positive?
48
+ key << (n & MASK32)
49
+ n >>= 32
50
+ end
51
+ end
52
+ init_by_array(key)
53
+ end
54
+
55
+ def genrand_uint32
56
+ if @mti >= N
57
+ (0...(N - M)).each do |kk|
58
+ y = (@mt[kk] & UPPER_MASK) | (@mt[kk + 1] & LOWER_MASK)
59
+ @mt[kk] = @mt[kk + M] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
60
+ end
61
+ ((N - M)...(N - 1)).each do |kk|
62
+ y = (@mt[kk] & UPPER_MASK) | (@mt[kk + 1] & LOWER_MASK)
63
+ @mt[kk] = @mt[kk + (M - N)] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
64
+ end
65
+ y = (@mt[N - 1] & UPPER_MASK) | (@mt[0] & LOWER_MASK)
66
+ @mt[N - 1] = @mt[M - 1] ^ (y >> 1) ^ (y.odd? ? MATRIX_A : 0)
67
+ @mti = 0
68
+ end
69
+
70
+ y = @mt[@mti]
71
+ @mti += 1
72
+ y ^= (y >> 11)
73
+ y ^= (y << 7) & 0x9d2c5680
74
+ y ^= (y << 15) & 0xefc60000
75
+ y &= MASK32
76
+ y ^ (y >> 18)
77
+ end
78
+
79
+ # CPython: (a >> 5) * 2**26 + (b >> 6), scaled into [0, 1).
80
+ def random
81
+ a = genrand_uint32 >> 5
82
+ b = genrand_uint32 >> 6
83
+ ((a * TWO_POW_26) + b) / TWO_POW_53
84
+ end
85
+
86
+ def getrandbits(k)
87
+ raise ArgumentError, "number of bits must be non-negative" if k.negative?
88
+ return 0 if k.zero?
89
+ return genrand_uint32 >> (32 - k) if k <= 32
90
+
91
+ words = ((k - 1) / 32) + 1
92
+ result = 0
93
+ remaining = k
94
+ words.times do |i|
95
+ r = genrand_uint32
96
+ r >>= (32 - remaining) if remaining < 32
97
+ result |= r << (32 * i)
98
+ remaining -= 32
99
+ end
100
+ result
101
+ end
102
+
103
+ # CPython's _randbelow_with_getrandbits: rejection-sample bit_length bits.
104
+ def randbelow(n)
105
+ return 0 unless n.positive?
106
+
107
+ k = n.bit_length
108
+ r = getrandbits(k)
109
+ r = getrandbits(k) while r >= n
110
+ r
111
+ end
112
+
113
+ def randrange(start, stop = nil, step = 1)
114
+ if stop.nil?
115
+ raise ArgumentError, "empty range for randrange(#{start})" unless start.positive?
116
+
117
+ return randbelow(start)
118
+ end
119
+
120
+ width = stop - start
121
+ if step == 1
122
+ raise ArgumentError, "empty range for randrange(#{start}, #{stop})" unless width.positive?
123
+
124
+ return start + randbelow(width)
125
+ end
126
+
127
+ n = step.positive? ? ((width + step - 1) / step) : ((width + step + 1) / step)
128
+ raise ArgumentError, "empty range for randrange(#{start}, #{stop}, #{step})" unless n.positive?
129
+
130
+ start + (step * randbelow(n))
131
+ end
132
+
133
+ def randint(a, b)
134
+ randrange(a, b + 1)
135
+ end
136
+
137
+ def choice(seq)
138
+ raise ArgumentError, "cannot choose from an empty sequence" if seq.empty?
139
+
140
+ seq[randbelow(seq.length)]
141
+ end
142
+
143
+ def uniform(a, b)
144
+ a + ((b - a) * random)
145
+ end
146
+
147
+ private
148
+
149
+ def init_genrand(s)
150
+ @mt[0] = s & MASK32
151
+ (1...N).each do |i|
152
+ @mt[i] = (1_812_433_253 * (@mt[i - 1] ^ (@mt[i - 1] >> 30)) + i) & MASK32
153
+ end
154
+ @mti = N
155
+ end
156
+
157
+ def init_by_array(key)
158
+ init_genrand(19_650_218)
159
+ i = 1
160
+ j = 0
161
+ k = [N, key.length].max
162
+ while k.positive?
163
+ @mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_664_525)) + key[j] + j) & MASK32
164
+ i += 1
165
+ j += 1
166
+ if i >= N
167
+ @mt[0] = @mt[N - 1]
168
+ i = 1
169
+ end
170
+ j = 0 if j >= key.length
171
+ k -= 1
172
+ end
173
+
174
+ k = N - 1
175
+ while k.positive?
176
+ @mt[i] = ((@mt[i] ^ ((@mt[i - 1] ^ (@mt[i - 1] >> 30)) * 1_566_083_941)) - i) & MASK32
177
+ i += 1
178
+ if i >= N
179
+ @mt[0] = @mt[N - 1]
180
+ i = 1
181
+ end
182
+ k -= 1
183
+ end
184
+
185
+ @mt[0] = UPPER_MASK
186
+ end
187
+ end
188
+ end
189
+ end