igniter_lang 0.1.0.alpha.1

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.
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "time"
6
+
7
+ module IgniterLang
8
+ module TemporalAccessRuntime
9
+ module Capabilities
10
+ HISTORY_READ = "history_read"
11
+ BIHISTORY_READ = "bihistory_read"
12
+ BITEMPORAL_READ = BIHISTORY_READ
13
+
14
+ module_function
15
+
16
+ def for_axis(axis)
17
+ case axis
18
+ when "single", "valid_time"
19
+ [HISTORY_READ]
20
+ when "bitemporal"
21
+ [BIHISTORY_READ]
22
+ else
23
+ []
24
+ end
25
+ end
26
+
27
+ def axis_for(access_node, input_node)
28
+ axis = access_node["axis"] || input_node["axis"]
29
+ axis == "single" ? "valid_time" : axis
30
+ end
31
+ end
32
+
33
+ module Canonical
34
+ module_function
35
+
36
+ def normalize(value)
37
+ case value
38
+ when Hash
39
+ value.keys.sort_by(&:to_s).each_with_object({}) { |key, out| out[key.to_s] = normalize(value[key]) }
40
+ when Array
41
+ value.map { |item| normalize(item) }
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def json(value)
48
+ JSON.generate(normalize(value))
49
+ end
50
+
51
+ def pretty(value)
52
+ "#{JSON.pretty_generate(normalize(value))}\n"
53
+ end
54
+
55
+ def hash(value)
56
+ "sha256:#{Digest::SHA256.hexdigest(json(value))}"
57
+ end
58
+
59
+ def short_hash(value)
60
+ hash(value).split(":").last[0, 16]
61
+ end
62
+ end
63
+
64
+ module Option
65
+ ENCODING = {
66
+ "some" => { "kind" => "some", "value" => "<value>" },
67
+ "none" => { "kind" => "none" }
68
+ }.freeze
69
+
70
+ module_function
71
+
72
+ def some(value)
73
+ { "kind" => "some", "value" => value }
74
+ end
75
+
76
+ def none
77
+ { "kind" => "none" }
78
+ end
79
+
80
+ def some?(value)
81
+ value.fetch("kind") == "some"
82
+ end
83
+
84
+ def value(option)
85
+ option.fetch("value")
86
+ end
87
+ end
88
+
89
+ class AxisTypeError < StandardError
90
+ attr_reader :axis, :value
91
+
92
+ def initialize(axis, value)
93
+ @axis = axis
94
+ @value = value
95
+ super("#{axis} must be ISO8601 DateTime")
96
+ end
97
+ end
98
+
99
+ class CapabilityError < StandardError
100
+ attr_reader :capability, :node
101
+
102
+ def initialize(capability, node)
103
+ @capability = capability
104
+ @node = node
105
+ super("temporal access requires capability: #{capability}")
106
+ end
107
+ end
108
+
109
+ class BackendContractError < StandardError
110
+ attr_reader :method_name, :axis
111
+
112
+ def initialize(method_name, axis)
113
+ @method_name = method_name
114
+ @axis = axis
115
+ super("temporal access backend must implement #{method_name} for #{axis}")
116
+ end
117
+ end
118
+
119
+ class RuntimeMachineHook
120
+ def initialize(backend:, capabilities: nil)
121
+ @backend = backend
122
+ @capabilities = Array(capabilities || infer_capabilities(backend))
123
+ @evaluator = SemanticIRTemporalAccessEvaluator.new(backend)
124
+ end
125
+
126
+ def load_check(contract:, requirements: {})
127
+ nodes = contract.fetch("nodes", [])
128
+ temporal_inputs = temporal_inputs_for(nodes)
129
+ checks = nodes
130
+ .select { |node| node.fetch("kind") == "temporal_access_node" }
131
+ .map { |node| load_check_node(node, temporal_inputs, requirements) }
132
+ {
133
+ "kind" => "temporal_access_hook_load_check",
134
+ "status" => checks.all? { |check| check.fetch("status") == "ok" } ? "ok" : "blocked",
135
+ "checks" => checks
136
+ }
137
+ end
138
+
139
+ def evaluate(access_node, temporal_inputs:, inputs:)
140
+ input_node = temporal_inputs.fetch(access_node.fetch("source_ref"))
141
+ axis = Capabilities.axis_for(access_node, input_node)
142
+ ensure_capabilities!(Capabilities.for_axis(axis), access_node)
143
+ ensure_backend_contract!(axis)
144
+ @evaluator.evaluate(access_node, temporal_inputs: temporal_inputs, inputs: inputs)
145
+ end
146
+
147
+ private
148
+
149
+ def load_check_node(access_node, temporal_inputs, requirements)
150
+ input_node = temporal_inputs.fetch(access_node.fetch("source_ref"))
151
+ axis = Capabilities.axis_for(access_node, input_node)
152
+ required = Capabilities.for_axis(axis)
153
+ declared = Array(requirements.dig("capabilities", "required_caps"))
154
+ missing_declared_caps = declared.empty? ? [] : required.reject { |capability| declared.include?(capability) }
155
+ missing_caps = required.reject { |capability| capability_available?(capability) }
156
+ missing_methods = backend_methods_for(axis).reject { |method_name| @backend.respond_to?(method_name) }
157
+ {
158
+ "node" => access_node.fetch("name"),
159
+ "axis" => axis,
160
+ "required_capabilities" => required,
161
+ "declared_capabilities" => declared,
162
+ "missing_declared_capabilities" => missing_declared_caps,
163
+ "missing_capabilities" => missing_caps,
164
+ "required_backend_methods" => backend_methods_for(axis).map(&:to_s),
165
+ "missing_backend_methods" => missing_methods.map(&:to_s),
166
+ "status" => missing_declared_caps.empty? && missing_caps.empty? && missing_methods.empty? ? "ok" : "blocked"
167
+ }
168
+ rescue KeyError => e
169
+ {
170
+ "node" => access_node.fetch("name", nil),
171
+ "axis" => nil,
172
+ "required_capabilities" => [],
173
+ "declared_capabilities" => [],
174
+ "missing_declared_capabilities" => [],
175
+ "missing_capabilities" => [],
176
+ "required_backend_methods" => [],
177
+ "missing_backend_methods" => [],
178
+ "status" => "blocked",
179
+ "error" => e.message
180
+ }
181
+ end
182
+
183
+ def ensure_capabilities!(required, access_node)
184
+ required.each do |capability|
185
+ raise CapabilityError.new(capability, access_node) unless capability_available?(capability)
186
+ end
187
+ end
188
+
189
+ def ensure_backend_contract!(axis)
190
+ backend_methods_for(axis).each do |method_name|
191
+ raise BackendContractError.new(method_name, axis) unless @backend.respond_to?(method_name)
192
+ end
193
+ end
194
+
195
+ def backend_methods_for(axis)
196
+ case axis
197
+ when "valid_time"
198
+ [:read_as_of]
199
+ when "bitemporal"
200
+ [:bihistory_at]
201
+ else
202
+ []
203
+ end
204
+ end
205
+
206
+ def capability_available?(capability)
207
+ return true if @capabilities.include?(capability)
208
+ return @backend.supports_capability?(capability) if @backend.respond_to?(:supports_capability?)
209
+
210
+ false
211
+ end
212
+
213
+ def infer_capabilities(backend)
214
+ capabilities = []
215
+ capabilities << Capabilities::HISTORY_READ if backend.respond_to?(:read_as_of)
216
+ capabilities << Capabilities::BIHISTORY_READ if backend.respond_to?(:bihistory_at)
217
+ capabilities
218
+ end
219
+
220
+ def temporal_inputs_for(nodes)
221
+ nodes
222
+ .select { |node| node.fetch("kind") == "temporal_input_node" }
223
+ .to_h { |node| [node.fetch("name"), node] }
224
+ end
225
+ end
226
+
227
+ class SemanticIRTemporalAccessEvaluator
228
+ def initialize(backend)
229
+ @backend = backend
230
+ end
231
+
232
+ def evaluate(access_node, temporal_inputs:, inputs:)
233
+ raise ArgumentError, "expected temporal_access_node" unless access_node.fetch("kind") == "temporal_access_node"
234
+ raise ArgumentError, "only point temporal access is supported" unless access_node.fetch("access") == "point"
235
+
236
+ input_node = temporal_inputs.fetch(access_node.fetch("source_ref"))
237
+ axis = normalized_axis(access_node, input_node)
238
+ case axis
239
+ when "valid_time"
240
+ evaluate_valid_time(access_node, input_node, inputs)
241
+ when "bitemporal"
242
+ evaluate_bitemporal(access_node, input_node, inputs)
243
+ else
244
+ raise ArgumentError, "unsupported temporal axis: #{axis}"
245
+ end
246
+ end
247
+
248
+ private
249
+
250
+ def evaluate_valid_time(access_node, input_node, inputs)
251
+ time_ref = access_node["time_ref"] || input_node.fetch("as_of_ref")
252
+ subject = render_ref(input_node.fetch("store_ref"), inputs)
253
+ result, observation = @backend.read_as_of(subject, inputs.fetch(time_ref))
254
+ envelope(access_node, "valid_time", result, observation, selected_ref_key: "selected_append_ref", rel: "selected_append")
255
+ end
256
+
257
+ def evaluate_bitemporal(access_node, input_node, inputs)
258
+ valid_time_ref = access_node.fetch("valid_time_ref")
259
+ transaction_time_ref = access_node.fetch("transaction_time_ref")
260
+ history_ref = render_ref(input_node.fetch("history_ref") { input_node.fetch("store_ref") }, inputs)
261
+ result, observation = @backend.bihistory_at(
262
+ history_ref,
263
+ vt: inputs.fetch(valid_time_ref),
264
+ tt: inputs.fetch(transaction_time_ref),
265
+ node_name: access_node.fetch("name")
266
+ )
267
+ envelope(access_node, "bitemporal", result, observation, selected_ref_key: "selected_event_ref", rel: "selected_event")
268
+ end
269
+
270
+ def envelope(access_node, axis, result, observation, selected_ref_key:, rel:)
271
+ selected_ref = observation[selected_ref_key]
272
+ {
273
+ "kind" => "temporal_access_evaluation",
274
+ "node" => access_node.fetch("name"),
275
+ "axis" => axis,
276
+ "result" => result,
277
+ "observation" => observation,
278
+ "evidence_links" => selected_ref ? [
279
+ {
280
+ "rel" => rel,
281
+ "from" => observation.fetch("observation_id"),
282
+ "to" => selected_ref
283
+ }
284
+ ] : []
285
+ }
286
+ end
287
+
288
+ def normalized_axis(access_node, input_node)
289
+ axis = access_node["axis"] || input_node["axis"]
290
+ return "valid_time" if axis == "single"
291
+
292
+ axis
293
+ end
294
+
295
+ def render_ref(template, inputs)
296
+ template.gsub(/\{([^}]+)\}/) do
297
+ inputs.fetch(Regexp.last_match(1))
298
+ end
299
+ end
300
+ end
301
+
302
+ class MemoryBackend
303
+ attr_reader :append_observations, :events, :access_observations
304
+
305
+ def initialize
306
+ @append_observations = []
307
+ @events = Hash.new { |hash, key| hash[key] = [] }
308
+ @access_observations = []
309
+ end
310
+
311
+ def seed_append_observations(observations)
312
+ observations.each do |observation|
313
+ append(observation.fetch("subject"), observation.fetch("valid_from"), observation.fetch("value"),
314
+ value_type: observation.fetch("value_type"))
315
+ end
316
+ end
317
+
318
+ def append(subject, valid_from, value, value_type:)
319
+ payload = {
320
+ "kind" => "history_append_observation",
321
+ "subject" => subject,
322
+ "valid_from" => valid_from,
323
+ "value" => value,
324
+ "value_type" => value_type
325
+ }
326
+ observation = payload.merge(
327
+ "observation_id" => "obs/history_append/#{Canonical.short_hash(payload)}",
328
+ "observed_at" => valid_from,
329
+ "temporal" => {
330
+ "axis" => "valid_time",
331
+ "as_of" => valid_from,
332
+ "lifecycle" => "durable"
333
+ }
334
+ )
335
+ @append_observations << observation
336
+ observation
337
+ end
338
+
339
+ def history_at(subject, as_of)
340
+ as_of_time = parse_axis!("as_of", as_of)
341
+ selected = @append_observations
342
+ .select { |obs| obs.fetch("subject") == subject && Time.iso8601(obs.fetch("valid_from")) <= as_of_time }
343
+ .max_by { |obs| Time.iso8601(obs.fetch("valid_from")) }
344
+ result = selected ? Option.some(selected.fetch("value")) : Option.none
345
+ observation = history_access_observation(subject, as_of, selected, result)
346
+ @access_observations << observation
347
+ [result, observation]
348
+ end
349
+
350
+ def read_as_of(subject, as_of)
351
+ history_at(subject, as_of)
352
+ end
353
+
354
+ def seed(events)
355
+ events.each { |event| append_bihistory_event(event) }
356
+ end
357
+
358
+ def append_bihistory_event(event)
359
+ history_ref = event.fetch("history_ref")
360
+ @events[history_ref] << event
361
+ @events[history_ref].sort_by! { |entry| [entry.fetch("valid_from"), entry.fetch("tx_from"), entry.fetch("event_id")] }
362
+ end
363
+
364
+ def bihistory_at(history_ref, vt:, tt:, node_name:)
365
+ vt_time = parse_axis!("vt", vt)
366
+ tt_time = parse_axis!("tt", tt)
367
+ selected = @events.fetch(history_ref, [])
368
+ .select { |event| covers_valid_time?(event, vt_time) && Time.iso8601(event.fetch("tx_from")) <= tt_time }
369
+ .max_by { |event| [Time.iso8601(event.fetch("tx_from")), event.fetch("event_id")] }
370
+ result = selected ? Option.some(selected.fetch("value")) : Option.none
371
+ observation = bihistory_access_observation(history_ref, vt, tt, node_name, selected, result)
372
+ @access_observations << observation
373
+ [result, observation]
374
+ end
375
+
376
+ private
377
+
378
+ def parse_axis!(axis, value)
379
+ raise AxisTypeError.new(axis, value) unless value.is_a?(String)
380
+
381
+ Time.iso8601(value)
382
+ rescue ArgumentError
383
+ raise AxisTypeError.new(axis, value)
384
+ end
385
+
386
+ def covers_valid_time?(event, vt_time)
387
+ valid_from = Time.iso8601(event.fetch("valid_from"))
388
+ valid_until = Time.iso8601(event.fetch("valid_until"))
389
+ valid_from <= vt_time && vt_time < valid_until
390
+ end
391
+
392
+ def history_access_observation(subject, as_of, selected, result)
393
+ payload = {
394
+ "kind" => "history_access_observation",
395
+ "subject" => subject,
396
+ "as_of" => as_of,
397
+ "access" => "point",
398
+ "selected_append_ref" => selected&.fetch("observation_id"),
399
+ "result" => result,
400
+ "option_encoding" => Option::ENCODING
401
+ }
402
+ payload.merge(
403
+ "observation_id" => "obs/history_access/#{Canonical.short_hash(payload)}",
404
+ "observed_at" => as_of,
405
+ "temporal" => {
406
+ "axis" => "valid_time",
407
+ "as_of" => as_of,
408
+ "lifecycle" => "session"
409
+ }
410
+ )
411
+ end
412
+
413
+ def bihistory_access_observation(history_ref, vt, tt, node_name, selected, result)
414
+ payload = {
415
+ "kind" => "bihistory_access_observation",
416
+ "history_ref" => history_ref,
417
+ "node" => node_name,
418
+ "axis" => "bitemporal",
419
+ "valid_time" => vt,
420
+ "transaction_time" => tt,
421
+ "selected_event_ref" => selected&.fetch("event_id"),
422
+ "result" => result,
423
+ "option_encoding" => Option::ENCODING
424
+ }
425
+ payload.merge(
426
+ "observation_id" => "obs/bihistory_access/#{Canonical.short_hash(payload)}",
427
+ "observed_at" => tt,
428
+ "temporal" => {
429
+ "valid_time" => vt,
430
+ "transaction_time" => tt,
431
+ "lifecycle" => "audit"
432
+ }
433
+ )
434
+ end
435
+ end
436
+ end
437
+ end