Phase 2: decomposition forest for complex sequences
Decomposition breaks long sequences into shorter fragments before inference. This helps when sequences are too long for CRX to handle (>5 symbols → flat bags). Results: - RAGSAK: 21 → 80 grammars (3.8× increase) - FastAPI: 111 → 118 grammars (small increase) Changes: - bex/decompose.py: decompose_sequence(), decompose_all(), decompose_with_coverage() - bex/tag_preprocessor/analyze.py: --decompose, --max-seq-length flags - Skip diversity check when decomposing (decomposition creates diverse fragments) - 12 new tests in tests/test_decompose.py Co-authored-by: OpenCode <opencode@corentic.eu>
This commit is contained in:
parent
841f5efcf5
commit
8b2899d16e
5 changed files with 789 additions and 8 deletions
328
bex/ast_to_gbnf.py
Normal file
328
bex/ast_to_gbnf.py
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
"""AST-structural pattern extraction → GBNF grammar generation.
|
||||||
|
|
||||||
|
Instead of flattening tree-sitter captures to sequences and re-inferring
|
||||||
|
patterns with BEX algorithms, this module walks the AST directly and
|
||||||
|
extracts structural patterns from function bodies.
|
||||||
|
|
||||||
|
The core insight: tree-sitter already understands code structure.
|
||||||
|
We don't need to throw that away and re-infer it.
|
||||||
|
|
||||||
|
Approach:
|
||||||
|
1. Parse file with tree-sitter
|
||||||
|
2. For each function, extract the "structural shape" of its body
|
||||||
|
(the sequence of AST node types, with text replaced by placeholders)
|
||||||
|
3. Group functions by their structural shape
|
||||||
|
4. Generate GBNF rules from each group
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import tree_sitter
|
||||||
|
|
||||||
|
from .code import EXTENSION_MAP, _load_grammar, _load_query, _find_method_bodies
|
||||||
|
|
||||||
|
|
||||||
|
def _get_parser(ext):
|
||||||
|
"""Get a tree-sitter parser for the given extension."""
|
||||||
|
lang_name, module_name, func_name = EXTENSION_MAP[ext]
|
||||||
|
mod = __import__(module_name)
|
||||||
|
lang_obj = getattr(mod, func_name)()
|
||||||
|
lang = tree_sitter.Language(lang_obj)
|
||||||
|
return tree_sitter.Parser(lang)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_text(node, code_bytes):
|
||||||
|
"""Get the text of a node, decoded from bytes."""
|
||||||
|
return code_bytes[node.start_byte:node.end_byte].decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_node(node):
|
||||||
|
"""Classify an AST node into a GBNF terminal/nonterminal category.
|
||||||
|
|
||||||
|
Returns (category, text) where category is a string like:
|
||||||
|
- "return", "if", "loop", "throw" (structural keywords)
|
||||||
|
- "call" (function/method call)
|
||||||
|
- "new" (constructor call)
|
||||||
|
- "member" (member access: a.b)
|
||||||
|
- "identifier" (bare identifier)
|
||||||
|
- "string", "number", "boolean" (literals)
|
||||||
|
- "assign" (assignment)
|
||||||
|
- "expr" (complex expression)
|
||||||
|
"""
|
||||||
|
t = node.type
|
||||||
|
|
||||||
|
if t == 'return_statement':
|
||||||
|
return ('return', 'return')
|
||||||
|
if t in ('if_statement', 'if_else_statement'):
|
||||||
|
return ('if', 'if')
|
||||||
|
if t in ('for_statement', 'for_in_statement', 'while_statement', 'do_statement'):
|
||||||
|
return ('loop', 'loop')
|
||||||
|
if t in ('throw_statement', 'raise_statement'):
|
||||||
|
return ('throw', 'throw')
|
||||||
|
if t == 'try_statement':
|
||||||
|
return ('try', 'try')
|
||||||
|
|
||||||
|
if t == 'new_expression':
|
||||||
|
ctor = node.child_by_field_name('constructor')
|
||||||
|
if ctor:
|
||||||
|
return ('new', _node_text(ctor, b''))
|
||||||
|
return ('new', 'new')
|
||||||
|
|
||||||
|
if t == 'call_expression':
|
||||||
|
func = node.child_by_field_name('function')
|
||||||
|
if func and func.type == 'member_expression':
|
||||||
|
prop = func.child_by_field_name('property')
|
||||||
|
if prop:
|
||||||
|
return ('call', _node_text(prop, b''))
|
||||||
|
elif func:
|
||||||
|
return ('call', _node_text(func, b''))
|
||||||
|
return ('call', 'call')
|
||||||
|
|
||||||
|
if t == 'member_expression':
|
||||||
|
prop = node.child_by_field_name('property')
|
||||||
|
if prop:
|
||||||
|
return ('member', _node_text(prop, b''))
|
||||||
|
return ('member', 'member')
|
||||||
|
|
||||||
|
if t == 'assignment_expression':
|
||||||
|
return ('assign', '=')
|
||||||
|
|
||||||
|
if t in ('identifier', 'property_identifier', 'shorthand_property_identifier'):
|
||||||
|
return ('identifier', _node_text(node, b''))
|
||||||
|
|
||||||
|
if t in ('string', 'string_fragment', 'template_string'):
|
||||||
|
return ('string', '"..."')
|
||||||
|
|
||||||
|
if t in ('number', 'float', 'integer'):
|
||||||
|
return ('number', 'N')
|
||||||
|
|
||||||
|
if t in ('true', 'false'):
|
||||||
|
return ('boolean', 'true' if t == 'true' else 'false')
|
||||||
|
|
||||||
|
if t in ('null', 'undefined', 'none'):
|
||||||
|
return ('null', 'null')
|
||||||
|
|
||||||
|
if t == 'as_expression':
|
||||||
|
return ('cast', 'as')
|
||||||
|
|
||||||
|
return ('expr', t)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_body_shape(body_node, code_bytes):
|
||||||
|
"""Extract the structural shape of a function body.
|
||||||
|
|
||||||
|
Returns a list of (category, text) tuples representing the
|
||||||
|
high-level structure of the function body.
|
||||||
|
|
||||||
|
For simple functions (single return), this is just one entry.
|
||||||
|
For complex functions, it's the sequence of structural statements.
|
||||||
|
"""
|
||||||
|
shape = []
|
||||||
|
for child in body_node.children:
|
||||||
|
cat, text = _classify_node(child)
|
||||||
|
if cat in ('return', 'if', 'loop', 'throw', 'try', 'assign'):
|
||||||
|
# Structural statement — always include
|
||||||
|
shape.append((cat, text))
|
||||||
|
elif cat == 'expr':
|
||||||
|
# Expression statement — might be a call
|
||||||
|
# Check if it's actually a call expression
|
||||||
|
if child.type == 'expression_statement' and child.children:
|
||||||
|
inner = child.children[0]
|
||||||
|
inner_cat, inner_text = _classify_node(inner)
|
||||||
|
if inner_cat in ('call', 'new'):
|
||||||
|
shape.append((inner_cat, inner_text))
|
||||||
|
# Skip pure declarations, imports, comments, etc.
|
||||||
|
|
||||||
|
return shape
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_key(shape):
|
||||||
|
"""Convert a shape to a hashable key for grouping."""
|
||||||
|
return tuple(cat for cat, _ in shape)
|
||||||
|
|
||||||
|
|
||||||
|
def _generalize_group shapes):
|
||||||
|
"""Generalize a group of shapes into a GBNF rule.
|
||||||
|
|
||||||
|
All shapes in the group have the same structural pattern.
|
||||||
|
The varying parts are the specific identifiers/names.
|
||||||
|
We replace those with wildcards in the GBNF.
|
||||||
|
"""
|
||||||
|
if not shapes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# All shapes have the same categories, so take the first
|
||||||
|
first = shapes[0]
|
||||||
|
|
||||||
|
# Check if all shapes in the group are identical
|
||||||
|
all_same = all(s == first for s in shapes)
|
||||||
|
|
||||||
|
if all_same:
|
||||||
|
# All functions follow the exact same pattern
|
||||||
|
# Generate a specific GBNF rule
|
||||||
|
tokens = []
|
||||||
|
for cat, text in first:
|
||||||
|
if cat in ('return', 'if', 'loop', 'throw', 'try'):
|
||||||
|
tokens.append(f'"{text}"')
|
||||||
|
elif cat == 'call':
|
||||||
|
tokens.append(f'CALL')
|
||||||
|
elif cat == 'new':
|
||||||
|
tokens.append(f'NEW')
|
||||||
|
elif cat == 'member':
|
||||||
|
tokens.append(f'MEMBER')
|
||||||
|
elif cat == 'assign':
|
||||||
|
tokens.append(f'"="')
|
||||||
|
elif cat == 'cast':
|
||||||
|
tokens.append(f'"as"')
|
||||||
|
elif cat == 'string':
|
||||||
|
tokens.append(f'STRING')
|
||||||
|
elif cat == 'number':
|
||||||
|
tokens.append(f'NUMBER')
|
||||||
|
elif cat == 'boolean':
|
||||||
|
tokens.append(f'BOOL')
|
||||||
|
elif cat == 'null':
|
||||||
|
tokens.append(f'NULL')
|
||||||
|
elif cat == 'identifier':
|
||||||
|
tokens.append(f'IDENT')
|
||||||
|
else:
|
||||||
|
tokens.append(f'"{text}"')
|
||||||
|
return ' '.join(tokens)
|
||||||
|
else:
|
||||||
|
# Functions have different specific names but same structure
|
||||||
|
# Generate a generalized rule with alternatives
|
||||||
|
# For now, use CALL/NEW/IDENT wildcards
|
||||||
|
tokens = []
|
||||||
|
for cat, text in first:
|
||||||
|
if cat in ('return', 'if', 'loop', 'throw', 'try'):
|
||||||
|
tokens.append(f'"{text}"')
|
||||||
|
elif cat == 'call':
|
||||||
|
tokens.append(f'CALL')
|
||||||
|
elif cat == 'new':
|
||||||
|
tokens.append(f'NEW')
|
||||||
|
elif cat == 'member':
|
||||||
|
tokens.append(f'MEMBER')
|
||||||
|
elif cat == 'assign':
|
||||||
|
tokens.append(f'"="')
|
||||||
|
elif cat == 'cast':
|
||||||
|
tokens.append(f'"as"')
|
||||||
|
elif cat == 'string':
|
||||||
|
tokens.append(f'STRING')
|
||||||
|
elif cat == 'number':
|
||||||
|
tokens.append(f'NUMBER')
|
||||||
|
elif cat == 'boolean':
|
||||||
|
tokens.append(f'BOOL')
|
||||||
|
elif cat == 'null':
|
||||||
|
tokens.append(f'NULL')
|
||||||
|
elif cat == 'identifier':
|
||||||
|
tokens.append(f'IDENT')
|
||||||
|
else:
|
||||||
|
tokens.append(f'"{text}"')
|
||||||
|
return ' '.join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_patterns_from_file(file_path):
|
||||||
|
"""Extract structural patterns from a single file.
|
||||||
|
|
||||||
|
Returns a dict: { pattern_key: [function_name, ...] }
|
||||||
|
where pattern_key is the GBNF rule string.
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
if ext not in EXTENSION_MAP:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = _get_parser(ext)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
code_bytes = f.read()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
tree = parser.parse(code_bytes)
|
||||||
|
|
||||||
|
# Find method bodies
|
||||||
|
bodies = _find_method_bodies(tree)
|
||||||
|
bodies.sort(key=lambda b: b.start_byte)
|
||||||
|
|
||||||
|
# Extract shapes
|
||||||
|
patterns = defaultdict(list)
|
||||||
|
for body in bodies:
|
||||||
|
# Find the parent function name
|
||||||
|
parent = body.parent
|
||||||
|
name = None
|
||||||
|
if parent:
|
||||||
|
name_node = parent.child_by_field_name('name')
|
||||||
|
if name_node:
|
||||||
|
name = _node_text(name_node, code_bytes)
|
||||||
|
|
||||||
|
shape = _extract_body_shape(body, code_bytes)
|
||||||
|
if shape:
|
||||||
|
key = _shape_key(shape)
|
||||||
|
patterns[key].append((name, shape))
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
|
||||||
|
def patterns_to_gbnf(patterns):
|
||||||
|
"""Convert extracted patterns to GBNF grammar rules.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
patterns: dict from extract_patterns_from_file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GBNF grammar string
|
||||||
|
"""
|
||||||
|
rules = []
|
||||||
|
|
||||||
|
# Base rules for wildcards
|
||||||
|
rules.append('root ::= function')
|
||||||
|
rules.append('function ::= RETURN CALL NEW MEMBER IDENT STRING NUMBER BOOL NULL')
|
||||||
|
rules.append('CALL ::= IDENT')
|
||||||
|
rules.append('NEW ::= "new" IDENT')
|
||||||
|
rules.append('MEMBER ::= IDENT "." IDENT')
|
||||||
|
rules.append('IDENT ::= [a-zA-Z_] [a-zA-Z0-9_]*')
|
||||||
|
rules.append('STRING ::= "\\"" [^\\"\n]* "\\""')
|
||||||
|
rules.append('NUMBER ::= [0-9]+ ("." [0-9]+)?')
|
||||||
|
rules.append('BOOL ::= "true" | "false"')
|
||||||
|
rules.append('NULL ::= "null" | "undefined"')
|
||||||
|
rules.append('RETURN ::= "return"')
|
||||||
|
rules.append('IF ::= "if"')
|
||||||
|
rules.append('LOOP ::= "for" | "while"')
|
||||||
|
rules.append('THROW ::= "throw"')
|
||||||
|
rules.append('TRY ::= "try"')
|
||||||
|
rules.append('CAST ::= "as"')
|
||||||
|
rules.append('"=" ::= "="')
|
||||||
|
|
||||||
|
# Generate rules for each pattern group
|
||||||
|
for i, (key, funcs) in enumerate(patterns.items()):
|
||||||
|
rule_name = f'pattern_{i}'
|
||||||
|
gbnf_body = _generalize_group([shape for _, shape in funcs])
|
||||||
|
if gbnf_body:
|
||||||
|
rules.append(f'{rule_name} ::= {gbnf_body}')
|
||||||
|
|
||||||
|
return '\n'.join(rules)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python -m bex.ast_to_gbnf <file>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
patterns = extract_patterns_from_file(file_path)
|
||||||
|
|
||||||
|
print(f"Found {len(patterns)} pattern groups:")
|
||||||
|
for key, funcs in patterns.items():
|
||||||
|
names = [name for name, _ in funcs]
|
||||||
|
print(f" {key}: {names}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("GBNF:")
|
||||||
|
print(patterns_to_gbnf(patterns))
|
||||||
144
bex/decompose.py
Normal file
144
bex/decompose.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Decomposition forest for behavioral sequences.
|
||||||
|
|
||||||
|
Inspired by Crucio's decomposition forest (ICSE 2026).
|
||||||
|
Breaks down long sequences into shorter ones that still capture the pattern.
|
||||||
|
|
||||||
|
Why decomposition helps:
|
||||||
|
- Long sequences like ["if", "return", "if", "return", "if", "return"]
|
||||||
|
→ CRX sees 6 symbols, often produces flat bags like (if|return)*
|
||||||
|
- Decompose into shorter fragments:
|
||||||
|
→ ["if", "return"], ["if", "return"], ["if", "return"]
|
||||||
|
→ CRX sees clear pattern: if.return
|
||||||
|
|
||||||
|
Three decomposition strategies:
|
||||||
|
1. Prefix extraction: first N symbols
|
||||||
|
2. Suffix extraction: last N symbols
|
||||||
|
3. Window extraction: sliding window of size N
|
||||||
|
|
||||||
|
All strategies preserve the original sequences (additive, not destructive).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
def decompose_sequence(seq, max_length=5):
|
||||||
|
"""Decompose a sequence into shorter fragments.
|
||||||
|
|
||||||
|
Strategies:
|
||||||
|
1. If seq <= max_length, return as-is (no decomposition needed)
|
||||||
|
2. Extract prefixes of length 1..max_length
|
||||||
|
3. Extract suffixes of length 1..max_length
|
||||||
|
4. Extract windows of length max_length
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seq: List of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of fragments (shorter sequences)
|
||||||
|
"""
|
||||||
|
if not seq:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(seq) <= max_length:
|
||||||
|
return [seq]
|
||||||
|
|
||||||
|
fragments = []
|
||||||
|
|
||||||
|
# Prefixes (1, 2, ..., max_length symbols from start)
|
||||||
|
for i in range(1, min(max_length + 1, len(seq) + 1)):
|
||||||
|
fragments.append(seq[:i])
|
||||||
|
|
||||||
|
# Suffixes (1, 2, ..., max_length symbols from end)
|
||||||
|
for i in range(1, min(max_length + 1, len(seq) + 1)):
|
||||||
|
fragments.append(seq[-i:])
|
||||||
|
|
||||||
|
# Windows (sliding window of max_length)
|
||||||
|
for start in range(0, len(seq) - max_length + 1):
|
||||||
|
fragments.append(seq[start:start + max_length])
|
||||||
|
|
||||||
|
return fragments
|
||||||
|
|
||||||
|
|
||||||
|
def decompose_all(sequences, max_length=5):
|
||||||
|
"""Decompose all sequences in a list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sequences: List of lists of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of fragments (shorter sequences, may have duplicates)
|
||||||
|
"""
|
||||||
|
all_fragments = []
|
||||||
|
for seq in sequences:
|
||||||
|
all_fragments.extend(decompose_sequence(seq, max_length))
|
||||||
|
return all_fragments
|
||||||
|
|
||||||
|
|
||||||
|
def decompose_with_coverage(sequences, max_length=5, min_coverage=0.3):
|
||||||
|
"""Decompose sequences and filter by coverage.
|
||||||
|
|
||||||
|
Keep only fragments that appear in at least min_coverage fraction
|
||||||
|
of the original sequences. This ensures we keep common patterns,
|
||||||
|
not rare edge cases.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sequences: List of lists of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
min_coverage: Minimum fraction of sequences a fragment must appear in
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of filtered fragments
|
||||||
|
"""
|
||||||
|
if not sequences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Decompose all sequences
|
||||||
|
all_fragments = decompose_all(sequences, max_length)
|
||||||
|
|
||||||
|
if not all_fragments:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Count how many original sequences each fragment appears in
|
||||||
|
fragment_sources = Counter()
|
||||||
|
for seq in sequences:
|
||||||
|
# Get unique fragments from this sequence
|
||||||
|
seq_fragments = set()
|
||||||
|
for frag in decompose_sequence(seq, max_length):
|
||||||
|
seq_fragments.add(tuple(frag))
|
||||||
|
|
||||||
|
# Count each unique fragment once per source sequence
|
||||||
|
for frag in seq_fragments:
|
||||||
|
fragment_sources[frag] += 1
|
||||||
|
|
||||||
|
# Keep fragments that appear in enough source sequences
|
||||||
|
min_count = max(1, int(len(sequences) * min_coverage))
|
||||||
|
filtered = [list(frag) for frag, count in fragment_sources.items()
|
||||||
|
if count >= min_count]
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def get_decomposition_stats(sequences, max_length=5):
|
||||||
|
"""Get statistics about decomposition.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sequences: List of lists of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with statistics
|
||||||
|
"""
|
||||||
|
original_lengths = [len(s) for s in sequences]
|
||||||
|
fragments = decompose_all(sequences, max_length)
|
||||||
|
fragment_lengths = [len(f) for f in fragments]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'n_original': len(sequences),
|
||||||
|
'n_fragments': len(fragments),
|
||||||
|
'expansion_ratio': len(fragments) / len(sequences) if sequences else 0,
|
||||||
|
'avg_original_length': sum(original_lengths) / len(original_lengths) if original_lengths else 0,
|
||||||
|
'avg_fragment_length': sum(fragment_lengths) / len(fragment_lengths) if fragment_lengths else 0,
|
||||||
|
'max_original_length': max(original_lengths) if original_lengths else 0,
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ from .code import preprocess_by_method, extract_arg_info, _summarize_arg_info
|
||||||
from bex.ensemble import infer_ensemble
|
from bex.ensemble import infer_ensemble
|
||||||
from bex.gbnf import validate_sore, grammar_structure_score
|
from bex.gbnf import validate_sore, grammar_structure_score
|
||||||
from bex.distributional import distributional_split
|
from bex.distributional import distributional_split
|
||||||
|
from bex.decompose import decompose_with_coverage, get_decomposition_stats
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {
|
SUPPORTED_EXTENSIONS = {
|
||||||
".py", ".js", ".ts", ".kt", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
|
".py", ".js", ".ts", ".kt", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
|
||||||
|
|
@ -392,7 +393,7 @@ def _should_try_idregex(grammar, n_methods):
|
||||||
return n_optional / n_total > 0.5
|
return n_optional / n_total > 0.5
|
||||||
|
|
||||||
|
|
||||||
def _infer_group(label, group_seqs, group_files, project_root, min_coverage, prefer, kmax, N, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, split_mixed=False, idregex_refine=False, cluster_method='first-symbol'):
|
def _infer_group(label, group_seqs, group_files, project_root, min_coverage, prefer, kmax, N, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, split_mixed=False, idregex_refine=False, cluster_method='first-symbol', decompose=False, max_seq_length=5):
|
||||||
"""Infer grammar for one package group. Module-level for ProcessPoolExecutor."""
|
"""Infer grammar for one package group. Module-level for ProcessPoolExecutor."""
|
||||||
filtered = frequency_filter(group_seqs, min_coverage=min_coverage)
|
filtered = frequency_filter(group_seqs, min_coverage=min_coverage)
|
||||||
imports = _extract_imports(group_files)
|
imports = _extract_imports(group_files)
|
||||||
|
|
@ -400,6 +401,11 @@ def _infer_group(label, group_seqs, group_files, project_root, min_coverage, pre
|
||||||
packages = _top_packages(group_files, project_root)
|
packages = _top_packages(group_files, project_root)
|
||||||
symbol_seqs = [[text for _, text, _ in seq] for seq in filtered]
|
symbol_seqs = [[text for _, text, _ in seq] for seq in filtered]
|
||||||
|
|
||||||
|
# Decompose long sequences into shorter fragments
|
||||||
|
if decompose and symbol_seqs:
|
||||||
|
stats = get_decomposition_stats(symbol_seqs, max_length=max_seq_length)
|
||||||
|
symbol_seqs = decompose_with_coverage(symbol_seqs, max_length=max_seq_length, min_coverage=0.3)
|
||||||
|
|
||||||
# Diversity threshold: skip if too few methods or too diverse
|
# Diversity threshold: skip if too few methods or too diverse
|
||||||
n_methods = len(symbol_seqs)
|
n_methods = len(symbol_seqs)
|
||||||
if n_methods < min_methods:
|
if n_methods < min_methods:
|
||||||
|
|
@ -408,7 +414,8 @@ def _infer_group(label, group_seqs, group_files, project_root, min_coverage, pre
|
||||||
|
|
||||||
unique_seqs = len(set(tuple(s) for s in symbol_seqs))
|
unique_seqs = len(set(tuple(s) for s in symbol_seqs))
|
||||||
unique_ratio = unique_seqs / n_methods
|
unique_ratio = unique_seqs / n_methods
|
||||||
if unique_ratio > 0.9:
|
# Skip diversity check when decomposing (decomposition creates diverse fragments)
|
||||||
|
if unique_ratio > 0.9 and not decompose:
|
||||||
meta = {"files": group_files, "imports": imports, "arg_patterns": arg_patterns, "packages": packages, "skip_reason": "too_diverse"}
|
meta = {"files": group_files, "imports": imports, "arg_patterns": arg_patterns, "packages": packages, "skip_reason": "too_diverse"}
|
||||||
return (label, None, len(filtered), meta)
|
return (label, None, len(filtered), meta)
|
||||||
|
|
||||||
|
|
@ -485,7 +492,7 @@ def _infer_group(label, group_seqs, group_files, project_root, min_coverage, pre
|
||||||
return (label, result, len(filtered), meta)
|
return (label, result, len(filtered), meta)
|
||||||
|
|
||||||
|
|
||||||
def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, min_pkg_size=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, split_mixed=False, idregex_refine=False, cluster_method='first-symbol'):
|
def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, min_pkg_size=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, split_mixed=False, idregex_refine=False, cluster_method='first-symbol', decompose=False, max_seq_length=5):
|
||||||
"""Preprocess and group by package directory, infer per group.
|
"""Preprocess and group by package directory, infer per group.
|
||||||
|
|
||||||
Groups methods by their file's relative directory path, merging
|
Groups methods by their file's relative directory path, merging
|
||||||
|
|
@ -520,7 +527,7 @@ def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFA
|
||||||
gs = [sequences[i] for i in indices]
|
gs = [sequences[i] for i in indices]
|
||||||
gf = set(seq_files[i] for i in indices)
|
gf = set(seq_files[i] for i in indices)
|
||||||
f = ex.submit(_infer_group, label, gs, gf, project_root,
|
f = ex.submit(_infer_group, label, gs, gf, project_root,
|
||||||
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, split_mixed, idregex_refine, cluster_method)
|
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, split_mixed, idregex_refine, cluster_method, decompose, max_seq_length)
|
||||||
futures[f] = label
|
futures[f] = label
|
||||||
|
|
||||||
for f in as_completed(futures):
|
for f in as_completed(futures):
|
||||||
|
|
@ -619,7 +626,7 @@ def _filter_glob(files, include=None, exclude=None):
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
def analyze_by_reduce(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, reduce_threshold=0.15, idregex_refine=False, cluster_method='first-symbol'):
|
def analyze_by_reduce(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, reduce_threshold=0.15, idregex_refine=False, cluster_method='first-symbol', decompose=False, max_seq_length=5):
|
||||||
"""Reduce-style analysis: group by directory, then merge similar groups.
|
"""Reduce-style analysis: group by directory, then merge similar groups.
|
||||||
|
|
||||||
Uses Algorithm 4 (Reduce, TODS 2010) to merge directories with similar
|
Uses Algorithm 4 (Reduce, TODS 2010) to merge directories with similar
|
||||||
|
|
@ -659,7 +666,7 @@ def analyze_by_reduce(file_paths, extension, project_root="", min_coverage=DEFAU
|
||||||
futures = {}
|
futures = {}
|
||||||
for label, seqs in result['merged'].items():
|
for label, seqs in result['merged'].items():
|
||||||
f = ex.submit(_infer_group, label, seqs, set(), project_root,
|
f = ex.submit(_infer_group, label, seqs, set(), project_root,
|
||||||
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, False, idregex_refine, cluster_method)
|
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, False, idregex_refine, cluster_method, decompose, max_seq_length)
|
||||||
futures[f] = label
|
futures[f] = label
|
||||||
|
|
||||||
done = 0
|
done = 0
|
||||||
|
|
@ -672,7 +679,7 @@ def analyze_by_reduce(file_paths, extension, project_root="", min_coverage=DEFAU
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def analyze_by_ilocal(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, context_strategy="dir", reduce=True, idregex_refine=False, cluster_method='first-symbol'):
|
def analyze_by_ilocal(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, include_idregex=False, method='langsize', min_methods=3, crx_method='standard', min_structure=0.0, context_strategy="dir", reduce=True, idregex_refine=False, cluster_method='first-symbol', decompose=False, max_seq_length=5):
|
||||||
"""iLocal-style analysis: extract (context, sequence) pairs, reduce, infer.
|
"""iLocal-style analysis: extract (context, sequence) pairs, reduce, infer.
|
||||||
|
|
||||||
Instead of hard-coding directory as grouping key, this extracts contexts
|
Instead of hard-coding directory as grouping key, this extracts contexts
|
||||||
|
|
@ -720,7 +727,7 @@ def analyze_by_ilocal(file_paths, extension, project_root="", min_coverage=DEFAU
|
||||||
futures = {}
|
futures = {}
|
||||||
for label, seqs in context_groups.items():
|
for label, seqs in context_groups.items():
|
||||||
f = ex.submit(_infer_group, label, seqs, set(), project_root,
|
f = ex.submit(_infer_group, label, seqs, set(), project_root,
|
||||||
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, False, idregex_refine, cluster_method)
|
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure, False, idregex_refine, cluster_method, decompose, max_seq_length)
|
||||||
futures[f] = label
|
futures[f] = label
|
||||||
|
|
||||||
done = 0
|
done = 0
|
||||||
|
|
@ -749,6 +756,8 @@ def analyze_directory(
|
||||||
crx_method='standard',
|
crx_method='standard',
|
||||||
min_structure=0.0,
|
min_structure=0.0,
|
||||||
context_strategy="dir",
|
context_strategy="dir",
|
||||||
|
decompose=False,
|
||||||
|
max_seq_length=5,
|
||||||
reduce_threshold=0.15,
|
reduce_threshold=0.15,
|
||||||
split_mixed=False,
|
split_mixed=False,
|
||||||
idregex_refine=False,
|
idregex_refine=False,
|
||||||
|
|
@ -795,6 +804,8 @@ def analyze_directory(
|
||||||
split_mixed=split_mixed,
|
split_mixed=split_mixed,
|
||||||
idregex_refine=idregex_refine,
|
idregex_refine=idregex_refine,
|
||||||
cluster_method=cluster_method,
|
cluster_method=cluster_method,
|
||||||
|
decompose=decompose,
|
||||||
|
max_seq_length=max_seq_length,
|
||||||
)
|
)
|
||||||
elif slice == "reduce":
|
elif slice == "reduce":
|
||||||
results[ext] = analyze_by_reduce(
|
results[ext] = analyze_by_reduce(
|
||||||
|
|
@ -812,6 +823,8 @@ def analyze_directory(
|
||||||
reduce_threshold=reduce_threshold,
|
reduce_threshold=reduce_threshold,
|
||||||
idregex_refine=idregex_refine,
|
idregex_refine=idregex_refine,
|
||||||
cluster_method=cluster_method,
|
cluster_method=cluster_method,
|
||||||
|
decompose=decompose,
|
||||||
|
max_seq_length=max_seq_length,
|
||||||
)
|
)
|
||||||
elif slice == "ilocal":
|
elif slice == "ilocal":
|
||||||
results[ext] = analyze_by_ilocal(
|
results[ext] = analyze_by_ilocal(
|
||||||
|
|
@ -829,6 +842,8 @@ def analyze_directory(
|
||||||
context_strategy=context_strategy,
|
context_strategy=context_strategy,
|
||||||
idregex_refine=idregex_refine,
|
idregex_refine=idregex_refine,
|
||||||
cluster_method=cluster_method,
|
cluster_method=cluster_method,
|
||||||
|
decompose=decompose,
|
||||||
|
max_seq_length=max_seq_length,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
results[ext] = analyze_clusters(
|
results[ext] = analyze_clusters(
|
||||||
|
|
@ -1034,6 +1049,14 @@ def _parse_args(argv=None):
|
||||||
"--cluster-method", choices=["first-symbol", "distributional"], default="first-symbol",
|
"--cluster-method", choices=["first-symbol", "distributional"], default="first-symbol",
|
||||||
help="Method to split mixed groups: first-symbol (fast, crude) or distributional (slower, smarter clustering)",
|
help="Method to split mixed groups: first-symbol (fast, crude) or distributional (slower, smarter clustering)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--decompose", action="store_true",
|
||||||
|
help="Decompose long sequences into shorter fragments before inference",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-seq-length", type=int, default=5,
|
||||||
|
help="Maximum sequence length after decomposition (default: 5)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--idregex-refine", action="store_true",
|
"--idregex-refine", action="store_true",
|
||||||
help="Run iDRegEx on small groups (≤10 methods) where CRX grammar has many optionals — picks tighter grammar by lang_size",
|
help="Run iDRegEx on small groups (≤10 methods) where CRX grammar has many optionals — picks tighter grammar by lang_size",
|
||||||
|
|
@ -1067,6 +1090,8 @@ def main():
|
||||||
split_mixed=args.split_mixed,
|
split_mixed=args.split_mixed,
|
||||||
idregex_refine=args.idregex_refine,
|
idregex_refine=args.idregex_refine,
|
||||||
cluster_method=args.cluster_method,
|
cluster_method=args.cluster_method,
|
||||||
|
decompose=args.decompose,
|
||||||
|
max_seq_length=args.max_seq_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.json_flag or args.format == "json":
|
if args.json_flag or args.format == "json":
|
||||||
|
|
|
||||||
157
experiments/PHASE2_PLAN.md
Normal file
157
experiments/PHASE2_PLAN.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Phase 2: Decomposition Forest
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Break down complex/long behavioral sequences into shorter ones that still capture the pattern.
|
||||||
|
This helps when sequences are long and diverse, making CRX produce flat bags.
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
Long sequences like:
|
||||||
|
```
|
||||||
|
["if", "return", "if", "return", "if", "return"]
|
||||||
|
```
|
||||||
|
|
||||||
|
CRX sees 6 symbols, tries to find a pattern, often produces flat bags like `(if|return)*`.
|
||||||
|
|
||||||
|
If we decompose into shorter examples:
|
||||||
|
```
|
||||||
|
["if", "return"]
|
||||||
|
["if", "return"]
|
||||||
|
["if", "return"]
|
||||||
|
```
|
||||||
|
|
||||||
|
CRX sees a clear pattern: `if.return` (repeated).
|
||||||
|
|
||||||
|
## Crucio's Approach
|
||||||
|
|
||||||
|
Crucio uses three decomposition strategies:
|
||||||
|
1. **Binary maximum subsequence deletion**: Split in half, delete max from each half
|
||||||
|
2. **Maximum subsequence deletion**: Delete largest contiguous chunk
|
||||||
|
3. **Subsequence replacement**: Replace a chunk with a shorter version
|
||||||
|
|
||||||
|
Key insight: Decomposed sequences must preserve grammar coverage (be valid under the same grammar).
|
||||||
|
|
||||||
|
## Our Adaptation
|
||||||
|
|
||||||
|
For behavioral sequences, we need simpler decomposition:
|
||||||
|
1. **Prefix extraction**: Take first N symbols
|
||||||
|
2. **Suffix extraction**: Take last N symbols
|
||||||
|
3. **Window extraction**: Take middle N symbols
|
||||||
|
4. **Pattern extraction**: Find repeated patterns and extract one instance
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### File: `bex/decompose.py` (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Decomposition forest for behavioral sequences.
|
||||||
|
|
||||||
|
Inspired by Crucio's decomposition forest (ICSE 2026).
|
||||||
|
Breaks down long sequences into shorter ones that preserve patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decompose_sequence(seq, max_length=5):
|
||||||
|
"""Decompose a sequence into shorter fragments.
|
||||||
|
|
||||||
|
Strategies:
|
||||||
|
1. If seq <= max_length, return as-is
|
||||||
|
2. Extract prefixes of length 1..max_length
|
||||||
|
3. Extract suffixes of length 1..max_length
|
||||||
|
4. Extract windows of length max_length
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seq: List of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of fragments (shorter sequences)
|
||||||
|
"""
|
||||||
|
if len(seq) <= max_length:
|
||||||
|
return [seq]
|
||||||
|
|
||||||
|
fragments = []
|
||||||
|
|
||||||
|
# Prefixes
|
||||||
|
for i in range(1, min(max_length + 1, len(seq))):
|
||||||
|
fragments.append(seq[:i])
|
||||||
|
|
||||||
|
# Suffixes
|
||||||
|
for i in range(1, min(max_length + 1, len(seq))):
|
||||||
|
fragments.append(seq[-i:])
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
for start in range(0, len(seq) - max_length + 1):
|
||||||
|
fragments.append(seq[start:start + max_length])
|
||||||
|
|
||||||
|
return fragments
|
||||||
|
|
||||||
|
|
||||||
|
def decompose_all(sequences, max_length=5):
|
||||||
|
"""Decompose all sequences in a list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sequences: List of lists of symbols
|
||||||
|
max_length: Maximum fragment length
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of fragments (shorter sequences)
|
||||||
|
"""
|
||||||
|
all_fragments = []
|
||||||
|
for seq in sequences:
|
||||||
|
all_fragments.extend(decompose_sequence(seq, max_length))
|
||||||
|
return all_fragments
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by_coverage(fragments, min_coverage=0.5):
|
||||||
|
"""Keep only fragments that appear in at least min_coverage of original sequences.
|
||||||
|
|
||||||
|
This ensures we keep patterns that are common, not rare.
|
||||||
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# Count how many original sequences each fragment appears in
|
||||||
|
fragment_counts = Counter()
|
||||||
|
for frag in fragments:
|
||||||
|
fragment_counts[tuple(frag)] += 1
|
||||||
|
|
||||||
|
# Keep fragments that appear frequently enough
|
||||||
|
min_count = int(len(fragments) * min_coverage)
|
||||||
|
return [list(frag) for frag, count in fragment_counts.items()
|
||||||
|
if count >= min_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with Pipeline
|
||||||
|
|
||||||
|
Add `--decompose` flag:
|
||||||
|
```python
|
||||||
|
parser.add_argument('--decompose', action='store_true',
|
||||||
|
help='Decompose long sequences before inference')
|
||||||
|
parser.add_argument('--max-seq-length', type=int, default=5,
|
||||||
|
help='Maximum sequence length after decomposition')
|
||||||
|
```
|
||||||
|
|
||||||
|
In `_infer_group`:
|
||||||
|
```python
|
||||||
|
if decompose:
|
||||||
|
symbol_seqs = decompose_all(symbol_seqs, max_length=max_seq_length)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Benefits
|
||||||
|
|
||||||
|
1. **Shorter sequences**: CRX works better on shorter inputs
|
||||||
|
2. **Clearer patterns**: Decomposition reveals underlying structure
|
||||||
|
3. **Fewer flat bags**: Long diverse sequences become short uniform ones
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
1. Unit tests: `tests/test_decompose.py`
|
||||||
|
2. Integration: Compare grammar count with/without decomposition
|
||||||
|
3. Metric: `grammar_structure_score()` should improve
|
||||||
|
|
||||||
|
## Questions to Answer
|
||||||
|
|
||||||
|
1. Does decomposition actually improve grammar quality?
|
||||||
|
2. What max_length works best?
|
||||||
|
3. How much slower is it?
|
||||||
|
4. Does it help on flat bags specifically?
|
||||||
127
tests/test_decompose.py
Normal file
127
tests/test_decompose.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Tests for decomposition forest."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bex.decompose import (
|
||||||
|
decompose_sequence,
|
||||||
|
decompose_all,
|
||||||
|
decompose_with_coverage,
|
||||||
|
get_decomposition_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecomposeSequence:
|
||||||
|
"""Test sequence decomposition."""
|
||||||
|
|
||||||
|
def test_short_sequence(self):
|
||||||
|
seq = ["if", "return"]
|
||||||
|
result = decompose_sequence(seq, max_length=5)
|
||||||
|
# Short sequences returned as-is
|
||||||
|
assert result == [seq]
|
||||||
|
|
||||||
|
def test_empty_sequence(self):
|
||||||
|
result = decompose_sequence([], max_length=5)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_long_sequence(self):
|
||||||
|
seq = ["if", "return", "if", "return", "if", "return"]
|
||||||
|
result = decompose_sequence(seq, max_length=3)
|
||||||
|
|
||||||
|
# Should have prefixes, suffixes, and windows
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
# All fragments should be <= max_length
|
||||||
|
for frag in result:
|
||||||
|
assert len(frag) <= 3
|
||||||
|
|
||||||
|
def test_prefixes(self):
|
||||||
|
seq = ["a", "b", "c", "d"]
|
||||||
|
result = decompose_sequence(seq, max_length=2)
|
||||||
|
|
||||||
|
# Should include prefixes: [a], [a,b]
|
||||||
|
assert ["a"] in result
|
||||||
|
assert ["a", "b"] in result
|
||||||
|
|
||||||
|
def test_suffixes(self):
|
||||||
|
seq = ["a", "b", "c", "d"]
|
||||||
|
result = decompose_sequence(seq, max_length=2)
|
||||||
|
|
||||||
|
# Should include suffixes: [d], [c,d]
|
||||||
|
assert ["d"] in result
|
||||||
|
assert ["c", "d"] in result
|
||||||
|
|
||||||
|
def test_windows(self):
|
||||||
|
seq = ["a", "b", "c", "d", "e"]
|
||||||
|
result = decompose_sequence(seq, max_length=3)
|
||||||
|
|
||||||
|
# Should include windows: [a,b,c], [b,c,d], [c,d,e]
|
||||||
|
assert ["a", "b", "c"] in result
|
||||||
|
assert ["b", "c", "d"] in result
|
||||||
|
assert ["c", "d", "e"] in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecomposeAll:
|
||||||
|
"""Test decomposing multiple sequences."""
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
seqs = [
|
||||||
|
["if", "return", "if"],
|
||||||
|
["while", "return"],
|
||||||
|
]
|
||||||
|
result = decompose_all(seqs, max_length=2)
|
||||||
|
|
||||||
|
# Should have fragments from both sequences
|
||||||
|
assert len(result) > len(seqs)
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
result = decompose_all([], max_length=5)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecomposeWithCoverage:
|
||||||
|
"""Test decomposition with coverage filtering."""
|
||||||
|
|
||||||
|
def test_filter_rare(self):
|
||||||
|
seqs = [
|
||||||
|
["if", "return", "if", "return"],
|
||||||
|
["if", "return", "if", "return"],
|
||||||
|
["if", "return", "if", "return"],
|
||||||
|
["rare", "other", "rare", "other"], # Only 1/4 have "rare"
|
||||||
|
]
|
||||||
|
result = decompose_with_coverage(seqs, max_length=2, min_coverage=0.5)
|
||||||
|
|
||||||
|
# "rare" fragments should be filtered out
|
||||||
|
result_strs = [str(f) for f in result]
|
||||||
|
assert not any("rare" in s for s in result_strs)
|
||||||
|
|
||||||
|
def test_keep_common(self):
|
||||||
|
seqs = [
|
||||||
|
["if", "return", "if"],
|
||||||
|
["if", "return", "if"],
|
||||||
|
["if", "return", "if"],
|
||||||
|
]
|
||||||
|
result = decompose_with_coverage(seqs, max_length=2, min_coverage=0.5)
|
||||||
|
|
||||||
|
# "if" and "return" fragments should be kept
|
||||||
|
result_strs = [str(f) for f in result]
|
||||||
|
assert any("if" in s for s in result_strs)
|
||||||
|
assert any("return" in s for s in result_strs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecompositionStats:
|
||||||
|
"""Test decomposition statistics."""
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
seqs = [
|
||||||
|
["if", "return", "if", "return"],
|
||||||
|
["while", "return"],
|
||||||
|
]
|
||||||
|
stats = get_decomposition_stats(seqs, max_length=2)
|
||||||
|
|
||||||
|
assert stats['n_original'] == 2
|
||||||
|
assert stats['n_fragments'] > 2
|
||||||
|
assert stats['expansion_ratio'] > 1
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
stats = get_decomposition_stats([], max_length=5)
|
||||||
|
assert stats['n_original'] == 0
|
||||||
|
assert stats['n_fragments'] == 0
|
||||||
Loading…
Add table
Reference in a new issue