hegeltest 0.3.1 → 0.4.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.ja.md +408 -0
  4. data/README.md +209 -103
  5. data/Rakefile +6 -3
  6. data/docs/README.md +11 -0
  7. data/docs/adr/0025-copy-each-string-input-into-a-hegel-string-t.md +61 -0
  8. data/docs/adr/0026-select-the-engine-release-repository-by-version.md +47 -0
  9. data/docs/adr/0027-report-each-runs-test-location-from-the-callers-frame.md +63 -0
  10. data/docs/adr/0028-expose-settings-profiles-as-a-keyword-and-module-functions.md +91 -0
  11. data/docs/adr/0029-keep-notes-local-and-defer-run-error-kinds.md +72 -0
  12. data/docs/adr/0030-represent-a-duration-as-integer-nanoseconds.md +48 -0
  13. data/docs/adr/0031-take-the-alphabet-options-from-hegel-rust.md +52 -0
  14. data/docs/adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md +147 -0
  15. data/docs/adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md +80 -0
  16. data/docs/adr/0034-repeat-a-test-body-with-a-ruby-side-indent.md +120 -0
  17. data/docs/adr/0035-defer-concurrent-state-machines-and-the-engine-printer.md +136 -0
  18. data/docs/architecture.md +33 -11
  19. data/lib/hegel/errors.rb +24 -0
  20. data/lib/hegel/generator.rb +52 -25
  21. data/lib/hegel/generators.rb +715 -45
  22. data/lib/hegel/grammar_cache.rb +36 -0
  23. data/lib/hegel/lib_hegel/real.rb +455 -120
  24. data/lib/hegel/lib_hegel.rb +33 -8
  25. data/lib/hegel/libhegel_version.rb +1 -1
  26. data/lib/hegel/locate.rb +10 -10
  27. data/lib/hegel/report.rb +13 -10
  28. data/lib/hegel/runner.rb +61 -27
  29. data/lib/hegel/settings.rb +92 -16
  30. data/lib/hegel/state_machine.rb +17 -4
  31. data/lib/hegel/stateful/pool.rb +52 -0
  32. data/lib/hegel/stateful.rb +2 -2
  33. data/lib/hegel/syntax/methods.rb +99 -15
  34. data/lib/hegel/test_case.rb +256 -35
  35. data/lib/hegel/test_location.rb +49 -0
  36. data/lib/hegel/version.rb +1 -1
  37. data/lib/hegel.rb +21 -5
  38. data/lib/tasks/libhegel.rake +17 -5
  39. data/lib/tasks/platform_gems.rake +3 -3
  40. data/sig/hegel.rbs +296 -43
  41. data/skills/hegel-ruby/references/ruby/reference.md +465 -43
  42. metadata +15 -1
