From 6412c7e2a1e6307f6713cb38fefde0abb4539b7e Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 06:20:15 +0000 Subject: [PATCH 1/7] Feat: Prototype Static Big-O Analyzer for Espalier --- gems/espalier/lib/espalier/big_o_analyzer.rb | 97 +++++++++++++++++++ .../lib/espalier/stdlib_complexity_ruby.yml | 23 +++++ gems/espalier/test_big_o.rb | 19 ++++ 3 files changed, 139 insertions(+) create mode 100644 gems/espalier/lib/espalier/big_o_analyzer.rb create mode 100644 gems/espalier/lib/espalier/stdlib_complexity_ruby.yml create mode 100644 gems/espalier/test_big_o.rb diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb new file mode 100644 index 000000000..5cd55aa42 --- /dev/null +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "yaml" +require "set" + +module Espalier + class BigOAnalyzer + attr_reader :registry, :nil_kill_evidence + + def initialize(language: :ruby, nil_kill_evidence: {}) + @language = language + @nil_kill_evidence = nil_kill_evidence + @registry = load_registry(language) + end + + def load_registry(language) + path = File.join(__dir__, "stdlib_complexity_#{language}.yml") + return {} unless File.exist?(path) + YAML.load_file(path) || {} + end + + # Prototypical analyzer for a method. + # We pass in `ast_nodes` which represents the parsed AST + # (mocked for this prototype as an array of operation hashes). + def analyze_method(method_name, ast_nodes) + complexity = "O(1)" + unknown_operations = [] + warnings = [] + + # Lower bound calculation + # For a prototype, we just scan for the most complex operation in the flat AST. + # In reality, this would recursively multiply nested loops. + + ast_nodes.each do |node| + if node[:type] == :call + receiver_type = resolve_type(node[:receiver], node[:line]) + method_called = node[:method].to_s + + if receiver_type + known_complexity = @registry.dig(receiver_type, method_called) + if known_complexity + # If it's sequential, we just take the max of what we've seen so far. + complexity = max_complexity(complexity, known_complexity) + end + else + unknown_operations << "#{node[:receiver]}.#{method_called}" + warnings << "Unknown receiver type for `#{node[:receiver]}` at line #{node[:line]}. Defaulting to O(1) for `.#{method_called}`, but this could be worse." + end + elsif node[:type] == :loop + # multiply inner complexity + complexity = multiply_complexity(complexity, "O(N)") + elsif node[:type] == :callback || node[:type] == :yield + warnings << "Function pointer / callback executed at line #{node[:line]}. This could execute arbitrary O(N^x) code, meaning our calculation is strictly a LOWER BOUND." + end + end + + { + method: method_name, + lower_bound_complexity: complexity, + unknown_operations: unknown_operations.uniq, + warnings: warnings.uniq + } + end + + private + + def resolve_type(receiver_name, line) + # Mock nil_kill resolution + # If nil_kill_evidence has a type for this receiver at this line, return it. + # Otherwise return nil. + @nil_kill_evidence.dig(line.to_s, receiver_name.to_s) + end + + def max_complexity(current, added) + rank = { + "O(1)" => 1, + "O(log N)" => 2, + "O(N)" => 3, + "O(N log N)" => 4, + "O(N * M)" => 5, + "O(N^2)" => 6 + } + + r1 = rank[current] || 1 + r2 = rank[added] || 1 + r1 > r2 ? current : added + end + + def multiply_complexity(current, multiplier) + return multiplier if current == "O(1)" + return "O(N^2)" if current == "O(N)" && multiplier == "O(N)" + return "O(N^2 log N)" if current == "O(N log N)" && multiplier == "O(N)" + # fallback + "#{current} * #{multiplier}" + end + end +end diff --git a/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml b/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml new file mode 100644 index 000000000..6d85e3e61 --- /dev/null +++ b/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml @@ -0,0 +1,23 @@ +Array: + each: "O(N)" + map: "O(N)" + select: "O(N)" + reject: "O(N)" + flatten: "O(N)" + sort: "O(N log N)" + sort_by: "O(N log N)" + "-": "O(N * M)" + "&": "O(N * M)" + "|": "O(N * M)" + +String: + scan: "O(N)" + gsub: "O(N)" + split: "O(N)" + "+": "O(N)" + +Hash: + merge: "O(N)" + keys: "O(N)" + values: "O(N)" + transform_values: "O(N)" diff --git a/gems/espalier/test_big_o.rb b/gems/espalier/test_big_o.rb new file mode 100644 index 000000000..1d3a2232b --- /dev/null +++ b/gems/espalier/test_big_o.rb @@ -0,0 +1,19 @@ +require_relative "lib/espalier/big_o_analyzer" +require "yaml" + +nil_kill_evidence = { + "10" => { "items" => "Array" }, + "12" => { "names" => "String" } +} + +analyzer = Espalier::BigOAnalyzer.new(language: :ruby, nil_kill_evidence: nil_kill_evidence) + +ast_mock = [ + { type: :call, receiver: "items", method: "sort", line: 10 }, + { type: :loop, line: 11 }, + { type: :call, receiver: "unknown_obj", method: "process", line: 12 }, + { type: :callback, line: 15 } +] + +result = analyzer.analyze_method("build_graph", ast_mock) +puts result.to_yaml From 8c1b092548a68f3b7cdac80b7227bdb068f478b5 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 13:07:45 +0000 Subject: [PATCH 2/7] Implement Big-O tracking for Espalier and integration with Nil-kill - Fix type inference wrapper in type_profile.rb to prevent TypeError on generic strings. - Enable NilKillEvidence to load dynamic loop iteration counts from runtime trace logs. - Update Espalier CLI/Aggregator to run BigOAnalyzer and attach big_o metrics to methods. - Enhance BigOAnalyzer type resolution (dotted chains, self calls, ivars) and complexity math. - Fix Nil-kill test failures in hash shape merging and Lua table normalization. - Add big_o_test.rb unit tests for analyzer correctness. Co-authored-by: gemini-cli <218195315+gemini-cli@users.noreply.github.com> --- gems/espalier/exe/espalier | 4 +- gems/espalier/lib/espalier/aggregator.rb | 38 ++++- gems/espalier/lib/espalier/big_o_analyzer.rb | 136 ++++++++++++++++-- .../lib/espalier/nil_kill_evidence.rb | 25 +++- gems/espalier/lib/espalier/type_profile.rb | 6 +- gems/espalier/test/big_o_test.rb | 79 ++++++++++ gems/nil-kill/lib/nil_kill/hash_shape_ops.rb | 36 +++-- .../lib/nil_kill/schema/runtime_type.rb | 2 +- 8 files changed, 298 insertions(+), 28 deletions(-) create mode 100644 gems/espalier/test/big_o_test.rb diff --git a/gems/espalier/exe/espalier b/gems/espalier/exe/espalier index e9f606764..4eab2c8f0 100755 --- a/gems/espalier/exe/espalier +++ b/gems/espalier/exe/espalier @@ -145,7 +145,9 @@ modules = Espalier::StaticEvidence.project_modules(evidence) aggregator = Espalier::Aggregator.new( decomplex_data: decomplex_data, nil_kill_data: nil_kill_data, - risk_data: risk_data + risk_data: risk_data, + nil_kill_loops: nil_kill_evidence.loop_counts, + nil_kill_evidence: nil_kill_evidence ) nil_kill_evidence.apply!(modules) diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index 236bd5c80..50d2f8d12 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "yaml" +require_relative "big_o_analyzer" module Espalier # Coalescing agent that imports the static skeleton maps and merges secondary @@ -9,15 +10,24 @@ class Aggregator def initialize( decomplex_data: {}, nil_kill_data: {}, - risk_data: {} + risk_data: {}, + nil_kill_loops: {}, + nil_kill_evidence: nil ) @decomplex_data = decomplex_data @nil_kill_data = nil_kill_data @risk_data = risk_data + @nil_kill_loops = nil_kill_loops + @nil_kill_evidence = nil_kill_evidence end # Aggregate extracted AST structure with auxiliary indicators def aggregate(modules) + analyzer = Espalier::BigOAnalyzer.new( + language: :ruby, + nil_kill: @nil_kill_evidence + ) + manifest = modules.map do |mod| internal_edges = internal_edges_for(mod) callers_by_method = internal_edges.each_with_object(Hash.new { |h, k| h[k] = [] }) do |edge, index| @@ -87,6 +97,32 @@ def aggregate(modules) quality[:coverage_gap] = file_risk[:coverage_gap] if file_risk[:coverage_gap] end + # Run Big-O analysis + meth_line = m[:line] || 0 + file = mod[:file] + next_meth = mod[:methods][mod[:methods].index(m) + 1] + end_line = next_meth ? (next_meth[:line] || Float::INFINITY) : Float::INFINITY + + ast_nodes = Array(m[:delegations]).map do |d| + { type: :call, receiver: d[:receiver], method: d[:message], line: m[:line] || 0 } + end + + if file && @nil_kill_loops && @nil_kill_loops[file] + @nil_kill_loops[file].each do |line, calls| + if line >= meth_line && line < end_line && calls > 0 + ast_nodes << { type: :loop, line: line, calls: calls } + end + end + end + + analyzer.instance_variable_set(:@class_name, mod[:name]) + analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) + + big_o_result = analyzer.analyze_method(key, ast_nodes) + if big_o_result[:lower_bound_complexity] != "O(1)" + quality[:big_o] = big_o_result[:lower_bound_complexity] + end + { name: m[:name], signature: sig, diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index 5cd55aa42..435890134 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -7,9 +7,12 @@ module Espalier class BigOAnalyzer attr_reader :registry, :nil_kill_evidence - def initialize(language: :ruby, nil_kill_evidence: {}) + def initialize(language: :ruby, nil_kill_evidence: {}, class_name: nil, ivar_types: {}, nil_kill: nil) @language = language @nil_kill_evidence = nil_kill_evidence + @class_name = class_name + @ivar_types = ivar_types || {} + @nil_kill = nil_kill @registry = load_registry(language) end @@ -64,11 +67,86 @@ def analyze_method(method_name, ast_nodes) private + def clean_type_name(type_str) + return nil unless type_str + type_str = type_str.to_s.strip + if type_str =~ /^T\.nilable\((.+)\)$/ + clean_type_name($1) + elsif type_str =~ /^T\.any\((.+)\)$/ + types = $1.split(/\s*,\s*/) + non_nil = types.reject { |t| t == "NilClass" || t == "nil" } + clean_type_name(non_nil.first || types.first) + elsif type_str =~ /^(?:T::|T\.)(Array|Hash|Set|Enumerable)\[.+\]$/ + $1 + else + type_str + end + end + + def extract_return_type(signature) + return nil unless signature + if signature =~ /returns\(([^)]+)\)/ + $1.strip + elsif signature =~ /->\s*([A-Za-z0-9_:]+)/ + $1.strip + end + end + def resolve_type(receiver_name, line) - # Mock nil_kill resolution - # If nil_kill_evidence has a type for this receiver at this line, return it. - # Otherwise return nil. - @nil_kill_evidence.dig(line.to_s, receiver_name.to_s) + return nil unless receiver_name + receiver_name = receiver_name.to_s + + if receiver_name.include?(".") + parts = receiver_name.split(".") + current_type = resolve_simple_type(parts.first, line) + parts[1..].each do |part| + return nil unless current_type + current_type = resolve_method_return_type(current_type, part) + end + clean_type_name(current_type) + else + resolve_simple_type(receiver_name, line) + end + end + + def resolve_simple_type(receiver_name, line) + if receiver_name == "self" + return clean_type_name(@class_name) + end + + if receiver_name.start_with?("@") + type = @ivar_types[receiver_name] || @ivar_types[receiver_name[1..]] + return clean_type_name(type) if type + end + + if (type = @ivar_types["@#{receiver_name}"] || @ivar_types[receiver_name]) + return clean_type_name(type) + end + + if @class_name && @nil_kill + sig = @nil_kill.method_signatures["#{@class_name}##{receiver_name}"] + if sig + ret = extract_return_type(sig) + return clean_type_name(ret) if ret + end + end + + if @nil_kill_evidence + type = @nil_kill_evidence.dig(line.to_s, receiver_name) || @nil_kill_evidence.dig(line.to_s, "@#{receiver_name}") + return clean_type_name(type) if type + end + + nil + end + + def resolve_method_return_type(class_name, method_name) + return nil unless @nil_kill && class_name + sig = @nil_kill.method_signatures["#{class_name}##{method_name}"] + if sig + ret = extract_return_type(sig) + return clean_type_name(ret) + end + nil end def max_complexity(current, added) @@ -88,10 +166,50 @@ def max_complexity(current, added) def multiply_complexity(current, multiplier) return multiplier if current == "O(1)" - return "O(N^2)" if current == "O(N)" && multiplier == "O(N)" - return "O(N^2 log N)" if current == "O(N log N)" && multiplier == "O(N)" - # fallback - "#{current} * #{multiplier}" + return current if multiplier == "O(1)" + + c_pow = 0 + c_log = false + if current == "O(N)" + c_pow = 1 + elsif current =~ /O\(N\^(\d+)\)/ + c_pow = $1.to_i + elsif current =~ /O\(N\^(\d+) log N\)/ + c_pow = $1.to_i + c_log = true + elsif current == "O(N log N)" + c_pow = 1 + c_log = true + elsif current == "O(log N)" + c_log = true + end + + m_pow = 0 + m_log = false + if multiplier == "O(N)" + m_pow = 1 + elsif multiplier =~ /O\(N\^(\d+)\)/ + m_pow = $1.to_i + elsif multiplier =~ /O\(N\^(\d+) log N\)/ + m_pow = $1.to_i + m_log = true + elsif multiplier == "O(N log N)" + m_pow = 1 + m_log = true + elsif multiplier == "O(log N)" + m_log = true + end + + new_pow = c_pow + m_pow + new_log = c_log || m_log + + if new_pow == 0 + new_log ? "O(log N)" : "O(1)" + elsif new_pow == 1 + new_log ? "O(N log N)" : "O(N)" + else + new_log ? "O(N^#{new_pow} log N)" : "O(N^#{new_pow})" + end end end end diff --git a/gems/espalier/lib/espalier/nil_kill_evidence.rb b/gems/espalier/lib/espalier/nil_kill_evidence.rb index bec8940dd..46a1c29f1 100644 --- a/gems/espalier/lib/espalier/nil_kill_evidence.rb +++ b/gems/espalier/lib/espalier/nil_kill_evidence.rb @@ -7,12 +7,12 @@ module Espalier # Normalizes Nil-Kill Espalier evidence across legacy Ruby ivar facts and the # Tree-sitter static schema v2. class NilKillEvidence - attr_reader :method_signatures, :state_types, :state_param_origins, :state_protocols + attr_reader :method_signatures, :state_types, :state_param_origins, :state_protocols, :loop_counts def self.load(path) return empty unless path && File.exist?(path) - new(FactMine::Syntax::TypeExpr.wrap_types!(JSON.parse(File.read(path)))) + new(FactMine::Syntax::TypeExpr.wrap_types!(JSON.parse(File.read(path))), path) rescue StandardError empty end @@ -21,14 +21,16 @@ def self.empty new({}) end - def initialize(data) + def initialize(data, path = nil) @data = data || {} @method_signatures = {} @state_types = nested_state_map(facts["state_types"] || {}) @state_param_origins = nested_state_map(facts["state_param_origins"] || facts["ivar_param_origins"] || {}) @state_protocols = nested_state_map(facts["state_protocols"] || facts["ivar_protocols"] || {}) + @loop_counts = Hash.new { |h, k| h[k] = Hash.new(0) } load_methods! load_legacy_runtime_types! + load_loops_if_present!(path) if path end def apply!(modules) @@ -117,5 +119,22 @@ def sorbet_type(classes) "T.any(#{classes.join(', ')})" end end + + def load_loops_if_present!(evidence_path) + dir = File.dirname(evidence_path) + runtime_dir = File.join(dir, "runtime") + return unless File.directory?(runtime_dir) + + Dir.glob(File.join(runtime_dir, "loops-*.jsonl")).each do |loop_file| + File.readlines(loop_file).each do |line| + data = JSON.parse(line) rescue next + path = data["path"] + line_num = data["line"]&.to_i + if path && line_num + @loop_counts[path][line_num] += 1 + end + end + end + end end end diff --git a/gems/espalier/lib/espalier/type_profile.rb b/gems/espalier/lib/espalier/type_profile.rb index 975e93b80..7d980aea8 100644 --- a/gems/espalier/lib/espalier/type_profile.rb +++ b/gems/espalier/lib/espalier/type_profile.rb @@ -159,13 +159,13 @@ def self.wrap_types!(val, current_lang = nil) langs = [] langs << val["language"] if val["language"] if val["methods"].is_a?(Array) - langs.concat(val["methods"].map { |m| m["language"] }) + langs.concat(val["methods"].map { |m| m.is_a?(Hash) ? m["language"] : nil }) end if val["fields"].is_a?(Array) - langs.concat(val["fields"].map { |f| f["language"] }) + langs.concat(val["fields"].map { |f| f.is_a?(Hash) ? f["language"] : nil }) end if val["type_definitions"].is_a?(Array) - langs.concat(val["type_definitions"].map { |d| d["language"] }) + langs.concat(val["type_definitions"].map { |d| d.is_a?(Hash) ? d["language"] : nil }) end current_lang = langs.compact.map(&:to_s).reject(&:empty?).first&.downcase end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb new file mode 100644 index 000000000..72457a0fa --- /dev/null +++ b/gems/espalier/test/big_o_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/espalier/big_o_analyzer" +require_relative "../lib/espalier/nil_kill_evidence" + +class BigOTest < Minitest::Test + def test_multiply_complexity + analyzer = Espalier::BigOAnalyzer.new + + # Basic multiplications + assert_equal "O(N)", analyzer.send(:multiply_complexity, "O(1)", "O(N)") + assert_equal "O(N)", analyzer.send(:multiply_complexity, "O(N)", "O(1)") + assert_equal "O(N^2)", analyzer.send(:multiply_complexity, "O(N)", "O(N)") + assert_equal "O(N^3)", analyzer.send(:multiply_complexity, "O(N^2)", "O(N)") + assert_equal "O(N^2 log N)", analyzer.send(:multiply_complexity, "O(N log N)", "O(N)") + assert_equal "O(N^3 log N)", analyzer.send(:multiply_complexity, "O(N^2 log N)", "O(N)") + assert_equal "O(log N)", analyzer.send(:multiply_complexity, "O(log N)", "O(1)") + end + + def test_resolve_type_with_nil_kill_evidence + ivar_types = { + "@receiver_state" => "ReceiverState", + "@items" => "Array" + } + + # Mock NilKillEvidence signatures + nil_kill = Object.new + def nil_kill.method_signatures + { + "ReceiverState#scopes" => "sig { returns(T::Array[Scope]) }", + "SemanticAnnotator#receiver_state" => "def receiver_state() -> ReceiverState" + } + end + + analyzer = Espalier::BigOAnalyzer.new( + class_name: "SemanticAnnotator", + ivar_types: ivar_types, + nil_kill: nil_kill + ) + + # 1. Resolve 'self' + assert_equal "SemanticAnnotator", analyzer.send(:resolve_type, "self", 10) + + # 2. Resolve ivar with @ + assert_equal "ReceiverState", analyzer.send(:resolve_type, "@receiver_state", 10) + + # 3. Resolve ivar without @ + assert_equal "Array", analyzer.send(:resolve_type, "items", 10) + + # 4. Resolve method call on self + assert_equal "ReceiverState", analyzer.send(:resolve_type, "receiver_state", 10) + + # 5. Resolve dotted chain + assert_equal "Array", analyzer.send(:resolve_type, "receiver_state.scopes", 10) + end + + def test_analyze_method + nil_kill_evidence = { + "10" => { "items" => "Array" } + } + + analyzer = Espalier::BigOAnalyzer.new(nil_kill_evidence: nil_kill_evidence) + + ast_mock = [ + { type: :call, receiver: "items", method: "sort", line: 10 }, + { type: :loop, line: 11 }, + { type: :callback, line: 12 } + ] + + result = analyzer.analyze_method("process_items", ast_mock) + + assert_equal "process_items", result[:method] + assert_equal "O(N^2 log N)", result[:lower_bound_complexity] # O(N log N) * O(N) = O(N^2 log N) + assert_empty result[:unknown_operations] + assert_equal 1, result[:warnings].size + assert_match(/Function pointer \/ callback/, result[:warnings].first) + end +end diff --git a/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb b/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb index 580ffa710..72c6a927f 100644 --- a/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb +++ b/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb @@ -17,12 +17,21 @@ def dup_shape(shape, stringify_keys: false) def merge_shapes(left, right, stringify_keys: false) return poisoned_shape if left["poisoned"] || right["poisoned"] - keys = Hash(left["keys"]).keys | Hash(right["keys"]).keys + merged_keys = {} + Hash(left["keys"]).each do |key, types| + out_key = shape_key(key, stringify_keys) + merged_keys[out_key] ||= [] + merged_keys[out_key].concat(Array(types)) + end + Hash(right["keys"]).each do |key, types| + out_key = shape_key(key, stringify_keys) + merged_keys[out_key] ||= [] + merged_keys[out_key].concat(Array(types)) + end + merged_keys.each { |_, v| v.uniq! } + { - "keys" => keys.each_with_object({}) do |key, merged| - out_key = shape_key(key, stringify_keys) - merged[out_key] = (Array(left.dig("keys", key)) + Array(right.dig("keys", key))).uniq - end, + "keys" => merged_keys, "value_hash_shapes" => merge_nested_shape_maps(left["value_hash_shapes"], right["value_hash_shapes"], stringify_keys: stringify_keys), "value_array_element_shapes" => merge_nested_shape_maps(left["value_array_element_shapes"], right["value_array_element_shapes"], stringify_keys: stringify_keys), "poisoned" => false, @@ -30,13 +39,20 @@ def merge_shapes(left, right, stringify_keys: false) end def merge_nested_shape_maps(left, right, stringify_keys: false) - keys = Hash(left).keys | Hash(right).keys - keys.each_with_object({}) do |key, merged| - l = Hash(left)[key] - r = Hash(right)[key] + merged = {} + Hash(left).each do |key, shape| + out_key = shape_key(key, stringify_keys) + merged[out_key] = dup_shape(shape, stringify_keys: stringify_keys) + end + Hash(right).each do |key, shape| out_key = shape_key(key, stringify_keys) - merged[out_key] = l && r ? merge_shapes(l, r, stringify_keys: stringify_keys) : dup_shape(l || r, stringify_keys: stringify_keys) + if merged.key?(out_key) + merged[out_key] = merge_shapes(merged[out_key], shape, stringify_keys: stringify_keys) + else + merged[out_key] = dup_shape(shape, stringify_keys: stringify_keys) + end end + merged end def poisoned_shape diff --git a/gems/nil-kill/lib/nil_kill/schema/runtime_type.rb b/gems/nil-kill/lib/nil_kill/schema/runtime_type.rb index 5f2e00e31..008d7bae8 100644 --- a/gems/nil-kill/lib/nil_kill/schema/runtime_type.rb +++ b/gems/nil-kill/lib/nil_kill/schema/runtime_type.rb @@ -48,10 +48,10 @@ def self.infer_kind(name, language) down = name.to_s.downcase return "null" if NULL_NAMES.any? { |n| down == n.downcase } return "array" if ARRAY_NAMES.any? { |n| down == n.downcase } + return "record" if language.to_s == "lua" && down == "table" return "map" if MAP_NAMES.any? { |n| down == n.downcase } return "union" if name.to_s.include?("|") return "primitive" if PRIMITIVE_NAMES.any? { |n| down == n.downcase } - return "record" if language.to_s == "lua" && down == "table" "class" end From aa9e2e006015b6ad620133abbae4c73f47c1adb0 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 16:06:07 +0000 Subject: [PATCH 3/7] Enhance Espalier Big-O diagnostic reporting and config expansion - Report unknown method operations when receiver type is known but complexity is missing from stdlib_complexity_ruby.yml. - Output warnings and unknowns list inside quality_metrics in the manifest. - Exclude internal warnings/unknowns metadata from the markdown report to keep tables clean. - Create tools/espalier_diagnostic.rb to show unexpected poorly performing functions, uncertainty stats, and suggested YAML config expansions. Co-authored-by: gemini-cli <218195315+gemini-cli@users.noreply.github.com> --- gems/espalier/lib/espalier/aggregator.rb | 6 +- gems/espalier/lib/espalier/big_o_analyzer.rb | 3 + gems/espalier/lib/espalier/reporter.rb | 6 +- tools/espalier_diagnostic.rb | 156 +++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100755 tools/espalier_diagnostic.rb diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index 50d2f8d12..18599eea1 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -119,9 +119,9 @@ def aggregate(modules) analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) big_o_result = analyzer.analyze_method(key, ast_nodes) - if big_o_result[:lower_bound_complexity] != "O(1)" - quality[:big_o] = big_o_result[:lower_bound_complexity] - end + quality[:big_o] = big_o_result[:lower_bound_complexity] + quality[:big_o_warnings] = big_o_result[:warnings] unless big_o_result[:warnings].empty? + quality[:big_o_unknowns] = big_o_result[:unknown_operations] unless big_o_result[:unknown_operations].empty? { name: m[:name], diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index 435890134..ce7452ef6 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -44,6 +44,9 @@ def analyze_method(method_name, ast_nodes) if known_complexity # If it's sequential, we just take the max of what we've seen so far. complexity = max_complexity(complexity, known_complexity) + else + unknown_operations << "#{receiver_type}##{method_called}" + warnings << "Missing method complexity for `#{receiver_type}##{method_called}` in stdlib_complexity_ruby.yml at line #{node[:line]}." end else unknown_operations << "#{node[:receiver]}.#{method_called}" diff --git a/gems/espalier/lib/espalier/reporter.rb b/gems/espalier/lib/espalier/reporter.rb index 8bcf8ac7d..af3abb825 100644 --- a/gems/espalier/lib/espalier/reporter.rb +++ b/gems/espalier/lib/espalier/reporter.rb @@ -488,7 +488,11 @@ def quality_summary(row) end def quality_hash(row) - (row[:quality] || {}).merge(external_overlap_for(row)) + h = (row[:quality] || {}).dup + h.delete(:big_o) if h[:big_o] == "O(1)" + h.delete(:big_o_warnings) + h.delete(:big_o_unknowns) + h.merge(external_overlap_for(row)) end def external_overlap_for(row) diff --git a/tools/espalier_diagnostic.rb b/tools/espalier_diagnostic.rb new file mode 100755 index 000000000..44dbadd1d --- /dev/null +++ b/tools/espalier_diagnostic.rb @@ -0,0 +1,156 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "yaml" +require "set" + +manifest_path = File.expand_path("../espalier_manifest.yml", __dir__) + +unless File.exist?(manifest_path) + puts "\e[31merror:\e[0m espalier_manifest.yml not found." + puts "Please generate it first by running:" + puts " bundle exec gems/espalier/exe/espalier --nil-kill=/tmp/clear-nil-kill/evidence.json --format yaml --output=espalier_manifest.yml src/" + exit 1 +end + +puts "Loading manifest..." +manifest = begin + YAML.unsafe_load(File.read(manifest_path)) + rescue => e + puts "YAML error: #{e.class}: #{e.message}" + nil + end +if manifest.nil? || !manifest.is_a?(Array) + puts "\e[31merror:\e[0m failed to parse espalier_manifest.yml." + exit 1 +end + +total_functions = 0 +uncertain_functions = 0 +poorly_performing = [] + +unknown_receivers = [] +missing_complexity = [] +dynamic_callbacks = [] + +manifest.each do |mod| + mod_name = mod[:module] || mod[:name] + functions = mod[:functions] || [] + + functions.each do |fn| + total_functions += 1 + quality = fn[:quality_metrics] || {} + + big_o = quality[:big_o] + warnings = quality[:big_o_warnings] || [] + unknowns = quality[:big_o_unknowns] || [] + + # Track poorly performing functions (O(N^2) or worse, or O(N log N)) + if big_o && big_o != "O(1)" && big_o != "O(N)" + poorly_performing << { + module: mod_name, + function: fn[:name], + complexity: big_o, + file: mod[:file], + line: fn[:line] + } + end + + # Check if runtime is uncertain + is_uncertain = false + + warnings.each do |warn| + is_uncertain = true + record = { + module: mod_name, + function: fn[:name], + warning: warn, + file: mod[:file], + line: fn[:line] + } + + if warn =~ /Unknown receiver type/ + unknown_receivers << record + elsif warn =~ /Missing method complexity/ + missing_complexity << record + elsif warn =~ /Function pointer|callback/ + dynamic_callbacks << record + end + end + + uncertain_functions += 1 if is_uncertain + end +end + +pct_uncertain = total_functions.zero? ? 0 : (uncertain_functions * 100.0 / total_functions).round(1) + +puts "\n\e[1m=== ESPALIER BIG-O RUNTIME DIAGNOSTIC ===\e[0m" +puts "Total functions analyzed: #{total_functions}" +puts "Uncertain runtime: #{uncertain_functions} (#{pct_uncertain}%)" +puts "Poorly performing (>=O(N log N)): #{poorly_performing.size}" +puts "----------------------------------------" + +# 1. Poorly Performing +puts "\n\e[1;31m1. Unexpected Poorly Performing Functions (>= O(N log N))\e[0m" +if poorly_performing.empty? + puts " None found." +else + poorly_performing.sort_by { |f| f[:complexity] }.reverse.each do |f| + puts " - \e[1m#{f[:module]}##{f[:function]}\e[0m -> #{f[:complexity]} at #{f[:file]}:#{f[:line]}" + end +end + +# Helper to print buckets +def print_bucket(title, records, color) + puts "\n#{color}#{title} (#{records.size})\e[0m" + if records.empty? + puts " None." + else + # Group by receiver/method name for clean reporting + grouped = Hash.new { |h, k| h[k] = [] } + records.each do |r| + detail = r[:warning].match(/for `([^`]+)`/)&.captures&.first || r[:warning] + grouped[detail] << r + end + + grouped.first(15).each do |detail, items| + puts " - \e[1m#{detail}\e[0m (affects #{items.size} function#{items.size > 1 ? 's' : ''})" + if ARGV.include?("--verbose") || ARGV.include?("-v") + items.each do |item| + puts " #{item[:module]}##{item[:function]} at #{item[:file]}:#{item[:line]}" + end + end + end + if grouped.size > 15 + puts " ... and #{grouped.size - 15} more types (run with -v to see details)" + end + end +end + +# 2. Categorized Uncertainties +print_bucket("2. Missing Method Complexity in stdlib_complexity_ruby.yml", missing_complexity, "\e[1;33m") +print_bucket("3. Unknown Receiver Type Resolution (Dynamic/Static Gaps)", unknown_receivers, "\e[1;34m") +print_bucket("4. Dynamic Callbacks / Function Pointer Invocations", dynamic_callbacks, "\e[1;35m") + +# 3. Generate YAML config suggestions +if missing_complexity.any? + puts "\n\e[1;32m=== SUGGESTED STDLIB_COMPLEXITY_RUBY.YML EXPANSIONS ===\e[0m" + puts "Add these signatures to gems/espalier/lib/espalier/stdlib_complexity_ruby.yml to resolve uncertainty:" + + # Group missing method complexities by receiver class + config_groups = Hash.new { |h, k| h[k] = Set.new } + missing_complexity.each do |r| + if r[:warning] =~ /for `([^#`]+)#([^`]+)`/ + config_groups[$1] << $2 + end + end + + config_groups.each do |klass, methods| + puts "#{klass}:" + methods.each do |method| + puts " #{method}: \"O(N)\" # TODO: Verify correct complexity" + end + end +end + +puts "\n\e[90mTip: Pass -v or --verbose to see individual functions and file lines.\e[0m" From 181796298d84ee032ff3ab4a18cc21ca33eea1ea Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 16:07:47 +0000 Subject: [PATCH 4/7] Add --explain option to Espalier diagnostic tool - Allows developers to run 'ruby tools/espalier_diagnostic.rb --explain ' to see a step-by-step breakdown of how the complexity was derived. - Helps identify if loops are sequential or nested, and highlights when a fallback method boundary causes trailing loops to be counted. Co-authored-by: gemini-cli <218195315+gemini-cli@users.noreply.github.com> --- tools/espalier_diagnostic.rb | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/tools/espalier_diagnostic.rb b/tools/espalier_diagnostic.rb index 44dbadd1d..6e61cfbae 100755 --- a/tools/espalier_diagnostic.rb +++ b/tools/espalier_diagnostic.rb @@ -4,7 +4,24 @@ require "yaml" require "set" +$LOAD_PATH.unshift("/home/yahn/litedb/gems/espalier/lib") +$LOAD_PATH.unshift("/home/yahn/litedb/gems/nil-kill/lib") +$LOAD_PATH.unshift("/home/yahn/litedb/gems/fact-mine/lib") + +require "espalier/big_o_analyzer" +require "espalier/nil_kill_evidence" +require "espalier/static_evidence" +require "espalier/aggregator" + manifest_path = File.expand_path("../espalier_manifest.yml", __dir__) +nil_kill_path = "/tmp/clear-nil-kill/evidence.json" + +explain_target = nil +ARGV.each_with_index do |arg, idx| + if arg == "--explain" + explain_target = ARGV[idx + 1] + end +end unless File.exist?(manifest_path) puts "\e[31merror:\e[0m espalier_manifest.yml not found." @@ -25,6 +42,121 @@ exit 1 end +if explain_target + puts "Loading nil-kill evidence for detailed trace..." + nk = if File.exist?(nil_kill_path) + Espalier::NilKillEvidence.load(nil_kill_path) + else + Espalier::NilKillEvidence.new({}) + end + + # Find the method + matched_fn = nil + matched_mod = nil + + manifest.each do |mod| + mod[:functions].each do |fn| + fn_key = "#{mod[:module] || mod[:name]}##{fn[:name]}" + if fn_key.downcase.include?(explain_target.downcase) + matched_fn = fn + matched_mod = mod + break + end + end + break if matched_fn + end + + unless matched_fn + puts "\e[31merror:\e[0m no method matches '#{explain_target}' in the manifest." + exit 1 + end + + mod_name = matched_mod[:module] || matched_mod[:name] + fn_name = matched_fn[:name] + file = matched_mod[:file] + meth_line = matched_fn[:line] || 0 + + # Re-evaluate ast_nodes like Aggregator does + next_meth = matched_mod[:functions][matched_mod[:functions].index(matched_fn) + 1] + end_line = next_meth ? (next_meth[:line] || Float::INFINITY) : Float::INFINITY + + ast_nodes = Array(matched_fn[:delegations] || []).map do |d| + { type: :call, receiver: d[:receiver], method: d[:message], line: matched_fn[:line] || 0 } + end + + if file && nk.loop_counts && nk.loop_counts[file] + nk.loop_counts[file].each do |line, calls| + if line >= meth_line && line < end_line && calls > 0 + ast_nodes << { type: :loop, line: line, calls: calls } + end + end + end + + puts "\n\e[1m=== EXPLAINING COMPLEXITY: #{mod_name}##{fn_name} ===\e[0m" + puts "File: #{file}" + puts "Line range: #{meth_line} to #{end_line == Float::INFINITY ? 'EOF' : end_line}" + puts "Analyzed AST operations: #{ast_nodes.size}" + puts "----------------------------------------" + + analyzer = Espalier::BigOAnalyzer.new( + nil_kill_evidence: nk.loop_counts, + class_name: mod_name, + ivar_types: matched_mod[:ivar_types] || {}, + nil_kill: nk + ) + + current_complexity = "O(1)" + ast_nodes.each_with_index do |node, index| + step_num = index + 1 + if node[:type] == :call + receiver_type = analyzer.send(:resolve_type, node[:receiver], node[:line]) + method_called = node[:method].to_s + + if receiver_type + known_complexity = analyzer.registry.dig(receiver_type, method_called) + if known_complexity + prev = current_complexity + current_complexity = analyzer.send(:max_complexity, current_complexity, known_complexity) + puts " \e[32m[Step #{step_num}]\e[0m Call: `#{node[:receiver]}.#{method_called}` at line #{node[:line]}" + puts " -> Resolved receiver to `#{receiver_type}`" + puts " -> Signature complexity: #{known_complexity}" + puts " -> Complexity update: #{prev} -> #{current_complexity}" + else + puts " \e[33m[Step #{step_num}]\e[0m Call: `#{node[:receiver]}.#{method_called}` at line #{node[:line]}" + puts " -> Resolved receiver to `#{receiver_type}`" + puts " -> \e[33mWarning:\e[0m Missing method complexity for `#{receiver_type}##{method_called}` in stdlib_complexity_ruby.yml." + puts " -> Complexity remains: #{current_complexity}" + end + else + puts " \e[34m[Step #{step_num}]\e[0m Call: `#{node[:receiver]}.#{method_called}` at line #{node[:line]}" + puts " -> \e[34mWarning:\e[0m Unknown receiver type for `#{node[:receiver]}`" + puts " -> Complexity remains: #{current_complexity}" + end + elsif node[:type] == :loop + prev = current_complexity + current_complexity = analyzer.send(:multiply_complexity, current_complexity, "O(N)") + puts " \e[35m[Step #{step_num}]\e[0m Loop: detected at line #{node[:line]} (iteration count: #{node[:calls]})" + puts " -> Multiplied complexity by O(N)" + puts " -> Complexity update: #{prev} -> #{current_complexity}" + end + end + + puts "----------------------------------------" + puts "\e[1;31mFinal calculated complexity:\e[0m #{current_complexity}" + + if current_complexity =~ /O\(N\^(\d+)\)/ && $1.to_i > 3 + puts "\n\e[1;33mDiagnostic Insight:\e[0m" + puts "This method has an unusually high complexity of #{current_complexity}." + puts "This is typically caused by one of two factors:" + puts "1. \e[1mSequential loops treated as nested:\e[0m The Big-O analyzer currently multiplies all loop complexities" + puts " found within the method's line span. If the loops are sequential rather than nested, this leads to an inflated" + puts " exponent (e.g. 5 sequential loops evaluated as O(N^5) instead of O(N))." + puts "2. \e[1mMethod boundary detection issue:\e[0m If this is the last method in the file, it defaults its end line" + puts " to the end of the file. Any loops in subsequent lines/methods will be incorrectly counted towards this function." + end + exit 0 +end + total_functions = 0 uncertain_functions = 0 poorly_performing = [] @@ -154,3 +286,4 @@ def print_bucket(title, records, color) end puts "\n\e[90mTip: Pass -v or --verbose to see individual functions and file lines.\e[0m" +puts "\e[90m Pass --explain \"#\" to trace the evaluation steps.\e[0m" From 465dd3ced597301e55614bf31c44f9b5ff339b52 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 21:55:34 +0000 Subject: [PATCH 5/7] Fix Espalier Big-O loop attribution --- gems/espalier/lib/espalier/aggregator.rb | 22 +++++++++-- gems/espalier/lib/espalier/big_o_analyzer.rb | 6 ++- gems/espalier/test/aggregator_test.rb | 36 ++++++++++++++++++ gems/espalier/test/big_o_test.rb | 14 ++++++- gems/nil-kill/lib/nil_kill/runtime_trace.rb | 5 +++ .../lib/nil_kill/source_instrumenter.rb | 37 +++++++++++++++---- .../lib/nil_kill/tree_sitter_adapter.rb | 19 ++++++---- gems/nil-kill/spec/runtime_trace_spec.rb | 30 +++++++++++++++ tools/espalier_diagnostic.rb | 32 ++++++++++------ 9 files changed, 166 insertions(+), 35 deletions(-) diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index 18599eea1..286698969 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -98,10 +98,8 @@ def aggregate(modules) end # Run Big-O analysis - meth_line = m[:line] || 0 + meth_line, end_line, end_inclusive = method_line_bounds(mod[:methods], m) file = mod[:file] - next_meth = mod[:methods][mod[:methods].index(m) + 1] - end_line = next_meth ? (next_meth[:line] || Float::INFINITY) : Float::INFINITY ast_nodes = Array(m[:delegations]).map do |d| { type: :call, receiver: d[:receiver], method: d[:message], line: m[:line] || 0 } @@ -109,7 +107,7 @@ def aggregate(modules) if file && @nil_kill_loops && @nil_kill_loops[file] @nil_kill_loops[file].each do |line, calls| - if line >= meth_line && line < end_line && calls > 0 + if line_in_method_bounds?(line, meth_line, end_line, end_inclusive) && calls > 0 ast_nodes << { type: :loop, line: line, calls: calls } end end @@ -198,5 +196,21 @@ def collect_state_properties(class_name, state_var) end props end + + def method_line_bounds(methods, method) + start_line = method[:line] || 0 + span = method[:span] + if span.is_a?(Array) && span[2] + return [start_line, span[2].to_i, true] + end + + next_method = methods[methods.index(method) + 1] + [start_line, next_method ? (next_method[:line] || Float::INFINITY) : Float::INFINITY, false] + end + + def line_in_method_bounds?(line, start_line, end_line, end_inclusive) + return false unless line >= start_line + end_inclusive ? line <= end_line : line < end_line + end end end diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index ce7452ef6..a438aedf7 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -53,8 +53,10 @@ def analyze_method(method_name, ast_nodes) warnings << "Unknown receiver type for `#{node[:receiver]}` at line #{node[:line]}. Defaulting to O(1) for `.#{method_called}`, but this could be worse." end elsif node[:type] == :loop - # multiply inner complexity - complexity = multiply_complexity(complexity, "O(N)") + # Runtime loop evidence is currently flat by source line. Without a + # nesting tree, multiple observed loops in one method are a sequential + # lower bound, not proof of nested O(N^k) behavior. + complexity = max_complexity(complexity, "O(N)") elsif node[:type] == :callback || node[:type] == :yield warnings << "Function pointer / callback executed at line #{node[:line]}. This could execute arbitrary O(N^x) code, meaning our calculation is strictly a LOWER BOUND." end diff --git a/gems/espalier/test/aggregator_test.rb b/gems/espalier/test/aggregator_test.rb index 109b6b98b..2f88082ce 100644 --- a/gems/espalier/test/aggregator_test.rb +++ b/gems/espalier/test/aggregator_test.rb @@ -181,4 +181,40 @@ def test_delegations_mapped_to_concrete_type_when_available assert_includes fn[:DELEGATIONS][:always_calls], "@unknown_ivar.process" end + def test_big_o_loop_attribution_uses_last_method_span + modules = [ + { + type: :class, + name: "Formatter", + file: "lib/formatter.rb", + states: Set.new, + methods: [ + { + name: "last_method", + signature: "def last_method", + parameters: [], + visibility: :public, + line: 10, + span: [10, 2, 12, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + aggregator = Espalier::Aggregator.new( + nil_kill_loops: { + "lib/formatter.rb" => { + 30 => 10 + } + } + ) + + manifest = aggregator.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(1)", fn[:quality_metrics][:big_o] + end + end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb index 72457a0fa..08e121f2c 100644 --- a/gems/espalier/test/big_o_test.rb +++ b/gems/espalier/test/big_o_test.rb @@ -71,9 +71,21 @@ def test_analyze_method result = analyzer.analyze_method("process_items", ast_mock) assert_equal "process_items", result[:method] - assert_equal "O(N^2 log N)", result[:lower_bound_complexity] # O(N log N) * O(N) = O(N^2 log N) + assert_equal "O(N log N)", result[:lower_bound_complexity] assert_empty result[:unknown_operations] assert_equal 1, result[:warnings].size assert_match(/Function pointer \/ callback/, result[:warnings].first) end + + def test_flat_sequential_loop_evidence_does_not_imply_nested_exponent + analyzer = Espalier::BigOAnalyzer.new + + result = analyzer.analyze_method("sequential_loops", [ + { type: :loop, line: 10 }, + { type: :loop, line: 20 }, + { type: :loop, line: 30 } + ]) + + assert_equal "O(N)", result[:lower_bound_complexity] + end end diff --git a/gems/nil-kill/lib/nil_kill/runtime_trace.rb b/gems/nil-kill/lib/nil_kill/runtime_trace.rb index 9db8d8ffb..fb42fd201 100755 --- a/gems/nil-kill/lib/nil_kill/runtime_trace.rb +++ b/gems/nil-kill/lib/nil_kill/runtime_trace.rb @@ -1393,6 +1393,11 @@ def self.install_collection_hook install_set_hook end + def self.record_loop_iteration(path, line) + @loop_file ||= File.open(File.join(OUT_DIR, "loops-#{Process.pid}.jsonl"), "a") + @loop_file.puts %({"path":#{path.inspect},"line":#{line}}) + end + # NOTE: the parallel instrumented tree and its require/require_relative # redirect (instrumented_copy_for / resolve_required_source / # install_instrumented_require_hook) were DELETED. In-place diff --git a/gems/nil-kill/lib/nil_kill/source_instrumenter.rb b/gems/nil-kill/lib/nil_kill/source_instrumenter.rb index ad3b171d8..f4a127bc0 100644 --- a/gems/nil-kill/lib/nil_kill/source_instrumenter.rb +++ b/gems/nil-kill/lib/nil_kill/source_instrumenter.rb @@ -79,6 +79,7 @@ def instrument_file_with_map(path) return [source, nil] unless parsed.success? edits = [] collect_ivar_assignment_edits(parsed.value, edits) if source.include?("@") + collect_loop_edits(parsed.value, File.expand_path(path, ROOT), edits) if source.include?("while") || source.include?("until") collect_method_edits(parsed.value, File.expand_path(path, ROOT), edits) collect_source_ref_edits(parsed.value, edits, File.expand_path(path, ROOT)) if source.include?("__LINE__") write_tracepoint_fallback_plan @@ -118,6 +119,7 @@ def instrument_file(path) return source unless parsed.success? edits = [] collect_ivar_assignment_edits(parsed.value, edits) if source.include?("@") + collect_loop_edits(parsed.value, File.expand_path(path, ROOT), edits) if source.include?("while") || source.include?("until") collect_method_edits(parsed.value, File.expand_path(path, ROOT), edits) collect_source_ref_edits(parsed.value, edits, File.expand_path(path, ROOT)) if source.include?("__LINE__") write_tracepoint_fallback_plan @@ -147,7 +149,8 @@ def line_number_for_byte(offsets, byte) [low, 1].max end - def collect_method_edits(node, path, edits) + def collect_method_edits(node, path, edits, visited = Set.new) + return unless visited.add?(node) case node when Syntax::DefNode plan = @method_plans_by_file_line[path][node.location.start_line] @@ -177,7 +180,7 @@ def collect_method_edits(node, path, edits) collect_return_edits(node.body, plan, edits) return end - node.child_nodes.compact.each { |child| collect_method_edits(child, path, edits) } if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| collect_method_edits(child, path, edits, visited) } if node.respond_to?(:child_nodes) end def insert_method_wrapper(node, plan, edits) @@ -205,11 +208,12 @@ def insert_method_wrapper(node, plan, edits) # rewrites -- are gone). Only __LINE__ still needs literalising: # the injected wrapper shifts every later line, so a raw __LINE__ # would yield the instrumented line, not the src line. - def collect_source_ref_edits(node, edits, _real_file = nil) + def collect_source_ref_edits(node, edits, _real_file = nil, visited = Set.new) + return unless visited.add?(node) if node.is_a?(Syntax::SourceLineNode) edits << [node.location.start_offset, node.location.end_offset, node.location.start_line.to_s] end - node.child_nodes.compact.each { |child| collect_source_ref_edits(child, edits, _real_file) } if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| collect_source_ref_edits(child, edits, _real_file, visited) } if node.respond_to?(:child_nodes) end def method_header_end_offset(node) @@ -217,8 +221,9 @@ def method_header_end_offset(node) loc.start_offset + loc.length end - def collect_return_edits(node, plan, edits) + def collect_return_edits(node, plan, edits, visited = Set.new) return unless node + return unless visited.add?(node) case node when Syntax::DefNode, Syntax::ClassNode, Syntax::ModuleNode, Syntax::LambdaNode # New scope: a `return` here belongs to it, not this method. @@ -247,7 +252,7 @@ def collect_return_edits(node, plan, edits) # returns. Skip the whole call subtree; the caller's recursion # still walks siblings. return if node.is_a?(Syntax::CallNode) && node.name == :lambda && node.block.is_a?(Syntax::BlockNode) - node.child_nodes.compact.each { |child| collect_return_edits(child, plan, edits) } if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| collect_return_edits(child, plan, edits, visited) } if node.respond_to?(:child_nodes) end def source_params_expr(plan) @@ -265,7 +270,8 @@ def end_line_offset(line) @line_offsets[line] ? @line_offsets[line] - 1 : @line_offsets.last.to_i end - def collect_ivar_assignment_edits(node, edits) + def collect_ivar_assignment_edits(node, edits, visited = Set.new) + return unless visited.add?(node) case node when Syntax::InstanceVariableWriteNode, Syntax::ClassVariableWriteNode, Syntax::GlobalVariableWriteNode value = node.value @@ -276,7 +282,22 @@ def collect_ivar_assignment_edits(node, edits) edits << [value.location.start_offset, value.location.end_offset, replacement] end end - node.child_nodes.compact.each { |child| collect_ivar_assignment_edits(child, edits) } if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| collect_ivar_assignment_edits(child, edits, visited) } if node.respond_to?(:child_nodes) + end + + def collect_loop_edits(node, path, edits, visited = Set.new) + return unless visited.add?(node) + abs_path = File.expand_path(path, NilKill::ROOT) + case node + when Syntax::WhileNode, Syntax::UntilNode + if node.predicate&.location + pred = node.predicate + replacement_start = "(NilKillRuntimeTrace.record_loop_iteration(#{abs_path.inspect}, #{node.location.start_line}); " + edits << [pred.location.start_offset, pred.location.start_offset, replacement_start] + edits << [pred.location.end_offset, pred.location.end_offset, ")"] + end + end + node.child_nodes.compact.each { |child| collect_loop_edits(child, path, edits, visited) } if node.respond_to?(:child_nodes) end def apply_edits(source, edits) diff --git a/gems/nil-kill/lib/nil_kill/tree_sitter_adapter.rb b/gems/nil-kill/lib/nil_kill/tree_sitter_adapter.rb index a00e9d138..bfe39994d 100644 --- a/gems/nil-kill/lib/nil_kill/tree_sitter_adapter.rb +++ b/gems/nil-kill/lib/nil_kill/tree_sitter_adapter.rb @@ -235,7 +235,7 @@ def location end def slice - @context.source[@raw.start_byte...@raw.end_byte] + @context.source.byteslice(@raw.start_byte...@raw.end_byte) end def child_nodes @@ -332,7 +332,7 @@ def body class DefNode < Node def name name_node = @raw.child_by_field_name("name") || @raw.named_children.find { |child| child.type == "identifier" } - name_node ? @context.source[name_node.start_byte...name_node.end_byte].to_sym : nil + name_node ? @context.source.byteslice(name_node.start_byte...name_node.end_byte).to_sym : nil end def receiver @@ -422,7 +422,7 @@ def name node = @raw.child_by_field_name("name") || @raw.named_children.find { |child| child.type == "identifier" } || (@raw.type == "identifier" ? @raw : nil) - node ? @context.source[node.start_byte...node.end_byte].to_sym : nil + node ? @context.source.byteslice(node.start_byte...node.end_byte).to_sym : nil end def value @@ -451,7 +451,7 @@ def child_nodes class VariableNode < Node def name name_node = @raw.child_by_field_name("name") || @raw.named_children.first || @raw - @context.source[name_node.start_byte...name_node.end_byte].to_sym + @context.source.byteslice(name_node.start_byte...name_node.end_byte).to_sym end end @@ -490,13 +490,16 @@ def name :[] when "binary" op = @raw.child_by_field_name("operator") - op ? @context.source[op.start_byte...op.end_byte].to_sym : nil + txt = op && @context.source ? @context.source.byteslice(op.start_byte...op.end_byte) : nil + txt ? txt.to_sym : nil when "unary" op = @raw.child_by_field_name("operator") - op ? @context.source[op.start_byte...op.end_byte].to_sym : nil + txt = op && @context.source ? @context.source.byteslice(op.start_byte...op.end_byte) : nil + txt ? txt.to_sym : nil else method_node = @raw.child_by_field_name("method") || @raw.named_children.find { |child| child.type == "identifier" } - method_node ? @context.source[method_node.start_byte...method_node.end_byte].to_sym : nil + txt = method_node && @context.source ? @context.source.byteslice(method_node.start_byte...method_node.end_byte) : nil + txt ? txt.to_sym : nil end end @@ -574,7 +577,7 @@ class IndexOrWriteNode < IndexOperatorWriteNode; end class LocalVariableReadNode < CallNode def name - @context.source[@raw.start_byte...@raw.end_byte].to_sym + @context.source.byteslice(@raw.start_byte...@raw.end_byte).to_sym end end diff --git a/gems/nil-kill/spec/runtime_trace_spec.rb b/gems/nil-kill/spec/runtime_trace_spec.rb index b03eadfcf..27ab11419 100644 --- a/gems/nil-kill/spec/runtime_trace_spec.rb +++ b/gems/nil-kill/spec/runtime_trace_spec.rb @@ -761,4 +761,34 @@ def compute_value end end end + + it "records loop iterations against original source lines even when __LINE__ is present" do + Dir.mktmpdir("nil-kill-loop-line", NilKill::ROOT) do |dir| + src = File.join(dir, "loop_line.rb") + File.write(src, <<~RUBY) + class LoopLine + MARKER = __LINE__ + + def run + i = 0 + while i < 1 + i += 1 + end + end + end + RUBY + + source_lines = File.read(src).lines + marker_line = source_lines.index { |line| line.include?("MARKER") } + 1 + while_line = source_lines.index { |line| line.include?("while i < 1") } + 1 + + instrumented = NilKill::SourceInstrumenter.new.instrument_file(src) + + expect(instrumented).to include("MARKER = #{marker_line}") + expect(instrumented).to include( + "NilKillRuntimeTrace.record_loop_iteration(#{File.expand_path(src, NilKill::ROOT).inspect}, #{while_line})" + ) + expect(instrumented).not_to include("MARKER = __LINE__") + end + end end diff --git a/tools/espalier_diagnostic.rb b/tools/espalier_diagnostic.rb index 6e61cfbae..6cf195795 100755 --- a/tools/espalier_diagnostic.rb +++ b/tools/espalier_diagnostic.rb @@ -4,17 +4,18 @@ require "yaml" require "set" -$LOAD_PATH.unshift("/home/yahn/litedb/gems/espalier/lib") -$LOAD_PATH.unshift("/home/yahn/litedb/gems/nil-kill/lib") -$LOAD_PATH.unshift("/home/yahn/litedb/gems/fact-mine/lib") +ROOT = File.expand_path("..", __dir__) +$LOAD_PATH.unshift(File.join(ROOT, "gems/espalier/lib")) +$LOAD_PATH.unshift(File.join(ROOT, "gems/nil-kill/lib")) +$LOAD_PATH.unshift(File.join(ROOT, "gems/fact-mine/lib")) require "espalier/big_o_analyzer" require "espalier/nil_kill_evidence" require "espalier/static_evidence" require "espalier/aggregator" -manifest_path = File.expand_path("../espalier_manifest.yml", __dir__) -nil_kill_path = "/tmp/clear-nil-kill/evidence.json" +manifest_path = File.join(ROOT, "espalier_manifest.yml") +nil_kill_path = ENV.fetch("NIL_KILL_EVIDENCE", "/tmp/clear-nil-kill/evidence.json") explain_target = nil ARGV.each_with_index do |arg, idx| @@ -78,7 +79,14 @@ # Re-evaluate ast_nodes like Aggregator does next_meth = matched_mod[:functions][matched_mod[:functions].index(matched_fn) + 1] - end_line = next_meth ? (next_meth[:line] || Float::INFINITY) : Float::INFINITY + span = matched_fn[:span] + if span.is_a?(Array) && span[2] + end_line = span[2].to_i + end_inclusive = true + else + end_line = next_meth ? (next_meth[:line] || Float::INFINITY) : Float::INFINITY + end_inclusive = false + end ast_nodes = Array(matched_fn[:delegations] || []).map do |d| { type: :call, receiver: d[:receiver], method: d[:message], line: matched_fn[:line] || 0 } @@ -86,7 +94,8 @@ if file && nk.loop_counts && nk.loop_counts[file] nk.loop_counts[file].each do |line, calls| - if line >= meth_line && line < end_line && calls > 0 + in_range = line >= meth_line && (end_inclusive ? line <= end_line : line < end_line) + if in_range && calls > 0 ast_nodes << { type: :loop, line: line, calls: calls } end end @@ -134,9 +143,9 @@ end elsif node[:type] == :loop prev = current_complexity - current_complexity = analyzer.send(:multiply_complexity, current_complexity, "O(N)") + current_complexity = analyzer.send(:max_complexity, current_complexity, "O(N)") puts " \e[35m[Step #{step_num}]\e[0m Loop: detected at line #{node[:line]} (iteration count: #{node[:calls]})" - puts " -> Multiplied complexity by O(N)" + puts " -> Flat loop evidence contributes a sequential O(N) lower bound" puts " -> Complexity update: #{prev} -> #{current_complexity}" end end @@ -148,9 +157,8 @@ puts "\n\e[1;33mDiagnostic Insight:\e[0m" puts "This method has an unusually high complexity of #{current_complexity}." puts "This is typically caused by one of two factors:" - puts "1. \e[1mSequential loops treated as nested:\e[0m The Big-O analyzer currently multiplies all loop complexities" - puts " found within the method's line span. If the loops are sequential rather than nested, this leads to an inflated" - puts " exponent (e.g. 5 sequential loops evaluated as O(N^5) instead of O(N))." + puts "1. \e[1mNested loop proof missing:\e[0m The Big-O analyzer only has flat runtime loop evidence." + puts " It should not produce high exponents unless static nesting evidence is added." puts "2. \e[1mMethod boundary detection issue:\e[0m If this is the last method in the file, it defaults its end line" puts " to the end of the file. Any loops in subsequent lines/methods will be incorrectly counted towards this function." end From afda6d85271fa9b1871076a5e39167b2d7311468 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 30 Jun 2026 22:17:04 +0000 Subject: [PATCH 6/7] Improve Espalier Big-O receiver modeling --- gems/espalier/lib/espalier/aggregator.rb | 57 +++++++++++- gems/espalier/lib/espalier/big_o_analyzer.rb | 91 +++++++++++++++++-- gems/espalier/lib/espalier/static_evidence.rb | 3 +- .../lib/espalier/stdlib_complexity_ruby.yml | 32 +++++++ gems/espalier/test/aggregator_test.rb | 31 +++++++ gems/espalier/test/big_o_test.rb | 83 +++++++++++++++++ gems/espalier/test/static_evidence_test.rb | 18 +++- 7 files changed, 303 insertions(+), 12 deletions(-) diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index 286698969..c65f26802 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -102,7 +102,7 @@ def aggregate(modules) file = mod[:file] ast_nodes = Array(m[:delegations]).map do |d| - { type: :call, receiver: d[:receiver], method: d[:message], line: m[:line] || 0 } + { type: :call, receiver: d[:receiver], method: d[:message], line: d[:line] || m[:line] || 0 } end if file && @nil_kill_loops && @nil_kill_loops[file] @@ -116,7 +116,7 @@ def aggregate(modules) analyzer.instance_variable_set(:@class_name, mod[:name]) analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) - big_o_result = analyzer.analyze_method(key, ast_nodes) + big_o_result = analyzer.analyze_method(key, ast_nodes, local_types: local_types_for_signature(sig)) quality[:big_o] = big_o_result[:lower_bound_complexity] quality[:big_o_warnings] = big_o_result[:warnings] unless big_o_result[:warnings].empty? quality[:big_o_unknowns] = big_o_result[:unknown_operations] unless big_o_result[:unknown_operations].empty? @@ -212,5 +212,58 @@ def line_in_method_bounds?(line, start_line, end_line, end_inclusive) return false unless line >= start_line end_inclusive ? line <= end_line : line < end_line end + + def local_types_for_signature(signature) + params_source = signature_params_source(signature.to_s) + return {} unless params_source + + split_signature_args(params_source).each_with_object({}) do |entry, types| + name, type = entry.split(":", 2) + next unless name && type + + types[name.strip] = type.strip + end + end + + def signature_params_source(signature) + start_idx = signature.index("params(") + return nil unless start_idx + + idx = start_idx + "params(".length + depth = 1 + while idx < signature.length + case signature[idx] + when "(", "[", "{" + depth += 1 + when ")", "]", "}" + depth -= 1 + return signature[(start_idx + "params(".length)...idx] if depth.zero? + end + idx += 1 + end + + nil + end + + def split_signature_args(source) + parts = [] + start_idx = 0 + depth = 0 + source.each_char.with_index do |char, idx| + case char + when "(", "[", "{" + depth += 1 + when ")", "]", "}" + depth -= 1 + when "," + next unless depth.zero? + + parts << source[start_idx...idx].strip + start_idx = idx + 1 + end + end + parts << source[start_idx..].to_s.strip + parts.reject(&:empty?) + end end end diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index a438aedf7..14857f04f 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -5,13 +5,42 @@ module Espalier class BigOAnalyzer + STDLIB_RETURN_TYPES = { + "Array" => { + "compact" => "Array", + "filter_map" => "Array", + "flatten" => "Array", + "map" => "Array", + "reject" => "Array", + "select" => "Array", + "sort" => "Array", + "sort_by" => "Array", + "to_a" => "Array" + }, + "Hash" => { + "keys" => "Array", + "map" => "Array", + "sort" => "Array", + "sort_by" => "Array", + "to_a" => "Array", + "values" => "Array" + }, + "Set" => { + "map" => "Array", + "sort" => "Array", + "sort_by" => "Array", + "to_a" => "Array" + } + }.freeze + attr_reader :registry, :nil_kill_evidence - def initialize(language: :ruby, nil_kill_evidence: {}, class_name: nil, ivar_types: {}, nil_kill: nil) + def initialize(language: :ruby, nil_kill_evidence: {}, class_name: nil, ivar_types: {}, nil_kill: nil, local_types: {}) @language = language @nil_kill_evidence = nil_kill_evidence @class_name = class_name @ivar_types = ivar_types || {} + @local_types = local_types || {} @nil_kill = nil_kill @registry = load_registry(language) end @@ -25,7 +54,9 @@ def load_registry(language) # Prototypical analyzer for a method. # We pass in `ast_nodes` which represents the parsed AST # (mocked for this prototype as an array of operation hashes). - def analyze_method(method_name, ast_nodes) + def analyze_method(method_name, ast_nodes, local_types: nil) + previous_local_types = @local_types + @local_types = local_types if local_types complexity = "O(1)" unknown_operations = [] warnings = [] @@ -44,6 +75,10 @@ def analyze_method(method_name, ast_nodes) if known_complexity # If it's sequential, we just take the max of what we've seen so far. complexity = max_complexity(complexity, known_complexity) + elsif (chained_complexity = flattened_chain_complexity(node, ast_nodes)) + complexity = max_complexity(complexity, chained_complexity) + elsif state_accessor_return_type(receiver_type, method_called) + complexity = max_complexity(complexity, "O(1)") else unknown_operations << "#{receiver_type}##{method_called}" warnings << "Missing method complexity for `#{receiver_type}##{method_called}` in stdlib_complexity_ruby.yml at line #{node[:line]}." @@ -68,6 +103,8 @@ def analyze_method(method_name, ast_nodes) unknown_operations: unknown_operations.uniq, warnings: warnings.uniq } + ensure + @local_types = previous_local_types if local_types end private @@ -119,6 +156,10 @@ def resolve_simple_type(receiver_name, line) return clean_type_name(@class_name) end + if (type = @local_types[receiver_name]) + return clean_type_name(type) + end + if receiver_name.start_with?("@") type = @ivar_types[receiver_name] || @ivar_types[receiver_name[1..]] return clean_type_name(type) if type @@ -145,12 +186,48 @@ def resolve_simple_type(receiver_name, line) end def resolve_method_return_type(class_name, method_name) - return nil unless @nil_kill && class_name - sig = @nil_kill.method_signatures["#{class_name}##{method_name}"] - if sig - ret = extract_return_type(sig) - return clean_type_name(ret) + return nil unless class_name + + if @nil_kill + sig = @nil_kill.method_signatures["#{class_name}##{method_name}"] + if sig + ret = extract_return_type(sig) + return clean_type_name(ret) + end + + state_type = state_accessor_return_type(class_name, method_name) + return state_type if state_type + end + + stdlib_type = STDLIB_RETURN_TYPES.dig(class_name, method_name) + return clean_type_name(stdlib_type) if stdlib_type + + nil + end + + def state_accessor_return_type(class_name, method_name) + return nil unless @nil_kill&.respond_to?(:state_types) + + clean_type_name(@nil_kill.state_types.dig(class_name, "@#{method_name}")) + end + + def flattened_chain_complexity(node, ast_nodes) + receiver = node[:receiver].to_s + method_called = node[:method].to_s + line = node[:line] + + Array(ast_nodes).each do |candidate| + next unless candidate[:type] == :call + next unless candidate[:receiver].to_s == receiver + next unless candidate[:line] == line + accessor = candidate[:method].to_s + next if accessor == method_called + + chained_type = resolve_type("#{receiver}.#{accessor}", line) + known_complexity = @registry.dig(chained_type, method_called) + return known_complexity if known_complexity end + nil end diff --git a/gems/espalier/lib/espalier/static_evidence.rb b/gems/espalier/lib/espalier/static_evidence.rb index d8f9fb251..8c9f9dd99 100644 --- a/gems/espalier/lib/espalier/static_evidence.rb +++ b/gems/espalier/lib/espalier/static_evidence.rb @@ -88,6 +88,7 @@ def self.project_modules(evidence) meth[:delegations] << { receiver: field, message: proto, + line: record["line"]&.to_i, type: :always } end @@ -603,4 +604,4 @@ def rel(path) path.to_s end end -end \ No newline at end of file +end diff --git a/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml b/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml index 6d85e3e61..4c9e70d0a 100644 --- a/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml +++ b/gems/espalier/lib/espalier/stdlib_complexity_ruby.yml @@ -1,9 +1,21 @@ Array: + "[]": "O(1)" each: "O(N)" + each_with_index: "O(N)" map: "O(N)" select: "O(N)" reject: "O(N)" flatten: "O(N)" + filter_map: "O(N)" + compact: "O(N)" + join: "O(N)" + values: "O(N)" + length: "O(1)" + size: "O(1)" + last: "O(1)" + first: "O(1)" + freeze: "O(1)" + to_set: "O(N)" sort: "O(N log N)" sort_by: "O(N log N)" "-": "O(N * M)" @@ -17,7 +29,27 @@ String: "+": "O(N)" Hash: + "[]": "O(1)" + each: "O(N)" + each_value: "O(N)" + map: "O(N)" merge: "O(N)" keys: "O(N)" values: "O(N)" + length: "O(1)" + size: "O(1)" + delete_if: "O(N)" + dup: "O(N)" + to_set: "O(N)" transform_values: "O(N)" + sort: "O(N log N)" + sort_by: "O(N log N)" + +StringScanner: + "[]": "O(1)" + eos?: "O(1)" + scan: "O(N)" + scan_until: "O(N)" + +Integer: + positive?: "O(1)" diff --git a/gems/espalier/test/aggregator_test.rb b/gems/espalier/test/aggregator_test.rb index 2f88082ce..19562dbcd 100644 --- a/gems/espalier/test/aggregator_test.rb +++ b/gems/espalier/test/aggregator_test.rb @@ -217,4 +217,35 @@ def test_big_o_loop_attribution_uses_last_method_span assert_equal "O(1)", fn[:quality_metrics][:big_o] end + def test_big_o_uses_signature_param_types + modules = [ + { + type: :class, + name: "SegmentRenumber", + file: "lib/segment_renumber.rb", + states: Set.new, + methods: [ + { + name: "self.renumber_with_entry", + signature: "sig { params(segments: T::Array[Segment], entry: Integer).returns(Result) }", + parameters: ["segments", "entry"], + visibility: :public, + line: 20, + span: [20, 2, 25, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [ + { receiver: "segments", message: "sort_by", line: 24, type: :always } + ] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N log N)", fn[:quality_metrics][:big_o] + refute fn[:quality_metrics].key?(:big_o_unknowns) + end + end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb index 08e121f2c..668e796c0 100644 --- a/gems/espalier/test/big_o_test.rb +++ b/gems/espalier/test/big_o_test.rb @@ -77,6 +77,89 @@ def test_analyze_method assert_match(/Function pointer \/ callback/, result[:warnings].first) end + def test_param_type_drives_sort_by_complexity + analyzer = Espalier::BigOAnalyzer.new + + result = analyzer.analyze_method( + "renumber", + [{ type: :call, receiver: "segments", method: "sort_by", line: 10 }], + local_types: { "segments" => "T::Array[Segment]" } + ) + + assert_equal "O(N log N)", result[:lower_bound_complexity] + assert_empty result[:unknown_operations] + end + + def test_hash_sort_is_n_log_n + analyzer = Espalier::BigOAnalyzer.new( + class_name: "Snapshot", + ivar_types: { "@alloc_kinds" => "Hash" } + ) + + assert_equal "Array", analyzer.send(:resolve_type, "alloc_kinds.map", 10) + + result = analyzer.analyze_method( + "summary", + [{ type: :call, receiver: "alloc_kinds", method: "sort", line: 10 }] + ) + + assert_equal "O(N log N)", result[:lower_bound_complexity] + assert_empty result[:unknown_operations] + end + + def test_flattened_chain_uses_stdlib_return_type + analyzer = Espalier::BigOAnalyzer.new( + class_name: "Snapshot", + ivar_types: { "@alloc_kinds" => "Hash" } + ) + + result = analyzer.analyze_method( + "summary", + [ + { type: :call, receiver: "alloc_kinds", method: "map", line: 10 }, + { type: :call, receiver: "alloc_kinds", method: "join", line: 10 } + ] + ) + + assert_equal "O(N)", result[:lower_bound_complexity] + refute_includes result[:unknown_operations], "Hash#join" + end + + def test_flattened_same_line_chain_uses_accessor_return_type + nil_kill = Object.new + def nil_kill.method_signatures + {} + end + def nil_kill.state_types + { "FunctionCFG" => { "@blocks" => "Array" } } + end + + analyzer = Espalier::BigOAnalyzer.new( + class_name: "OwnershipDataflow", + ivar_types: { "@cfg" => "FunctionCFG" }, + nil_kill: nil_kill + ) + + result = analyzer.analyze_method( + "analyze!", + [ + { type: :call, receiver: "cfg", method: "blocks", line: 20 }, + { type: :call, receiver: "cfg", method: "sort_by", line: 20 } + ] + ) + + assert_equal "O(N log N)", result[:lower_bound_complexity] + refute_includes result[:unknown_operations], "FunctionCFG#sort_by" + + accessor_result = analyzer.analyze_method( + "blocks_only", + [{ type: :call, receiver: "cfg", method: "blocks", line: 20 }] + ) + + assert_equal "O(1)", accessor_result[:lower_bound_complexity] + refute_includes accessor_result[:unknown_operations], "FunctionCFG#blocks" + end + def test_flat_sequential_loop_evidence_does_not_imply_nested_exponent analyzer = Espalier::BigOAnalyzer.new diff --git a/gems/espalier/test/static_evidence_test.rb b/gems/espalier/test/static_evidence_test.rb index f0c24098b..e2285e523 100644 --- a/gems/espalier/test/static_evidence_test.rb +++ b/gems/espalier/test/static_evidence_test.rb @@ -120,7 +120,15 @@ def test_project_modules_groups_by_owner ], "facts" => { "call_graph_edges" => [], - "state_protocol_records" => [], + "state_protocol_records" => [ + { + "owner" => "ConnectionManager", + "function" => "connect", + "field" => "@active_connections", + "protocol" => "sort_by", + "line" => 23 + } + ], "state_param_origin_records" => [] } } @@ -133,6 +141,12 @@ def test_project_modules_groups_by_owner assert_includes mod[:states], "@active_connections" assert_equal 1, mod[:methods].size assert_equal "connect", mod[:methods].first[:name] + assert_includes mod[:methods].first[:delegations], { + receiver: "@active_connections", + message: "sort_by", + line: 23, + type: :always + } end def test_builds_using_fact_mine_facts_file @@ -172,4 +186,4 @@ def test_builds_using_fact_mine_facts_file def loaded_nil_kill_features $LOADED_FEATURES.grep(%r{/nil[-_]kill/}).sort end -end \ No newline at end of file +end From 3e8ef9cf38358111df20a4e8a7f0023187b6a4f4 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Wed, 1 Jul 2026 04:57:56 +0000 Subject: [PATCH 7/7] Improve Espalier Big-O structural analysis --- gems/espalier/docs/agents/big-o-design.md | 164 +++++ gems/espalier/lib/espalier/aggregator.rb | 113 +++- gems/espalier/lib/espalier/big_o_analyzer.rb | 45 +- .../espalier/lib/espalier/structural_big_o.rb | 487 ++++++++++++++ gems/espalier/test/aggregator_test.rb | 593 ++++++++++++++++++ gems/espalier/test/big_o_test.rb | 34 + 6 files changed, 1411 insertions(+), 25 deletions(-) create mode 100644 gems/espalier/docs/agents/big-o-design.md create mode 100644 gems/espalier/lib/espalier/structural_big_o.rb diff --git a/gems/espalier/docs/agents/big-o-design.md b/gems/espalier/docs/agents/big-o-design.md new file mode 100644 index 000000000..4fae92b83 --- /dev/null +++ b/gems/espalier/docs/agents/big-o-design.md @@ -0,0 +1,164 @@ +# Espalier Big-O Design + +Status: active design note for the Big-O analyzer. + +## Problem + +Espalier originally treated Big-O evidence as a flat set of method calls plus +flat nil-kill loop observations. That avoided false `O(N^19)` reports, but it +also hid real structural multipliers. A method that calls an `O(N)` operation +inside a loop is `O(N^2)` in the relevant dimension, and a graph/dataflow +fixpoint pass can be quadratic even when every individual runtime loop record is +only flat `O(N)`. + +The current `src/` audit found high-confidence under-reported shapes: + +- fixpoint loops over slots or call graph edges; +- graph traversal per graph node; +- aggregate scans such as `states.count { ... }` or `states.all? { ... }` + inside another collection loop; +- shifting array operations such as `Array#insert` inside a body walk; +- known project-local `O(N)` helper calls inside fixpoint loops; +- loops calling known `O(N^2+)` helpers; +- direct nested loop containment such as `O(N^3)` for three nested collection + loops; +- branching recursion such as Fibonacci-style `O(2^N)`; +- recursive branching over a shrinking collection such as permutation search + `O(N!)`. + +Examples include: + +- `AutoUnifier#resolve!`: `while progress` over `@slots.each`. +- `EffectTracker#compute_effects!` and `#compute_can_fail!`: fixpoint + propagation over `function_call_graph`. +- `EffectTracker#check_indirect_reentrancy!`: graph traversal per function. +- `LockHelper#propagate_lock_acquires!`: fixpoint graph propagation. +- `MIRChecker#normalize_guarded_conditional_releases!`: for each name, scan + all branch states. +- `Hoist.hoist_body!`: body loop plus `Array#insert` shifts. + +## Target Model + +Espalier should report a lower bound plus explicit confidence notes: + +- `O(1)`, `O(N)`, `O(N log N)`, `O(N^2)`, etc. are lower-bound estimates. +- Runtime loop lines alone are sequential evidence, not proof of nesting. +- Structural source evidence can multiply when an operation is syntactically + inside a loop body. +- Unknown receiver or callback work should remain warnings rather than being + silently treated as safe. + +## Current Implementation + +The Big-O analyzer consumes operation nodes: + +- `:call`: resolved through known receiver type and stdlib complexity. +- `:loop`: flat runtime loop evidence, contributing a sequential `O(N)`. +- `:structural`: source-span structural hints, such as `O(N^2)` from an + `O(N)` operation inside a loop. + +Complexity comparison uses a small lattice: + +- constant/logarithmic work: `O(1)`, `O(log N)`; +- polynomial work: `O(N)`, `O(N log N)`, `O(N^k)`, + `O(N^k log N)`; +- multi-dimensional polynomial work: currently `O(N * M)`; +- exponential work: `O(2^N)`; +- factorial work: `O(N!)`. + +Sequential work takes the maximum class. Loop containment multiplies the +contained class by `O(N)`. Super-polynomial classes dominate polynomial work: +`O(N!) > O(2^N) > O(N^k)`. + +The structural pass is intentionally conservative. It is not a full language +parser. It uses method spans already present in Espalier static evidence and +looks for high-confidence Ruby shapes inside those spans: + +- fixpoint loops such as `while changed`, `while progress`, or + `loop do ... break unless changed`; +- collection scans inside those fixpoint loops; +- known project-local linear calls inside those fixpoint loops; +- direct block-loop containment, including `O(N^3)` and higher; +- known project-local `O(N^2+)` calls inside loops, propagated through a small + fixed-point pass over methods in the same owner; +- aggregate collection scans inside another collection loop; +- graph traversal inside a per-node graph loop; +- shifting `insert` calls inside explicit loops; +- multiple direct recursive calls with shrinking arguments, reported as + `O(2^N)`; +- recursive calls inside a collection loop that branches over a shrinking + collection, reported as `O(N!)`. + +This catches the concrete under-reporting bugs without reintroducing the old +flat-loop multiplication bug. + +The pass deliberately does not promote every helper call inside every parser, +scanner, or lexer loop. Those loops often consume disjoint token spans, so +`while scanner` plus `read_string` can be amortized `O(N)` even when the helper +has its own loop. Espalier needs normalized loop containment and cursor-progress +facts before it can prove those cases either way. + +## Implementation Plan + +The practical solution is split into layers so Espalier can improve without +returning to the old false `O(N^19)` behavior: + +1. Keep runtime nil-kill loop evidence flat unless FactMine provides a nesting + tree. Repeated loop hits on different lines are sequential evidence. +2. Build source-span loop containment inside each method. Count block-loop + depth directly and emit polynomial structural hints for `O(N^2)`, + `O(N^3)`, and higher. +3. Compute method complexities by fixed point. First use flat calls and runtime + loop evidence, then re-run structural hints until callers can see callee + complexity. This detects a loop calling an `O(N^2)` helper as `O(N^3)`. +4. Detect high-confidence super-polynomial recursion: + - two or more direct recursive branches over shrinking inputs => `O(2^N)`; + - recursive call inside a loop over a shrinking collection => `O(N!)`. +5. Keep ambiguous amortized parser/scanner helper calls as warnings or + future-facts rather than eagerly multiplying them. +6. Move this source scanning into FactMine facts once possible: + loop spans, call-site spans, call containment, cursor advancement, + recursive-call argument deltas, and collection-shrinking operations. + +## Future FactMine Boundary + +Long-term, structural Big-O evidence should be mined by FactMine as normalized +facts: + +- loop/iterator spans; +- call sites with receiver and callee identity; +- callee containment within loop spans; +- fixpoint loop markers such as `while changed`, `while progress`, and + `loop do ... break unless changed`; +- graph-work classifications for traversals over adjacency maps. +- recursion facts: direct recursive calls, argument deltas, recursive calls + inside collection loops, and shrinking collection expressions. + +Espalier should then consume those facts instead of scanning source text. The +current source-span pass is an interim implementation to make Big-O reporting +actionable now. + +## Known Limitations + +- It can under-report parser loops that call helpers whose own loop consumes + the same token stream in non-amortized ways. +- It can over-report when aggregate scan work is bounded by a constant or a + small unrelated dimension. +- It does not yet prove graph density, so `O(V * (V + E))` is reported as + `O(N^2)` unless richer graph facts are available. +- It reports all polynomial nesting in the same `N` dimension until dimension + facts can separate `N`, `M`, tokens, slots, states, and graph edges. +- Exponential and factorial recursion detection is intentionally heuristic; it + requires direct recursion patterns and does not yet infer mutual recursion. +- It does not model callbacks or yielded blocks precisely. + +## Roadmap + +1. Keep runtime loop evidence flat and sequential. +2. Add structural hints for high-confidence loop-contained linear work. +3. Add direct polynomial loop containment and fixed-point helper propagation. +4. Add explicit recursion classification for `O(2^N)` and `O(N!)`. +5. Report structural warnings explaining why a function was promoted. +6. Move loop/call containment mining into FactMine once normalized facts exist. +7. Add dimension-aware graph notation when Espalier can distinguish `V`, `E`, + states, slots, tokens, and body size. diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index c65f26802..bed5c055f 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -2,6 +2,7 @@ require "yaml" require_relative "big_o_analyzer" +require_relative "structural_big_o" module Espalier # Coalescing agent that imports the static skeleton maps and merges secondary @@ -27,6 +28,10 @@ def aggregate(modules) language: :ruby, nil_kill: @nil_kill_evidence ) + method_complexities = structural_method_complexities(modules) + structural_big_o = Espalier::StructuralBigO.new( + method_complexities: method_complexities + ) manifest = modules.map do |mod| internal_edges = internal_edges_for(mod) @@ -97,21 +102,9 @@ def aggregate(modules) quality[:coverage_gap] = file_risk[:coverage_gap] if file_risk[:coverage_gap] end - # Run Big-O analysis - meth_line, end_line, end_inclusive = method_line_bounds(mod[:methods], m) file = mod[:file] - - ast_nodes = Array(m[:delegations]).map do |d| - { type: :call, receiver: d[:receiver], method: d[:message], line: d[:line] || m[:line] || 0 } - end - - if file && @nil_kill_loops && @nil_kill_loops[file] - @nil_kill_loops[file].each do |line, calls| - if line_in_method_bounds?(line, meth_line, end_line, end_inclusive) && calls > 0 - ast_nodes << { type: :loop, line: line, calls: calls } - end - end - end + ast_nodes = big_o_nodes_for(mod, m) + ast_nodes.concat(structural_big_o.hints_for(file, m, mod[:name])) analyzer.instance_variable_set(:@class_name, mod[:name]) analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) @@ -213,6 +206,98 @@ def line_in_method_bounds?(line, start_line, end_line, end_inclusive) end_inclusive ? line <= end_line : line < end_line end + def preliminary_method_complexities(modules) + analyzer = Espalier::BigOAnalyzer.new( + language: :ruby, + nil_kill: @nil_kill_evidence + ) + modules.each_with_object(Hash.new { |h, k| h[k] = {} }) do |mod, complexities| + Array(mod[:methods]).each do |method| + analyzer.instance_variable_set(:@class_name, mod[:name]) + analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) + key = "#{mod[:name]}##{method[:name]}" + sig = @nil_kill_data[key] || method[:signature] + result = analyzer.analyze_method( + key, + big_o_nodes_for(mod, method), + local_types: local_types_for_signature(sig) + ) + complexities[mod[:name]][method[:name].to_s] = result[:lower_bound_complexity] + end + end + end + + def structural_method_complexities(modules) + complexities = preliminary_method_complexities(modules) + analyzer = Espalier::BigOAnalyzer.new( + language: :ruby, + nil_kill: @nil_kill_evidence + ) + + 8.times do + changed = false + structural_big_o = Espalier::StructuralBigO.new(method_complexities: complexities) + modules.each do |mod| + Array(mod[:methods]).each do |method| + analyzer.instance_variable_set(:@class_name, mod[:name]) + analyzer.instance_variable_set(:@ivar_types, mod[:ivar_types] || {}) + key = "#{mod[:name]}##{method[:name]}" + sig = @nil_kill_data[key] || method[:signature] + nodes = big_o_nodes_for(mod, method) + nodes.concat(structural_big_o.hints_for(mod[:file], method, mod[:name])) + result = analyzer.analyze_method(key, nodes, local_types: local_types_for_signature(sig)) + current = complexities[mod[:name]][method[:name].to_s] || "O(1)" + next unless complexity_rank(result[:lower_bound_complexity]) > complexity_rank(current) + + complexities[mod[:name]][method[:name].to_s] = result[:lower_bound_complexity] + changed = true + end + end + break unless changed + end + + complexities + end + + def complexity_rank(complexity) + case complexity.to_s + when "O(1)" then 1 + when "O(log N)" then 2 + when "O(N)" then 10 + when "O(N log N)" then 11 + when "O(N * M)" then 14 + when /\AO\(N\^(\d+)( log N)?\)\z/ + 10 + ($1.to_i * 2) + ($2 ? 1 : 0) + when "O(2^N)" then 100 + when "O(N!)" then 200 + else + 1 + end + end + + def big_o_nodes_for(mod, method) + nodes = Array(method[:delegations]).map do |delegation| + { + type: :call, + receiver: delegation[:receiver], + method: delegation[:message], + line: delegation[:line] || method[:line] || 0 + } + end + + meth_line, end_line, end_inclusive = method_line_bounds(mod[:methods], method) + file = mod[:file] + if file && @nil_kill_loops && @nil_kill_loops[file] + @nil_kill_loops[file].each do |line, calls| + if line_in_method_bounds?(line, meth_line, end_line, end_inclusive) && calls > 0 + nodes << { type: :loop, line: line, calls: calls } + end + end + end + + nodes + end + def local_types_for_signature(signature) params_source = signature_params_source(signature.to_s) return {} unless params_source diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index 14857f04f..3b29c5dfc 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -92,6 +92,10 @@ def analyze_method(method_name, ast_nodes, local_types: nil) # nesting tree, multiple observed loops in one method are a sequential # lower bound, not proof of nested O(N^k) behavior. complexity = max_complexity(complexity, "O(N)") + elsif node[:type] == :structural + structural_complexity = node[:complexity].to_s + complexity = max_complexity(complexity, structural_complexity) + warnings << structural_warning(node) if structural_complexity != "O(1)" elsif node[:type] == :callback || node[:type] == :yield warnings << "Function pointer / callback executed at line #{node[:line]}. This could execute arbitrary O(N^x) code, meaning our calculation is strictly a LOWER BOUND." end @@ -232,23 +236,42 @@ def flattened_chain_complexity(node, ast_nodes) end def max_complexity(current, added) - rank = { - "O(1)" => 1, - "O(log N)" => 2, - "O(N)" => 3, - "O(N log N)" => 4, - "O(N * M)" => 5, - "O(N^2)" => 6 - } - - r1 = rank[current] || 1 - r2 = rank[added] || 1 + r1 = complexity_rank(current) + r2 = complexity_rank(added) r1 > r2 ? current : added end + def structural_warning(node) + parts = ["Structural Big-O hint at line #{node[:line]}"] + parts << node[:reason].to_s unless node[:reason].to_s.empty? + parts << "`#{node[:operation]}`" unless node[:operation].to_s.empty? + parts << "=> #{node[:complexity]}" + parts.join(": ") + end + + def complexity_rank(complexity) + return 1 if complexity.nil? + + case complexity.to_s + when "O(1)" then 1 + when "O(log N)" then 2 + when "O(N)" then 10 + when "O(N log N)" then 11 + when "O(N * M)" then 14 + when /\AO\(N\^(\d+)( log N)?\)\z/ + 10 + ($1.to_i * 2) + ($2 ? 1 : 0) + when "O(2^N)" then 100 + when "O(N!)" then 200 + else + 1 + end + end + def multiply_complexity(current, multiplier) return multiplier if current == "O(1)" return current if multiplier == "O(1)" + return "O(N!)" if current == "O(N!)" || multiplier == "O(N!)" + return "O(2^N)" if current == "O(2^N)" || multiplier == "O(2^N)" c_pow = 0 c_log = false diff --git a/gems/espalier/lib/espalier/structural_big_o.rb b/gems/espalier/lib/espalier/structural_big_o.rb new file mode 100644 index 000000000..87ded228d --- /dev/null +++ b/gems/espalier/lib/espalier/structural_big_o.rb @@ -0,0 +1,487 @@ +# frozen_string_literal: true + +module Espalier + class StructuralBigO + FIXPOINT_NAMES = %w[changed progress dirty updated again].freeze + FIXPOINT_COLLECTION_METHODS = %w[ + each each_key each_value each_with_index any? all? count + ].freeze + AGGREGATE_SCAN_METHODS = %w[all? any? count].freeze + + LOOP_START = / + \b(?:while|until)\b| + \bloop\s+do\b| + \.(?:each|each_key|each_value|each_with_index|map|map!|select|reject|filter|filter_map|flat_map|sort_by|reverse_each)\b + /x.freeze + + def initialize(source_cache: {}, method_complexities: {}) + @source_cache = source_cache + @method_complexities = method_complexities.transform_values do |methods| + methods.select { |_name, complexity| complexity_rank(complexity) >= complexity_rank("O(N)") } + end + end + + def hints_for(file, method, owner) + return [] unless file && method[:span].is_a?(Array) + lines = source_lines(file) + return [] if lines.empty? + + start_line = method[:line].to_i + end_line = method[:span][2].to_i + return [] if start_line <= 0 || end_line < start_line + + slice = lines[(start_line - 1)..(end_line - 1)] || [] + loops = loop_ranges(slice, start_line) + hints = [] + hints.concat(polynomial_loop_hints(lines, loops)) + hints.concat(recursion_hints(lines, method, start_line, end_line)) + loops.each do |loop_range| + if fixpoint_loop?(lines, loop_range) + hints.concat(fixpoint_collection_hints(lines, loop_range)) + hints.concat(project_call_hints(lines, loop_range, owner)) + end + hints.concat(expensive_project_call_hints(lines, loop_range, owner, method[:name].to_s)) + hints.concat(aggregate_scan_hints(lines, loop_range)) + hints.concat(shifting_insert_hints(lines, loop_range)) + hints.concat(graph_traversal_hints(lines, loops, loop_range)) + end + hints.uniq { |hint| [hint[:line], hint[:reason], hint[:operation]] } + end + + private + + def source_lines(file) + @source_cache[file] ||= File.file?(file) ? File.readlines(file) : [] + end + + def loop_ranges(slice, start_line) + ranges = [] + heredoc_end = nil + slice.each_with_index do |line, offset| + stripped = line.strip + if heredoc_end + heredoc_end = nil if stripped == heredoc_end + next + end + if (match = line.match(/<<[-~]?([A-Z][A-Z0-9_]*)/)) + heredoc_end = match[1] + end + next unless loop_start?(line) + + absolute_line = start_line + offset + ranges << { + start: absolute_line, + finish: block_finish(slice, offset, start_line), + inline: inline_loop?(line), + text: line.strip + } + end + ranges + end + + def loop_start?(line) + stripped = line.strip + return false if stripped.start_with?("#", '"', "'", "%Q", "%q") + + line.match?(LOOP_START) + end + + def block_finish(slice, start_offset, start_line) + return start_line + start_offset if inline_loop?(slice[start_offset]) + return brace_block_finish(slice, start_offset, start_line) if opens_brace_block?(slice[start_offset]) + + start_indent = indent_width(slice[start_offset]) + ((start_offset + 1)...slice.length).each do |idx| + line = slice[idx] + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + if stripped == "end" && indent_width(line) <= start_indent + return start_line + idx + end + end + start_line + slice.length - 1 + end + + def opens_brace_block?(line) + line.include?("{") && !balanced_braces?(line) + end + + def brace_block_finish(slice, start_offset, start_line) + depth = 0 + (start_offset...slice.length).each do |idx| + slice[idx].each_char do |char| + depth += 1 if char == "{" + depth -= 1 if char == "}" + end + return start_line + idx if depth <= 0 + end + start_line + slice.length - 1 + end + + def inline_loop?(line) + stripped = line.strip + return true if stripped.match?(/\A.+\b(?:while|until)\b.+\z/) && !stripped.start_with?("while ", "until ") + return true if stripped.include?("{") && balanced_braces?(stripped) + return true if collection_method_call?(stripped) && !stripped.match?(/\bdo\b/) && !stripped.include?("{") + + false + end + + def collection_method_call?(line) + line.match?(/\.(?:each|each_key|each_value|each_with_index|map|map!|select|reject|filter|filter_map|flat_map|sort_by|reverse_each)\b/) + end + + def balanced_braces?(line) + depth = 0 + line.each_char do |char| + depth += 1 if char == "{" + depth -= 1 if char == "}" + end + depth <= 0 + end + + def fixpoint_loop?(lines, loop_range) + text = loop_range[:text] + return true if text.match?(/\bwhile\s+(?:#{FIXPOINT_NAMES.join("|")})\b/) + + body = body_text(lines, loop_range) + return false unless text.match?(/\bloop\s+do\b/) || body.match?(/\bwhile\b/) + + body.match?(/\b(?:#{FIXPOINT_NAMES.join("|")})\s*=\s*(?:false|true)\b/) && + body.match?(/\bbreak\s+unless\s+(?:#{FIXPOINT_NAMES.join("|")})\b/) + end + + def polynomial_loop_hints(lines, loops) + block_loops = loops.reject { |loop_range| loop_range[:inline] } + block_loops.filter_map do |loop_range| + depth = loop_depth(block_loops, loop_range) + next if depth < 2 + next if containing_fixpoint_loop?(lines, block_loops, loop_range) + + structural_hint( + line: loop_range[:start], + complexity: polynomial_complexity(depth), + operation: loop_range[:text], + reason: "nested loop containment depth #{depth}", + detail: lines[loop_range[:start] - 1].to_s.strip + ) + end + end + + def loop_depth(loops, loop_range) + loops.count do |candidate| + candidate[:start] <= loop_range[:start] && candidate[:finish] >= loop_range[:finish] + end + end + + def containing_fixpoint_loop?(lines, loops, loop_range) + loops.any? do |candidate| + next false if candidate.equal?(loop_range) + next false unless candidate[:start] < loop_range[:start] && candidate[:finish] >= loop_range[:finish] + + fixpoint_loop?(lines, candidate) + end + end + + def recursion_hints(lines, method, start_line, end_line) + method_name = method[:name].to_s.sub(/\Aself\./, "") + return [] if method_name.empty? + + recursive_calls = recursive_call_lines(lines, method_name, start_line, end_line) + return [] if recursive_calls.empty? + + if factorial_recursion?(lines, recursive_calls, start_line, end_line) + return [ + structural_hint( + line: recursive_calls.first, + complexity: "O(N!)", + operation: method_name, + reason: "recursive branching over shrinking collection", + detail: lines[recursive_calls.first - 1].to_s.strip + ) + ] + end + + return [] unless exponential_recursion?(lines, recursive_calls) + + [ + structural_hint( + line: recursive_calls.first, + complexity: "O(2^N)", + operation: method_name, + reason: "multiple recursive branches", + detail: lines[recursive_calls.first - 1].to_s.strip + ) + ] + end + + def recursive_call_lines(lines, method_name, start_line, end_line) + escaped = Regexp.escape(method_name) + ((start_line + 1)..end_line).flat_map do |line_no| + line = lines[line_no - 1].to_s + stripped = line.strip + next [] if stripped.start_with?("#", "def ") + + line.scan(/(?:^|[^\.\w])#{escaped}\s*\(/).map { line_no } + end + end + + def factorial_recursion?(lines, recursive_calls, start_line, end_line) + slice = lines[(start_line - 1)..(end_line - 1)] || [] + loops = loop_ranges(slice, start_line).reject { |loop_range| loop_range[:inline] } + return false if loops.empty? + + loops.any? do |loop_range| + recursive_calls.any? { |line_no| line_no > loop_range[:start] && line_no <= loop_range[:finish] } && + shrinking_collection?(body_text(lines, loop_range)) + end + end + + def shrinking_collection?(body) + body.match?(/\b(?:remaining|rest|tail|without|others)\b/) || + body.match?(/\.(?:reject|drop|delete|delete_at|slice)\b/) || + body.match?(/\s-\s*\[/) + end + + def exponential_recursion?(lines, recursive_calls) + return false if recursive_calls.length < 2 + + recursive_calls.tally.any? do |line_no, count| + next false if count < 2 + + lines[line_no - 1].to_s.match?(/-\s*(?:1|2)\b|\b(?:rest|tail|remaining)\b/) + end + end + + def fixpoint_collection_hints(lines, loop_range) + body_lines(lines, loop_range).filter_map do |line_no, line| + method_name = fixpoint_collection_method_on_line(line) + next unless method_name + + structural_hint( + line: line_no, + operation: method_name, + reason: "linear collection scan inside fixpoint loop", + detail: line.strip + ) + end + end + + def fixpoint_collection_method_on_line(line) + return nil if line.strip.start_with?("#") + + FIXPOINT_COLLECTION_METHODS.find do |method_name| + escaped = Regexp.escape(method_name) + line.match?(/(?:\.|\b)#{escaped}\b\s*(?:\{|do|\()/) + end + end + + def project_call_hints(lines, loop_range, owner) + return [] unless owner + + linear_methods = @method_complexities.fetch(owner, {}) + return [] if linear_methods.empty? + + body_lines(lines, loop_range).filter_map do |line_no, line| + method_name = linear_methods.keys.find { |name| bare_method_call?(line, name) } + next unless method_name + + structural_hint( + line: line_no, + operation: method_name, + reason: "known linear project call inside fixpoint loop", + detail: line.strip + ) + end + end + + def expensive_project_call_hints(lines, loop_range, owner, current_method_name) + return [] unless owner + + current_method_name = current_method_name.sub(/\Aself\./, "") + expensive_methods = @method_complexities.fetch(owner, {}).select do |_name, complexity| + complexity_rank(complexity) >= complexity_rank("O(N^2)") + end + return [] if expensive_methods.empty? + + body_lines(lines, loop_range).filter_map do |line_no, line| + method_name, method_complexity = expensive_methods.find do |name, _| + next false if name.sub(/\Aself\./, "") == current_method_name + + bare_method_call?(line, name) + end + next unless method_name && method_complexity + + structural_hint( + line: line_no, + complexity: multiply_complexity("O(N)", method_complexity), + operation: method_name, + reason: "known expensive project call inside loop", + detail: line.strip + ) + end + end + + def bare_method_call?(line, method_name) + return false if method_name.start_with?("self.") + return false if line.strip.start_with?("#") + + escaped = Regexp.escape(method_name) + line.match?(/(?:^|[^\.\w])#{escaped}\b\s*(?:\(|\{|do|\z)/) + end + + def aggregate_scan_hints(lines, loop_range) + return [] unless collection_iterator?(loop_range[:text]) + + body_lines(lines, loop_range).filter_map do |line_no, line| + method_name = aggregate_scan_on_line(line) + next unless method_name + + structural_hint( + line: line_no, + operation: method_name, + reason: "aggregate collection scan inside collection loop", + detail: line.strip + ) + end + end + + def aggregate_scan_on_line(line) + stripped = line.strip + return nil if stripped.start_with?("#") + + AGGREGATE_SCAN_METHODS.find do |method_name| + escaped = Regexp.escape(method_name) + stripped.match?(/\.#{escaped}\b\s*(?:\{|do)/) + end + end + + def shifting_insert_hints(lines, loop_range) + return [] unless explicit_loop?(loop_range[:text]) + + body_lines(lines, loop_range).filter_map do |line_no, line| + next unless line.match?(/\.insert\s*\(/) + + structural_hint( + line: line_no, + operation: "insert", + reason: "array insert inside loop can shift remaining elements", + detail: line.strip + ) + end + end + + def graph_traversal_hints(lines, loops, outer_range) + return [] unless collection_iterator?(outer_range[:text]) + return [] unless graph_source?(outer_range[:text]) + + loops.filter_map do |inner| + next if inner.equal?(outer_range) + next unless inner[:start] > outer_range[:start] && inner[:start] <= outer_range[:finish] + next unless queue_loop?(inner[:text]) + next unless body_text(lines, inner).match?(/function_call_graph\s*\[/) + + structural_hint( + line: inner[:start], + operation: inner[:text], + reason: "graph traversal inside per-node graph loop", + detail: lines[inner[:start] - 1].to_s.strip + ) + end + end + + def collection_iterator?(line) + line.match?(/\.(?:each|each_key|each_value|each_with_index|map|map!|select|reject|filter|filter_map|flat_map|sort_by|reverse_each)\b\s*(?:\{|do)/) + end + + def explicit_loop?(line) + line.match?(/\A\s*(?:while|until)\b|\bloop\s+do\b/) + end + + def graph_source?(line) + line.match?(/\b(?:function_call_graph|call_graph|cfg|graph)\b/) + end + + def queue_loop?(line) + line.match?(/\b(?:while|until)\s+[^#]*(?:queue|worklist|stack)\./) + end + + def body_lines(lines, loop_range) + ((loop_range[:start] + 1)..loop_range[:finish]).map do |line_no| + [line_no, lines[line_no - 1].to_s] + end + end + + def body_text(lines, loop_range) + body_lines(lines, loop_range).map { |_, line| line }.join + end + + def indent_width(line) + line[/\A\s*/].to_s.length + end + + def structural_hint(line:, operation:, reason:, detail:, complexity: "O(N^2)") + { + type: :structural, + line: line, + complexity: complexity, + operation: operation, + reason: reason, + detail: detail + } + end + + def polynomial_complexity(depth) + depth == 1 ? "O(N)" : "O(N^#{depth})" + end + + def complexity_rank(complexity) + case complexity.to_s + when "O(1)" then 1 + when "O(log N)" then 2 + when "O(N)" then 10 + when "O(N log N)" then 11 + when "O(N * M)" then 14 + when /\AO\(N\^(\d+)( log N)?\)\z/ + 10 + ($1.to_i * 2) + ($2 ? 1 : 0) + when "O(2^N)" then 100 + when "O(N!)" then 200 + else + 1 + end + end + + def multiply_complexity(current, multiplier) + return multiplier if current == "O(1)" + return current if multiplier == "O(1)" + return "O(N!)" if current == "O(N!)" || multiplier == "O(N!)" + return "O(2^N)" if current == "O(2^N)" || multiplier == "O(2^N)" + + current_power, current_log = polynomial_parts(current) + multiplier_power, multiplier_log = polynomial_parts(multiplier) + power = current_power + multiplier_power + with_log = current_log || multiplier_log + + if power.zero? + with_log ? "O(log N)" : "O(1)" + elsif power == 1 + with_log ? "O(N log N)" : "O(N)" + else + with_log ? "O(N^#{power} log N)" : "O(N^#{power})" + end + end + + def polynomial_parts(complexity) + case complexity.to_s + when "O(N)" then [1, false] + when "O(N log N)" then [1, true] + when "O(log N)" then [0, true] + when /\AO\(N\^(\d+)( log N)?\)\z/ + [$1.to_i, !$2.nil?] + else + [0, false] + end + end + end +end diff --git a/gems/espalier/test/aggregator_test.rb b/gems/espalier/test/aggregator_test.rb index 19562dbcd..77a14cedc 100644 --- a/gems/espalier/test/aggregator_test.rb +++ b/gems/espalier/test/aggregator_test.rb @@ -248,4 +248,597 @@ def test_big_o_uses_signature_param_types refute fn[:quality_metrics].key?(:big_o_unknowns) end + def test_big_o_detects_fixpoint_loop_over_collection + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "worker.rb") + File.write(file, <<~RUBY) + class Worker + def resolve! + progress = true + while progress + progress = false + @slots.each do |id, slot| + progress = true if slot + end + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Worker", + file: file, + states: Set.new(["@slots"]), + ivar_types: { "@slots" => "Hash" }, + methods: [ + { + name: "resolve!", + signature: "def resolve!", + parameters: [], + visibility: :public, + line: 2, + span: [2, 2, 9, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N^2)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("linear collection scan inside fixpoint loop") } + end + end + + def test_big_o_detects_linear_project_call_inside_loop + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "worker.rb") + File.write(file, <<~RUBY) + class Worker + def helper + while more? + advance + end + end + + def driver + while progress + helper + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Worker", + file: file, + states: Set.new, + methods: [ + { + name: "helper", + signature: "def helper", + parameters: [], + visibility: :public, + line: 2, + span: [2, 2, 6, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + }, + { + name: "driver", + signature: "def driver", + parameters: [], + visibility: :public, + line: 8, + span: [8, 2, 12, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new( + nil_kill_loops: { + file => { 3 => 5 } + } + ).aggregate(modules) + driver = manifest.first[:functions].find { |fn| fn[:name] == "driver" } + + assert_equal "O(N^2)", driver[:quality_metrics][:big_o] + assert driver[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("known linear project call inside fixpoint loop") } + end + end + + def test_big_o_does_not_promote_scanner_helper_inside_plain_loop + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "lexer.rb") + File.write(file, <<~RUBY) + class Lexer + def read_interpolated_string + while more? + advance + end + end + + def tokenize + while more? + read_interpolated_string + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Lexer", + file: file, + states: Set.new, + methods: [ + { + name: "read_interpolated_string", + signature: "def read_interpolated_string", + parameters: [], + visibility: :public, + line: 2, + span: [2, 2, 6, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + }, + { + name: "tokenize", + signature: "def tokenize", + parameters: [], + visibility: :public, + line: 8, + span: [8, 2, 12, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new( + nil_kill_loops: { + file => { 3 => 5, 9 => 5 } + } + ).aggregate(modules) + tokenize = manifest.first[:functions].find { |fn| fn[:name] == "tokenize" } + + assert_equal "O(N)", tokenize[:quality_metrics][:big_o] + refute Array(tokenize[:quality_metrics][:big_o_warnings]).any? { |warning| warning.include?("known linear project call") } + end + end + + def test_big_o_detects_aggregate_scan_inside_collection_loop + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "checker.rb") + File.write(file, <<~RUBY) + class Checker + def normalize(states) + names.each do |name| + released_count = states.count { |state| state.released.include?(name) } + next unless states.all? { |state| state.guarded.include?(name) } + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Checker", + file: file, + states: Set.new, + methods: [ + { + name: "normalize", + signature: "def normalize", + parameters: ["states"], + visibility: :public, + line: 2, + span: [2, 2, 7, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N^2)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("aggregate collection scan inside collection loop") } + end + end + + def test_big_o_detects_insert_inside_loop_shift + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "hoist.rb") + File.write(file, <<~RUBY) + class Hoist + def self.hoist_body!(body) + i = 0 + while i < body.length + hoists.each_with_index { |decl, j| body.insert(i + j, decl) } + i += 1 + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Hoist", + file: file, + states: Set.new, + methods: [ + { + name: "self.hoist_body!", + signature: "def self.hoist_body!", + parameters: ["body"], + visibility: :public, + line: 2, + span: [2, 2, 8, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N^2)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("array insert inside loop") } + end + end + + def test_big_o_detects_triple_nested_loop + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "cubic.rb") + File.write(file, <<~RUBY) + class Cubic + def triple(xs, ys, zs) + xs.each do |x| + ys.each do |y| + zs.each do |z| + use(x, y, z) + end + end + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Cubic", + file: file, + states: Set.new, + methods: [ + { + name: "triple", + signature: "def triple", + parameters: %w[xs ys zs], + visibility: :public, + line: 2, + span: [2, 2, 10, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N^3)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("nested loop containment depth 3") } + end + end + + def test_big_o_propagates_quadratic_helper_inside_loop + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "driver.rb") + File.write(file, <<~RUBY) + class Driver + def pairwise(xs, ys) + xs.each do |x| + ys.each do |y| + use(x, y) + end + end + end + + def run(groups) + groups.each do |group| + pairwise(group.left, group.right) + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Driver", + file: file, + states: Set.new, + methods: [ + { + name: "pairwise", + signature: "def pairwise", + parameters: %w[xs ys], + visibility: :public, + line: 2, + span: [2, 2, 8, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + }, + { + name: "run", + signature: "def run", + parameters: ["groups"], + visibility: :public, + line: 10, + span: [10, 2, 14, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + pairwise = manifest.first[:functions].find { |fn| fn[:name] == "pairwise" } + run = manifest.first[:functions].find { |fn| fn[:name] == "run" } + + assert_equal "O(N^2)", pairwise[:quality_metrics][:big_o] + assert_equal "O(N^3)", run[:quality_metrics][:big_o] + assert run[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("known expensive project call inside loop") } + end + end + + def test_big_o_detects_exponential_branching_recursion + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "fib.rb") + File.write(file, <<~RUBY) + class Fib + def fib(n) + return n if n <= 1 + fib(n - 1) + fib(n - 2) + end + end + RUBY + + modules = [ + { + type: :class, + name: "Fib", + file: file, + states: Set.new, + methods: [ + { + name: "fib", + signature: "def fib", + parameters: ["n"], + visibility: :public, + line: 2, + span: [2, 2, 5, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(2^N)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("multiple recursive branches") } + end + end + + def test_big_o_does_not_treat_linear_case_recursion_as_exponential + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "pipeline.rb") + File.write(file, <<~RUBY) + class Pipeline + def build(stages) + remaining = stages[1..-1] + case stages.first + when :where + build(remaining) + when :select + build(remaining) + else + [] + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Pipeline", + file: file, + states: Set.new, + methods: [ + { + name: "build", + signature: "def build", + parameters: ["stages"], + visibility: :public, + line: 2, + span: [2, 2, 12, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + refute_equal "O(2^N)", fn[:quality_metrics][:big_o] + refute Array(fn[:quality_metrics][:big_o_warnings]).any? { |warning| warning.include?("multiple recursive branches") } + end + end + + def test_big_o_detects_factorial_recursive_branching + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "permuter.rb") + File.write(file, <<~RUBY) + class Permuter + def permute(items) + return [[]] if items.empty? + items.each do |item| + remaining = items.reject { |candidate| candidate == item } + permute(remaining) + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "Permuter", + file: file, + states: Set.new, + methods: [ + { + name: "permute", + signature: "def permute", + parameters: ["items"], + visibility: :public, + line: 2, + span: [2, 2, 8, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + assert_equal "O(N!)", fn[:quality_metrics][:big_o] + assert fn[:quality_metrics][:big_o_warnings].any? { |warning| warning.include?("recursive branching over shrinking collection") } + end + end + + def test_big_o_does_not_build_fake_depth_from_sequential_single_line_maps + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "catch_builder.rb") + File.write(file, <<~RUBY) + class CatchBuilder + def build(clauses) + clauses.each do |clause| + kinds = clause.kinds.map(&:to_s) + types = clause.types.map(&:to_s) + filters = clause.filters.map(&:to_s) + use(kinds, types, filters) + end + end + end + RUBY + + modules = [ + { + type: :class, + name: "CatchBuilder", + file: file, + states: Set.new, + methods: [ + { + name: "build", + signature: "def build", + parameters: ["clauses"], + visibility: :public, + line: 2, + span: [2, 2, 10, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + refute_equal "O(N^4)", fn[:quality_metrics][:big_o] + refute Array(fn[:quality_metrics][:big_o_warnings]).any? { |warning| warning.include?("nested loop containment depth 4") } + end + end + + def test_big_o_does_not_build_fake_depth_from_sequential_brace_blocks + Dir.mktmpdir("espalier-big-o") do |dir| + file = File.join(dir, "emitter.rb") + File.write(file, <<~RUBY) + class Emitter + def emit(entries) + first = entries.map { |entry| + emit_one(entry) + }.join("\\n") + second = entries.map { |entry| + emit_two(entry) + }.join("\\n") + third = entries.map { |entry| + emit_three(entry) + }.join("\\n") + [first, second, third].join("\\n") + end + end + RUBY + + modules = [ + { + type: :class, + name: "Emitter", + file: file, + states: Set.new, + methods: [ + { + name: "emit", + signature: "def emit", + parameters: ["entries"], + visibility: :public, + line: 2, + span: [2, 2, 14, 5], + effects: { reads: Set.new, writes: Set.new }, + delegations: [] + } + ] + } + ] + + manifest = Espalier::Aggregator.new.aggregate(modules) + fn = manifest.first[:functions].first + + refute_equal "O(N^3)", fn[:quality_metrics][:big_o] + refute Array(fn[:quality_metrics][:big_o_warnings]).any? { |warning| warning.include?("nested loop containment depth 3") } + end + end + end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb index 668e796c0..93a051f77 100644 --- a/gems/espalier/test/big_o_test.rb +++ b/gems/espalier/test/big_o_test.rb @@ -13,9 +13,12 @@ def test_multiply_complexity assert_equal "O(N)", analyzer.send(:multiply_complexity, "O(N)", "O(1)") assert_equal "O(N^2)", analyzer.send(:multiply_complexity, "O(N)", "O(N)") assert_equal "O(N^3)", analyzer.send(:multiply_complexity, "O(N^2)", "O(N)") + assert_equal "O(N^5)", analyzer.send(:multiply_complexity, "O(N^2)", "O(N^3)") assert_equal "O(N^2 log N)", analyzer.send(:multiply_complexity, "O(N log N)", "O(N)") assert_equal "O(N^3 log N)", analyzer.send(:multiply_complexity, "O(N^2 log N)", "O(N)") assert_equal "O(log N)", analyzer.send(:multiply_complexity, "O(log N)", "O(1)") + assert_equal "O(2^N)", analyzer.send(:multiply_complexity, "O(2^N)", "O(N)") + assert_equal "O(N!)", analyzer.send(:multiply_complexity, "O(N!)", "O(N^3)") end def test_resolve_type_with_nil_kill_evidence @@ -171,4 +174,35 @@ def test_flat_sequential_loop_evidence_does_not_imply_nested_exponent assert_equal "O(N)", result[:lower_bound_complexity] end + + def test_structural_hint_promotes_loop_contained_linear_work + analyzer = Espalier::BigOAnalyzer.new + + result = analyzer.analyze_method("fixpoint", [ + { type: :loop, line: 10 }, + { + type: :structural, + line: 12, + complexity: "O(N^2)", + operation: "items.each", + reason: "linear stdlib call inside loop" + } + ]) + + assert_equal "O(N^2)", result[:lower_bound_complexity] + assert_match(/Structural Big-O hint/, result[:warnings].first) + end + + def test_structural_hint_ranks_super_polynomial_work + analyzer = Espalier::BigOAnalyzer.new + + result = analyzer.analyze_method("search", [ + { type: :structural, line: 10, complexity: "O(N^4)", operation: "nested", reason: "nested loop containment depth 4" }, + { type: :structural, line: 20, complexity: "O(2^N)", operation: "fib", reason: "multiple recursive branches" }, + { type: :structural, line: 30, complexity: "O(N!)", operation: "permute", reason: "recursive branching over shrinking collection" } + ]) + + assert_equal "O(N!)", result[:lower_bound_complexity] + assert_equal 3, result[:warnings].size + end end