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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +601 -16
- data/lib/statifier/accepts.rb +167 -0
- data/lib/statifier/basichttp/decode.rb +142 -0
- data/lib/statifier/basichttp/encoding.rb +224 -0
- data/lib/statifier/basichttp/net_http_transport.rb +157 -0
- data/lib/statifier/basichttp/outbound.rb +87 -0
- data/lib/statifier/basichttp/processor.rb +170 -0
- data/lib/statifier/basichttp/request.rb +94 -0
- data/lib/statifier/basichttp/response.rb +34 -0
- data/lib/statifier/basichttp.rb +47 -0
- data/lib/statifier/compiler/content.rb +131 -0
- data/lib/statifier/compiler/error.rb +30 -0
- data/lib/statifier/compiler/expressions.rb +64 -0
- data/lib/statifier/compiler/numbering.rb +145 -0
- data/lib/statifier/compiler/parts.rb +144 -0
- data/lib/statifier/compiler/walk.rb +61 -0
- data/lib/statifier/compiler.rb +82 -0
- data/lib/statifier/datamodel/data_binding.rb +114 -0
- data/lib/statifier/datamodel/delay.rb +54 -0
- data/lib/statifier/datamodel/location.rb +145 -0
- data/lib/statifier/datamodel/program.rb +82 -0
- data/lib/statifier/datamodel/reason_value.rb +42 -0
- data/lib/statifier/datamodel/reasons.rb +88 -0
- data/lib/statifier/datamodel/stores.rb +169 -0
- data/lib/statifier/datamodel/system_variables.rb +69 -0
- data/lib/statifier/datamodel.rb +120 -0
- data/lib/statifier/document/data.rb +36 -0
- data/lib/statifier/document/executable.rb +69 -0
- data/lib/statifier/document/invoke.rb +17 -0
- data/lib/statifier/document/states.rb +40 -0
- data/lib/statifier/document.rb +51 -0
- data/lib/statifier/driver/calls.rb +183 -0
- data/lib/statifier/driver/checks.rb +110 -0
- data/lib/statifier/driver/delivery.rb +45 -0
- data/lib/statifier/driver/invocations.rb +129 -0
- data/lib/statifier/driver/mailbox.rb +120 -0
- data/lib/statifier/driver/routing.rb +193 -0
- data/lib/statifier/driver/session_id.rb +40 -0
- data/lib/statifier/driver/work.rb +196 -0
- data/lib/statifier/driver.rb +205 -0
- data/lib/statifier/effect.rb +208 -0
- data/lib/statifier/event.rb +67 -0
- data/lib/statifier/frozen.rb +22 -0
- data/lib/statifier/interpreter/blocks.rb +142 -0
- data/lib/statifier/interpreter/content.rb +228 -0
- data/lib/statifier/interpreter/entering.rb +141 -0
- data/lib/statifier/interpreter/exit_entry.rb +174 -0
- data/lib/statifier/interpreter/foreach.rb +83 -0
- data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
- data/lib/statifier/interpreter/invoke/passes.rb +143 -0
- data/lib/statifier/interpreter/invoke.rb +145 -0
- data/lib/statifier/interpreter/loop.rb +170 -0
- data/lib/statifier/interpreter/machine_state.rb +152 -0
- data/lib/statifier/interpreter/macrostep.rb +185 -0
- data/lib/statifier/interpreter/name_match.rb +43 -0
- data/lib/statifier/interpreter/selection.rb +221 -0
- data/lib/statifier/interpreter/send.rb +223 -0
- data/lib/statifier/interpreter/step.rb +138 -0
- data/lib/statifier/interpreter/stop.rb +66 -0
- data/lib/statifier/interpreter/targets.rb +107 -0
- data/lib/statifier/interpreter.rb +57 -0
- data/lib/statifier/lowering/branches.rb +84 -0
- data/lib/statifier/lowering/data.rb +102 -0
- data/lib/statifier/lowering/error.rb +102 -0
- data/lib/statifier/lowering/executable.rb +106 -0
- data/lib/statifier/lowering/placement.rb +65 -0
- data/lib/statifier/lowering/reading.rb +53 -0
- data/lib/statifier/lowering/states.rb +111 -0
- data/lib/statifier/lowering.rb +129 -0
- data/lib/statifier/machine/content.rb +81 -0
- data/lib/statifier/machine/data.rb +42 -0
- data/lib/statifier/machine/states.rb +56 -0
- data/lib/statifier/machine/values.rb +37 -0
- data/lib/statifier/machine.rb +103 -0
- data/lib/statifier/position/rebuild.rb +84 -0
- data/lib/statifier/position/shape.rb +73 -0
- data/lib/statifier/position/text.rb +43 -0
- data/lib/statifier/position.rb +206 -0
- data/lib/statifier/validator/attributes.rb +35 -0
- data/lib/statifier/validator/context.rb +74 -0
- data/lib/statifier/validator/data.rb +136 -0
- data/lib/statifier/validator/error.rb +90 -0
- data/lib/statifier/validator/executable.rb +139 -0
- data/lib/statifier/validator/history.rb +81 -0
- data/lib/statifier/validator/messages.rb +142 -0
- data/lib/statifier/validator/states.rb +161 -0
- data/lib/statifier/validator.rb +94 -0
- data/lib/statifier/version.rb +4 -1
- data/lib/statifier/xml/character_data.rb +142 -0
- data/lib/statifier/xml/cursor.rb +131 -0
- data/lib/statifier/xml/markup.rb +153 -0
- data/lib/statifier/xml/nodes.rb +50 -0
- data/lib/statifier/xml/parser.rb +121 -0
- data/lib/statifier/xml/scope.rb +46 -0
- data/lib/statifier/xml/tags.rb +112 -0
- data/lib/statifier/xml.rb +85 -0
- data/lib/statifier.rb +386 -2
- metadata +129 -20
- data/.gitignore +0 -8
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -33
- data/Rakefile +0 -10
- data/bin/console +0 -8
- data/bin/setup +0 -6
- 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
|