@@ -0,0 +1,136 @@
1
+ # 0035: Defer concurrent state machines and the engine printer
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ libhegel 0.47.2 offers two facilities that hegel-rust uses and this gem
10
+ does not: concurrent state machines, and a printer that builds the failure
11
+ report inside the engine. ADR 0015 counts a feature that this gem lacks as
12
+ a divergence, so each needs a recorded reason. Paths and line numbers
13
+ below are in hegeldev/hegel as of libhegel 0.47.2.
14
+
15
+ ### Concurrent state machines
16
+
17
+ hegel-rust runs a `ConcurrentStateMachine` with `run_concurrent`
18
+ (`frontends/rust/src/stateful.rs:989`). `hegel_new_state_machine` draws a
19
+ concurrency level between `min_concurrency` and `max_concurrency`.
20
+ `run_concurrent_machine` (`stateful.rs:1361`) spawns one thread per level
21
+ inside `std::thread::scope` (`stateful.rs:1415`). Each round, it hands every
22
+ worker a clone of the test case, `tc.clone()`, attributed to that worker
23
+ with `set_worker` (`stateful.rs:1449-1450`). A `ConcurrentRule`'s `apply`
24
+ takes `&M` (`stateful.rs:1097-1102`): the doc comment above it
25
+ (`stateful.rs:1086-1096`) says the
26
+ machine is "shared by reference across the worker threads, so any mutable
27
+ model state needs interior mutability", and that the feature "is intended
28
+ for advanced users. We otherwise discourage using it."
29
+
30
+ The engine calls behind this are `hegel_test_case_clone`
31
+ (`libhegel/include/hegel.h:1553`), which gives "an independent stream of the
32
+ same test case" that "can be driven concurrently from different threads",
33
+ and `hegel_test_case_set_worker` (`hegel.h:1614`), which prefixes each line
34
+ the handle records with `[worker N +X.XXXms] `.
35
+
36
+ Four things stand between this gem and a port:
37
+
38
+ 1. **Each worker needs its own context.** The threading section of
39
+ `hegel.h` (lines 101-117) says "A context must not be used concurrently
40
+ from multiple threads". hegel-rust keeps one context per thread in a
41
+ `thread_local!` (`frontends/rust/src/ffi.rs:72`). `Hegel::Runner.run`
42
+ opens one context for the whole run and threads it through every call,
43
+ so each worker would need a context of its own, and every handle
44
+ allocated from it freed before that context.
45
+ 2. **Ruby threads take turns.** `Hegel::LibHegel::Real#bind` builds each
46
+ `FFI::Function` without the `blocking:` option, so every engine call
47
+ holds the GVL for its duration. Between engine calls, Ruby threads
48
+ running pure-Ruby code switch on the VM's timer or at blocking I/O. A
49
+ short rule body that does no I/O then tends to run to completion before
50
+ another thread gets the GVL, and the interleavings a concurrent machine
51
+ exists to find seldom happen. A body that waits on I/O, a database, or a
52
+ C extension that releases the GVL would interleave.
53
+ 3. **Ractors cannot reach a shared machine.** Measured with ffi 1.17.4 on
54
+ Ruby 3.4.6: passing an unfrozen `FFI::Pointer` to `Ractor.new` raises
55
+ "cannot duplicate unbounded memory area", and passing an unfrozen
56
+ `FFI::Function` raises "cannot duplicate function instances". Both can
57
+ be made shareable with `Ractor.make_shareable`, and a shareable
58
+ `FFI::Function` is callable inside another Ractor. The handles are not
59
+ the obstacle. The machine is: hegel-rust's rules share one `&M`, and a
60
+ `Hegel::StateMachine` subclass instance with mutable state is an object
61
+ that only one Ractor can reach.
62
+ 4. **`CLAUDE.md` would change.** Its standing constraints say "The engine
63
+ is single-threaded. A context, a run, and a test case each belong to one
64
+ thread at a time." A concurrent runner needs that rule restated for
65
+ clones and per-thread contexts.
66
+
67
+ `Hegel::LibHegel::Real#new_state_machine` passes a concurrency of exactly
68
+ 1, and ADR 0021 records the sequential machine this gem runs.
69
+
70
+ ### The engine printer
71
+
72
+ hegel-rust prints each drawn value and note into the engine's document as
73
+ the test body runs. `hegel_test_case_printer` (`hegel.h:3052`) gives a
74
+ handle onto a test-case handle's print region, the `hegel_printer_*`
75
+ calls (the comment above `hegel_printer_t`, `hegel.h:538-574`, describes
76
+ the model) lay text out in groups that
77
+ break to fit a width (default 79, `hegel.h:3073`), `hegel_printer_reflow`
78
+ (`hegel.h:2910`) re-lays a flat debug string such as Ruby's `#inspect`, and
79
+ `hegel_note` (`hegel.h:3079`) appends a line. A note written while a drawn
80
+ value is mid-print is held back and placed after the value (`hegel.h`, the
81
+ comment above `hegel_note`). `hegel_test_case_block` (`hegel.h:1589`)
82
+ indents a section, and `hegel_test_case_set_worker` prefixes a worker's
83
+ lines.
84
+
85
+ This gem builds its report in Ruby. `Hegel::TestCase` records draws and
86
+ notes on the cases the engine stamps for capture (ADR 0019),
87
+ `Hegel::DrawName` names each draw from the caller's source (ADR 0005 and
88
+ ADR 0014), and `Hegel::Report` renders the list. `Hegel::LibHegel::Real`
89
+ passes a NULL output callback to `hegel_run_start`.
90
+
91
+ ## Decision
92
+
93
+ **Concurrent state machines are not ported.** `Hegel::Stateful.run` stays
94
+ sequential, at a concurrency of 1. This is a divergence under ADR 0015,
95
+ recorded here. It is to be revisited when a caller asks for it, together
96
+ with the engine printer: `hegel_test_case_set_worker` attributes
97
+ `hegel_note` lines and lines started through a printer from
98
+ `hegel_test_case_printer` (`hegel.h:1595-1599`), so a report that reads
99
+ across workers comes from the engine document.
100
+
101
+ **The engine printer is not adopted for draws.** `Hegel::Report` keeps
102
+ building the report. Moving it into the engine would change two things
103
+ that work today:
104
+
105
+ - `Hegel::Report.assign_names` gives a name a counter only when that name
106
+ appears more than once among the whole case's draws (ADR 0014 decides
107
+ which draws get a name at all). At draw time, the rest of the case has
108
+ not run, so the printed name is not yet known.
109
+ `hegel_printer_deferred` could hold each name open until the case ends,
110
+ at the price of one hole per draw and a resolve step this gem does not
111
+ have.
112
+ - The report's shape, which `test/hegel/test_report.rb` pins line by line,
113
+ and which a caller reads on every failure.
114
+
115
+ The gains, width wrapping, a note placed after a value whose generation
116
+ emitted it, indented step blocks, and worker prefixes, do not yet outweigh
117
+ that.
118
+
119
+ A narrower change stays open: `Hegel::Report` could lay out a long value
120
+ through a standalone document, `hegel_printer_new` (`hegel.h:2746`), and
121
+ `hegel_printer_reflow` over the value's `#inspect`, then read it back with
122
+ `hegel_printer_value` (`hegel.h:3011`). That brings width wrapping without
123
+ moving naming or the report into the engine.
124
+
125
+ ## Consequences
126
+
127
+ A caller who wants to exercise concurrency can start Ruby threads inside a
128
+ single rule. The engine then neither chooses nor shrinks the interleaving.
129
+
130
+ A long drawn value prints on one line, as `#inspect` writes it.
131
+
132
+ A later feature that wants an indented section of the report, the effect
133
+ `hegel_test_case_block` gives in the engine document, builds that indent in
134
+ `Hegel::Report`.
135
+
136
+ A later record that ports either facility supersedes this one.
data/docs/architecture.md CHANGED
@@ -18,7 +18,7 @@ recorded, and prints a blob that `Hegel.test(reproduce_failure:)` replays.
18
18
  The report comes from the cases the engine stamps for capture during the
