statifier 0.0.1 → 0.1.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. data/statifier.gemspec +0 -34
data/README.md CHANGED
@@ -1,43 +1,628 @@
1
1
  # Statifier
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/statifier`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ [![MIT License][badge_license_url]](LICENSE)
4
+ [![CI][badge_ci_url]](https://github.com/riddler/statifier-rb/actions/workflows/ci.yml)
4
5
 
5
- TODO: Delete this and the text above, and describe your gem
6
+ The interpreter core of an SCXML statechart engine, for Ruby. Give it a chart
7
+ and an event and it answers the chart's next configuration and what the host
8
+ should do about it, as plain data. The reference implementation is
9
+ [statifier-ex](https://github.com/riddler/statifier-ex), written in Elixir,
10
+ and this gem is checked against that implementation's conformance corpus.
6
11
 
7
- ## Installation
12
+ ## Why this package
8
13
 
9
- Add this line to your application's Gemfile:
14
+ A chart is authored once and should decide the same way wherever it runs. This
15
+ gem is the same algorithm as the reference, held case by case to the
16
+ reference's conformance corpus, with no process, no real clock, no I/O and no
17
+ storage of its own: state is an immutable value, a step is
18
+ `(state, event) -> (state, effects)`, and the effects are plain data. A host
19
+ keeps the state however it likes, delivers what a chart sends, and passes its
20
+ own processors and clock with each call. Persistence, a real clock and
21
+ delivering a send stay the host's.
22
+
23
+ ## Install
24
+
25
+ Add `statifier` to your Gemfile:
10
26
 
11
27
  ```ruby
