kumi 0.0.13 → 0.0.15
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/.rspec +0 -1
- data/BACKLOG.md +34 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +4 -6
- data/README.md +0 -45
- data/config/functions.yaml +352 -0
- data/docs/dev/analyzer-debug.md +52 -0
- data/docs/dev/parse-command.md +64 -0
- data/docs/dev/vm-profiling.md +95 -0
- data/docs/features/README.md +0 -7
- data/docs/functions/analyzer_integration.md +199 -0
- data/docs/functions/signatures.md +171 -0
- data/examples/hash_objects_demo.rb +138 -0
- data/golden/array_operations/schema.kumi +17 -0
- data/golden/cascade_logic/schema.kumi +16 -0
- data/golden/mixed_nesting/schema.kumi +42 -0
- data/golden/simple_math/schema.kumi +10 -0
- data/lib/kumi/analyzer.rb +76 -22
- data/lib/kumi/compiler.rb +6 -5
- data/lib/kumi/core/analyzer/checkpoint.rb +72 -0
- data/lib/kumi/core/analyzer/debug.rb +167 -0
- data/lib/kumi/core/analyzer/passes/broadcast_detector.rb +1 -3
- data/lib/kumi/core/analyzer/passes/function_signature_pass.rb +199 -0
- data/lib/kumi/core/analyzer/passes/ir_dependency_pass.rb +67 -0
- data/lib/kumi/core/analyzer/passes/load_input_cse.rb +120 -0
- data/lib/kumi/core/analyzer/passes/lower_to_ir_pass.rb +72 -157
- data/lib/kumi/core/analyzer/passes/toposorter.rb +40 -36
- data/lib/kumi/core/analyzer/state_serde.rb +64 -0
- data/lib/kumi/core/analyzer/structs/access_plan.rb +12 -10
- data/lib/kumi/core/compiler/access_planner.rb +3 -2
- data/lib/kumi/core/function_registry/collection_functions.rb +3 -1
- data/lib/kumi/core/functions/dimension.rb +98 -0
- data/lib/kumi/core/functions/dtypes.rb +20 -0
- data/lib/kumi/core/functions/errors.rb +11 -0
- data/lib/kumi/core/functions/kernel_adapter.rb +45 -0
- data/lib/kumi/core/functions/loader.rb +119 -0
- data/lib/kumi/core/functions/registry_v2.rb +68 -0
- data/lib/kumi/core/functions/shape.rb +70 -0
- data/lib/kumi/core/functions/signature.rb +122 -0
- data/lib/kumi/core/functions/signature_parser.rb +86 -0
- data/lib/kumi/core/functions/signature_resolver.rb +272 -0
- data/lib/kumi/core/ir/execution_engine/interpreter.rb +110 -7
- data/lib/kumi/core/ir/execution_engine/profiler.rb +330 -0
- data/lib/kumi/core/ir/execution_engine.rb +6 -15
- data/lib/kumi/dev/ir.rb +75 -0
- data/lib/kumi/dev/parse.rb +105 -0
- data/lib/kumi/dev/profile_aggregator.rb +301 -0
- data/lib/kumi/dev/profile_runner.rb +199 -0
- data/lib/kumi/dev/runner.rb +85 -0
- data/lib/kumi/dev.rb +14 -0
- data/lib/kumi/frontends/ruby.rb +28 -0
- data/lib/kumi/frontends/text.rb +46 -0
- data/lib/kumi/frontends.rb +29 -0
- data/lib/kumi/kernels/ruby/aggregate_core.rb +105 -0
- data/lib/kumi/kernels/ruby/datetime_scalar.rb +21 -0
- data/lib/kumi/kernels/ruby/mask_scalar.rb +15 -0
- data/lib/kumi/kernels/ruby/scalar_core.rb +63 -0
- data/lib/kumi/kernels/ruby/string_scalar.rb +19 -0
- data/lib/kumi/kernels/ruby/vector_struct.rb +39 -0
- data/lib/kumi/runtime/executable.rb +108 -45
- data/lib/kumi/schema.rb +12 -6
- data/lib/kumi/support/diff.rb +22 -0
- data/lib/kumi/support/ir_render.rb +61 -0
- data/lib/kumi/version.rb +1 -1
- data/lib/kumi.rb +3 -0
- data/performance_results.txt +63 -0
- data/scripts/test_mixed_nesting_performance.rb +206 -0
- metadata +50 -6
- data/docs/features/analysis-cascade-mutual-exclusion.md +0 -89
- data/docs/features/javascript-transpiler.md +0 -148
- data/lib/kumi/js.rb +0 -23
- data/lib/kumi/support/ir_dump.rb +0 -491
@@ -0,0 +1,330 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
require "time"
|
6
|
+
require "set"
|
7
|
+
|
8
|
+
module Kumi
|
9
|
+
module Core
|
10
|
+
module IR
|
11
|
+
module ExecutionEngine
|
12
|
+
module Profiler
|
13
|
+
class << self
|
14
|
+
def enabled? = ENV["KUMI_PROFILE"] == "1"
|
15
|
+
def ops_enabled? = ENV.fetch("KUMI_PROFILE_OPS", "1") == "1"
|
16
|
+
def sample_rate = (ENV["KUMI_PROFILE_SAMPLE"]&.to_i || 1)
|
17
|
+
def persistent? = ENV["KUMI_PROFILE_PERSISTENT"] == "1"
|
18
|
+
|
19
|
+
def set_schema_name(name)
|
20
|
+
@schema_name = name
|
21
|
+
|
22
|
+
# Ensure profiler is initialized in persistent mode
|
23
|
+
unless @initialized
|
24
|
+
@events = []
|
25
|
+
@meta = {}
|
26
|
+
@file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
|
27
|
+
@run_id ||= 1
|
28
|
+
@op_seq ||= 0
|
29
|
+
@aggregated_stats ||= Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } }
|
30
|
+
|
31
|
+
# Truncate file if needed
|
32
|
+
if ENV["KUMI_PROFILE_TRUNCATE"] == "1" && !@persistent_initialized
|
33
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
34
|
+
File.write(@file, "")
|
35
|
+
@aggregated_stats.clear
|
36
|
+
@persistent_initialized = true
|
37
|
+
end
|
38
|
+
|
39
|
+
@initialized = true
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def reset!(meta: {})
|
44
|
+
set_schema_name(meta[:schema_name]) if meta[:schema_name]
|
45
|
+
return unless enabled?
|
46
|
+
|
47
|
+
# In persistent mode, don't reset aggregated stats or increment run_id
|
48
|
+
# This allows profiling across multiple schema creations
|
49
|
+
if persistent?
|
50
|
+
@events = []
|
51
|
+
@meta = (@meta || {}).merge(meta)
|
52
|
+
@schema_name = meta[:schema_name] if meta[:schema_name]
|
53
|
+
@file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
|
54
|
+
@run_id ||= 1
|
55
|
+
@op_seq ||= 0
|
56
|
+
@aggregated_stats ||= Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } }
|
57
|
+
|
58
|
+
# Only truncate on very first reset in persistent mode
|
59
|
+
if ENV["KUMI_PROFILE_TRUNCATE"] == "1" && !@persistent_initialized
|
60
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
61
|
+
File.write(@file, "")
|
62
|
+
@aggregated_stats.clear
|
63
|
+
@persistent_initialized = true
|
64
|
+
end
|
65
|
+
else
|
66
|
+
# Original behavior: full reset each time
|
67
|
+
@events = []
|
68
|
+
@meta = meta
|
69
|
+
@schema_name = meta[:schema_name]
|
70
|
+
@file = ENV["KUMI_PROFILE_FILE"] || "tmp/profile.jsonl"
|
71
|
+
@run_id = (@run_id || 0) + 1
|
72
|
+
@op_seq = 0
|
73
|
+
@aggregated_stats = (@aggregated_stats || Hash.new { |h, k| h[k] = { count: 0, total_ms: 0.0, total_cpu_ms: 0.0, rows: 0, runs: Set.new } })
|
74
|
+
|
75
|
+
if ENV["KUMI_PROFILE_TRUNCATE"] == "1"
|
76
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
77
|
+
File.write(@file, "")
|
78
|
+
@aggregated_stats.clear
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# monotonic start time
|
84
|
+
def t0
|
85
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
86
|
+
end
|
87
|
+
|
88
|
+
# CPU time start (process + thread)
|
89
|
+
def cpu_t0
|
90
|
+
Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Phase timing for coarse-grained operations
|
94
|
+
def phase(name, tags = {})
|
95
|
+
return yield unless enabled?
|
96
|
+
p0 = t0; c0 = cpu_t0
|
97
|
+
result = yield
|
98
|
+
wall_ms = (t0 - p0) * 1000.0
|
99
|
+
cpu_ms = (cpu_t0 - c0) * 1000.0
|
100
|
+
stream({
|
101
|
+
ts: Time.now.utc.iso8601(3),
|
102
|
+
kind: "phase",
|
103
|
+
name: name,
|
104
|
+
wall_ms: wall_ms.round(3),
|
105
|
+
cpu_ms: cpu_ms.round(3),
|
106
|
+
tags: tags,
|
107
|
+
run: @run_id
|
108
|
+
})
|
109
|
+
result
|
110
|
+
end
|
111
|
+
|
112
|
+
# Memory snapshot with GC statistics
|
113
|
+
def memory_snapshot(label, extra: {})
|
114
|
+
return unless enabled?
|
115
|
+
s = GC.stat
|
116
|
+
stream({
|
117
|
+
ts: Time.now.utc.iso8601(3),
|
118
|
+
kind: "mem",
|
119
|
+
label: label,
|
120
|
+
heap_live: s[:heap_live_slots],
|
121
|
+
old_objects: s[:old_objects],
|
122
|
+
minor_gc: s[:minor_gc_count],
|
123
|
+
major_gc: s[:major_gc_count],
|
124
|
+
rss_mb: read_rss_mb,
|
125
|
+
run: @run_id,
|
126
|
+
**extra
|
127
|
+
})
|
128
|
+
end
|
129
|
+
|
130
|
+
def read_rss_mb
|
131
|
+
((File.read("/proc/#{$$}/status")[/VmRSS:\s+(\d+)\skB/, 1].to_i) / 1024.0).round(2)
|
132
|
+
rescue
|
133
|
+
nil
|
134
|
+
end
|
135
|
+
|
136
|
+
# Per-op record with both wall time and CPU time (with sampling support)
|
137
|
+
def record!(decl:, idx:, tag:, op:, t0:, cpu_t0: nil, rows: nil, note: nil)
|
138
|
+
return unless enabled? && ops_enabled?
|
139
|
+
@op_seq += 1
|
140
|
+
return unless sample_rate <= 1 || (@op_seq % sample_rate).zero?
|
141
|
+
|
142
|
+
wall_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000.0)
|
143
|
+
cpu_ms = cpu_t0 ? ((Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu_t0) * 1000.0) : wall_ms
|
144
|
+
|
145
|
+
ev = {
|
146
|
+
ts: Time.now.utc.iso8601(3),
|
147
|
+
run: @run_id,
|
148
|
+
schema: @schema_name, # schema identifier for multi-schema differentiation
|
149
|
+
decl: decl, # decl name (string/symbol)
|
150
|
+
i: idx, # op index
|
151
|
+
tag: tag, # op tag (symbol)
|
152
|
+
wall_ms: wall_ms.round(4),
|
153
|
+
cpu_ms: cpu_ms.round(4),
|
154
|
+
rows: rows,
|
155
|
+
note: note,
|
156
|
+
key: op_key(decl, idx, tag, op), # stable key for grep/diff
|
157
|
+
attrs: compact_attrs(op.attrs)
|
158
|
+
}
|
159
|
+
|
160
|
+
# Aggregate stats for multi-run averaging
|
161
|
+
op_key = "#{decl}@#{idx}:#{tag}"
|
162
|
+
agg = @aggregated_stats[op_key]
|
163
|
+
agg[:count] += 1
|
164
|
+
agg[:total_ms] += wall_ms
|
165
|
+
agg[:total_cpu_ms] += cpu_ms
|
166
|
+
agg[:rows] += (rows || 0)
|
167
|
+
agg[:runs] << @run_id
|
168
|
+
agg[:decl] = decl
|
169
|
+
agg[:tag] = tag
|
170
|
+
agg[:idx] = idx
|
171
|
+
agg[:note] = note if note
|
172
|
+
|
173
|
+
(@events ||= []) << ev
|
174
|
+
stream(ev) if ENV["KUMI_PROFILE_STREAM"] == "1"
|
175
|
+
ev
|
176
|
+
end
|
177
|
+
|
178
|
+
def summary(top: 20)
|
179
|
+
return {} unless enabled?
|
180
|
+
|
181
|
+
# Current run summary (legacy format)
|
182
|
+
current_agg = Hash.new { |h, k| h[k] = { count: 0, ms: 0.0, rows: 0 } }
|
183
|
+
(@events || []).each do |e|
|
184
|
+
k = [e[:decl], e[:tag]]
|
185
|
+
a = current_agg[k]
|
186
|
+
a[:count] += 1
|
187
|
+
a[:ms] += (e[:wall_ms] || e[:ms] || 0)
|
188
|
+
a[:rows] += (e[:rows] || 0)
|
189
|
+
end
|
190
|
+
current_ranked = current_agg.map { |(decl, tag), v|
|
191
|
+
{ decl: decl, tag: tag, count: v[:count], ms: v[:ms].round(3), rows: v[:rows],
|
192
|
+
rps: v[:rows] > 0 ? (v[:rows] / v[:ms]).round(1) : nil }
|
193
|
+
}.sort_by { |h| -h[:ms] }.first(top)
|
194
|
+
|
195
|
+
{ meta: @meta || {}, top: current_ranked,
|
196
|
+
total_ms: ((@events || []).sum { |e| e[:wall_ms] || e[:ms] || 0 }).round(3),
|
197
|
+
op_count: (@events || []).size,
|
198
|
+
run_id: @run_id }
|
199
|
+
end
|
200
|
+
|
201
|
+
# Multi-run averaged analysis
|
202
|
+
def averaged_analysis(top: 20)
|
203
|
+
return {} unless enabled? && @aggregated_stats&.any?
|
204
|
+
|
205
|
+
# Convert aggregated stats to averaged metrics
|
206
|
+
averaged = @aggregated_stats.map do |op_key, stats|
|
207
|
+
num_runs = stats[:runs].size
|
208
|
+
avg_wall_ms = stats[:total_ms] / stats[:count]
|
209
|
+
avg_cpu_ms = stats[:total_cpu_ms] / stats[:count]
|
210
|
+
total_wall_ms = stats[:total_ms]
|
211
|
+
total_cpu_ms = stats[:total_cpu_ms]
|
212
|
+
|
213
|
+
{
|
214
|
+
op_key: op_key,
|
215
|
+
decl: stats[:decl],
|
216
|
+
idx: stats[:idx],
|
217
|
+
tag: stats[:tag],
|
218
|
+
runs: num_runs,
|
219
|
+
total_calls: stats[:count],
|
220
|
+
calls_per_run: stats[:count] / num_runs.to_f,
|
221
|
+
avg_wall_ms: avg_wall_ms.round(4),
|
222
|
+
avg_cpu_ms: avg_cpu_ms.round(4),
|
223
|
+
total_wall_ms: total_wall_ms.round(3),
|
224
|
+
total_cpu_ms: total_cpu_ms.round(3),
|
225
|
+
cpu_efficiency: total_wall_ms > 0 ? (total_cpu_ms / total_wall_ms * 100).round(1) : 100,
|
226
|
+
rows_total: stats[:rows],
|
227
|
+
note: stats[:note]
|
228
|
+
}
|
229
|
+
end.sort_by { |s| -s[:total_wall_ms] }.first(top)
|
230
|
+
|
231
|
+
{
|
232
|
+
meta: @meta || {},
|
233
|
+
total_runs: (@aggregated_stats.values.map { |s| s[:runs].size }.max || 0),
|
234
|
+
averaged_ops: averaged,
|
235
|
+
total_operations: @aggregated_stats.size
|
236
|
+
}
|
237
|
+
end
|
238
|
+
|
239
|
+
# Identify potential cache overhead operations
|
240
|
+
def cache_overhead_analysis
|
241
|
+
return {} unless enabled? && @aggregated_stats&.any?
|
242
|
+
|
243
|
+
# Look for operations that might be cache-related
|
244
|
+
cache_ops = @aggregated_stats.select do |op_key, stats|
|
245
|
+
op_key.include?("ref") || op_key.include?("load_input") || stats[:note]&.include?("cache")
|
246
|
+
end
|
247
|
+
|
248
|
+
cache_analysis = cache_ops.map do |op_key, stats|
|
249
|
+
num_runs = stats[:runs].size
|
250
|
+
avg_wall_ms = stats[:total_ms] / stats[:count]
|
251
|
+
|
252
|
+
{
|
253
|
+
op_key: op_key,
|
254
|
+
decl: stats[:decl],
|
255
|
+
tag: stats[:tag],
|
256
|
+
avg_time_ms: avg_wall_ms.round(4),
|
257
|
+
total_time_ms: stats[:total_ms].round(3),
|
258
|
+
call_count: stats[:count],
|
259
|
+
overhead_per_call: avg_wall_ms.round(6)
|
260
|
+
}
|
261
|
+
end.sort_by { |s| -s[:total_time_ms] }
|
262
|
+
|
263
|
+
{
|
264
|
+
cache_operations: cache_analysis,
|
265
|
+
total_cache_time: cache_analysis.sum { |op| op[:total_time_ms] }.round(3)
|
266
|
+
}
|
267
|
+
end
|
268
|
+
|
269
|
+
def emit_summary!
|
270
|
+
return unless enabled?
|
271
|
+
stream({ ts: Time.now.utc.iso8601(3), kind: "summary", data: summary })
|
272
|
+
end
|
273
|
+
|
274
|
+
def init_persistent!
|
275
|
+
return unless enabled? && persistent?
|
276
|
+
@persistent_initialized = false
|
277
|
+
reset!
|
278
|
+
end
|
279
|
+
|
280
|
+
def finalize!
|
281
|
+
return unless enabled?
|
282
|
+
|
283
|
+
# Emit final aggregated summary
|
284
|
+
if @aggregated_stats&.any?
|
285
|
+
stream({
|
286
|
+
ts: Time.now.utc.iso8601(3),
|
287
|
+
kind: "final_summary",
|
288
|
+
data: averaged_analysis
|
289
|
+
})
|
290
|
+
end
|
291
|
+
|
292
|
+
# Emit cache analysis if available
|
293
|
+
cache_analysis = cache_overhead_analysis
|
294
|
+
if cache_analysis[:cache_operations]&.any?
|
295
|
+
stream({
|
296
|
+
ts: Time.now.utc.iso8601(3),
|
297
|
+
kind: "cache_analysis",
|
298
|
+
data: cache_analysis
|
299
|
+
})
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
# Stable textual key for "match ops one by one"
|
304
|
+
def op_key(decl, idx, tag, op)
|
305
|
+
attrs = compact_attrs(op.attrs)
|
306
|
+
args = op.args
|
307
|
+
"#{decl}@#{idx}:#{tag}|#{attrs.keys.sort_by(&:to_s).map { |k| "#{k}=#{attrs[k].inspect}" }.join(",")}|args=#{args.inspect}"
|
308
|
+
end
|
309
|
+
|
310
|
+
def compact_attrs(h)
|
311
|
+
return {} unless h
|
312
|
+
h.transform_values do |v|
|
313
|
+
case v
|
314
|
+
when Array, Hash, Symbol, String, Numeric, TrueClass, FalseClass, NilClass then v
|
315
|
+
else v.to_s
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
def stream(obj)
|
321
|
+
return unless @file
|
322
|
+
FileUtils.mkdir_p(File.dirname(@file))
|
323
|
+
File.open(@file, "a") { |f| f.puts(obj.to_json) }
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
@@ -43,15 +43,15 @@ module Kumi
|
|
43
43
|
module ExecutionEngine
|
44
44
|
def self.run(ir_module, ctx, accessors:, registry:)
|
45
45
|
# Use persistent accessor cache if available, otherwise create temporary one
|
46
|
-
|
46
|
+
memoized_accessors = Dev::Profiler.phase("engine.memoization") do
|
47
47
|
# Include input data in cache key to avoid cross-context pollution
|
48
48
|
input_key = ctx[:input]&.hash || ctx["input"]&.hash || 0
|
49
|
-
|
50
|
-
|
51
|
-
|
49
|
+
add_persistent_memoization(accessors, ctx[:accessor_cache], input_key)
|
50
|
+
end
|
51
|
+
|
52
|
+
Dev::Profiler.phase("engine.interpreter") do
|
53
|
+
Interpreter.run(ir_module, ctx, accessors: memoized_accessors, registry: registry)
|
52
54
|
end
|
53
|
-
|
54
|
-
Interpreter.run(ir_module, ctx, accessors: memoized_accessors, registry: registry)
|
55
55
|
end
|
56
56
|
|
57
57
|
private
|
@@ -64,15 +64,6 @@ module Kumi
|
|
64
64
|
end]
|
65
65
|
end.to_h
|
66
66
|
end
|
67
|
-
|
68
|
-
def self.add_temporary_memoization(accessors)
|
69
|
-
cache = {}
|
70
|
-
accessors.map do |plan_id, accessor_fn|
|
71
|
-
[plan_id, lambda do |input_data|
|
72
|
-
cache[plan_id] ||= accessor_fn.call(input_data)
|
73
|
-
end]
|
74
|
-
end.to_h
|
75
|
-
end
|
76
67
|
end
|
77
68
|
end
|
78
69
|
end
|
data/lib/kumi/dev/ir.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Kumi
|
6
|
+
module Dev
|
7
|
+
module IR
|
8
|
+
module_function
|
9
|
+
|
10
|
+
def to_text(ir_module)
|
11
|
+
raise "nil IR" unless ir_module
|
12
|
+
|
13
|
+
lines = []
|
14
|
+
lines << "IR Module"
|
15
|
+
lines << "decls: #{ir_module.decls.size}"
|
16
|
+
|
17
|
+
ir_module.decls.each_with_index do |decl, i|
|
18
|
+
lines << "decl[#{i}] #{decl.kind}:#{decl.name} shape=#{decl.shape} ops=#{decl.ops.size}"
|
19
|
+
|
20
|
+
decl.ops.each_with_index do |op, j|
|
21
|
+
# Sort attribute keys for deterministic output
|
22
|
+
sorted_attrs = op.attrs.keys.sort.map { |k| "#{k}=#{format_value(op.attrs[k])}" }.join(" ")
|
23
|
+
args_str = op.args.inspect
|
24
|
+
lines << " #{j}: #{op.tag} #{sorted_attrs} #{args_str}".rstrip
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
lines.join("\n") + "\n"
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def self.format_value(val)
|
34
|
+
case val
|
35
|
+
when true, false
|
36
|
+
val.to_s
|
37
|
+
when Symbol
|
38
|
+
":#{val}"
|
39
|
+
when Array
|
40
|
+
val.inspect
|
41
|
+
else
|
42
|
+
val.to_s
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_json(ir_module, pretty: true)
|
47
|
+
raise "nil IR" unless ir_module
|
48
|
+
|
49
|
+
data = {
|
50
|
+
inputs: ir_module.inputs,
|
51
|
+
decls: ir_module.decls.map do |decl|
|
52
|
+
{
|
53
|
+
name: decl.name,
|
54
|
+
kind: decl.kind,
|
55
|
+
shape: decl.shape,
|
56
|
+
ops: decl.ops.map do |op|
|
57
|
+
{
|
58
|
+
tag: op.tag,
|
59
|
+
attrs: op.attrs,
|
60
|
+
args: op.args
|
61
|
+
}
|
62
|
+
end
|
63
|
+
}
|
64
|
+
end
|
65
|
+
}
|
66
|
+
|
67
|
+
if pretty
|
68
|
+
JSON.pretty_generate(data)
|
69
|
+
else
|
70
|
+
JSON.generate(data)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
|
5
|
+
module Kumi
|
6
|
+
module Dev
|
7
|
+
module Parse
|
8
|
+
module_function
|
9
|
+
|
10
|
+
def run(schema_path, opts = {})
|
11
|
+
# Load schema via text frontend
|
12
|
+
begin
|
13
|
+
schema, _inputs = Kumi::Frontends::Text.load(path: schema_path)
|
14
|
+
rescue LoadError => e
|
15
|
+
puts "Error: kumi-parser gem not available. Install: gem install kumi-parser"
|
16
|
+
return false
|
17
|
+
rescue StandardError => e
|
18
|
+
puts "Parse error: #{e.message}"
|
19
|
+
return false
|
20
|
+
end
|
21
|
+
|
22
|
+
# Run analyzer
|
23
|
+
runner_opts = opts.slice(:trace, :snap, :snap_dir, :resume_from, :resume_at, :stop_after)
|
24
|
+
res = Dev::Runner.run(schema, runner_opts)
|
25
|
+
|
26
|
+
unless res.ok?
|
27
|
+
puts "Analysis errors:"
|
28
|
+
res.errors.each { |err| puts " #{err}" }
|
29
|
+
return false
|
30
|
+
end
|
31
|
+
|
32
|
+
unless res.ir
|
33
|
+
puts "Error: No IR generated"
|
34
|
+
return false
|
35
|
+
end
|
36
|
+
|
37
|
+
# Report trace file if enabled
|
38
|
+
if opts[:trace] && res.respond_to?(:trace_file)
|
39
|
+
puts "Trace written to: #{res.trace_file}"
|
40
|
+
end
|
41
|
+
|
42
|
+
# Determine file extension and renderer
|
43
|
+
extension = opts[:json] ? "json" : "txt"
|
44
|
+
golden_path = File.join(File.dirname(schema_path), "expected", "ir.#{extension}")
|
45
|
+
|
46
|
+
# Render IR
|
47
|
+
rendered = if opts[:json]
|
48
|
+
Dev::IR.to_json(res.ir, pretty: true)
|
49
|
+
else
|
50
|
+
Dev::IR.to_text(res.ir)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Handle write mode
|
54
|
+
if opts[:write]
|
55
|
+
FileUtils.mkdir_p(File.dirname(golden_path))
|
56
|
+
File.write(golden_path, rendered)
|
57
|
+
puts "Wrote: #{golden_path}"
|
58
|
+
return true
|
59
|
+
end
|
60
|
+
|
61
|
+
# Handle update mode (write only if different)
|
62
|
+
if opts[:update]
|
63
|
+
if File.exist?(golden_path) && File.read(golden_path) == rendered
|
64
|
+
puts "No changes (#{golden_path})"
|
65
|
+
return true
|
66
|
+
else
|
67
|
+
FileUtils.mkdir_p(File.dirname(golden_path))
|
68
|
+
File.write(golden_path, rendered)
|
69
|
+
puts "Updated: #{golden_path}"
|
70
|
+
return true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Handle no-diff mode
|
75
|
+
if opts[:no_diff]
|
76
|
+
puts rendered
|
77
|
+
return true
|
78
|
+
end
|
79
|
+
|
80
|
+
# Default: diff mode (same as write but show diff instead)
|
81
|
+
if File.exist?(golden_path)
|
82
|
+
# Use diff directly with the golden file path
|
83
|
+
require "tempfile"
|
84
|
+
Tempfile.create(["actual", File.extname(golden_path)]) do |actual_file|
|
85
|
+
actual_file.write(rendered)
|
86
|
+
actual_file.flush
|
87
|
+
|
88
|
+
result = `diff -u --label=expected --label=actual #{golden_path} #{actual_file.path}`
|
89
|
+
if result.empty?
|
90
|
+
puts "No changes (#{golden_path})"
|
91
|
+
return true
|
92
|
+
else
|
93
|
+
puts result.chomp
|
94
|
+
return false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
else
|
98
|
+
# No golden file exists, just print the output
|
99
|
+
puts rendered
|
100
|
+
return true
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|