From 1e0cca1ef397db4d781da8bd05caf72717f46dc6 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 31 Jul 2025 15:05:41 +0100 Subject: [PATCH] feat(frontend): big O complexity estimator --- frontend/src/lib/complexity-analyzer.ts | 398 ++++++++++++++++++++++++ frontend/src/lib/python-helpers.ts | 200 ++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 frontend/src/lib/complexity-analyzer.ts create mode 100644 frontend/src/lib/python-helpers.ts diff --git a/frontend/src/lib/complexity-analyzer.ts b/frontend/src/lib/complexity-analyzer.ts new file mode 100644 index 0000000..ff853bb --- /dev/null +++ b/frontend/src/lib/complexity-analyzer.ts @@ -0,0 +1,398 @@ +/** + * Python code for analyzing Big O complexity of user solutions. + * Runs in Pyodide and returns an estimate with explanation. + */ + +export const COMPLEXITY_ANALYZER = ` +import ast +import json + +class ComplexityAnalyzer(ast.NodeVisitor): + """Analyzes Python code to estimate Big O complexity.""" + + def __init__(self, source_code): + self.source_code = source_code + self.source_lines = source_code.split('\\n') + self.findings = [] + self.loop_depth = 0 + self.max_loop_depth = 0 + self.has_recursion = False + self.recursive_calls = [] + self.has_sorting = False + self.has_linear_search = False + self.has_binary_search_pattern = False + self.function_name = None + self.input_params = [] + # Track variables that are dicts or sets (O(1) lookup) + self.hash_containers = set() + # Track variables that are lists (O(n) lookup) + self.list_containers = set() + + def get_line_snippet(self, lineno): + """Get a short snippet of the line.""" + if 1 <= lineno <= len(self.source_lines): + line = self.source_lines[lineno - 1].strip() + if len(line) > 50: + return line[:47] + "..." + return line + return "" + + def visit_FunctionDef(self, node): + if self.function_name is None: + self.function_name = node.name + self.input_params = [arg.arg for arg in node.args.args] + # Input params that look like lists + for arg in node.args.args: + if arg.annotation: + ann_str = self._node_to_str(arg.annotation) + if 'list' in ann_str.lower() or 'List' in ann_str: + self.list_containers.add(arg.arg) + self.generic_visit(node) + + def visit_Assign(self, node): + """Track variable assignments to identify container types.""" + for target in node.targets: + if isinstance(target, ast.Name): + var_name = target.id + # Check what's being assigned + if isinstance(node.value, ast.Dict): + self.hash_containers.add(var_name) + elif isinstance(node.value, ast.Set): + self.hash_containers.add(var_name) + elif isinstance(node.value, ast.Call): + if isinstance(node.value.func, ast.Name): + func_name = node.value.func.id + if func_name in ('dict', 'set', 'defaultdict', 'Counter', 'OrderedDict'): + self.hash_containers.add(var_name) + elif func_name == 'list': + self.list_containers.add(var_name) + elif isinstance(node.value, ast.List): + self.list_containers.add(var_name) + self.generic_visit(node) + + def visit_For(self, node): + self.loop_depth += 1 + self.max_loop_depth = max(self.max_loop_depth, self.loop_depth) + + # Determine what we're iterating over + iter_desc = self._describe_iterator(node.iter) + + self.findings.append({ + "type": "loop", + "kind": "for", + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno), + "depth": self.loop_depth, + "iterates_over": iter_desc + }) + + self.generic_visit(node) + self.loop_depth -= 1 + + def visit_While(self, node): + self.loop_depth += 1 + self.max_loop_depth = max(self.max_loop_depth, self.loop_depth) + + # Check for binary search pattern (low < high, left <= right, etc.) + condition = self._get_condition_pattern(node.test) + + self.findings.append({ + "type": "loop", + "kind": "while", + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno), + "depth": self.loop_depth, + "condition": condition + }) + + # Check body for binary search indicators (mid = ..., //= 2, etc.) + for child in ast.walk(node): + if isinstance(child, ast.BinOp) and isinstance(child.op, ast.FloorDiv): + if isinstance(child.right, ast.Constant) and child.right.value == 2: + self.has_binary_search_pattern = True + + self.generic_visit(node) + self.loop_depth -= 1 + + def visit_Call(self, node): + # Check for recursive calls + if isinstance(node.func, ast.Name) and node.func.id == self.function_name: + self.has_recursion = True + self.recursive_calls.append({ + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno) + }) + + # Check for sorting + if isinstance(node.func, ast.Attribute): + if node.func.attr in ('sort', 'sorted'): + self.has_sorting = True + self.findings.append({ + "type": "operation", + "kind": "sort", + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno), + "complexity": "O(n log n)" + }) + elif isinstance(node.func, ast.Name) and node.func.id == 'sorted': + self.has_sorting = True + self.findings.append({ + "type": "operation", + "kind": "sort", + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno), + "complexity": "O(n log n)" + }) + + self.generic_visit(node) + + def visit_Compare(self, node): + # Check for "x in container" - O(1) for dict/set, O(n) for list + for op in node.ops: + if isinstance(op, (ast.In, ast.NotIn)): + for comparator in node.comparators: + if isinstance(comparator, ast.Name): + var_name = comparator.id + # Check if it's a known hash container (O(1)) + if var_name in self.hash_containers: + # O(1) lookup, no need to flag + pass + # Check if it's a known list or input param that's a list (O(n)) + elif var_name in self.list_containers: + if self.loop_depth > 0: + self.has_linear_search = True + self.findings.append({ + "type": "operation", + "kind": "linear_search", + "line": node.lineno, + "snippet": self.get_line_snippet(node.lineno), + "note": f"'in' on list '{var_name}' is O(n)" + }) + # Unknown variable - could be either, don't flag + self.generic_visit(node) + + def _describe_iterator(self, node): + """Describe what a for loop is iterating over.""" + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name): + if node.func.id == 'range': + if node.args: + return self._describe_range_args(node.args) + elif node.func.id == 'enumerate': + if node.args: + return f"enumerate({self._node_to_str(node.args[0])})" + elif node.func.id == 'zip': + return "zip(...)" + elif isinstance(node, ast.Name): + return node.id + elif isinstance(node, ast.Attribute): + return f"{self._node_to_str(node.value)}.{node.attr}" + elif isinstance(node, ast.Subscript): + return self._node_to_str(node) + return "iterable" + + def _describe_range_args(self, args): + """Describe range() arguments.""" + if len(args) == 1: + return f"range({self._node_to_str(args[0])})" + elif len(args) >= 2: + return f"range({self._node_to_str(args[0])}, {self._node_to_str(args[1])})" + return "range(...)" + + def _node_to_str(self, node): + """Convert an AST node to a simple string representation.""" + if isinstance(node, ast.Name): + return node.id + elif isinstance(node, ast.Constant): + return str(node.value) + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Name): + return f"{node.func.id}(...)" + elif isinstance(node, ast.Attribute): + return f"{self._node_to_str(node.value)}.{node.attr}" + elif isinstance(node, ast.BinOp): + left = self._node_to_str(node.left) + right = self._node_to_str(node.right) + op = self._op_to_str(node.op) + return f"{left} {op} {right}" + elif isinstance(node, ast.Subscript): + return f"{self._node_to_str(node.value)}[...]" + return "..." + + def _op_to_str(self, op): + """Convert operator to string.""" + ops = { + ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", + ast.FloorDiv: "//", ast.Mod: "%", ast.Pow: "**" + } + return ops.get(type(op), "?") + + def _get_condition_pattern(self, node): + """Describe a while loop condition.""" + return self._node_to_str(node) + + def analyze(self): + """Run the analysis and return results.""" + try: + tree = ast.parse(self.source_code) + self.visit(tree) + except SyntaxError as e: + return { + "success": False, + "error": f"Syntax error: {e}" + } + + # Determine overall complexity + complexity, explanation = self._determine_complexity() + + return { + "success": True, + "complexity": complexity, + "explanation": explanation, + "details": { + "max_loop_depth": self.max_loop_depth, + "has_recursion": self.has_recursion, + "has_sorting": self.has_sorting, + "has_binary_search_pattern": self.has_binary_search_pattern, + "findings": self.findings, + "recursive_calls": self.recursive_calls + } + } + + def _determine_complexity(self): + """Determine the overall complexity based on findings.""" + explanations = [] + complexity_factors = [] + + # Handle recursion + if self.has_recursion: + if self.has_binary_search_pattern: + complexity_factors.append(("log n", "binary search recursion")) + explanations.append("Recursion with halving pattern suggests O(log n)") + else: + complexity_factors.append(("?", "recursion detected")) + explanations.append(f"Recursion found - complexity depends on recurrence relation") + for rc in self.recursive_calls: + explanations.append(f" Line {rc['line']}: {rc['snippet']}") + + # Handle loops + if self.max_loop_depth > 0: + loop_findings = [f for f in self.findings if f["type"] == "loop"] + + if self.has_binary_search_pattern and self.max_loop_depth == 1: + complexity_factors.append(("log n", "binary search loop")) + explanations.append("While loop with halving pattern: O(log n)") + else: + # Describe each loop level + for finding in loop_findings: + snippet = finding.get("snippet", "") + depth_info = f" (nested, depth {finding['depth']})" if finding['depth'] > 1 else "" + if finding["kind"] == "for": + iter_over = finding.get("iterates_over", "iterable") + explanations.append( + f"Loop over {iter_over}{depth_info} → O(n)" + ) + if snippet: + explanations.append(f" └─ {snippet}") + else: + condition = finding.get("condition", "...") + explanations.append( + f"While loop{depth_info}" + ) + if snippet: + explanations.append(f" └─ {snippet}") + + # Estimate based on nesting + if self.max_loop_depth == 1: + complexity_factors.append(("n", "single loop")) + elif self.max_loop_depth == 2: + complexity_factors.append(("n²", "nested loops")) + elif self.max_loop_depth == 3: + complexity_factors.append(("n³", "triple nested loops")) + else: + complexity_factors.append((f"n^{self.max_loop_depth}", f"{self.max_loop_depth} nested loops")) + + # Handle sorting + if self.has_sorting: + complexity_factors.append(("n log n", "sorting")) + sort_findings = [f for f in self.findings if f.get("kind") == "sort"] + for sf in sort_findings: + explanations.append(f"Line {sf['line']}: sorting operation O(n log n)") + + # Handle linear search in loop + if self.has_linear_search and self.max_loop_depth >= 1: + explanations.append("Note: 'in' operator on list inside loop adds O(n) per iteration") + # This could bump n to n², but let's just note it + + # Combine factors to get final complexity + if not complexity_factors: + return "O(1)", ["No loops or recursion detected - constant time"] + + # Take the dominant term + complexity = self._get_dominant_complexity(complexity_factors) + + return f"O({complexity})", explanations + + def _get_dominant_complexity(self, factors): + """Get the dominant complexity from a list of factors.""" + # Priority order (higher = more complex) + priority = { + "1": 0, "log n": 1, "n": 2, "n log n": 3, + "n²": 4, "n³": 5, "n^4": 6, "2^n": 7, "n!": 8, "?": 10 + } + + dominant = "1" + for factor, _ in factors: + if factor.startswith("n^"): + try: + exp = int(factor[2:]) + if exp > priority.get(dominant, 0): + dominant = factor + except: + pass + elif priority.get(factor, 0) > priority.get(dominant, 0): + dominant = factor + + # Handle sorting + loops + has_sort = any(f[0] == "n log n" for f in factors) + has_n2 = any(f[0] == "n²" for f in factors) + + if has_sort and not has_n2 and dominant == "n": + return "n log n" + + return dominant + + +def analyze_complexity(code): + """Main entry point for complexity analysis.""" + analyzer = ComplexityAnalyzer(code) + return analyzer.analyze() +`.trim(); + +export interface ComplexityResult { + success: boolean; + error?: string; + complexity?: string; + explanation?: string[]; + details?: { + max_loop_depth: number; + has_recursion: boolean; + has_sorting: boolean; + has_binary_search_pattern: boolean; + findings: Array<{ + type: string; + kind: string; + line: number; + snippet: string; + depth?: number; + iterates_over?: string; + condition?: string; + complexity?: string; + note?: string; + }>; + recursive_calls: Array<{ + line: number; + snippet: string; + }>; + }; +} diff --git a/frontend/src/lib/python-helpers.ts b/frontend/src/lib/python-helpers.ts new file mode 100644 index 0000000..92cb3b2 --- /dev/null +++ b/frontend/src/lib/python-helpers.ts @@ -0,0 +1,200 @@ +/** + * Python helper code and detection utilities for the test runner. + * + * This module provides Python code as string constants for data structure + * classes, conversion functions, and utilities to detect problem types. + */ + +// ============================================================================= +// Data Structure Classes +// ============================================================================= + +export const TREE_NODE_CLASS = ` +class TreeNode: + def __init__(self, val=0, left=None, right=None): + self.val = val + self.left = left + self.right = right +`.trim(); + +export const LIST_NODE_CLASS = ` +class ListNode: + def __init__(self, val=0, next=None): + self.val = val + self.next = next +`.trim(); + +// ============================================================================= +// Conversion Functions +// ============================================================================= + +/** + * Builds a binary tree from a level-order array representation. + * Example: [1, 2, 3, null, 4] -> TreeNode(1, TreeNode(2, None, TreeNode(4)), TreeNode(3)) + */ +export const BUILD_TREE = ` +def __build_tree(arr): + if not arr or arr[0] is None: + return None + root = TreeNode(arr[0]) + queue = [root] + i = 1 + while queue and i < len(arr): + node = queue.pop(0) + if i < len(arr) and arr[i] is not None: + node.left = TreeNode(arr[i]) + queue.append(node.left) + i += 1 + if i < len(arr) and arr[i] is not None: + node.right = TreeNode(arr[i]) + queue.append(node.right) + i += 1 + return root +`.trim(); + +/** + * Converts a binary tree to a level-order array representation. + * Example: TreeNode(1, TreeNode(2), TreeNode(3)) -> [1, 2, 3] + */ +export const TREE_TO_ARRAY = ` +def __tree_to_array(root): + if root is None: + return [] + result = [] + queue = [root] + while queue: + node = queue.pop(0) + if node is None: + result.append(None) + else: + result.append(node.val) + queue.append(node.left) + queue.append(node.right) + # Trim trailing nulls + while result and result[-1] is None: + result.pop() + return result +`.trim(); + +/** + * Builds a linked list from an array. + * Example: [1, 2, 3] -> ListNode(1, ListNode(2, ListNode(3))) + */ +export const BUILD_LIST = ` +def __build_list(arr): + if not arr: + return None + head = ListNode(arr[0]) + current = head + for val in arr[1:]: + current.next = ListNode(val) + current = current.next + return head +`.trim(); + +/** + * Converts a linked list to an array. + * Example: ListNode(1, ListNode(2, ListNode(3))) -> [1, 2, 3] + */ +export const LIST_TO_ARRAY = ` +def __list_to_array(head): + result = [] + current = head + while current: + result.append(current.val) + current = current.next + return result +`.trim(); + +// ============================================================================= +// Problem Type Detection +// ============================================================================= + +/** Parameter names that indicate a tree-based problem */ +export const TREE_PARAMS = ["root", "tree", "p", "q"]; + +/** Parameter names that indicate a linked-list problem */ +export const LIST_PARAMS = ["head", "l1", "l2", "list1", "list2", "node"]; + +export type ProblemType = "simple" | "tree" | "linkedlist" | "class-based"; + +/** + * Detects the problem type based on the function signature and input. + * + * @param signature - The function signature (e.g., "def two_sum(nums, target):") + * @param input - The test case input object + * @returns The detected problem type + */ +export function detectProblemType( + signature: string, + input: Record +): ProblemType { + // Class-based: check if input has "operations" key + if ("operations" in input) { + return "class-based"; + } + + // Check signature for type hints + const hasTreeNode = signature.includes("TreeNode"); + const hasListNode = signature.includes("ListNode"); + + if (hasTreeNode) { + return "tree"; + } + + if (hasListNode) { + return "linkedlist"; + } + + // Check parameter names in signature and input keys + const inputKeys = Object.keys(input); + + const hasTreeParam = + TREE_PARAMS.some((param) => signature.includes(param)) || + inputKeys.some((key) => TREE_PARAMS.includes(key)); + + const hasListParam = + LIST_PARAMS.some((param) => signature.includes(param)) || + inputKeys.some((key) => LIST_PARAMS.includes(key)); + + if (hasTreeParam) { + return "tree"; + } + + if (hasListParam) { + return "linkedlist"; + } + + return "simple"; +} + +/** + * Gets the Python helper code needed for a given problem type. + */ +export function getPythonHelpers(problemType: ProblemType): string { + switch (problemType) { + case "tree": + return [TREE_NODE_CLASS, BUILD_TREE, TREE_TO_ARRAY].join("\n\n"); + case "linkedlist": + return [LIST_NODE_CLASS, BUILD_LIST, LIST_TO_ARRAY].join("\n\n"); + case "class-based": + return ""; // Class-based problems define their own class + case "simple": + default: + return ""; + } +} + +/** + * Gets the list of parameter names that need tree conversion. + */ +export function getTreeParams(input: Record): string[] { + return Object.keys(input).filter((key) => TREE_PARAMS.includes(key)); +} + +/** + * Gets the list of parameter names that need linked list conversion. + */ +export function getListParams(input: Record): string[] { + return Object.keys(input).filter((key) => LIST_PARAMS.includes(key)); +}