19
19
  run; see
20
20
  [ADR 0019](adr/0019-report-failures-from-the-cases-the-engine-stamps.md).
21
- The generator layer covers twenty-five generators composing through `map`
21
+ The generator layer covers thirty-four generators composing through `map`
22
22
  and `filter`, reachable bare through `Hegel::Syntax::Methods`. A test case
23
23
  can discard itself with `tc.assume` or `tc.reject`. A run can be shaped by
24
24
  `phases:`, `suppress_health_check:`, `report_multiple_failures:`, and the
@@ -43,10 +43,13 @@ schedules it.
43
43
 
44
44
  ## Where meaning comes from
45
45
 
46
- libhegel is hegel-rust's native engine. hegel-rust's Cargo workspace builds
47
- it from the `hegel-c` directory, as the crate `hegeltest-c`, whose header is
48
- `hegel-c/include/hegel.h`. That header defines the ABI this gem calls: what
49
- each function does, what its error codes mean, and who owns each pointer.
46
+ libhegel is the native engine of every Hegel frontend. Since 0.47.2, the
47
+ hegeldev/hegel Cargo workspace builds it from the `libhegel` directory, as
48
+ the crate `hegeltest-c`, whose header is `libhegel/include/hegel.h`. The
49
+ same workspace holds the Rust frontend, hegel-rust, at `frontends/rust`.
50
+ Releases up to 0.47.1 came from hegeldev/hegel-rust, where the engine was at
51
+ `hegel-c`. The header defines the ABI this gem calls: what each function
52
+ does, what its error codes mean, and who owns each pointer.
50
53
 
51
54
  The Java, Go, TypeScript, OCaml, and C++ implementations bind the same
52
55
  engine. Each works under a constraint hegel-rust does not have: a dynamic
@@ -118,6 +121,12 @@ test-case handle, in nested `ensure`s so a failure releasing one cannot skip
118
121
  the other. See
119
122
  [ADR 0011](adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md).
120
123
 
124
+ A compiled grammar outlives the test case that compiled it, since compiling
125
+ costs far more than drawing. `Hegel::Runner.run` keeps the run's grammars
126
+ in a `Hegel::GrammarCache` and frees them after the run and before the
127
+ context. See
128
+ [ADR 0033](adr/0033-cache-compiled-grammars-for-the-length-of-a-run.md).
129
+
121
130
  ## A rule's own control flow
122
131
 
123
132
  Inside a stateful rule, `tc.assume(false)` means something narrower than it
@@ -132,6 +141,15 @@ exception can be raised inside an open span, which is why
132
141
  than beside the run loop: two `rescue Exception` sites need it now, and both
