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,821 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module IgniterLang
6
+ class TypeChecker
7
+ DEFAULT_VERSION = "typed-pass-executable-proof-v0"
8
+
9
+ def initialize(typechecker_version: DEFAULT_VERSION)
10
+ @typechecker_version = typechecker_version
11
+ end
12
+
13
+ def typecheck(classified_program)
14
+ @type_shapes = type_shapes(classified_program)
15
+ @assumption_registry = classified_program.fetch("assumption_registry", [])
16
+ @type_shapes["Assumption"] = assumption_shape if assumptions_present?(classified_program)
17
+ @assumption_errors = assumption_errors_by_name(@assumption_registry)
18
+ @olap_env = olap_env(classified_program.fetch("olap_points", []))
19
+ @olap_errors = olap_declaration_errors(@olap_env)
20
+ typed_contracts = classified_program.fetch("contracts").map do |contract|
21
+ typecheck_contract(contract)
22
+ end
23
+
24
+ result = {
25
+ "kind" => "typed_program",
26
+ "typechecker_version" => @typechecker_version,
27
+ "program_id" => program_id(classified_program),
28
+ "classified_program_id" => classified_program.fetch("program_id"),
29
+ "source_path" => classified_program.fetch("source_path"),
30
+ "source_hash" => classified_program.fetch("source_hash"),
31
+ "grammar_version" => classified_program.fetch("grammar_version"),
32
+ "module" => classified_program.fetch("module"),
33
+ "type_env" => @type_shapes,
34
+ "contracts" => typed_contracts,
35
+ "type_errors" => typed_contracts.flat_map { |contract| contract.fetch("type_errors") },
36
+ "semantic_ir_ref" => nil
37
+ }
38
+ result["assumption_registry"] = @assumption_registry unless @assumption_registry.empty?
39
+ result["olap_points"] = @olap_env.values.map { |decl| decl.fetch("semantic_node") } unless @olap_env.empty?
40
+ type_warnings = typed_contracts.flat_map { |contract| contract.fetch("type_warnings", []) }
41
+ result["type_warnings"] = type_warnings unless type_warnings.empty?
42
+ result
43
+ end
44
+
45
+ private
46
+
47
+ def program_id(classified_program)
48
+ seed = [
49
+ classified_program.fetch("program_id"),
50
+ classified_program.fetch("source_hash"),
51
+ @typechecker_version
52
+ ].join("|")
53
+ "typed_pass/#{Digest::SHA256.hexdigest(seed)[0, 16]}"
54
+ end
55
+
56
+ def type_shapes(classified_program)
57
+ classified_program.fetch("type_declarations").each_with_object({}) do |type, shapes|
58
+ shapes[type.fetch("name")] = type.fetch("fields", []).each_with_object({}) do |field, fields|
59
+ fields[field.fetch("name")] = type_ir(normalize_type(field.fetch("type_annotation")))
60
+ end
61
+ end
62
+ end
63
+
64
+ def typecheck_contract(classified_contract)
65
+ declared_oofs = classified_contract.fetch("oof_log")
66
+ assumption_refs = classified_contract.fetch("assumption_refs", [])
67
+ type_errors = declared_oofs + @olap_errors + assumption_refs.flat_map { |name| @assumption_errors.fetch(name, []) }
68
+ type_warnings = []
69
+ symbol_types = {}
70
+ typed_decls = []
71
+ invariant_effects = [] # [{"name" => ..., "effect" => "warns"|"uncertain"|"metric"}] for output propagation
72
+
73
+ classified_contract.fetch("declarations").each do |decl|
74
+ case decl.fetch("kind")
75
+ when "input"
76
+ type = type_ir(decl.fetch("type_annotation"))
77
+ symbol_types[decl.fetch("name")] = type
78
+ typed_decls << typed_decl(decl, type, nil, [])
79
+ when "read"
80
+ type = type_ir(decl.fetch("type_annotation"))
81
+ symbol_types[decl.fetch("name")] = type
82
+ typed_decls << typed_decl(decl, type, nil, [])
83
+ when "stream"
84
+ # stream declarations are ESCAPE; register their type for body-escape checks
85
+ type = decl.key?("type_annotation") ? type_ir(decl.fetch("type_annotation")) : type_ir("Unknown")
86
+ symbol_types[decl.fetch("name")] = type
87
+ typed_decls << typed_decl(decl, type, nil, [])
88
+ when "window"
89
+ typed_decls << typed_decl(decl, type_ir("Window"), nil, [])
90
+ when "fold_stream"
91
+ # OOF-S3: ESCAPE construct (stream ref) inside fold_stream accumulator function body
92
+ stream_symbols = stream_symbol_names(classified_contract)
93
+ check_fold_stream_body(decl, stream_symbols, type_errors)
94
+ result_type = fold_stream_result_type(decl)
95
+ symbol_types[decl.fetch("name")] = result_type
96
+ typed_decls << typed_decl(decl, result_type, decl.fetch("expr", nil), decl.fetch("deps", []))
97
+ when "uses_assumptions"
98
+ type = type_ir("Assumption")
99
+ symbol_types[decl.fetch("name")] = type
100
+ typed_decls << typed_decl(decl, type, nil, [])
101
+ when "invariant"
102
+ # TINV-1/2/3: Resolve predicate_ref, validate overridable_with, compute output_effect
103
+ check_invariant(decl, symbol_types, type_errors, invariant_effects)
104
+ typed_decls << typed_decl_invariant(decl, symbol_types)
105
+ when "compute"
106
+ typed_expr = infer_expr(decl.fetch("expr"), symbol_types, type_errors, type_warnings, decl.fetch("name"))
107
+ validate_declared_olap_type(decl, typed_expr, type_errors)
108
+ symbol_types[decl.fetch("name")] = typed_expr.fetch("resolved_type")
109
+ typed_decls << typed_decl(decl, typed_expr.fetch("resolved_type"), typed_expr, typed_expr.fetch("deps"))
110
+ when "output"
111
+ expected = type_ir(decl.fetch("type_annotation"))
112
+ actual = symbol_types.fetch(decl.fetch("name"), type_ir("Unknown"))
113
+ if type_name(actual) != type_name(expected) && !blocking_rule_present?(type_errors)
114
+ type_errors << type_mismatch(expected, actual, decl.fetch("name"))
115
+ end
116
+ # TINV-4: propagate invariant output effects to output nodes
117
+ typed_decls << typed_decl_output(decl, expected, invariant_effects)
118
+ end
119
+ end
120
+
121
+ status = type_errors.empty? ? "accepted" : "blocked"
122
+ result = {
123
+ "kind" => "typed_contract",
124
+ "contract_id" => classified_contract.fetch("contract_id"),
125
+ "name" => classified_contract.fetch("name"),
126
+ "modifier" => classified_contract.fetch("modifier", "pure"),
127
+ "status" => status,
128
+ "fragment_class" => classified_contract.fetch("fragment_class"),
129
+ "symbols" => symbol_types.keys.sort.map do |name|
130
+ { "name" => name, "type" => symbol_types.fetch(name), "resolved" => type_name(symbol_types.fetch(name)) != "Unknown" }
131
+ end,
132
+ "declarations" => typed_decls,
133
+ "type_errors" => dedupe_errors(type_errors)
134
+ }
135
+ result["assumption_refs"] = assumption_refs unless assumption_refs.empty?
136
+ warnings = dedupe_errors(type_warnings)
137
+ result["type_warnings"] = warnings unless warnings.empty?
138
+ result
139
+ end
140
+
141
+ def typed_decl(decl, type, expr, deps)
142
+ result = {
143
+ "decl_id" => decl.fetch("decl_id"),
144
+ "kind" => decl.fetch("kind"),
145
+ "name" => decl.fetch("name"),
146
+ "fragment_class" => decl.fetch("fragment_class"),
147
+ "type" => type,
148
+ "deps" => deps
149
+ }
150
+ result["expr"] = expr if expr
151
+ result["semantic_node"] = expr.fetch("semantic_node") if expr&.key?("semantic_node")
152
+ %w[node_fragment_class value_fragment_class required_capability temporal_axis].each do |key|
153
+ result[key] = decl.fetch(key) if decl.key?(key)
154
+ end
155
+ %w[from lifecycle].each do |key|
156
+ result[key] = decl.fetch(key) if decl.key?(key)
157
+ end
158
+ %w[bound options window_ref key window_kind size period idle on_close fn_ref init stream_ref].each do |key|
159
+ result[key] = decl.fetch(key) if decl.key?(key)
160
+ end
161
+ result
162
+ end
163
+
164
+ def assumption_shape
165
+ {
166
+ "kind" => type_ir("Symbol"),
167
+ "statement" => type_ir("String"),
168
+ "strength" => type_ir("Decimal"),
169
+ "source" => type_ir("String")
170
+ }
171
+ end
172
+
173
+ def assumptions_present?(classified_program)
174
+ @assumption_registry.any? ||
175
+ classified_program.fetch("contracts").any? { |contract| contract.fetch("assumption_refs", []).any? }
176
+ end
177
+
178
+ def assumption_errors_by_name(registry)
179
+ registry.each_with_object({}) do |entry, errors|
180
+ strength = entry.fetch("fields", {}).fetch("strength", nil)
181
+ next if strength.nil? || valid_assumption_strength?(strength)
182
+
183
+ errors[entry.fetch("name")] ||= []
184
+ errors[entry.fetch("name")] << oof(
185
+ "TASSUMP-1",
186
+ "assumption strength must be between 0.0 and 1.0",
187
+ "assumption:#{entry.fetch("name")}"
188
+ )
189
+ end
190
+ end
191
+
192
+ def valid_assumption_strength?(strength)
193
+ strength.is_a?(Numeric) && strength >= 0.0 && strength <= 1.0
194
+ end
195
+
196
+ def infer_expr(expr, symbol_types, type_errors, type_warnings, node_name)
197
+ case expr.fetch("kind")
198
+ when "literal"
199
+ type = type_ir(expr.fetch("type_tag"))
200
+ typed_expr("literal", type, [], "value" => expr.fetch("value"), "literal_type" => literal_type(type_name(type)))
201
+ when "symbol"
202
+ typed_expr("symbol", type_ir("Symbol"), [], "value" => expr.fetch("value"))
203
+ when "ref"
204
+ name = expr.fetch("name")
205
+ type = symbol_types.fetch(name, @olap_env.fetch(name, {}).fetch("type", type_ir("Unknown")))
206
+ type_errors << oof("OOF-P1", "Unresolved symbol: #{name}", node_name) if type_name(type) == "Unknown" && !rule_present?(type_errors, "OOF-P1")
207
+ typed_expr("ref", type, [name], "name" => name)
208
+ when "field_access"
209
+ object = infer_expr(expr.fetch("object"), symbol_types, type_errors, type_warnings, node_name)
210
+ object_type = type_name(object.fetch("resolved_type"))
211
+ field_type = @type_shapes.fetch(object_type, {})[expr.fetch("field")] || type_ir("Unknown")
212
+ if type_name(field_type) == "Unknown"
213
+ type_errors << oof("OOF-P1", "Unresolved field: #{object_type}.#{expr.fetch("field")}", node_name)
214
+ end
215
+ typed_expr(
216
+ "field_access",
217
+ field_type,
218
+ object.fetch("deps"),
219
+ "object" => object,
220
+ "field" => expr.fetch("field")
221
+ )
222
+ when "binary_op"
223
+ infer_binary(expr, symbol_types, type_errors, type_warnings, node_name)
224
+ when "call"
225
+ infer_call(expr, symbol_types, type_errors, type_warnings, node_name)
226
+ when "index_access"
227
+ infer_index_access(expr, symbol_types, type_errors, type_warnings, node_name)
228
+ else
229
+ type_errors << oof("OOF-TY0", "Unsupported expression kind: #{expr.fetch("kind")}", node_name)
230
+ typed_expr("unsupported", type_ir("Unknown"), [], "source_kind" => expr.fetch("kind"))
231
+ end
232
+ end
233
+
234
+ def infer_call(expr, symbol_types, type_errors, type_warnings, node_name)
235
+ fn = expr.fetch("fn")
236
+ args = expr.fetch("args")
237
+ case fn
238
+ when "history_at"
239
+ infer_history_at(fn, args, symbol_types, type_errors, type_warnings, node_name)
240
+ when "bihistory_at"
241
+ infer_bihistory_at(fn, args, symbol_types, type_errors, type_warnings, node_name)
242
+ when "olap_rollup"
243
+ infer_olap_rollup(fn, args, symbol_types, type_errors, type_warnings, node_name)
244
+ else
245
+ type_errors << oof("OOF-TY0", "Unknown function: #{fn}", node_name)
246
+ typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
247
+ end
248
+ end
249
+
250
+ def infer_history_at(fn, args, symbol_types, type_errors, type_warnings, node_name)
251
+ if args.length < 2
252
+ type_errors << oof_alias("OOF-H1", "history_at requires as_of argument", node_name, ["OOF-TM1"])
253
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
254
+ end
255
+
256
+ history_ref = infer_expr(args[0], symbol_types, type_errors, type_warnings, node_name)
257
+ as_of_ref = infer_expr(args[1], symbol_types, type_errors, type_warnings, node_name)
258
+ unless type_name(as_of_ref.fetch("resolved_type")) == "DateTime" ||
259
+ type_name(as_of_ref.fetch("resolved_type")) == "Unknown"
260
+ type_errors << oof_alias("OOF-BT1", "history_at: as_of must be DateTime, got #{type_name(as_of_ref.fetch("resolved_type"))}", node_name, ["OOF-TM3"])
261
+ end
262
+ result_type = option_type_from(history_ref.fetch("resolved_type"))
263
+ typed_expr(
264
+ "call",
265
+ result_type,
266
+ history_ref.fetch("deps") + as_of_ref.fetch("deps"),
267
+ "fn" => fn,
268
+ "args" => [history_ref, as_of_ref],
269
+ "semantic_node" => temporal_access_node(node_name, "valid_time", history_ref, [as_of_ref], result_type)
270
+ )
271
+ end
272
+
273
+ def infer_bihistory_at(fn, args, symbol_types, type_errors, type_warnings, node_name)
274
+ if args.length < 2
275
+ type_errors << oof_alias("OOF-BT2", "bihistory_at requires valid_time (vt) argument", node_name, ["OOF-TM4"])
276
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
277
+ end
278
+ if args.length < 3
279
+ type_errors << oof_alias("OOF-BT3", "bihistory_at requires transaction_time (tt) argument", node_name, ["OOF-TM5"])
280
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
281
+ end
282
+
283
+ history_ref = infer_expr(args[0], symbol_types, type_errors, type_warnings, node_name)
284
+ vt_ref = infer_expr(args[1], symbol_types, type_errors, type_warnings, node_name)
285
+ tt_ref = infer_expr(args[2], symbol_types, type_errors, type_warnings, node_name)
286
+ [vt_ref, tt_ref].each_with_index do |axis_ref, idx|
287
+ axis_name = idx.zero? ? "valid_time" : "transaction_time"
288
+ unless type_name(axis_ref.fetch("resolved_type")) == "DateTime" ||
289
+ type_name(axis_ref.fetch("resolved_type")) == "Unknown"
290
+ type_errors << oof_alias("OOF-BT4", "bihistory_at: #{axis_name} must be DateTime, got #{type_name(axis_ref.fetch("resolved_type"))}", node_name, ["OOF-TM6"])
291
+ end
292
+ end
293
+ result_type = option_type_from(history_ref.fetch("resolved_type"))
294
+ typed_expr(
295
+ "call",
296
+ result_type,
297
+ history_ref.fetch("deps") + vt_ref.fetch("deps") + tt_ref.fetch("deps"),
298
+ "fn" => fn,
299
+ "args" => [history_ref, vt_ref, tt_ref],
300
+ "semantic_node" => temporal_access_node(node_name, "bitemporal", history_ref, [vt_ref, tt_ref], result_type)
301
+ )
302
+ end
303
+
304
+ def temporal_access_node(node_name, axis, history_ref, axis_refs, result_type)
305
+ capability = axis == "bitemporal" ? "bihistory_read" : "history_read"
306
+ result = {
307
+ "kind" => "temporal_access_node",
308
+ "name" => node_name,
309
+ "source_ref" => history_ref.fetch("name", nil),
310
+ "axis" => axis,
311
+ "temporal_axis" => axis,
312
+ "history_ref" => history_ref.fetch("name", nil),
313
+ "axis_refs" => axis_refs.map { |ref| ref.fetch("name", nil) }.compact,
314
+ "coordinate_refs" => temporal_coordinate_refs(axis, axis_refs),
315
+ "result_type" => result_type,
316
+ "node_fragment_class" => "temporal",
317
+ "value_fragment_class" => "core",
318
+ "required_capability" => capability,
319
+ "required_caps" => [capability],
320
+ "deps" => history_ref.fetch("deps", []) + axis_refs.flat_map { |ref| ref.fetch("deps", []) },
321
+ "evidence_policy" => axis == "bitemporal" ? "link_selected_event_observation" : "link_selected_append_observation",
322
+ "fragment" => "temporal"
323
+ }
324
+ if axis == "bitemporal"
325
+ result["valid_time_ref"] = axis_refs[0]&.fetch("name", nil)
326
+ result["transaction_time_ref"] = axis_refs[1]&.fetch("name", nil)
327
+ else
328
+ result["as_of_ref"] = axis_refs[0]&.fetch("name", nil)
329
+ end
330
+ result
331
+ end
332
+
333
+ def temporal_coordinate_refs(axis, axis_refs)
334
+ if axis == "bitemporal"
335
+ {
336
+ "valid_time" => axis_refs[0]&.fetch("name", nil),
337
+ "transaction_time" => axis_refs[1]&.fetch("name", nil)
338
+ }
339
+ else
340
+ { "as_of" => axis_refs[0]&.fetch("name", nil) }
341
+ end
342
+ end
343
+
344
+ def infer_index_access(expr, symbol_types, type_errors, type_warnings, node_name)
345
+ object = expr.fetch("object")
346
+ return unsupported_index_access(expr, type_errors, node_name) unless object.fetch("kind") == "ref"
347
+
348
+ olap_name = object.fetch("name")
349
+ olap_decl = @olap_env[olap_name]
350
+ return unsupported_index_access(expr, type_errors, node_name) unless olap_decl
351
+
352
+ index = expr.fetch("index")
353
+ unless index.fetch("kind") == "slice_record"
354
+ type_errors << oof("OOF-O4", "OLAPPoint access requires a dimension slice record", node_name)
355
+ return typed_expr("index_access", type_ir("Unknown"), [olap_name], "object" => typed_expr("ref", olap_decl.fetch("type"), [olap_name], "name" => olap_name))
356
+ end
357
+
358
+ slices = index.fetch("fields")
359
+ dims = olap_decl.fetch("dimensions")
360
+ missing = dims.keys.sort - slices.keys.sort
361
+ missing.each do |dim|
362
+ type_errors << oof("OOF-O4", "OLAPPoint access missing required dimension: #{dim}", node_name)
363
+ end
364
+
365
+ typed_slices = slices.keys.sort.map do |dim|
366
+ expected = dims[dim]
367
+ value = infer_expr(slices.fetch(dim), symbol_types, type_errors, type_warnings, node_name)
368
+ if expected && !unknown?(value.fetch("resolved_type")) && !same_type?(expected, value.fetch("resolved_type"))
369
+ type_errors << oof("OOF-O5", "OLAPPoint dimension '#{dim}' expected #{type_display(expected)}, got #{type_display(value.fetch("resolved_type"))}", node_name)
370
+ end
371
+ {
372
+ "dim" => dim,
373
+ "value" => value,
374
+ "value_ref" => slice_value_ref(slices.fetch(dim)),
375
+ "expected_type" => expected || type_ir("Unknown")
376
+ }
377
+ end
378
+
379
+ semantic_node = olap_access_node(node_name, olap_decl, typed_slices)
380
+ typed_expr(
381
+ "index_access",
382
+ olap_decl.fetch("measure_type"),
383
+ typed_slices.flat_map { |slice| slice.fetch("value").fetch("deps") },
384
+ "object" => typed_expr("ref", olap_decl.fetch("type"), [olap_name], "name" => olap_name),
385
+ "slices" => typed_slices,
386
+ "semantic_node" => semantic_node
387
+ )
388
+ end
389
+
390
+ def unsupported_index_access(expr, type_errors, node_name)
391
+ type_errors << oof("OOF-TY0", "Unsupported index access", node_name)
392
+ typed_expr("index_access", type_ir("Unknown"), [], "source_kind" => expr.fetch("kind"))
393
+ end
394
+
395
+ def infer_olap_rollup(fn, args, symbol_types, type_errors, type_warnings, node_name)
396
+ if args.length < 2
397
+ type_errors << oof("OOF-O4", "olap_rollup requires point and dimension arguments", node_name)
398
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
399
+ end
400
+
401
+ olap_ref = args[0]
402
+ dim_arg = args[1]
403
+ unless olap_ref.fetch("kind") == "ref" && dim_arg.fetch("kind") == "symbol"
404
+ type_errors << oof("OOF-O4", "olap_rollup requires a named OLAPPoint and dimension symbol", node_name)
405
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
406
+ end
407
+
408
+ olap_decl = @olap_env[olap_ref.fetch("name")]
409
+ unless olap_decl
410
+ type_errors << oof("OOF-P1", "Unresolved symbol: #{olap_ref.fetch("name")}", node_name)
411
+ return typed_expr("call", type_ir("Unknown"), [], "fn" => fn, "args" => [])
412
+ end
413
+
414
+ dim = dim_arg.fetch("value")
415
+ unless olap_decl.fetch("dimensions").key?(dim)
416
+ type_errors << oof("OOF-O4", "OLAPPoint access missing required dimension: #{dim}", node_name)
417
+ end
418
+ unless olap_decl.fetch("indexed").include?(dim) || explicit_scatter_gather?(args)
419
+ type_warnings << oof("OOF-O2", "rollup over non-indexed dimension may be slow; add to indexed:", node_name).merge("severity" => "warning")
420
+ end
421
+
422
+ remaining_dims = olap_decl.fetch("dimensions").reject { |name, _type| name == dim }
423
+ result_type = olap_type(olap_decl.fetch("measure_type"), remaining_dims)
424
+ typed_expr(
425
+ "call",
426
+ result_type,
427
+ [olap_decl.fetch("name")],
428
+ "fn" => fn,
429
+ "args" => [
430
+ typed_expr("ref", olap_decl.fetch("type"), [olap_decl.fetch("name")], "name" => olap_decl.fetch("name")),
431
+ typed_expr("symbol", type_ir("Symbol"), [], "value" => dim)
432
+ ]
433
+ )
434
+ end
435
+
436
+ def option_type_from(history_type)
437
+ inner = history_type.fetch("params", []).first
438
+ inner_name = inner.is_a?(Hash) ? inner.fetch("name", "Unknown") : (inner || "Unknown")
439
+ { "name" => "Option", "params" => [{ "name" => inner_name, "params" => [] }] }
440
+ end
441
+
442
+ def infer_binary(expr, symbol_types, type_errors, type_warnings, node_name)
443
+ left = infer_expr(expr.fetch("left"), symbol_types, type_errors, type_warnings, node_name)
444
+ right = infer_expr(expr.fetch("right"), symbol_types, type_errors, type_warnings, node_name)
445
+ operator, result_type = operator_type(expr.fetch("op"), left.fetch("resolved_type"), right.fetch("resolved_type"), type_errors, node_name)
446
+ typed_expr(
447
+ "call",
448
+ result_type,
449
+ left.fetch("deps") + right.fetch("deps"),
450
+ "fn" => operator,
451
+ "args" => [left, right]
452
+ )
453
+ end
454
+
455
+ def operator_type(op, left, right, type_errors, node_name)
456
+ left_name = type_name(left)
457
+ right_name = type_name(right)
458
+ case op
459
+ when "+"
460
+ type_errors << type_mismatch(type_ir("Integer"), type_ir("#{left_name}+#{right_name}"), node_name) unless unknown?(left, right) || left_name == "Integer" && right_name == "Integer"
461
+ ["stdlib.integer.add", type_ir("Integer")]
462
+ when ">"
463
+ type_errors << type_mismatch(type_ir("Integer"), type_ir("#{left_name}+#{right_name}"), node_name) unless unknown?(left, right) || left_name == "Integer" && right_name == "Integer"
464
+ ["stdlib.integer.gt", type_ir("Bool")]
465
+ when "&&"
466
+ type_errors << type_mismatch(type_ir("Bool"), type_ir("#{left_name}+#{right_name}"), node_name) unless unknown?(left, right) || left_name == "Bool" && right_name == "Bool"
467
+ ["stdlib.bool.and", type_ir("Bool")]
468
+ else
469
+ type_errors << oof("OOF-TY0", "Unsupported operator: #{op}", node_name)
470
+ ["stdlib.unsupported.#{op}", type_ir("Unknown")]
471
+ end
472
+ end
473
+
474
+ def typed_expr(kind, type, deps, extra)
475
+ { "kind" => kind }.merge(extra).merge("resolved_type" => type, "deps" => deps.uniq)
476
+ end
477
+
478
+ def type_ir(annotation)
479
+ return annotation.dup if annotation.is_a?(Hash) && annotation.key?("name")
480
+
481
+ name = annotation.is_a?(Hash) ? annotation.fetch("name", "Unknown") : annotation.to_s
482
+ params = annotation.is_a?(Hash) ? annotation.fetch("params", []).map { |p| type_ir(p) } : []
483
+ { "name" => name, "params" => params }
484
+ end
485
+
486
+ def dims_record_type(dims)
487
+ {
488
+ "name" => "DimsRecord",
489
+ "params" => [],
490
+ "dims" => dims.transform_values { |type| type_ir(type) }
491
+ }
492
+ end
493
+
494
+ def olap_type(measure_type, dims)
495
+ {
496
+ "name" => "OLAPPoint",
497
+ "params" => [measure_type, dims_record_type(dims)]
498
+ }
499
+ end
500
+
501
+ def olap_env(olap_points)
502
+ olap_points.each_with_object({}) do |point, env|
503
+ dimensions = point.fetch("dimensions", {}).transform_values { |type| type_ir(type) }
504
+ measure_type = type_ir(point.fetch("measure", point.fetch("measure_type", "Unknown")))
505
+ name = point.fetch("name")
506
+ semantic_node = {
507
+ "kind" => "olap_point_decl",
508
+ "name" => name,
509
+ "dimensions" => dimensions.transform_values { |type| type_display(type) },
510
+ "measure_type" => type_display(measure_type),
511
+ "granularity" => point.fetch("granularity", {}),
512
+ "source_ref" => point.fetch("source_ref", nil),
513
+ "indexed" => point.fetch("indexed", [])
514
+ }
515
+ env[name] = {
516
+ "name" => name,
517
+ "dimensions" => dimensions,
518
+ "measure_type" => measure_type,
519
+ "granularity" => point.fetch("granularity", {}),
520
+ "source" => point.fetch("source", nil),
521
+ "source_ref" => point.fetch("source_ref", nil),
522
+ "seeded_data" => point.fetch("seeded_data", false),
523
+ "indexed" => point.fetch("indexed", []),
524
+ "type" => olap_type(measure_type, dimensions),
525
+ "semantic_node" => semantic_node
526
+ }
527
+ end
528
+ end
529
+
530
+ def olap_declaration_errors(olap_env)
531
+ olap_env.values.filter_map do |point|
532
+ next if point.fetch("source") || point.fetch("source_ref") || point.fetch("seeded_data")
533
+
534
+ oof("OOF-O3", "OLAPPoint must declare a source function or be populated via stream snapshot", point.fetch("name"))
535
+ end
536
+ end
537
+
538
+ def validate_declared_olap_type(decl, typed_expr, type_errors)
539
+ annotation = decl["type_annotation"]
540
+ return unless annotation.is_a?(Hash) && annotation.fetch("name", nil) == "OLAPPoint"
541
+
542
+ expected = type_ir(annotation)
543
+ actual_node = typed_expr.fetch("semantic_node", nil)
544
+ return unless actual_node
545
+
546
+ measure = expected.fetch("params", []).fetch(0, type_ir("Unknown"))
547
+ if type_display(measure) != actual_node.dig("result_type", "measure")
548
+ type_errors << oof("OOF-TY0", "OLAPPoint measure expected #{type_display(measure)}, got #{actual_node.dig("result_type", "measure")}", decl.fetch("name"))
549
+ end
550
+ dims = dims_from_type(expected)
551
+ actual_dims = actual_node.dig("result_type", "dims_record", "dims") || {}
552
+ dims.each do |dim, expected_type|
553
+ actual_type = actual_dims[dim]
554
+ next if actual_type.nil? || type_display(expected_type) == actual_type
555
+
556
+ type_errors << oof("OOF-O5", "OLAPPoint dimension '#{dim}' expected #{type_display(expected_type)}, got #{actual_type}", decl.fetch("name"))
557
+ end
558
+ end
559
+
560
+ def dims_from_type(type)
561
+ dims_record = type.fetch("params", []).find { |param| param.is_a?(Hash) && (param.fetch("kind", nil) == "dims_record" || param.fetch("name", nil) == "DimsRecord") }
562
+ return {} unless dims_record
563
+
564
+ dims_record.fetch("dims", {}).transform_values { |dim_type| type_ir(dim_type) }
565
+ end
566
+
567
+ def olap_access_node(node_name, olap_decl, typed_slices)
568
+ {
569
+ "kind" => "olap_access_node",
570
+ "name" => node_name,
571
+ "olap_ref" => olap_decl.fetch("name"),
572
+ "slices" => typed_slices.map do |slice|
573
+ {
574
+ "dim" => slice.fetch("dim"),
575
+ "value_ref" => slice.fetch("value_ref"),
576
+ "value_type" => type_display(slice.fetch("value").fetch("resolved_type"))
577
+ }
578
+ end,
579
+ "operation" => "point",
580
+ "result_type" => {
581
+ "constructor" => "OLAPPoint",
582
+ "measure" => type_display(olap_decl.fetch("measure_type")),
583
+ "dims_record" => {
584
+ "kind" => "dims_record",
585
+ "dims" => olap_decl.fetch("dimensions").transform_values { |type| type_display(type) }
586
+ }
587
+ },
588
+ "resolved_type" => type_display(olap_decl.fetch("measure_type"))
589
+ }
590
+ end
591
+
592
+ def slice_value_ref(expr)
593
+ expr.fetch("kind") == "ref" ? expr.fetch("name") : nil
594
+ end
595
+
596
+ def explicit_scatter_gather?(args)
597
+ args.any? { |arg| arg.fetch("kind", nil) == "symbol" && arg.fetch("value") == "scatter_gather" }
598
+ end
599
+
600
+ def same_type?(expected, actual)
601
+ type_display(expected) == type_display(actual)
602
+ end
603
+
604
+ def type_display(type)
605
+ return type.to_s unless type.is_a?(Hash)
606
+
607
+ params = type.fetch("params", [])
608
+ return type.fetch("name") if params.empty?
609
+
610
+ rendered = params.map { |param| param.is_a?(Hash) ? type_display(param) : param.to_s }.join(",")
611
+ "#{type.fetch("name")}[#{rendered}]"
612
+ end
613
+
614
+ def type_name(type)
615
+ type.fetch("name")
616
+ end
617
+
618
+ def normalize_type(type)
619
+ type.is_a?(Hash) ? type.fetch("name") : type.to_s
620
+ end
621
+
622
+ def literal_type(name)
623
+ {
624
+ "Integer" => "int",
625
+ "Float" => "float",
626
+ "String" => "string",
627
+ "Bool" => "bool",
628
+ "Nil" => "nil"
629
+ }.fetch(name, name.downcase)
630
+ end
631
+
632
+ def unknown?(*types)
633
+ types.any? { |type| type_name(type) == "Unknown" }
634
+ end
635
+
636
+ def type_mismatch(expected, actual, node)
637
+ oof("OOF-TY0", "Type mismatch: expected #{type_name(expected)}, got #{type_name(actual)}", node)
638
+ end
639
+
640
+ def oof(rule, message, node_name)
641
+ { "rule" => rule, "message" => message, "node" => node_name, "line" => nil }
642
+ end
643
+
644
+ def oof_alias(rule, message, node_name, aliases)
645
+ oof(rule, message, node_name).merge("aliases" => aliases)
646
+ end
647
+
648
+ def rule_present?(errors, rule)
649
+ errors.any? { |entry| entry.fetch("rule") == rule }
650
+ end
651
+
652
+ def blocking_rule_present?(errors)
653
+ %w[OOF-P1 OOF-CE4 OOF-OS2 OOF-H1 OOF-BT1 OOF-BT2 OOF-BT3 OOF-BT4 OOF-TM1 OOF-TM3 OOF-TM4 OOF-TM5 OOF-TM6 OOF-S3 OOF-O3 OOF-O4 OOF-O5 OOF-IV3].any? { |rule| rule_present?(errors, rule) }
654
+ end
655
+
656
+ # OOF-IV helpers -------------------------------------------------------
657
+
658
+ # TC-INV-1: resolve predicate_ref, check Bool type.
659
+ # TC-INV-2: validate overridable_with semantics.
660
+ # TC-INV-3: compute output_effect and record for output propagation.
661
+ def check_invariant(decl, symbol_types, type_errors, invariant_effects)
662
+ predicate_ref = decl.fetch("predicate_ref", nil)
663
+ severity = decl.fetch("severity", "error")
664
+ name = decl.fetch("name")
665
+
666
+ # TC-INV-1: predicate must resolve to Bool
667
+ if predicate_ref
668
+ pred_type = symbol_types.fetch(predicate_ref, type_ir("Unknown"))
669
+ unless type_name(pred_type) == "Bool" || type_name(pred_type) == "Unknown"
670
+ type_errors << oof("OOF-IV3", "invariant predicate must be Bool, got #{type_name(pred_type)}", name)
671
+ end
672
+ end
673
+
674
+ # TC-INV-2: overridable_with on :error is OOF-I4 (dynamic/inferred case; parser catches static)
675
+ overridable_with = decl.fetch("overridable_with", nil)
676
+ if overridable_with && severity == "error"
677
+ type_errors << oof("OOF-I4", ":error invariants cannot be overridden", name)
678
+ end
679
+
680
+ # TC-INV-3: record output effect for TINV-4 propagation
681
+ effect = invariant_output_effect(severity)
682
+ invariant_effects << { "name" => name, "effect" => effect } if %w[warns uncertain metric].include?(effect)
683
+ end
684
+
685
+ # Typed node for an invariant declaration.
686
+ def typed_decl_invariant(decl, symbol_types)
687
+ predicate_ref = decl.fetch("predicate_ref", nil)
688
+ pred_type = predicate_ref ? symbol_types.fetch(predicate_ref, type_ir("Unknown")) : type_ir("Unknown")
689
+ output_effect = invariant_output_effect(decl.fetch("severity", "error"))
690
+ result = {
691
+ "decl_id" => decl.fetch("decl_id"),
692
+ "kind" => "invariant",
693
+ "name" => decl.fetch("name"),
694
+ "fragment_class" => decl.fetch("fragment_class"),
695
+ "predicate_ref" => predicate_ref,
696
+ "predicate_type" => pred_type,
697
+ "severity" => decl.fetch("severity", "error"),
698
+ "label" => decl.fetch("label", nil),
699
+ "message" => decl.fetch("message", nil),
700
+ "overridable_with" => decl.fetch("overridable_with", nil),
701
+ "output_effect" => output_effect,
702
+ "type" => type_ir("Bool"),
703
+ "deps" => predicate_ref ? [predicate_ref] : []
704
+ }
705
+ result["source_span"] = decl.fetch("source_span") if decl.key?("source_span")
706
+ result["source_metadata"] = decl.fetch("source_metadata") if decl.key?("source_metadata")
707
+ result["threshold"] = decl.fetch("threshold") if decl.key?("threshold")
708
+ result["threshold_ms"] = decl.fetch("threshold_ms") if decl.key?("threshold_ms")
709
+ result
710
+ end
711
+
712
+ # Typed output decl with invariant effect propagation (TINV-4).
713
+ def typed_decl_output(decl, type, invariant_effects)
714
+ result = {
715
+ "decl_id" => decl.fetch("decl_id"),
716
+ "kind" => "output",
717
+ "name" => decl.fetch("name"),
718
+ "fragment_class" => decl.fetch("fragment_class"),
719
+ "type" => type,
720
+ "deps" => decl.fetch("deps")
721
+ }
722
+ warnings_from = invariant_effects.select { |e| e["effect"] == "warns" }.map { |e| e["name"] }
723
+ uncertain_from = invariant_effects.select { |e| e["effect"] == "uncertain" }.map { |e| e["name"] }
724
+ metrics_from = invariant_effects.select { |e| e["effect"] == "metric" }.map { |e| e["name"] }
725
+ result["warnings_from"] = warnings_from unless warnings_from.empty?
726
+ result["uncertain_from"] = uncertain_from unless uncertain_from.empty?
727
+ result["metrics_from"] = metrics_from unless metrics_from.empty?
728
+ result
729
+ end
730
+
731
+ # Maps severity to the output_effect string (per PROP-025 §3 / spec track Part 3).
732
+ def invariant_output_effect(severity)
733
+ case severity
734
+ when "error" then "blocks"
735
+ when "warn" then "warns"
736
+ when "soft" then "uncertain"
737
+ when "metric" then "metric"
738
+ else "blocks"
739
+ end
740
+ end
741
+
742
+ # OOF-S3 helpers -------------------------------------------------------
743
+
744
+ # Collect the names of all stream-kind symbols in the classified contract.
745
+ def stream_symbol_names(classified_contract)
746
+ classified_contract.fetch("symbols", []).filter_map do |sym|
747
+ sym.fetch("name") if sym.fetch("kind") == "stream"
748
+ end.to_set
749
+ end
750
+
751
+ # Walk the fold_stream accumulator lambda body and emit OOF-S3 for any
752
+ # ref that names a stream symbol (ESCAPE construct inside CORE-required fn).
753
+ def check_fold_stream_body(decl, stream_symbols, type_errors)
754
+ return if stream_symbols.empty?
755
+ return unless decl.fetch("expr", nil)&.fetch("kind", nil) == "call"
756
+
757
+ call = decl.fetch("expr")
758
+ lambda_arg = call.fetch("args", []).find { |arg| arg.fetch("kind", nil) == "lambda" }
759
+ return unless lambda_arg
760
+
761
+ body = lambda_arg.fetch("body", nil)
762
+ return unless body
763
+
764
+ lambda_params = lambda_arg.fetch("params", []).map(&:to_s).to_set
765
+ escape_refs = collect_escape_refs(body, stream_symbols, lambda_params)
766
+ escape_refs.each do |ref_name|
767
+ type_errors << oof(
768
+ "OOF-S3",
769
+ "fold_stream accumulator must be CORE - found ESCAPE: #{ref_name}",
770
+ decl.fetch("name")
771
+ )
772
+ end
773
+ end
774
+
775
+ # Recursively collect ref-names from the body AST that are stream symbols
776
+ # but NOT lambda parameters (those shadow the outer stream names).
777
+ def collect_escape_refs(node, stream_symbols, lambda_params)
778
+ return [] unless node.is_a?(Hash)
779
+
780
+ case node.fetch("kind", nil)
781
+ when "ref"
782
+ name = node.fetch("name")
783
+ stream_symbols.include?(name) && !lambda_params.include?(name) ? [name] : []
784
+ when "lambda"
785
+ # Nested lambda: extend lambda_params with inner params to avoid false positives
786
+ inner_params = lambda_params + node.fetch("params", []).map(&:to_s)
787
+ collect_escape_refs(node.fetch("body", {}), stream_symbols, inner_params)
788
+ when "binary_op"
789
+ collect_escape_refs(node.fetch("left", {}), stream_symbols, lambda_params) +
790
+ collect_escape_refs(node.fetch("right", {}), stream_symbols, lambda_params)
791
+ when "call"
792
+ node.fetch("args", []).flat_map { |arg| collect_escape_refs(arg, stream_symbols, lambda_params) } +
793
+ collect_escape_refs(node.fetch("object", {}), stream_symbols, lambda_params)
794
+ when "field_access"
795
+ collect_escape_refs(node.fetch("object", {}), stream_symbols, lambda_params)
796
+ else
797
+ # Walk all Hash values for any other node kinds
798
+ node.values.flat_map { |v| v.is_a?(Hash) ? collect_escape_refs(v, stream_symbols, lambda_params) : [] }
799
+ end.uniq
800
+ end
801
+
802
+ # Determine the fold_stream result type from the init literal or annotation.
803
+ # Returns Unknown if the init expression does not carry a type_tag.
804
+ def fold_stream_result_type(decl)
805
+ expr = decl.fetch("expr", nil)
806
+ return type_ir("Unknown") unless expr&.fetch("kind", nil) == "call"
807
+
808
+ args = expr.fetch("args", [])
809
+ init_arg = args[1] # args[0]=stream_ref, args[1]=init, args[2]=lambda
810
+ return type_ir("Unknown") unless init_arg&.fetch("kind", nil) == "literal"
811
+
812
+ type_ir(init_arg.fetch("type_tag", "Unknown"))
813
+ end
814
+
815
+ def dedupe_errors(errors)
816
+ errors.uniq { |entry| [entry.fetch("rule"), entry.fetch("message"), entry.fetch("node"), entry.fetch("line")] }
817
+ end
818
+ end
819
+
820
+ Typechecker = TypeChecker
821
+ end