code-towel 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ AST Pretty Printer
17
+
18
+ A utility for displaying Python AST structures in a human-readable format.
19
+ """
20
+
21
+ import ast
22
+ from typing import Any, Optional, List
23
+
24
+
25
+ class ASTPrettyPrinter:
26
+ """Pretty-print Python AST nodes in a readable tree format."""
27
+
28
+ def __init__(self, indent_size: int = 2, show_line_numbers: bool = False):
29
+ self.indent_size = indent_size
30
+ self.show_line_numbers = show_line_numbers
31
+
32
+ def format(self, node: ast.AST, indent: int = 0) -> str:
33
+ """Format an AST node as a pretty-printed string."""
34
+ lines: List[str] = []
35
+ self._format_node(node, indent, lines)
36
+ return "\n".join(lines)
37
+
38
+ def _format_node(self, node: Any, indent: int, lines: List[str]) -> None:
39
+ """Recursively format an AST node."""
40
+ prefix = " " * indent
41
+
42
+ if isinstance(node, ast.AST):
43
+ # Format the node type
44
+ node_name = node.__class__.__name__
45
+
46
+ # Add line number if available and requested
47
+ if self.show_line_numbers and hasattr(node, "lineno"):
48
+ node_name = f"{node_name}@L{node.lineno}"
49
+
50
+ lines.append(f"{prefix}{node_name}(")
51
+
52
+ # Get all fields for this node
53
+ for field_name, field_value in ast.iter_fields(node):
54
+ field_prefix = " " * (indent + self.indent_size)
55
+
56
+ if field_value is None:
57
+ continue
58
+ elif isinstance(field_value, list):
59
+ if not field_value:
60
+ continue
61
+ lines.append(f"{field_prefix}{field_name}=[")
62
+ for item in field_value:
63
+ self._format_node(item, indent + self.indent_size * 2, lines)
64
+ lines.append(f"{field_prefix}]")
65
+ elif isinstance(field_value, ast.AST):
66
+ lines.append(f"{field_prefix}{field_name}=")
67
+ self._format_node(field_value, indent + self.indent_size * 2, lines)
68
+ else:
69
+ # Primitive value (string, int, etc.)
70
+ lines.append(f"{field_prefix}{field_name}={self._format_value(field_value)}")
71
+
72
+ lines.append(f"{prefix})")
73
+
74
+ elif isinstance(node, list):
75
+ lines.append(f"{prefix}[")
76
+ for item in node:
77
+ self._format_node(item, indent + self.indent_size, lines)
78
+ lines.append(f"{prefix}]")
79
+
80
+ else:
81
+ # Primitive value
82
+ lines.append(f"{prefix}{self._format_value(node)}")
83
+
84
+ def _format_value(self, value: Any) -> str:
85
+ """Format a primitive value for display."""
86
+ if isinstance(value, str):
87
+ return repr(value)
88
+ else:
89
+ return str(value)
90
+
91
+ def print(self, node: ast.AST, title: Optional[str] = None) -> None:
92
+ """Print an AST node with an optional title."""
93
+ if title:
94
+ print("=" * 80)
95
+ print(title)
96
+ print("=" * 80)
97
+ print(self.format(node))
98
+ print()
99
+
100
+
101
+ def print_ast(
102
+ node: ast.AST,
103
+ title: Optional[str] = None,
104
+ indent_size: int = 2,
105
+ show_line_numbers: bool = False,
106
+ ) -> None:
107
+ """
108
+ Convenience function to pretty-print an AST node.
109
+
110
+ Args:
111
+ node: The AST node to print
112
+ title: Optional title to display before the AST
113
+ indent_size: Number of spaces per indentation level
114
+ show_line_numbers: Whether to show line numbers in the output
115
+ """
116
+ printer = ASTPrettyPrinter(indent_size=indent_size, show_line_numbers=show_line_numbers)
117
+ printer.print(node, title=title)
118
+
119
+
120
+ def compare_asts(
121
+ node1: ast.AST, node2: ast.AST, title1: str = "AST 1", title2: str = "AST 2"
122
+ ) -> None:
123
+ """
124
+ Print two ASTs side by side for comparison.
125
+
126
+ Args:
127
+ node1: First AST node
128
+ node2: Second AST node
129
+ title1: Title for first AST
130
+ title2: Title for second AST
131
+ """
132
+ print_ast(node1, title=title1)
133
+ print_ast(node2, title=title2)
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2025 Eric Allen
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ Binding Detector for Nominal Unification
18
+
19
+ This module identifies all Python constructs that bind variables, which is
20
+ essential for implementing nominal unification as described in:
21
+ https://arxiv.org/pdf/1012.4890
22
+
23
+ Nominal unification handles α-equivalence: code that differs only in the
24
+ names of bound variables. For example:
25
+ Block 1: user = {...}; validate(user)
26
+ Block 2: admin = {...}; validate(admin)
27
+
28
+ These blocks are α-equivalent if 'user' and 'admin' play the same structural
29
+ role (both are bound variables with identical usage patterns).
30
+ """
31
+
32
+ import ast
33
+ from typing import List, Optional, Set, Union
34
+ from dataclasses import dataclass
35
+ from enum import Enum
36
+
37
+
38
+ class BindingKind(Enum):
39
+ """Types of variable bindings in Python."""
40
+
41
+ ASSIGNMENT = "assignment" # x = 1
42
+ AUG_ASSIGNMENT = "aug_assignment" # x += 1 (requires x to exist)
43
+ FOR_LOOP = "for_loop" # for x in iterable:
44
+ COMPREHENSION = "comprehension" # [x for x in ...]
45
+ EXCEPTION = "exception" # except E as e:
46
+ WITH_STMT = "with" # with ... as x:
47
+ FUNCTION_PARAM = "function_param" # def f(x): or lambda x:
48
+ NAMED_EXPR = "named_expr" # if (x := foo()):
49
+ MATCH_CASE = "match_case" # case pattern as x:
50
+ IMPORT = "import" # import x, from y import x
51
+ FUNCTION_DEF = "function_def" # def foo():
52
+ CLASS_DEF = "class_def" # class Foo:
53
+
54
+
55
+ @dataclass
56
+ class Binding:
57
+ """Represents a variable binding."""
58
+
59
+ name: str # Variable name
60
+ kind: BindingKind # Type of binding
61
+ node: ast.AST # AST node where binding occurs
62
+ scope_node: Optional[ast.AST] # AST node defining the scope (function, class, module)
63
+ line_number: int # Line number for debugging
64
+
65
+
66
+ class BindingDetector(ast.NodeVisitor):
67
+ """
68
+ Detects all variable bindings in a Python AST.
69
+
70
+ This visitor traverses the AST and identifies every construct that
71
+ introduces a new variable binding, tracking:
72
+ - The variable name
73
+ - The kind of binding (assignment, loop, exception, etc.)
74
+ - The AST node where the binding occurs
75
+ - The scope in which the binding is visible
76
+ """
77
+
78
+ def __init__(self) -> None:
79
+ self.bindings: List[Binding] = []
80
+ self.scope_stack: List[ast.AST] = [] # Track nested scopes
81
+
82
+ def _current_scope(self) -> Optional[ast.AST]:
83
+ """Get the current scope node (function, class, or module)."""
84
+ return self.scope_stack[-1] if self.scope_stack else None
85
+
86
+ def _add_binding(self, name: str, kind: BindingKind, node: ast.AST) -> None:
87
+ """Record a variable binding."""
88
+ self.bindings.append(
89
+ Binding(
90
+ name=name,
91
+ kind=kind,
92
+ node=node,
93
+ scope_node=self._current_scope(),
94
+ line_number=getattr(node, "lineno", -1),
95
+ )
96
+ )
97
+
98
+ def _extract_names_from_target(self, target: ast.AST, kind: BindingKind) -> None:
99
+ """
100
+ Extract all variable names from an assignment target.
101
+
102
+ Handles complex targets like:
103
+ - Simple names: x
104
+ - Tuples: x, y = ...
105
+ - Lists: [x, y] = ...
106
+ - Starred: x, *rest = ...
107
+ - Nested: (x, (y, z)) = ...
108
+
109
+ Does NOT extract attribute assignments (x.y) or subscripts (x[i])
110
+ as these don't create new bindings.
111
+ """
112
+ if isinstance(target, ast.Name):
113
+ self._add_binding(target.id, kind, target)
114
+
115
+ elif isinstance(target, (ast.Tuple, ast.List)):
116
+ for elt in target.elts:
117
+ self._extract_names_from_target(elt, kind)
118
+
119
+ elif isinstance(target, ast.Starred):
120
+ self._extract_names_from_target(target.value, kind)
121
+
122
+ # Ignore ast.Subscript (x[i] = ...) and ast.Attribute (x.y = ...)
123
+ # as these modify existing objects rather than creating new bindings
124
+
125
+ # Assignment statements
126
+
127
+ def visit_Assign(self, node: ast.Assign) -> None:
128
+ """Handle simple assignments: x = 1, x = y = 1, etc."""
129
+ for target in node.targets:
130
+ self._extract_names_from_target(target, BindingKind.ASSIGNMENT)
131
+ self.generic_visit(node)
132
+
133
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
134
+ """Handle augmented assignments: x += 1, x *= 2, etc."""
135
+ # Note: Augmented assignment requires the variable to already exist,
136
+ # but it's still considered a binding for our purposes
137
+ self._extract_names_from_target(node.target, BindingKind.AUG_ASSIGNMENT)
138
+ self.generic_visit(node)
139
+
140
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
141
+ """Handle annotated assignments: x: int = 1"""
142
+ if node.value is not None: # x: int (without assignment) doesn't bind
143
+ self._extract_names_from_target(node.target, BindingKind.ASSIGNMENT)
144
+ self.generic_visit(node)
145
+
146
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
147
+ """Handle walrus operator: if (x := foo()):"""
148
+ self._extract_names_from_target(node.target, BindingKind.NAMED_EXPR)
149
+ self.generic_visit(node)
150
+
151
+ # Loop constructs
152
+
153
+ def visit_For(self, node: ast.For) -> None:
154
+ """Handle for loops: for x in iterable:"""
155
+ self._extract_names_from_target(node.target, BindingKind.FOR_LOOP)
156
+ self.generic_visit(node)
157
+
158
+ def visit_comprehension(self, node: ast.comprehension) -> None:
159
+ """
160
+ Handle comprehension variables: [x for x in ...], {k: v for k, v in ...}
161
+
162
+ Note: This is called for list/dict/set comprehensions and generators.
163
+ """
164
+ self._extract_names_from_target(node.target, BindingKind.COMPREHENSION)
165
+ self.generic_visit(node)
166
+
167
+ # Exception handling
168
+
169
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
170
+ """Handle exception binding: except Exception as e:"""
171
+ self._add_optional_binding(node.name, BindingKind.EXCEPTION, node)
172
+ self.generic_visit(node)
173
+
174
+ # Context managers
175
+
176
+ def visit_With(self, node: ast.With) -> None:
177
+ """Handle with statements: with open() as f:"""
178
+ for item in node.items:
179
+ if item.optional_vars:
180
+ self._extract_names_from_target(item.optional_vars, BindingKind.WITH_STMT)
181
+ self.generic_visit(node)
182
+
183
+ # Function and class definitions
184
+
185
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
186
+ """Handle function definitions: def foo(x, y):"""
187
+ self._visit_function_definition(node)
188
+
189
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
190
+ """Handle async function definitions: async def foo():"""
191
+ self._visit_function_definition(node)
192
+
193
+ def visit_Lambda(self, node: ast.Lambda) -> None:
194
+ """Handle lambda expressions: lambda x, y: x + y"""
195
+ self._enter_function_like_scope(node)
196
+ self.visit(node.body)
197
+ self.scope_stack.pop()
198
+
199
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
200
+ """Handle class definitions: class Foo:"""
201
+ # The class name is a binding in the enclosing scope
202
+ self._add_binding(node.name, BindingKind.CLASS_DEF, node)
203
+
204
+ # Enter class scope
205
+ self.scope_stack.append(node)
206
+
207
+ # Visit class body
208
+ self._visit_body_and_pop(node)
209
+
210
+ # Visit decorators and bases (in enclosing scope)
211
+ for decorator in node.decorator_list:
212
+ self.visit(decorator)
213
+ for base in node.bases:
214
+ self.visit(base)
215
+ for keyword in node.keywords:
216
+ self.visit(keyword.value)
217
+
218
+ # Import statements
219
+
220
+ def visit_Import(self, node: ast.Import) -> None:
221
+ """Handle import statements: import x, import y as z"""
222
+ for alias in node.names:
223
+ # Use the alias if provided, otherwise the module name
224
+ name = alias.asname if alias.asname else alias.name
225
+ self._add_binding(name, BindingKind.IMPORT, node)
226
+ self.generic_visit(node)
227
+
228
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
229
+ """Handle from-import statements: from x import y, from x import y as z"""
230
+ for alias in node.names:
231
+ resolved = _resolve_import_alias(alias)
232
+ if resolved:
233
+ self._add_binding(resolved, BindingKind.IMPORT, node)
234
+ self.generic_visit(node)
235
+
236
+ # Match statements (Python 3.10+)
237
+
238
+ def visit_Match(self, node: ast.Match) -> None:
239
+ """Handle match statements: match x: case pattern:"""
240
+ # Visit the subject
241
+ self.visit(node.subject)
242
+
243
+ # Visit each case
244
+ for case in node.cases:
245
+ # Extract bindings from the pattern
246
+ self._extract_pattern_bindings(case.pattern)
247
+
248
+ # Visit guard and body
249
+ if case.guard:
250
+ self.visit(case.guard)
251
+ for stmt in case.body:
252
+ self.visit(stmt)
253
+
254
+ def _extract_pattern_bindings(self, pattern: ast.AST) -> None:
255
+ """Extract variable bindings from match patterns."""
256
+ if isinstance(pattern, ast.MatchAs):
257
+ # case pattern as x:
258
+ self._add_optional_binding(pattern.name, BindingKind.MATCH_CASE, pattern)
259
+ if pattern.pattern:
260
+ self._extract_pattern_bindings(pattern.pattern)
261
+
262
+ elif isinstance(pattern, ast.MatchOr):
263
+ # case pattern1 | pattern2:
264
+ for subpattern in pattern.patterns:
265
+ self._extract_pattern_bindings(subpattern)
266
+
267
+ elif isinstance(pattern, ast.MatchSequence):
268
+ # case [x, y]:
269
+ for subpattern in pattern.patterns:
270
+ self._extract_pattern_bindings(subpattern)
271
+
272
+ elif isinstance(pattern, ast.MatchMapping):
273
+ # case {"key": value}:
274
+ for subpattern in pattern.patterns:
275
+ self._extract_pattern_bindings(subpattern)
276
+ if pattern.rest:
277
+ self._add_binding(pattern.rest, BindingKind.MATCH_CASE, pattern)
278
+
279
+ elif isinstance(pattern, ast.MatchClass):
280
+ # case ClassName(x, y):
281
+ for subpattern in pattern.patterns:
282
+ self._extract_pattern_bindings(subpattern)
283
+ for subpattern in pattern.kwd_patterns:
284
+ self._extract_pattern_bindings(subpattern)
285
+
286
+ elif isinstance(pattern, ast.MatchStar):
287
+ # case [*rest]:
288
+ if pattern.name:
289
+ self._add_binding(pattern.name, BindingKind.MATCH_CASE, pattern)
290
+
291
+ # Other patterns (MatchValue, MatchSingleton) don't bind variables
292
+
293
+ def _visit_function_definition(
294
+ self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
295
+ ) -> None:
296
+ self._add_binding(node.name, BindingKind.FUNCTION_DEF, node)
297
+ self._enter_function_like_scope(node)
298
+ self._visit_body_and_pop(node)
299
+ for decorator in node.decorator_list:
300
+ self.visit(decorator)
301
+
302
+ def _enter_function_like_scope(
303
+ self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda]
304
+ ) -> None:
305
+ self.scope_stack.append(node)
306
+ self._bind_function_parameters(node.args)
307
+
308
+ def _visit_body_and_pop(self, node: ast.AST) -> None:
309
+ for stmt in getattr(node, "body", []):
310
+ self.visit(stmt)
311
+ self.scope_stack.pop()
312
+
313
+ def _bind_function_parameters(self, args: ast.arguments) -> None:
314
+ for arg in (*args.posonlyargs, *args.args):
315
+ self._add_binding(arg.arg, BindingKind.FUNCTION_PARAM, arg)
316
+ if args.vararg:
317
+ self._add_binding(args.vararg.arg, BindingKind.FUNCTION_PARAM, args.vararg)
318
+ if args.kwarg:
319
+ self._add_binding(args.kwarg.arg, BindingKind.FUNCTION_PARAM, args.kwarg)
320
+ for arg in args.kwonlyargs:
321
+ self._add_binding(arg.arg, BindingKind.FUNCTION_PARAM, arg)
322
+
323
+ def _add_optional_binding(self, name: Optional[str], kind: BindingKind, node: ast.AST) -> None:
324
+ if name:
325
+ self._add_binding(name, kind, node)
326
+
327
+
328
+ def detect_bindings(tree: ast.AST) -> List[Binding]:
329
+ """
330
+ Detect all variable bindings in an AST.
331
+
332
+ Args:
333
+ tree: The AST to analyze
334
+
335
+ Returns:
336
+ List of all variable bindings found
337
+ """
338
+ detector = BindingDetector()
339
+ detector.visit(tree)
340
+ return detector.bindings
341
+
342
+
343
+ def get_bound_variables(tree: ast.AST, scope_node: Optional[ast.AST] = None) -> Set[str]:
344
+ """
345
+ Get the set of all variable names bound in an AST.
346
+
347
+ Args:
348
+ tree: The AST to analyze
349
+ scope_node: If provided, only return bindings in this scope
350
+
351
+ Returns:
352
+ Set of variable names
353
+ """
354
+ bindings = detect_bindings(tree)
355
+
356
+ if scope_node is not None:
357
+ bindings = [b for b in bindings if b.scope_node == scope_node]
358
+
359
+ return {b.name for b in bindings}
360
+
361
+
362
+ def get_bindings_by_kind(tree: ast.AST, kind: BindingKind) -> List[Binding]:
363
+ """
364
+ Get all bindings of a specific kind.
365
+
366
+ Args:
367
+ tree: The AST to analyze
368
+ kind: The kind of binding to filter for
369
+
370
+ Returns:
371
+ List of bindings of the specified kind
372
+ """
373
+ bindings = detect_bindings(tree)
374
+ return [b for b in bindings if b.kind == kind]
375
+
376
+
377
+ def _resolve_import_alias(alias: ast.alias) -> Optional[str]:
378
+ """Return the binding name for an import alias, or None for ``*`` imports."""
379
+ if alias.name == "*":
380
+ return None
381
+ return alias.asname if alias.asname else alias.name
@@ -0,0 +1,123 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Conservative block signature utilities used for cheap pre-filtering.
16
+
17
+ This rollback version intentionally omits aggressive heuristics and telemetry.
18
+ Only very low-risk structural gates are applied:
19
+ 1. Same statement count
20
+ 2. try/with presence alignment
21
+ 3. Matching first and last statement types
22
+
23
+ Anything passing these gates is considered for full unification. All other
24
+ behavior (ratio/histogram/simhash) has been removed to restore baseline
25
+ proposal counts and prevent false negatives introduced by over‑filtering.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ast
31
+ from dataclasses import dataclass
32
+ from typing import List, Tuple, Dict, Any
33
+
34
+
35
+ IDENT_COUNT_TOLERANCE = 2
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BlockSignature:
40
+ """Structural fingerprint of a code block for fast similarity pre-filtering.
41
+
42
+ Contains conservative metrics (statement count, control flow presence, name/call counts)
43
+ used to quickly reject obviously dissimilar blocks before expensive unification.
44
+ """
45
+
46
+ stmt_count: int
47
+ stmt_seq: Tuple[str, ...]
48
+ has_with: bool
49
+ has_try: bool
50
+ name_load_count: int
51
+ name_store_count: int
52
+ call_count: int
53
+
54
+
55
+ def extract_block_signature(block: List[ast.AST]) -> BlockSignature:
56
+ stmt_seq = tuple(type(s).__name__ for s in block)
57
+ has_with = False
58
+ has_try = False
59
+ name_load_count = 0
60
+ name_store_count = 0
61
+ call_count = 0
62
+
63
+ skip_types = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
64
+
65
+ for stmt in block:
66
+ stack = [stmt]
67
+ while stack:
68
+ node = stack.pop()
69
+
70
+ if isinstance(node, skip_types):
71
+ continue
72
+
73
+ if not has_with and isinstance(node, ast.With):
74
+ has_with = True
75
+ if not has_try and isinstance(node, ast.Try):
76
+ has_try = True
77
+ if isinstance(node, ast.Name):
78
+ if isinstance(node.ctx, ast.Load):
79
+ name_load_count += 1
80
+ elif isinstance(node.ctx, ast.Store):
81
+ name_store_count += 1
82
+ elif isinstance(node, ast.Call):
83
+ call_count += 1
84
+
85
+ stack.extend(ast.iter_child_nodes(node))
86
+
87
+ return BlockSignature(
88
+ stmt_count=len(block),
89
+ stmt_seq=stmt_seq,
90
+ has_with=has_with,
91
+ has_try=has_try,
92
+ name_load_count=name_load_count,
93
+ name_store_count=name_store_count,
94
+ call_count=call_count,
95
+ )
96
+
97
+
98
+ def quick_filter(sig1: BlockSignature, sig2: BlockSignature) -> bool:
99
+ """Return True if the pair should be considered for unification.
100
+
101
+ Extremely simple structural checks matching the original conservative
102
+ behavior prior to aggressive experimentation.
103
+ """
104
+ if sig1.stmt_count != sig2.stmt_count:
105
+ return False
106
+ if sig1.has_with != sig2.has_with or sig1.has_try != sig2.has_try:
107
+ return False
108
+ if abs(sig1.name_load_count - sig2.name_load_count) > IDENT_COUNT_TOLERANCE:
109
+ return False
110
+ if abs(sig1.name_store_count - sig2.name_store_count) > IDENT_COUNT_TOLERANCE:
111
+ return False
112
+ if abs(sig1.call_count - sig2.call_count) > IDENT_COUNT_TOLERANCE:
113
+ return False
114
+ if sig1.stmt_seq and sig2.stmt_seq:
115
+ if sig1.stmt_seq[0] != sig2.stmt_seq[0] or sig1.stmt_seq[-1] != sig2.stmt_seq[-1]:
116
+ return False
117
+ return True
118
+
119
+
120
+ def evaluate_signature(sig1: BlockSignature, sig2: BlockSignature) -> Tuple[bool, Dict[str, Any]]:
121
+ """Minimal compatibility wrapper retained so refactor_engine telemetry calls
122
+ do not break. Returns (decision, empty_info)."""
123
+ return quick_filter(sig1, sig2), {}