133
142
  have to let those four straight through before anything else.
134
143
 
144
+ ## An abandoned recursive attempt
145
+
146
+ `recursive` unwinds through open spans on purpose. When an attempt outgrows its leaf budget, `Hegel::TestCase` raises
147
+ `Hegel::LeafBudgetExceeded` and holds it until `hegel_recursion_retry`.
148
+ Meanwhile `stop_span` makes no native call, so the retry can discard the
149
+ spans the attempt opened, and every other call that takes the test-case
150
+ handle raises the held exception again instead of reaching the engine. See
151
+ [ADR 0032](adr/0032-suppress-span-closes-while-an-abandoned-recursion-attempt-unwinds.md).
152
+
135
153
  ## Generator validation happens at draw time
136
154
 
137
155
  hegel-rust's own contributor documentation states the rule for its
@@ -171,12 +189,16 @@ fields.
171
189
 
172
190
  ## Answered
173
191
 
174
- A drawn string arrives as a pointer and a length, is not NUL-terminated,
175
- and can hold interior NUL bytes, because the drawn alphabet can include
176
- U+0000. Read by length. Measured against 0.45.0: an alphabet pinned to
177
- U+0000 produces a three-byte draw that reads as `""` when taken up to the
178
- first NUL and as three NUL bytes when taken by its reported length. Every
179
- string observed reports `valid_encoding?` after `force_encoding(UTF-8)`.
192
+ A drawn string arrives as a pointer and a length, and can hold interior
193
+ NUL bytes, because the drawn alphabet can include U+0000. Read by length.
194
+ Before libhegel 0.46 the string had no NUL terminator. Since 0.46 every
195
+ string the engine hands back is a `hegel_string_t` with a trailing NUL that
196
+ its length excludes, but an interior NUL still ends the string early for a
197
+ reader that stops at the first NUL. Measured against 0.45.0: an alphabet
198
+ pinned to U+0000 produces a three-byte draw that reads as `""` when taken
199
+ up to the first NUL and as three NUL bytes when taken by its reported
200
+ length. Every string observed reports `valid_encoding?` after
201
+ `force_encoding(UTF-8)`.
180
202
 
181
203
  Prism recovers a draw's assignment target through a method chain, an
182
204
  instance-variable assignment, and an assignment spanning several lines.
data/lib/hegel/errors.rb CHANGED
@@ -31,6 +31,30 @@ module Hegel
31
31
  # Descends from Exception for the same reason as StopTest above.
32
32
  class AssumeFailed < Exception; end
33
33
 
34
+ # Raised for HEGEL_E_RETRY from hegel_recursion_leaf: the attempt at a
35
+ # recursive value outgrew its leaf budget and must unwind to the
36
+ # Generators::RecursiveGenerator that owns +recursion+, which retries it.
37
+ # Internal to this library. See docs/adr/0032.
38
+ class LeafBudgetExceeded < Exception # :nodoc:
39
+ attr_reader :recursion
40
+
41
+ def initialize(recursion)
42
+ super("hegel: a recursive draw outgrew its leaf budget; the attempt is retried")
43
+ @recursion = recursion
44
+ end
45
+ end
46
+
47
+ # Raised by Hegel::TestCase#repeat when the engine ends its loop: the
48
+ # repetition ran as long as the engine wanted it to and nothing failed,
49
+ # so Hegel::Runner.classify reports the case VALID. hegel-rust raises its
50
+ # own LoopDone at the same point, and its run lifecycle maps it to VALID
51
+ # the same way.
52
+ #
53
+ # Descends from Exception for the same reason as StopTest above: a
54
+ # `rescue => e` written after the loop must not turn the end of the loop
55
+ # into ordinary control flow. See docs/adr/0034.
56
+ class LoopDone < Exception; end
57
+
34
58
  # standard:enable Lint/InheritException
35
59
 
36
60
  # Say the process is ending, not that a property failed. Every
@@ -18,6 +18,41 @@ module Hegel
18
18
  raise NotImplementedError, "#{self.class} must implement #do_draw"
19
19
  end
20
20
 
