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
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # The invoke passes, as the reference ports them
6
+ # (`run_invoke_pass`, `invoke_one`, `generate_invoke_id`,
7
+ # `apply_invoke_passes`, `apply_finalize` and `auto_assign_finalize` in
8
+ # `Statifier.Interpreter`, and `cancel_invocations_for_state` in
9
+ # `Statifier.Interpreter.ExitEntry`, statifier-ex): starting the
10
+ # invocations of the states a macrostep entered, the finalize and
11
+ # autoforward pass an external event meets before its selection, and the
12
+ # cancel of a state's live invocations as it exits.
13
+ #
14
+ # The core starts no service. Starting an invocation answers an
15
+ # {Effect::Invoke} and records it live in the state's
16
+ # `active_invocations`; forwarding an event answers an
17
+ # {Effect::Autoforward} carrying the event whole; a state that exits
18
+ # answers an {Effect::CancelInvoke} per live invocation it owns. Running
19
+ # the service, delivering to it and turning what it answers into events
20
+ # are the driver's. An event an invocation sent is an ordinary external
21
+ # event here: its `invokeid` decides only which `<finalize>` runs.
22
+ #
23
+ # No host declares the invoke types it runs, so every invocation is
24
+ # judged as the reference judges one in a session that declared none:
25
+ # the effect is answered for any type, and only the built-in SCXML type
26
+ # ({built_in_type?}) is recorded live.
27
+ #
28
+ # @api private
29
+ module Invoke
30
+ # The URI of the SCXML invoke type.
31
+ SCXML_TYPE = "http://www.w3.org/TR/scxml/"
32
+
33
+ # The spellings of the built-in type: none, the short form, and the
34
+ # URI with and without its trailing slash.
35
+ BUILT_IN_TYPES = [nil, "scxml", SCXML_TYPE, SCXML_TYPE.chomp("/")].freeze
36
+
37
+ module_function
38
+
39
+ # Whether the SCXML processor runs an invocation of `type` itself.
40
+ def built_in_type?(type) = BUILT_IN_TYPES.include?(type)
41
+
42
+ # `for state in statesToInvoke.sort(entryOrder): for inv in
43
+ # state.invoke.sort(documentOrder): invoke(inv)`, then
44
+ # `statesToInvoke.clear()`, with its {Effect::Trace::InvokePass} last.
45
+ # One context is threaded through the pass, so an invocation's
46
+ # arguments read an id an earlier one wrote to its `idlocation`. An
47
+ # invocation whose start fails raises `error.execution` and answers
48
+ # nothing; its siblings are unaffected.
49
+ #
50
+ # @return [Array] `[state, effects]`
51
+ def invoke_pass(state)
52
+ order = state.states_to_invoke
53
+ passed, _context, effects = order.reduce([state, state.context, []]) do |acc, index|
54
+ state.machine.state(index).invoke.reduce(acc) { |inner, invoke| invoke_one(inner, index, invoke) }
55
+ end
56
+ invoke_ids = live_ids(passed, effects)
57
+ trace = Blocks.traced(passed, Effect::Trace::InvokePass, state_indexes: order, invoke_ids:)
58
+ [passed.with(states_to_invoke: [].freeze), effects + trace]
59
+ end
60
+
61
+ # The ids of the invocations the pass left live, in the order it
62
+ # started them.
63
+ def live_ids(state, effects)
64
+ effects.grep(Effect::Invoke).select { state.active_invocations[key(_1)] == _1.invoke_id }.map(&:invoke_id)
65
+ end
66
+
67
+ # The key an invocation is live under.
68
+ def key(effect) = [effect.state_index, effect.invoke_index].freeze
69
+
70
+ # `invoke(inv)`: the type, the source, the `namelist` and `<param>`
71
+ # values in order, then `<content>`, the first failure abandoning this
72
+ # invocation; then the id minted or read, `idlocation` written, the
73
+ # invocation recorded live for the built-in type, and the effect,
74
+ # after the write's {Effect::DatamodelChange}.
75
+ #
76
+ # @return [Array] `[state, context, effects]`
77
+ def invoke_one((state, context, effects), index, invoke)
78
+ origin = { kind: :invoke, state_index: index, invoke_index: invoke.index }
79
+ resolved = Arguments.resolve(context, invoke)
80
+ return [state.raise_failure(origin, resolved), context, effects] unless resolved.is_a?(Hash)
81
+
82
+ state, invoke_id = mint(state, index, invoke)
83
+ write = invoke.idlocation && Datamodel::Location.write(context, invoke.idlocation, invoke_id)
84
+ return [state.raise_failure(origin, write.error), context, effects] if write && !write.ok?
85
+
86
+ started(state, (write ? write.context : context), effects, invoke, resolved.merge(origin, invoke_id:, write:))
87
+ end
88
+
89
+ # An invocation whose arguments resolved and whose id is written:
90
+ # recorded live for the built-in type, its effects answered.
91
+ def started(state, context, effects, invoke, fields)
92
+ state = state.with(datamodel: Ractor.make_shareable(context.data))
93
+ state = record(state, fields) if built_in_type?(fields[:type])
94
+ [state, context, effects + changes(state, invoke, fields) + [effect(state, invoke, fields)]]
95
+ end
96
+
97
+ # The state with the invocation live under its key.
98
+ def record(state, fields)
99
+ key = [fields[:state_index], fields[:invoke_index]].freeze
100
+ state.with(active_invocations: state.active_invocations.merge(key => -fields[:invoke_id]).freeze)
101
+ end
102
+
103
+ # The {Effect::Invoke}.
104
+ def effect(state, invoke, fields)
105
+ Frozen.copy(Effect::Invoke.new(
106
+ **fields.slice(:invoke_id, :type, :src, :params, :content, :state_index, :invoke_index),
107
+ autoforward: invoke.autoforward, **state.counters, caller_context: state.caller_context
108
+ ))
109
+ end
110
+
111
+ # The `idlocation` write's {Effect::DatamodelChange}, when there was
112
+ # one.
113
+ def changes(state, invoke, fields)
114
+ write = fields[:write]
115
+ return [] unless write
116
+
117
+ [change(state, write, invoke.idlocation, fields[:invoke_id], fields.slice(:kind, :state_index, :invoke_index))]
118
+ end
119
+
120
+ # A write no content node made, owned by the invocation (`{kind:
121
+ # :invoke, ...}`) or by its empty `<finalize>` (`{kind: :finalize,
122
+ # ...}`).
123
+ def change(state, write, source, value, owner)
124
+ Frozen.copy(Effect::DatamodelChange.new(
125
+ location_path: write.path, location_source: source, new_value: value,
126
+ prior_value: write.prior_value, d_index: nil, c_index: nil, owner:, **state.counters
127
+ ))
128
+ end
129
+
130
+ # `generate_invoke_id`: the author's id, which consumes no counter;
131
+ # else `inv_` and the counter plus one, after the state's id and a dot
132
+ # when the state has one. `[state, invoke_id]`.
133
+ def mint(state, index, invoke)
134
+ return [state, invoke.id] unless invoke.id.nil?
135
+
136
+ counter = state.invoke_counter + 1
137
+ owner = state.machine.state(index).id
138
+ [state.with(invoke_counter: counter), owner.nil? ? "inv_#{counter}" : "#{owner}.inv_#{counter}"]
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ require_relative "invoke/arguments"
145
+ require_relative "invoke/passes"
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # What {Loop.handle_event} and {Loop.deliver_internal} answered: the
6
+ # {MachineState} and effects of the step, or, as `error`,
7
+ # `:not_running` for a chart that has stopped, with the state given
8
+ # back unchanged and no effects.
9
+ #
10
+ # @api private
11
+ Handled = ::Data.define(:state, :effects, :error) do
12
+ # @return [Boolean] whether the step ran
13
+ def ok? = error.nil?
14
+ end
15
+
16
+ # The specification's `interpret` and `mainEventLoop`, ported as the
17
+ # reference ports them (`Statifier.Interpreter` in statifier-ex): three
18
+ # entry points over a {MachineState}, each answering the state it leaves
19
+ # with its effects. {initialize} starts a chart, {handle_event} takes one
20
+ # external event and {deliver_internal} queues one event on the internal
21
+ # queue (the driver's door for a send to `#_internal`, or for a delivery
22
+ # failure it found); each then runs the macrostep ({Macrostep}) to a
23
+ # stable configuration, or to the chart stopping ({Stop}).
24
+ #
25
+ # The outer `while running` loop is the caller's, as in the reference:
26
+ # the core takes one external event per call, and the driver keeps the
27
+ # external events still waiting.
28
+ #
29
+ # Starting a chart answers {Effect::DatamodelInit} first, then a
30
+ # {Effect::DatamodelChange} per `<data>` that binds. With the state's
31
+ # `trace` set, the loop answers the reference's trace effects in its
32
+ # order.
33
+ #
34
+ # Before an external event's selection the finalize and autoforward
35
+ # pass runs over the live invocations ({Invoke::Passes}); {cancel} is
36
+ # the specification's cancel path, which stops a running chart.
37
+ #
38
+ # @api private
39
+ module Loop
40
+ module_function
41
+
42
+ # `interpret`: a fresh {MachineState} over `machine` (the options are
43
+ # {MachineState.fresh}'s), its datamodel initialized, the `<script>`
44
+ # children of `<scxml>` run in document order, the initial states
45
+ # entered and the macrostep run. This is macrostep 1, and the initial
46
+ # entry its microstep 1. `clock` is the host's clock for the call.
47
+ #
48
+ # @return [Stepped]
49
+ def self.initialize(machine, session_id:, clock: nil, **)
50
+ state = MachineState.fresh(machine, session_id:, **).begin_macrostep.begin_microstep.with(clock:)
51
+ bound = initialize_datamodel(state)
52
+ scripts = bound.then_step(run_global_scripts(bound.state))
53
+ entered = scripts.then_step(Step.enter_states(scripts.state, [initial_transition(machine)]))
54
+ entered.then_step(Macrostep.main_event_loop(entered.state))
55
+ end
56
+
57
+ # `mainEventLoop`'s external-event body: a new macrostep stamped with
58
+ # the event's `caller_context`, `_event` set, the transitions the
59
+ # event enables selected and taken, then the macrostep run. The
60
+ # answered state's `last_selection` says whether the event selected a
61
+ # transition. `event` is an {Event}, copied before it is frozen except
62
+ # its `caller_context`, which the state carries as the same object.
63
+ #
64
+ # @return [Handled]
65
+ def handle_event(state, event, clock: nil)
66
+ return Handled.new(state:, effects: [], error: :not_running) unless state.running
67
+
68
+ event = Frozen.copy(event)
69
+ state = state.begin_macrostep.with(caller_context: event.caller_context, clock:)
70
+ dequeued = Blocks.traced(state, Effect::Trace::EventDequeued, event:, from: :external)
71
+ taken = take(state.put_event(event), event)
72
+ Handled.new(state: taken.state, effects: dequeued + taken.effects, error: nil)
73
+ end
74
+
75
+ # The finalize and autoforward pass, the event's selection and
76
+ # the macrostep after it, with `last_selection` written.
77
+ #
78
+ # @return [Stepped]
79
+ def take(state, event)
80
+ state, passes = Invoke::Passes.finalize_autoforward(state, event)
81
+ state, transitions, conds = Macrostep.select(state, event)
82
+ ran = Macrostep.run_selected(state, transitions, event)
83
+ looped = ran.then_step(Macrostep.main_event_loop(ran.state))
84
+ Stepped.new(state: looped.state.with(last_selection: transitions.empty? ? :none : :selected),
85
+ effects: passes + conds + looped.effects)
86
+ end
87
+
88
+ # `mainEventLoop`'s cancel path: `running` cleared, then
89
+ # `exitInterpreter` ({Stop.exit_interpreter}), with no caller context,
90
+ # since a cancel has no sending caller. A stopped chart answers
91
+ # `:not_running`, its state unchanged.
92
+ #
93
+ # @return [Handled]
94
+ def cancel(state)
95
+ return Handled.new(state:, effects: [], error: :not_running) unless state.running
96
+
97
+ exited = Stop.exit_interpreter(state.with(running: false, caller_context: nil))
98
+ Handled.new(state: exited.state, effects: exited.effects, error: nil)
99
+ end
100
+
101
+ # The door back onto the internal queue: one event of `kind`
102
+ # (`:internal` or `:platform`) named `name`, its cause `origin` and
103
+ # the counters as they stand, queued as an in-loop raise would queue
104
+ # it, then the macrostep run. `options` are the event's `data:` and
105
+ # `sendid:`, and `clock:`, the host's clock for the call. The driver
106
+ # routes a send to `#_internal` here, and an `error.communication` for
107
+ # a send it could not deliver.
108
+ #
109
+ # @return [Handled]
110
+ def deliver_internal(state, kind, name, origin, **options)
111
+ return Handled.new(state:, effects: [], error: :not_running) unless state.running
112
+
113
+ raised = state.with(clock: options[:clock])
114
+ .public_send(kind == :internal ? :raise_internal : :raise_platform, name, origin,
115
+ **options.except(:clock))
116
+ looped = Macrostep.main_event_loop(raised)
117
+ Handled.new(state: looped.state, effects: looped.effects, error: nil)
118
+ end
119
+
120
+ # The datamodel initialized: {Effect::DatamodelInit} with every
121
+ # `<data>` id declared, then the binding of what binds when the chart
122
+ # starts ({Datamodel::DataBinding.start}).
123
+ #
124
+ # @return [Stepped]
125
+ def initialize_datamodel(state)
126
+ context = state.context
127
+ init = Effect::DatamodelInit.new(datamodel: Datamodel::DataBinding.seed(context), **state.counters)
128
+ Stepped.new(state:, effects: [Frozen.copy(init)])
129
+ .then_step(Blocks.bound(state, Datamodel::DataBinding.start(context)))
130
+ end
131
+
132
+ # `executeGlobalScriptElement`: each `<script>` child of `<scxml>` in
133
+ # document order against the datamodel the one before it left. A
134
+ # script that did not compile, or whose run fails, raises
135
+ # `error.execution` (origin `{kind: :global_script, index:}`), keeping
136
+ # what a failing run wrote. Each answers a
137
+ # {Effect::Trace::ContentExecuted} naming no node.
138
+ #
139
+ # @return [Stepped]
140
+ def run_global_scripts(state)
141
+ state.machine.global_scripts.each_with_index.reduce(Stepped.new(state:, effects: [])) do |acc, (script, index)|
142
+ acc.then_step(run_global_script(acc.state, script, { kind: :global_script, index: }))
143
+ end
144
+ end
145
+
146
+ # One `<script>` child of `<scxml>`, as {run_global_scripts}.
147
+ #
148
+ # @return [Stepped]
149
+ def run_global_script(state, script, origin)
150
+ executed = Datamodel.execute(state.context, script)
151
+ after = state.with(datamodel: Ractor.make_shareable(executed.data))
152
+ after = after.raise_failure(origin, executed.error) unless executed.ok?
153
+ Stepped.new(state: after, effects: Blocks.traced(after, Effect::Trace::ContentExecuted, owner: origin,
154
+ c_indexes: []))
155
+ end
156
+
157
+ # The root's `<initial>` transition, or the one the reference
158
+ # synthesizes from the root's default targets; a synthesized
159
+ # transition is no document element and has no index.
160
+ def initial_transition(machine)
161
+ root = machine.state(0)
162
+ return machine.transitions.fetch(root.initial_transition) unless root.initial_transition.nil?
163
+
164
+ Machine::Transition.new(t_index: nil, source: 0, targets: root.initial, events: [].freeze, cond: nil,
165
+ type: :external, content: [].freeze, location: machine.location, cond_location: nil,
166
+ attribute_locations: {}.freeze)
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # The interpreter's position, as the reference keeps it
6
+ # (`Statifier.MachineState` in statifier-ex): every global and loop
7
+ # variable of the specification's pseudocode as one immutable value, so
8
+ # a position between two calls is data and never a stack frame. Every
9
+ # function of the loop takes one and answers the one it leaves.
10
+ #
11
+ # The fields: `machine`; `configuration`, the active state indexes, a
12
+ # frozen ascending Array; `history`, the recorded value of each history
13
+ # state that has one; `datamodel`; `internal_queue`, the queued
14
+ # {Event}s, oldest first; `entered_states`, every state ever entered
15
+ # (a state's own `<data>` binds on its first entry under late binding);
16
+ # `states_to_invoke`, the states entered since the last invoke pass;
17
+ # `sends`, the {Send::SendState}; `caller_context`, the opaque value the
18
+ # host attached to the event the current macrostep handles, nil for
19
+ # none; `clock`, the host's clock for the current call, nil for
20
+ # predicator's own; `last_selection`, `:selected` or `:none` for the
21
+ # last external event, nil before one; `running`, false once a
22
+ # top-level final is entered; `status`, `:running`, or `:done` once the
23
+ # chart has stopped; the counters `macrostep`, `microstep` and `round`;
24
+ # `trace`, whether the loop answers trace effects;
25
+ # `max_macrostep_rounds`, the round budget, a positive Integer or
26
+ # `:infinity`; `active_invocations`, the live invocations, a frozen Hash
27
+ # of `[state_index, invoke_index]` to the invocation's id; and
28
+ # `invoke_counter`, the sequence a generated invoke id is minted from.
29
+ # The invoke pass ({Invoke.invoke_pass}) records an invocation of the
30
+ # built-in type there, and the cancel of its state removes it.
31
+ #
32
+ # The counters: `macrostep` advances once when the chart starts and once
33
+ # per external event, and starts the other two over; `microstep`
34
+ # advances once per microstep that exits and enters (the initial entry is
35
+ # microstep 1); `round` once per pass of the macrostep's loop, a pass
36
+ # that moves nothing included.
37
+ #
38
+ # @api private
39
+ MachineState = ::Data.define(
40
+ :machine, :configuration, :history, :datamodel, :internal_queue, :entered_states, :states_to_invoke,
41
+ :sends, :caller_context, :clock, :last_selection, :running, :status, :macrostep, :microstep, :round,
42
+ :trace, :max_macrostep_rounds, :active_invocations, :invoke_counter
43
+ ) do
44
+ # The {Snapshot} selection, exit and entry read.
45
+ def snapshot = Snapshot.new(machine:, configuration:, history:)
46
+
47
+ # The {Datamodel::Context} an evaluation site runs against now.
48
+ def context = Datamodel.context(datamodel, machine, configuration, clock:)
49
+
50
+ # The counters as they stand.
51
+ def counters = { macrostep:, microstep:, round: }
52
+
53
+ # The {Event::Cause} of an event raised now about `origin`.
54
+ def cause(origin) = Event::Cause.new(origin:, **counters)
55
+
56
+ # A new macrostep: the other two counters start over.
57
+ def begin_macrostep = with(macrostep: macrostep + 1, microstep: 0, round: 0)
58
+
59
+ # The next microstep.
60
+ def begin_microstep = with(microstep: microstep + 1)
61
+
62
+ # The next round.
63
+ def begin_round = with(round: round + 1)
64
+
65
+ # The state with `events` queued, in order.
66
+ def enqueue(*events) = with(internal_queue: (internal_queue + events).freeze)
67
+
68
+ # The state with a platform event named `name` queued, its cause
69
+ # `origin` and the counters as they stand.
70
+ def raise_platform(name, origin, data: Predicator::UNDEFINED, sendid: nil)
71
+ enqueue(Frozen.copy(Event.platform(name, cause(origin), data:, sendid:)))
72
+ end
73
+
74
+ # The state with an `error.execution` queued about `origin`, its data
75
+ # the failure `reason` as a predicator value
76
+ # ({Datamodel::ReasonValue.of}).
77
+ def raise_failure(origin, reason)
78
+ raise_platform("error.execution", origin, data: Datamodel::ReasonValue.of(reason))
79
+ end
80
+
81
+ # The state with an internal event queued, as {raise_platform}.
82
+ def raise_internal(name, origin, data: Predicator::UNDEFINED, sendid: nil)
83
+ enqueue(Frozen.copy(Event.internal(name, cause(origin), data:, sendid:)))
84
+ end
85
+
86
+ # The oldest queued event and the state without it, or nil when the
87
+ # queue is empty.
88
+ def dequeue
89
+ return nil if internal_queue.empty?
90
+
91
+ [internal_queue.first, with(internal_queue: internal_queue.drop(1).freeze)]
92
+ end
93
+
94
+ # The state with `_event` set to `event`'s value.
95
+ def put_event(event)
96
+ variables = Datamodel::SystemVariables
97
+ with(datamodel: Ractor.make_shareable(variables.put_event(datamodel, variables.of_event(event))))
98
+ end
99
+
100
+ # Whether the state at `index` is active.
101
+ def active?(index) = configuration.include?(index)
102
+ end
103
+
104
+ # The constructor of a fresh position and its defaults.
105
+ class MachineState
106
+ # The round budget when the host sets none.
107
+ MAX_MACROSTEP_ROUNDS = 10_000
108
+
109
+ # The options {fresh} takes, as they stand when the host names none.
110
+ OPTIONS = {
111
+ datamodel: {}.freeze, send_types: nil, ioprocessors: {}.freeze, routes: nil, trace: false,
112
+ max_macrostep_rounds: MAX_MACROSTEP_ROUNDS
113
+ }.freeze
114
+
115
+ # Every field of a position before anything is entered.
116
+ EMPTY = {
117
+ configuration: [].freeze, history: {}.freeze, internal_queue: [].freeze, entered_states: [].freeze,
118
+ states_to_invoke: [].freeze, caller_context: nil, clock: nil, last_selection: nil, running: true,
119
+ status: :running, macrostep: 0, microstep: 0, round: 0, active_invocations: {}.freeze, invoke_counter: 0
120
+ }.freeze
121
+
122
+ # A fresh position over `machine`: nothing entered, the counters at
123
+ # zero, and the host's `datamodel` with the system variables over it
124
+ # (`session_id`, the id the host minted; `send_types`, the types it
125
+ # registered a processor for, or nil for none; `ioprocessors`, the
126
+ # `_ioprocessors` entry of each, by type). `routes` are the
127
+ # {Targets::Routes} the driver declares, or nil; `trace` and
128
+ # `max_macrostep_rounds` as the fields. The datamodel is copied before
129
+ # it is frozen, so nothing the host passed is frozen in place.
130
+ #
131
+ # @return [MachineState]
132
+ # @raise [ArgumentError] for an option {OPTIONS} does not name
133
+ def self.fresh(machine, session_id:, **options)
134
+ unknown = options.keys - OPTIONS.keys
135
+ raise ArgumentError, "unknown options: #{unknown.inspect}" unless unknown.empty?
136
+
137
+ options = OPTIONS.merge(options)
138
+ sends = Send::SendState::INITIAL.with(send_types: options[:send_types]&.dup&.freeze, routes: options[:routes])
139
+ new(machine:, **EMPTY, datamodel: seeded(machine, session_id, options), sends:,
140
+ **options.slice(:trace, :max_macrostep_rounds))
141
+ end
142
+
143
+ # The starting datamodel, a deeply frozen copy.
144
+ def self.seeded(machine, session_id, options)
145
+ seeded = Datamodel::SystemVariables.seed(options[:datamodel], session_id:, name: machine.name,
146
+ entries: options[:ioprocessors])
147
+ Ractor.make_shareable(seeded, copy: true)
148
+ end
149
+ private_class_method :seeded
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statifier
4
+ module Interpreter
5
+ # `mainEventLoop`'s inner loop and the outer body after a selection, as
6
+ # the reference ports them (`Statifier.Interpreter.microstep/1`,
7
+ # `macrostep/1` and `main_event_loop/1` in statifier-ex): rounds folded
8
+ # to a stable configuration, the invoke pass, and the stop.
9
+ #
10
+ # Where this departs from the pseudocode, as the reference does:
11
+ #
12
+ # - The macrostep is bounded. The pseudocode lets it run forever; here
13
+ # the rounds spend a budget ({MachineState}'s `max_macrostep_rounds`,
14
+ # `:infinity` for none), and rounds that spend it stop where the last
15
+ # one left the state, the chart still running, and answer
16
+ # {Effect::BudgetExhausted} last. One budget spans the whole of
17
+ # {main_event_loop}, the invoke pass's re-entry included.
18
+ # - A round is one pass of the inner loop, counted whether or not it
19
+ # moves anything, so a position between two rounds is a value.
20
+ # - A selection's trace carries the microstep the selection ran in,
21
+ # before the microstep it starts.
22
+ #
23
+ # The invoke pass is {Invoke.invoke_pass}.
24
+ #
25
+ # @api private
26
+ module Macrostep
27
+ module_function
28
+
29
+ # `mainEventLoop`'s outer body after the caller's own selection: the
30
+ # rounds run; then, while the chart runs, the invoke pass, and the
31
+ # pseudocode's `continue` when the pass left the internal queue
32
+ # non-empty, under the budget the rounds left; once the chart has
33
+ # stopped, `exitInterpreter` ({Stop.exit_interpreter}).
34
+ #
35
+ # @return [Stepped]
36
+ def main_event_loop(state, budget = state.max_macrostep_rounds, effects = [])
37
+ outcome, state, folded, budget = fold(state, budget)
38
+ effects += folded
39
+ return stop(state, effects, outcome) unless state.running
40
+
41
+ state, invoked = Invoke.invoke_pass(state)
42
+ after_invoke_pass(state, effects + invoked, outcome, budget)
43
+ end
44
+
45
+ # After the invoke pass: `continue` (the loop again, under the budget
46
+ # left) when the pass left the queue non-empty and budget remains;
47
+ # else the effect that ends the macrostep, {Effect::BudgetExhausted}
48
+ # when the queue is still non-empty.
49
+ #
50
+ # @return [Stepped]
51
+ def after_invoke_pass(state, effects, outcome, budget)
52
+ waiting = !state.internal_queue.empty?
53
+ return main_event_loop(state, budget, effects) if waiting && outcome == :quiescent && !spent?(budget)
54
+
55
+ Stepped.new(state:, effects: effects + terminal(state, waiting ? :exhausted : outcome))
56
+ end
57
+
58
+ # The stop after the rounds: the effect that ended them, then
59
+ # `exitInterpreter`'s.
60
+ #
61
+ # @return [Stepped]
62
+ def stop(state, effects, outcome)
63
+ exited = Stop.exit_interpreter(state)
64
+ Stepped.new(state: exited.state, effects: effects + terminal(state, outcome) + exited.effects)
65
+ end
66
+
67
+ # The inner loop on its own, under a fresh budget: the rounds until
68
+ # the macrostep is stable, the chart stops or the budget is spent,
69
+ # then the one effect that ends it.
70
+ #
71
+ # @return [Stepped]
72
+ def macrostep(state)
73
+ outcome, state, effects, = fold(state, state.max_macrostep_rounds)
74
+ Stepped.new(state:, effects: effects + terminal(state, outcome))
75
+ end
76
+
77
+ # The rounds, each charged against `budget`: `[outcome, state,
78
+ # effects, budget left]`, `outcome` `:quiescent` (stable, or stopped)
79
+ # or `:exhausted`. Reaching a stable configuration costs nothing beyond
80
+ # the rounds already charged.
81
+ def fold(state, budget)
82
+ effects = []
83
+ loop do
84
+ return [:exhausted, state, effects, budget] if spent?(budget)
85
+
86
+ quiescent, stepped = round(state)
87
+ state = stepped.state
88
+ effects += stepped.effects
89
+ return [:quiescent, state, effects, budget] if quiescent
90
+
91
+ budget -= 1 unless budget == :infinity
92
+ end
93
+ end
94
+
95
+ # Whether `budget` (a count of rounds left, or `:infinity`) is spent.
96
+ def spent?(budget) = budget != :infinity && budget.zero?
97
+
98
+ # One pass of the inner `while running and not macrostepDone` loop:
99
+ # the round counter advances, then the eventless transitions are
100
+ # taken when any are enabled, else one internal event is taken and
101
+ # the transitions it enables. `[quiescent, stepped]`: a round that
102
+ # finds neither, or a chart no longer running, ends the macrostep.
103
+ def round(state)
104
+ state = state.begin_round
105
+ return [true, Stepped.new(state:, effects: [])] unless state.running
106
+
107
+ state, transitions, conds = select(state, nil)
108
+ quiescent, stepped = transitions.empty? ? internal_round(state) : [false, run_selected(state, transitions, nil)]
109
+ [quiescent, Stepped.new(state: stepped.state, effects: conds + stepped.effects)]
110
+ end
111
+
112
+ # The internal half of a round: the empty eventless selection's trace,
113
+ # then the oldest queued event set as `_event` and the transitions it
114
+ # enables taken; an empty queue ends the macrostep.
115
+ def internal_round(state)
116
+ event, rest = state.dequeue
117
+ return [true, run_selected(state, [], nil)] if event.nil?
118
+
119
+ dequeued = Blocks.traced(rest, Effect::Trace::EventDequeued, event:, from: :internal)
120
+ state, transitions, conds = select(rest.put_event(event), event)
121
+ head = Stepped.new(state:, effects: run_selected(rest, [], nil).effects + dequeued + conds)
122
+ [false, head.then_step(run_selected(state, transitions, event))]
123
+ end
124
+
125
+ # A selection for `event` (nil for the eventless walk): `[state,
126
+ # transitions, effects]`, the state with an `error.execution` queued
127
+ # per condition that failed (origin `{kind: :transition, t_index:}`),
128
+ # in walk order, and the round's {Effect::Trace::CondsEvaluated} when
129
+ # it evaluated a written condition.
130
+ def select(state, event)
131
+ selected = selection(state, event)
132
+ failed = selected.evaluations.select { _1.outcome == :error }
133
+ state = failed.reduce(state) do |acc, one|
134
+ acc.raise_failure({ kind: :transition, t_index: one.t_index }, one.reason)
135
+ end
136
+ [state, selected.transitions, conds_evaluated(state, selected.evaluations)]
137
+ end
138
+
139
+ # The {Selection} walk for `event`, the eventless one for nil.
140
+ def selection(state, event)
141
+ return Selection.select_eventless_transitions(state.snapshot, state.datamodel, clock: state.clock) if event.nil?
142
+
143
+ Selection.select_transitions(state.snapshot, event.name, state.datamodel, clock: state.clock)
144
+ end
145
+
146
+ # The trace of a round's written conditions, none when it evaluated
147
+ # none.
148
+ def conds_evaluated(state, evaluations)
149
+ return [] if evaluations.empty?
150
+
151
+ Blocks.traced(state, Effect::Trace::CondsEvaluated, evaluations: evaluations.map(&:to_h).freeze)
152
+ end
153
+
154
+ # The tail every selection shares: its trace, then, when it took
155
+ # anything, a new microstep that takes it.
156
+ #
157
+ # @return [Stepped]
158
+ def run_selected(state, transitions, event)
159
+ selected = Blocks.traced(state, Effect::Trace::TransitionsSelected,
160
+ t_indexes: transitions.map(&:t_index).freeze, event:)
161
+ return Stepped.new(state:, effects: selected) if transitions.empty?
162
+
163
+ Stepped.new(state:, effects: selected).then_step(Step.microstep(state.begin_microstep, transitions))
164
+ end
165
+
166
+ # The one effect that ends a macrostep: {Effect::Trace::MacrostepStable}
167
+ # when it is stable with the chart running, nothing once it has
168
+ # stopped, and {Effect::BudgetExhausted} when it spent its budget.
169
+ def terminal(state, outcome)
170
+ return exhausted(state) if outcome == :exhausted
171
+ return [] unless state.running
172
+
173
+ Blocks.traced(state, Effect::Trace::MacrostepStable, configuration: state.configuration)
174
+ end
175
+
176
+ # The {Effect::BudgetExhausted} of a macrostep that spent its budget.
177
+ def exhausted(state)
178
+ [Frozen.copy(Effect::BudgetExhausted.new(configuration: state.configuration,
179
+ budget: state.max_macrostep_rounds,
180
+ pending_internal_events: state.internal_queue,
181
+ **state.counters))]
182
+ end
183
+ end
184
+ end
185
+ end