12
- gem 'statifier'
28
+ gem "statifier", "~> 0.1.0"
13
29
  ```
14
30
 
15
31
  And then execute:
16
32
 
17
- $ bundle
33
+ $ bundle install
18
34
 
19
35
  Or install it yourself as:
20
36
 
21
37
  $ gem install statifier
22
38
 
23
- ## Usage
39
+ The constraint matches the 0.1 series, which begins with the 0.1.0 release of
40
+ this rewrite. The 0.0.1 version on rubygems.org is a stub from 2019 that
41
+ carries no statechart code, and the constraint excludes it.
24
42
 
25
- TODO: Write usage instructions here
43
+ The gem has one runtime dependency, [`predicator`](https://github.com/riddler/predicator-rb)
44
+ (`>= 2.0`, `< 3`), which evaluates the conditions, expressions and script
45
+ bodies a chart carries.
26
46
 
27
- ## Development
47
+ ## Basic usage
48
+
49
+ A host compiles a chart once and drives it with the other calls. The chart
50
+ below starts in `a`, where it arms a ten-second timer; the timer moves it to
51
+ `b`, `e.inc` counts, and `e.go` stops it in the final state `c`, answering the
52
+ count. Every Ruby example on this page is run against the code in this
53
+ repository, and a line ending in `# =>` is an assertion that the value on its
54
+ left equals the value on its right.
55
+
56
+ ```ruby
57
+ require "statifier"
58
+
59
+ source = <<~SCXML
60
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a">
61
+ <datamodel><data id="count" expr="0"/></datamodel>
62
+ <state id="a">
63
+ <onentry><send id="t" event="e.timeout" delay="10s"/></onentry>
64
+ <onexit><cancel sendid="t"/></onexit>
65
+ <transition event="e.inc" target="a"><assign location="count" expr="count + 1"/></transition>
66
+ <transition event="e.timeout" target="b"/>
67
+ <transition event="e.go" target="c"/>
68
+ </state>
69
+ <state id="b"><transition event="e.go" target="c"/></state>
70
+ <final id="c"><donedata><param name="count" expr="count"/></donedata></final>
71
+ </scxml>
72
+ SCXML
73
+
74
+ # Compile once: the machine is a frozen value, shared by every execution
75
+ compiled = Statifier.compile(source)
76
+ compiled.ok? # => true
77
+ machine = compiled.value
78
+
79
+ # The host names the session; each call answers a result holding the state and its effects
80
+ started = Statifier.start(machine, session_id: "s1")
81
+ started.value.effects.map { |effect| effect.class.name.split("::").last } # => ["DatamodelInit", "DatamodelChange", "SendDelayed"]
82
+ Statifier.configuration(started.value.state).value # => ["a"]
83
+
84
+ counted = Statifier.step(started.value.state, "e.inc")
85
+ Statifier.configuration(counted.value.state).value # => ["a"]
86
+
87
+ # advance moves the virtual clock and fires every timer due by then
88
+ lapsed = Statifier.advance(counted.value.state, 10_000)
89
+ Statifier.configuration(lapsed.value.state).value # => ["b"]
90
+
91
+ stopped = Statifier.step(lapsed.value.state, "e.go")
92
+ Statifier.done?(stopped.value.state).value.done # => true
93
+ Statifier.done?(stopped.value.state).value.donedata # => { "count" => 1 }
94
+ Statifier.done?(stopped.value.state).value.configuration # => ["c"]
95
+ ```
96
+
97
+ ## The driver surface
98
+
99
+ Ten calls make up the public surface. Nine of them answer a
100
+ `Statifier::Result`, which has `ok?`, `value` and `error`. A successful result has `error == nil`
101
+ and a failed one has `value == nil`; read `ok?` and not the value.
102
+
103
+ | Call | Answers, in the result |
104
+ |---|---|
105
+ | `Statifier.compile(source)` | the machine, or the refusals of the first stage that refused |
106
+ | `Statifier.start(machine, **options)` | a `Statifier::Driver::Moved`: the `state` and the `effects` of starting the chart |
107
+ | `Statifier.step(state, name, data:, processors:, clock:)` | a `Moved`, after one external event and every event the chart queues for itself; `:not_running` once the chart has stopped |
108
+ | `Statifier.advance(state, milliseconds, processors:, clock:)` | a `Moved`, after the virtual clock moves and every timer due fires, earliest first; `:invalid_duration` for a negative, infinite or non-numeric duration |
109
+ | `Statifier.report_send_failed(machine, state, send, **options)` | a `Moved`, after the chart takes `error.communication` for a send the host could not deliver; `:not_running` once the chart has stopped, `:halted` while a round budget has halted it |
110
+ | `Statifier.configuration(state)` | the active state ids, sorted, as a frozen list |
111
+ | `Statifier.done?(state)` | a `Statifier::Driver::DoneStatus`: `done`, and once the chart has stopped its `donedata` and `configuration` |
112
+ | `Statifier.export_position(state)` | the position, plain data ready for JSON, or a `Statifier::Position::Refusal` |
113
+ | `Statifier.import_position(machine, position, processors:)` | a state, or a `Position::Refusal` |
114
+
115
+ The tenth, `Statifier.check_accepts(machine, declared_events)`, refuses
116
+ nothing and answers a `Statifier::AcceptsCheck` directly (below).
117
+
118
+ `start` takes these options, each optional: `session_id:` (the id `_sessionid`
119
+ reads; the driver mints one when it is absent), `datamodel:` (starting values
120
+ for the datamodel), `processors:`, `max_macrostep_rounds:` (a positive Integer
121
+ or `:infinity`; 10000 when absent), `trace:` (whether the chart answers trace
122
+ effects; false when absent) and `clock:`. The state is an immutable value that
123
+ a call answers and the next call takes; a host reads it only through
124
+ `configuration` and `done?`, and its members are internal.
125
+
126
+ ### Declared events
127
+
128
+ `Statifier.check_accepts` compares the event names a host declares a chart
129
+ accepts with the event descriptors the chart listens for, on its transitions
130
+ from every state some path can enter. A descriptor matches a name as
131
+ transition selection matches an event: `e`, `e.`, `e.*` and `*` all match
132
+ `e.go`, and `e.gone` does not. It answers two frozen lists:
133
+ `unreachable`, the declared names no descriptor matches, in the declaration's
134
+ order; and `undeclared`, the descriptors that match no declared name, in
135
+ transition order (a state's own transitions before its children's). With `nil` for the declaration both lists are empty.
136
+
137
+ ```ruby
138
+ machine = Statifier.compile(<<~SCXML).value
139
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
140
+ <state id="a"><transition event="e.go" target="b"/></state>
141
+ <state id="b"><transition event="e.back" target="a"/></state>
142
+ </scxml>
143
+ SCXML
144
+
145
+ check = Statifier.check_accepts(machine, ["e.go", "e.stop"])
146
+ check.unreachable # => ["e.stop"]
147
+ check.undeclared # => ["e.back"]
148
+ ```
149
+
150
+ ### Refusals
151
+
152
+ A call never raises for something a chart or a source does. A refusal is a
153
+ value:
154
+
155
+ ```ruby
156
+ stopped = Statifier.step(lapsed.value.state, "e.go")
157
+ Statifier.step(stopped.value.state, "e.go").error # => :not_running
158
+ Statifier.advance(lapsed.value.state, -1).error # => :invalid_duration
159
+ ```
160
+
161
+ `Statifier.compile` answers the list of refusals of the first of four stages
162
+ that refused the source: the XML reader, the lowering, the validator and the
163
+ compiler (when predicator refuses an expression). Their classes are internal.
164
+ Each refusal carries a `reason` symbol, a `message` for a person and a
165
+ `location`, the span of the source it names (`start_line`, `start_column`,
166
+ `start_offset`, `end_line`, `end_column` and `end_offset`); all but the
167
+ reader's carry a `detail` Hash too:
168
+
169
+ ```ruby
170
+ Statifier.compile("<scxml>").error.first.reason # => :unclosed_element
171
+
172
+ unresolved = Statifier.compile(<<~SCXML)
173
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
174
+ <state id="a"><transition event="e.go" target="zz"/></state>
175
+ </scxml>
176
+ SCXML
177
+ unresolved.error.first.reason # => :unresolved_target
178
+
179
+ badcond = Statifier.compile(<<~SCXML)
180
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
181
+ <state id="a"><transition event="e.go" cond="1 +" target="a"/></state>
182
+ </scxml>
183
+ SCXML
184
+ badcond.error.first.reason # => :expression_compile_error
185
+ ```
186
+
187
+ A validator warning never refuses: a chart that compiles carries its warnings
188
+ on the machine, `machine.warnings`, in document order.
189
+
190
+ A bug in the call itself raises: a `TypeError` for an argument of the wrong
191
+ kind (a state that is not a driver state, an event name that is not a String)
192
+ and an `ArgumentError` for an option the call does not name or processors that
193
+ register other send types than the chart started with.
194
+
195
+ ## Effects as data
196
+
197
+ Every effect a call answers is a frozen `Data` value under `Statifier::Effect`,
198
+ named for what the host does with it. The reference's vocabulary is ported
199
+ field for field, and each effect carries the `macrostep`, `microstep` and
200
+ `round` counters as they stood when it was made. Every effect the chart's
201
+ runs answered is among the call's effects, in the order made, whatever the
202
+ driver did with it:
203
+
204
+ | Effect | What the driver does | What the host does with it |
205
+ |---|---|---|
206
+ | `Send` of a type the host registered | hands it to that type's processor, `deliver` | delivers it, in its processor |
207
+ | `Send` of the SCXML type, or none | routes it by its `target`: to the chart's own external queue, its internal queue, its parent or a live child | reads it, if it wants a record |
208
+ | `SendDelayed` of a type the host registered | hands it to that type's processor, which holds it | schedules it, in its processor |
209
+ | `SendDelayed` of the SCXML type, or none | holds it as a pending timer until `advance` reaches it | learns of an event due later |
210
+ | a send of any other type | nothing: the chart refuses it with `error.execution`, and no effect is answered | nothing |
211
+ | `Cancel` | drops every pending timer under its `send_id`, and tells a processor holding a delayed send under it, once | learns that the delayed send is dropped |
212
+ | `Invoke`, `CancelInvoke`, `Autoforward` | runs an invocation of the SCXML type in process as a child session, stops it, or forwards an event to it; any other type raises `error.execution` | learns of an invocation the core starts, stops or forwards an event to |
213
+ | `Log` | nothing | records the label and the evaluated value |
214
+ | `DatamodelInit`, `DatamodelChange` | nothing | learns the datamodel as the chart starts, and each write after |
215
+ | `BudgetExhausted` | queues later external events without taking them; a fired timer to the chart itself joins its external queue, and any other is dropped | learns that a macrostep spent its round budget; the chart keeps running |
216
+ | `Done` | discards the pending timers and the queued external events | learns that the chart stopped, with the top-level final's donedata |
217
+ | `Child` | answers each effect an invoked child's execution answered, tagged with the child's `session_id`, a grandchild's with its own | reads the child's effects |
218
+
219
+ An error is never an effect: `error.execution` and `error.communication` are
220
+ events the chart takes from its internal queue, which a host sees in what the
221
+ chart does next, or as a `Trace::EventDequeued` effect for a chart started with
222
+ `trace: true`.
223
+
224
+ The `Trace` effects follow the interpreter step by step and are answered only
225
+ for a chart started with `trace: true`. An event is a `Statifier::Event`, the
226
+ one value every queue holds.
227
+
228
+ The surface is fixed: the ten calls, the values above and below, and, after
229
+ `require "statifier/basichttp"`, the Basic HTTP processor's. Every other
230
+ constant in the gem is private, and `test/surface_test.rb` lists the public
231
+ surface whole, failing on an addition or a removal. A value whose class is
232
+ private, such as the machine, a driver state or a compile refusal, is read by
233
+ its documented members.
234
+
235
+ The driver delivers nothing itself that leaves the process. A send of a type
236
+ the host registers is handed to the host's processor, an object that answers
237
+ `deliver(send, event, context)` (and, optionally, `cancel(cancel, context)`).
238
+ Processors are passed per call, by send type, and name the same types for the
239
+ whole life of a chart; they are never stored in the state:
240
+
241
+ ```ruby
242
+ class Outbox
243
+ attr_reader :seen
244
+
245
+ def initialize = @seen = []
28
246
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
247
+ def deliver(send, event, context) = @seen << [send.target, event.name, context.session_id]
248
+ end
30
249
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
250
+ notify = Statifier.compile(<<~SCXML).value
251
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
252
+ <state id="a">
253
+ <transition event="e.go" target="b"><send type="outbox" event="e.out" target="t1"/></transition>
254
+ </state>
255
+ <state id="b"/>
256
+ </scxml>
257
+ SCXML
258
+
259
+ outbox = Outbox.new
260
+ processors = { "outbox" => outbox }
261
+ waiting = Statifier.start(notify, session_id: "s1", processors:)
262
+ sent = Statifier.step(waiting.value.state, "e.go", processors:)
263
+ outbox.seen # => [["t1", "e.out", "s1"]]
264
+ sent.value.effects.map { |effect| effect.class.name.split("::").last } # => ["Send"]
265
+ ```
266
+
267
+ A send a processor cannot deliver comes back to the chart as
268
+ `error.communication`, its `sendid` the send's id whether or not the author
269
+ named it. A processor that knows while it is handed the send answers
270
+ `Statifier::Driver::DeliveryFailure.new(reason: ...)` from `deliver`, and the
271
+ chart takes the error within that call. A host that learns of the miss later
272
+ reports it with the send it was handed; the reason is the host's own and the
273
+ chart is not handed it:
274
+
275
+ ```ruby
276
+ failed = Statifier.report_send_failed(notify, sent.value.state, sent.value.effects.first,
277
+ reason: "unreachable", processors:)
278
+ failed.ok? # => true
279
+ ```
280
+
281
+ Once the chart has stopped the report is refused with `:not_running`, and while
282
+ a round budget has halted it with `:halted`; nothing is written, and the miss is
283
+ the host's to record.
284
+
285
+ An `<invoke>` of the SCXML type runs in process: the driver compiles the
286
+ `<content>` markup and starts it as a child session on the parent's virtual
287
+ clock, its session id the parent's, a dot and the invoke id. `src` is never
288
+ fetched, and an invoke of any other type raises `error.execution` inside the
289
+ chart.
290
+
291
+ ## Position export and import
292
+
293
+ A position is where an execution stands, written as plain data in the
294
+ reference's string-id vocabulary: a Hash with String keys, whose values are
295
+ Strings, Integers, booleans, and Arrays and Hashes of them. A host stores it as
296
+ JSON, and imports it into a machine, which may be a fresh compile of the same
297
+ source, to continue from there. Every datamodel value in it is text in
298
+ predicator's tagged-value encoding, so a date, a duration or an undefined value
299
+ survives JSON:
300
+
301
+ ```ruby
302
+ position = Statifier.export_position(counted.value.state).value
303
+ position["configuration"] # => ["a"]
304
+ position["datamodel"]["count"] # => "1"
305
+
306
+ require "json"
307
+
308
+ stored = JSON.generate(position)
309
+ restored = Statifier.import_position(Statifier.compile(source).value, JSON.parse(stored), processors: {})
310
+ restored.ok? # => true
311
+
312
+ # The restored execution steps as the original does
313
+ from_original = Statifier.step(counted.value.state, "e.inc")
314
+ from_restored = Statifier.step(restored.value, "e.inc")
315
+ Statifier.export_position(from_restored.value.state).value == Statifier.export_position(from_original.value.state).value # => true
316
+ ```
317
+
318
+ A position holds what the interpreter holds between two calls and nothing of
319
+ the driver's own: no pending timer, no queued external event, no send a
320
+ processor holds, no virtual time and no donedata. An import starts those as a
321
+ fresh start does, so a host that needs a timer keeps it itself. An export is
322
+ refused with a `Statifier::Position::Refusal` (`:internal_queue_not_empty`,
323
+ `:unnameable_states` or `:unencodable_value`) and an import with
324
+ `:malformed_export` or `:unknown_state_ids`:
325
+
326
+ ```ruby
327
+ Statifier.import_position(machine, {}).error.reason # => :malformed_export
328
+ ```
329
+
330
+ The round trip is proved over the conformance corpus, not only by the example
331
+ above: the test suite takes a point after the start and after every step of
332
+ each scion case, exports the position there, carries it through JSON, imports
333
+ it into a fresh compile and drives the remaining steps, requiring the imported
334
+ execution to agree with the unbroken one at every later step
335
+ (`test/conformance/position_property.rb`).
336
+
337
+ ## The Basic HTTP processor
338
+
339
+ The HTTP side of the gem is opt-in: `require "statifier/basichttp"` loads it,
340
+ and `require "statifier"` alone loads none of it and no HTTP library. It holds
341
+ the Basic HTTP Event I/O Processor of SCXML appendix C.2, the decoder for the
342
+ requests it sends, and the HTTP transport the processor makes its requests
343
+ through.
344
+
345
+ ### Registering the processor
346
+
347
+ `Statifier::BasicHTTP::Processor.new` takes the address the host's own server
348
+ answers at (`base_url:`), a `report:` callable that takes each send the
349
+ processor could not deliver, and optionally a `transport:` (below; a
350
+ `NetHTTPTransport` when absent). Its `send_types` names it under the
351
+ processor's URI, `Statifier::BasicHTTP::EVENT_PROCESSOR`
352
+ (`http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor`), and under the short
353
+ form `basichttp`, so a chart may name either; pass them as `processors:` on
354
+ every call. A session's `_ioprocessors` then carries an entry under each, both
355
+ with the same `location`: the base URL, `/` and the session's id.
356
+
357
+ ```ruby
358
+ require "statifier/basichttp"
359
+
360
+ misses = []
361
+ http = Statifier::BasicHTTP::Processor.new(base_url: "http://localhost:8080/scxml", report: misses.method(:<<))
362
+ processors = http.send_types
363
+
364
+ machine = Statifier.compile(<<~SCXML).value
365
+ <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0">
366
+ <state id="a">
367
+ <onentry>
368
+ <send event="e.go" type="basichttp" target="http://localhost:8080/scxml/s2">
369
+ <param name="reply" expr="_ioprocessors['basichttp']['location']"/>
370
+ </send>
371
+ </onentry>
372
+ <transition event="error.communication" target="failed"/>
373
+ </state>
374
+ <state id="failed"/>
375
+ </scxml>
376
+ SCXML
377
+
378
+ moved = Statifier.start(machine, session_id: "s1", processors:).value
379
+ # The POST was made while the call ran; a miss is reported back after it returns
380
+ misses.each do |miss|
381
+ moved = Statifier.report_send_failed(machine, moved.state, miss.effect, reason: miss.reason, processors:).value
382
+ end
383
+ ```
384
+
385
+ ### What a send becomes
386
+
387
+ Each send the processor is handed becomes one POST to its target, made once
388
+ through the transport while the call that handed it runs. The request is built
389
+ as the reference's processor builds it, by the shape of the send's data, with
390
+ one difference: names and keys always stand in byte order, which is the
391
+ reference's order for a map of 32 keys or fewer, while it lists a larger map in
392
+ an order of its own:
393
+
394
+ | The send's data | Body | Content type | The event name |
395
+ |---|---|---|---|
396
+ | a Hash (its `namelist` and `<param>` values) | each name and value as a form field, in byte order of the names | `application/x-www-form-urlencoded` | the form field `_scxmleventname`, first |
397
+ | none | the event name alone, as a form field | `application/x-www-form-urlencoded` | the form field `_scxmleventname` |
398
+ | any other value (a `<content>` body) | the value written as text | `text/plain` | `_scxmleventname` in the target's query string |
399
+
400
+ A send with no event name carries no `_scxmleventname`. A value is written as
401
+ text the same way wherever it goes:
402
+
403
+ | Value | Text |
404
+ |---|---|
405
+ | a String | as it is |
406
+ | `nil` | `null` |
407
+ | `Predicator::UNDEFINED` | the empty string |
408
+ | `true`, `false` | `true`, `false` |
409
+ | an Integer | its decimal digits |
410
+ | a Float | its shortest digits, as the reference spells them: `1.5`, `2.0`, `1.0e15`, `1.0e-7` |
411
+ | a list or a map | JSON text, `[1,2]` or `{"a":1}`, a map's members in byte order of their keys and `Predicator::UNDEFINED` inside as `null` |
412
+ | a date, a datetime, a duration, or a list or a map holding a value with no JSON form | predicator's tagged-value text, or the empty string when that encoder refuses the value |
413
+
414
+ Every request also carries the send's deduplication key in the
415
+ `scxml-send-key` header: the session's id, the send id, the macrostep, the
416
+ microstep, the round, the content index, the owner (`onentry.2.0`,
417
+ `transition.4`) and the ordinal, joined by `/`, the first two percent-encoded.
418
+ The same send carries the same key each time, so a receiver that deduplicates
419
+ on it takes each send once.
420
+
421
+ ### When a send fails
422
+
423
+ A send with no target fails within the call that handed it, as appendix C.2.2
424
+ requires: the chart takes `error.communication` at the send's place and no
425
+ request is made. Any other miss is handed to `report:` as a
426
+ `Statifier::BasicHTTP::Failure` (its `session_id`, the send as `effect`, a
427
+ `reason` and a `detail`) before the processor returns, as the reference's
428
+ processor reports a miss to the sending session, which takes it after the step
429
+ it is in. The host
430
+ passes `effect` to `Statifier.report_send_failed` once the call has returned,
431
+ with the state that call answered:
432
+
433
+ | `reason` | When | `detail` |
434
+ |---|---|---|
435
+ | `:http_status` | the response's status is outside 2xx | the `Response` |
436
+ | `:transport_error` | the transport raised, or answered something other than a `Response` | the error |
437
+ | `:invalid_target` | the target is not a String | the target |
438
+ | `:no_timer` | the send has a delay | `nil` |
439
+
440
+ The processor holds no timer, so a delayed send is reported rather than held,
441
+ where the reference's processor holds it on a timer of its own.
442
+
443
+ ### The receive side
444
+
445
+ `Statifier::BasicHTTP.decode` turns a request a host's own server took into the
446
+ event it carries, as the reference's decoder does; the gem runs no server. It
447
+ takes the method, the content type, the body, the query string and the
448
+ `scxml-send-key` header's value, and answers a `Statifier::Result` holding a
449
+ `Statifier::Event` or a `Statifier::BasicHTTP::Refusal`:
450
+
451
+ | Request | Event |
452
+ |---|---|
453
+ | a method other than POST | refused, `:method_not_allowed`: answer 405 with `Allow: POST` |
454
+ | a query string or body that does not decode to UTF-8 | refused, `:not_utf8`: answer 400 |
455
+ | an `scxml-send-key` that is not eight fields joined by `/` | refused, `:malformed_send_key`: answer 400 |
456
+ | a `_scxmleventname` field, in the query string or a form body | its first value, the query string's first, is the name |
457
+ | no `_scxmleventname` | the name is `HTTP.POST` |
458
+ | a form body | its other fields and the query string's are the data, a Hash, each value read as a `<content>` body's text is (`2` is the number 2), the last of a repeated name winning |
459
+ | a body of any other content type, JSON included | the body is the data, read the same way; the query string gives the name only |
460
+
461
+ The event's `origintype` is `EVENT_PROCESSOR`. A server resolves the request's
462
+ location to the session it holds, passes the event's `name` and `data` to
463
+ `Statifier.step`, and answers 204; deduplicating on the `scxml-send-key` value
464
+ is the server's. `Statifier.step` takes a name and data, so the chart reads no
465
+ `_event.origintype` for the event.
466
+
467
+ ```ruby
468
+ decoded = Statifier::BasicHTTP.decode(method: "POST", content_type: "application/x-www-form-urlencoded",
469
+ body: "_scxmleventname=e.go&n=2", query: nil)
470
+ decoded.value.name # => "e.go"
471
+ decoded.value.data # => {"n" => 2}
472
+ ```
473
+
474
+ ### The HTTP transport
475
+
476
+ A transport is anything that responds to `call`: a lambda, a method object or
477
+ an object. It takes one request and makes it, and either answers the response
478
+ that came back or raises the transport error:
479
+
480
+ - **The request**, `Statifier::BasicHTTP::Request`, a frozen value with
481
+ `http_method` (an HTTP token such as `"POST"`), `url` (an absolute URL),
482
+ `headers` (name and value pairs, in order, a name allowed more than once)
483
+ and `body` (a String, already encoded, empty for none). Building one keeps
484
+ a string that is already frozen and copies any other, and refuses a method or header name that is not an
485
+ HTTP token and a header value holding a carriage return, a line feed or a
486
+ NUL.
487
+ - **The response**, `Statifier::BasicHTTP::Response`, a frozen value with
488
+ `status` (an Integer from 100 to 599), `headers` (name and value pairs) and
489
+ `body` (a String, empty for none). Every status is a response: a status
490
+ outside 2xx is answered, not raised.
491
+ - **The error**, `Statifier::BasicHTTP::TransportError`, the one error a
492
+ transport raises when no response came back: a URL it cannot request, a
493
+ connection refused or failed, a time bound passed, an exchange broken off.
494
+ The error that caused it, when there was one, is its `cause`. One edge: a
495
+ response the default transport reads but cannot build a `Response` from (a
496
+ header name that is not an HTTP token, a header value holding a carriage
497
+ return, a line feed or a NUL) is raised as a `TransportError` too, since no
498
+ usable response came back.
499
+
500
+ A transport makes one attempt; retrying is not its job.
501
+
502
+ The default transport, `Statifier::BasicHTTP::NetHTTPTransport`, uses
503
+ Net::HTTP from the standard library (the `net-http` default gem), so it adds
504
+ no runtime dependency. Each call opens a connection of its own, verifies an
505
+ `https` peer as Net::HTTP does by default, and bounds each of opening the
506
+ connection, writing the request and reading the response by `timeout:`
507
+ seconds, five when absent. A host that needs other Net::HTTP settings (a
508
+ proxy, a certificate file) passes `connect:`, a callable that answers the
509
+ connection for a URL; `NetHTTPTransport.connection(uri, timeout:)` is the
510
+ default one, for a host's own to start from.
511
+
512
+ ```ruby
513
+ require "statifier/basichttp"
514
+
515
+ request = Statifier::BasicHTTP::Request.new(
516
+ http_method: "POST",
517
+ url: "http://localhost:8080/s1",
518
+ headers: [["content-type", "text/plain"]],
519
+ body: "e.go"
520
+ )
521
+
522
+ # A host's own transport, here one that answers every request in memory
523
+ echo = ->(req) { Statifier::BasicHTTP::Response.new(status: 200, body: req.body) }
524
+ echo.call(request).body # => "e.go"
525
+
526
+ # The default transport
527
+ transport = Statifier::BasicHTTP::NetHTTPTransport.new(timeout: 2)
528
+ begin
529
+ transport.call(request).status
530
+ rescue Statifier::BasicHTTP::TransportError => e
531
+ e.message # why no response came back
532
+ end
533
+ ```
534
+
535
+ ## Compatibility
536
+
537
+ - **Ruby 3.3 and later.** `required_ruby_version` in the gemspec is
538
+ `>= 3.3`, and CI runs the test suite on Ruby 3.3, 3.4 and 4.0.
539
+ - **One runtime dependency**, `predicator` (`>= 2.0`, `< 3`). The XML front
540
+ end is hand-written, so no XML gem is needed, and the default HTTP
541
+ transport uses Net::HTTP, a default gem.
542
+ - **No shared mutable state.** The machine and the state are immutable
543
+ values, there is no global configuration, and nothing is kept between calls
544
+ but the state a call answers. Processors and the clock are passed with each
545
+ call, so several executions can run side by side.
546
+
547
+ ## Conformance
548
+
549
+ The gem is held to the conformance corpus of statifier-ex, vendored under
550
+ `conformance/statifier/` at the tag `v2.16.0` (commit
551
+ `76742af12138887c7f95125a80edf2b0a7dbaaf3`, corpus hash
552
+ `sha256:7440f9a34a84b0657cb3bde11f96cf1707db08fb05e202c1e0249ab965517cc8`,
553
+ recorded in `conformance/statifier.vendored.json`). The corpus is the arbiter:
554
+ a case this package answers differently from the corpus is this package's bug,
555
+ and no case is skipped. `conformance/registry.json` lists the cases this gem
556
+ claims to pass, and the gate fails if one of them does not.
557
+
558
+ | Suite | Claim | Cases | Claimed | Unclaimed |
559
+ |---|---|---|---|---|
560
+ | scion | `scion` | 119 | 119 | 0 |
561
+ | statifier | `statifier` | 36 | 36 | 0 |
562
+ | w3c | `w3c-mandatory` | 154 | 152 | 2 |
563
+ | w3c | `w3c-optional` | 14 | 13 | 1 |
564
+ | all | | 323 | 320 | 3 |
565
+
566
+ The registry holds 320 entries, which is the sum of the Claimed column. The
567
+ numbers are read from the registry and from the vendored corpus, not counted
568
+ by hand, and `test/conformance/readme_test.rb` fails when this section and
569
+ the registry disagree. A claim is the set of its entries, not a statement
570
+ that every case of its suite passes. The registry holds the same entries and
571
+ the same four claims as the reference's registry at that tag.
572
+
573
+ The optional w3c cases whose host needs the Basic HTTP Event I/O Processor
574
+ are driven through this gem's processor, delivering to an in-memory loopback
575
+ front in the test suite rather than to an HTTP server on a local port as the
576
+ reference's harness does: no socket is opened, so these cases prove the
577
+ processor and the core's event path, not a network round trip.
578
+
579
+ Three cases are not claimed, and the reference's registry does not claim any
580
+ of them either:
581
+
582
+ | Case | Claim | Why it is not claimed |
583
+ |---|---|---|
584
+ | `w3c/test201` | `w3c-optional` | The chart sends to itself over Basic HTTP and also sends its own timeout event; the chart's own event is taken before the delivered one, so it ends in `fail` |
585
+ | `w3c/test330` | `w3c-mandatory` | The guards compare `_event` fields against `undefined`, and a field the event does not carry (`sendid`, `origin`, `origintype`, `invokeid`, `data`) makes the comparison false, so it ends in `fail` |
586
+ | `w3c/test552` | `w3c-mandatory` | A `<data src>` is never fetched, so the datamodel value the case reads is never bound and it ends in `fail` |
587
+
588
+ Running the gate prints the gap list: every unclaimed case with the features it
589
+ needs and whether it passes or why it fails.
590
+
591
+ ## Documentation
592
+
593
+ - Look up
594
+ - Every public method and class carries a YARD doc comment in `lib/`:
595
+ the `Statifier` module's ten calls, `Statifier::Result` and
596
+ `Statifier::AcceptsCheck`, the driver's values (`Statifier::Driver::Moved`,
597
+ `DoneStatus`, `Context` and `DeliveryFailure`), `Statifier::Effect`,
598
+ `Statifier::Event` and `Statifier::Position`, and under
599
+ `Statifier::BasicHTTP` the processor, its failure, the decoder and its
600
+ refusal, and the HTTP transport's request, response, error and default
601
+ transport.
602
+ - [The changelog](CHANGELOG.md): what changed in each version.
603
+ - [The conformance apparatus](conformance/README.md): what is vendored, from
604
+ where, and how the check, the runner and the ratchet work.
605
+ - Understand
606
+ - [The reference implementation](https://github.com/riddler/statifier-ex),
607
+ whose records decide the statechart semantics this gem implements.
608
+
609
+ ## Development
610
+
611
+ ```bash
612
+ mise install # the Ruby that mise.toml pins
613
+ mise exec -- bundle install # the dev tools
614
+ mise exec -- bundle exec rake # the gate: rubocop, corpus:check, registry, then the tests
615
+ ```
32
616
 
33
617
  ## Contributing
34
618
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/riddler/statifier-rb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
619
+ Bug reports and pull requests are welcome on GitHub at
620
+ https://github.com/riddler/statifier-rb.
36
621
 
37
622
  ## License
38
623
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
-
41
- ## Code of Conduct
624
+ MIT; see `LICENSE`.
42
625
 
43
- Everyone interacting in the Statifier project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/riddler/statifier-rb/blob/master/CODE_OF_CONDUCT.md).
626
+ <!-- BADGES -->
627
+ [badge_license_url]: https://img.shields.io/badge/license-MIT-brightgreen.svg?cacheSeconds=3600?style=flat-square
628
+ [badge_ci_url]: https://github.com/riddler/statifier-rb/actions/workflows/ci.yml/badge.svg