21
+ # The span label of one attempt at a filtered draw, shared by every
22
+ # generator that runs attempts.
23
+ FILTER_ATTEMPT_LABEL = LibHegel.label_from_name("hegel-ruby.Hegel::Generator.filter_attempt")
24
+
25
+ # Draws one value that +predicate+ accepts, the draw behind #filter, which
26
+ # calls this on its source generator. The default makes up to
27
+ # Filtered::MAX_ATTEMPTS attempts, each a #do_draw in a span of its own
28
+ # that closes as discarded when the predicate rejects the value, then
29
+ # discards the test case (Hegel::AssumeFailed).
30
+ #
31
+ # A generator whose draws have effects a rejected value must not keep
32
+ # overrides this and runs the attempts itself, as
33
+ # Hegel::Stateful::Pool::ValuesConsumed does. One that only forwards
34
+ # #do_draw to another generator forwards this too, so that a filter around
35
+ # it still reaches the other generator's attempts.
36
+ def do_draw_filtered(tc, predicate)
37
+ Filtered::MAX_ATTEMPTS.times do
38
+ # Each variable is set again before it is read, or stays nil, which
39
+ # acts like false (!nil == !false).
40
+ # The initializer also shows intent: discard the span when an
41
+ # exception skips the reassignment.
42
+ accepted = false # mutineer:disable-line statement_removal
43
+ value = nil # mutineer:disable-line statement_removal, boolean_literal
44
+ tc.start_span(FILTER_ATTEMPT_LABEL)
45
+ begin
46
+ value = do_draw(tc)
47
+ accepted = predicate.call(value)
48
+ ensure
49
+ tc.stop_span(discard: !accepted)
50
+ end
51
+ return value if accepted
52
+ end
53
+ raise Hegel::AssumeFailed
54
+ end
55
+
21
56
  # The span label naming this generator's draws to the engine, which
22
57
  # treats two spans with one label as draws of one generator when it
23
58
  # shrinks. A generator built from others overrides this and combines
@@ -30,7 +65,7 @@ module Hegel
30
65
  # The class name, prefixed with this binding's name as hegel.h asks, to
31
66
  # keep clear of the engine's own hegel.<kind> labels.
32
67
  def self.label
33
- LibHegel.label_from_name("hegel-ruby.#{name}")
68
+ @label ||= LibHegel.label_from_name("hegel-ruby.#{name}")
34
69
  end
35
70
 
36
71
  # Combines this generator's class label with the labels of +parts+, the
@@ -92,13 +127,17 @@ module Hegel
92
127
  end
93
128
  end
94
129
 
95
- # Generator#filter's result. Retries the source generator, each
96
- # attempt in its own span, up to MAX_ATTEMPTS times before discarding
97
- # the test case (Hegel::AssumeFailed) -- the same budget hegel-rust's
98
- # Filtered spends before calling assume(false)
99
- # (src/generators/generators.rs), ported here rather than reinvented so
100
- # a predicate that (almost) never holds fails the same way in both
101
- # bindings.
130
+ # Generator#filter's result. Hands its predicate to the source
131
+ # generator's #do_draw_filtered, which makes up to MAX_ATTEMPTS attempts
132
+ # before discarding the test case (Hegel::AssumeFailed) -- the same
133
+ # budget hegel-rust's Filtered spends before calling assume(false)
134
+ # (frontends/rust/src/generators/generators.rs), ported here rather than
135
+ # reinvented so a predicate that (almost) never holds fails the same way
136
+ # in both bindings.
137
+ #
138
+ # A filter over a filter combines the two predicates into one draw, so
139
+ # the pair spends MAX_ATTEMPTS attempts between them, not that many per
140
+ # level.
102
141
  #
103
142
  # A predicate that never holds does not loop forever: libhegel's own
104
143
  # health check aborts a run that discards too many test cases in a
@@ -116,23 +155,11 @@ module Hegel
116
155
  attr_reader :label
117
156
 
118
157
  def do_draw(tc)
119
- MAX_ATTEMPTS.times do
120
- # Each variable is set again before it is read, or stays nil, which
121
- # acts like false (!nil == !false).
122
- # The initializer also shows intent: discard the span when an
123
- # exception skips the reassignment.
124
- accepted = false # mutineer:disable-line statement_removal
125
- value = nil # mutineer:disable-line statement_removal, boolean_literal
126
- tc.start_span(label)
127
- begin
128
- value = @source.do_draw(tc)
129
- accepted = @block.call(value)
130
- ensure
131
- tc.stop_span(discard: !accepted)
132
- end
133
- return value if accepted
134
- end
135
- raise Hegel::AssumeFailed
158
+ @source.do_draw_filtered(tc, @block)
159
+ end
160
+
161
+ def do_draw_filtered(tc, predicate)
162
+ @source.do_draw_filtered(tc, ->(value) { @block.call(value) && predicate.call(value) })
136
163
  end
137
164
  end
138
165
  end