File size: 16,662 Bytes
fed9343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ad067c
fed9343
 
 
 
 
 
 
 
 
 
6ad067c
fed9343
 
 
 
 
 
6ad067c
 
fed9343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
import networkx as nx
import time
import json
import io
import matplotlib.pyplot as plt
import pandas as pd
from typing import List, Dict, Any, Optional
from langchain_openai import ChatOpenAI
from langchain_ollama import ChatOllama
from pydantic import BaseModel, Field
from datetime import datetime

# --- Structured Output for Evaluator ---
class EvalResponse(BaseModel):
    """The model's selected answer ID."""
    choice_id: int = Field(description="The index of the correct choice (0, 1, 2, or 3).")

# --- Evaluation Engine ---
class EvaluationEngine:
    def __init__(self, models: List[str], failure_threshold: int, provider: str = "OpenAI", api_key: Optional[str] = None):
        """
        models: List of model names sorted from smallest to largest capability.
        failure_threshold: Number of allowed failures along a path before upgrading the model.
        provider: 'OpenAI' or 'Ollama'.
        api_key: Optional OpenAI API key.
        """
        self.models = models
        self.threshold = failure_threshold
        self.provider = provider
        self.api_key = api_key
        self.results_map = {} # To store node-specific results for reporting

    def run_cascade_eval(self, df: pd.DataFrame):
        # 1. Build Directed Acyclic Graph (DAG)
        G = nx.DiGraph()
        node_data = {}
        for _, row in df.iterrows():
            node_id = str(row['id'])
            G.add_node(node_id)
            node_data[node_id] = row.to_dict()
            if row['parent_id'] and str(row['parent_id']) != "None":
                G.add_edge(str(row['parent_id']), node_id)

        # 2. Identify Root Nodes
        roots = [n for n, d in G.in_degree() if d == 0]
        
        # 3. DFS Traversal with Stack State
        # Stack structure: (node_id, current_model_idx, path_failures, path_successes, is_skipped)
        stack = []
        for root in reversed(roots): # Process roots
            stack.append((root, 0, 0, 0, False))

        visited = set()
        final_results = []

        while stack:
            node_id, model_idx, p_fail, p_succ, is_skipped = stack.pop()
            
            if node_id in visited:
                continue
            visited.add(node_id)

            # Record for this node
            node_info = node_data[node_id]
            result_entry = {
                "id": node_id,
                "parent_id": node_info.get('parent_id'),
                "tags": node_info.get('tags'),
                "ground_truth": node_info.get('correct_idx')
            }

            # Handle Propagation of Failure
            if is_skipped:
                result_entry.update({
                    "used_model": "failed_all (skipped)",
                    "prediction": -1,
                    "path_successes": p_succ,
                    "path_failures": p_fail,
                    "inference_time": 0,
                    "status": "skipped"
                })
                self.results_map[node_id] = result_entry
                final_results.append(result_entry)
                
                # Push children to stack as skipped
                for child in reversed(list(G.successors(node_id))):
                    stack.append((child, model_idx, p_fail, p_succ, True))
                continue

            # --- MODEL EVALUATION / UPGRADE LOOP ---
            success = False
            curr_idx = model_idx
            
            while curr_idx < len(self.models):
                model_name = self.models[curr_idx]
                start_time = time.time()
                
                try:
                    prediction = self._call_model(model_name, node_info)
                    inf_time = time.time() - start_time
                    
                    if prediction == int(node_info['correct_idx']):
                        # SUCCESS
                        result_entry.update({
                            "used_model": model_name,
                            "prediction": prediction,
                            "path_successes": p_succ + 1,
                            "path_failures": p_fail,
                            "inference_time": inf_time,
                            "status": "success"
                        })
                        success = True
                        break
                    else:
                        # FAILURE (Wrong Answer)
                        p_fail += 1
                        if p_fail > self.threshold:
                            # Upgrade search: try next model on same question
                            curr_idx += 1
                            continue # Loop to next model
                        else:
                            # Failure recorded but stay with current model for now
                            result_entry.update({
                                "used_model": model_name,
                                "prediction": prediction,
                                "path_successes": p_succ,
                                "path_failures": p_fail,
                                "inference_time": inf_time,
                                "status": "fail"
                            })
                            break # Move to children
                except Exception as e:
                    # API EXCEPTION counts as failure
                    print(f"Error calling {model_name}: {e}")
                    curr_idx += 1
                    p_fail += 1
            
            if not success and curr_idx >= len(self.models):
                # ALL MODELS FAILED
                result_entry.update({
                    "used_model": "failed_all",
                    "prediction": -1,
                    "path_successes": p_succ,
                    "path_failures": p_fail,
                    "inference_time": 0,
                    "status": "critical_failure"
                })
                is_skipped_for_children = True
            else:
                is_skipped_for_children = False

            self.results_map[node_id] = result_entry
            final_results.append(result_entry)

            # 4. Push children with persistent state
            # If successful, we use the potentially 'upgraded' model for all descendants
            for child in reversed(list(G.successors(node_id))):
                stack.append((child, curr_idx, p_fail, p_succ + (1 if success else 0), is_skipped_for_children))

        return pd.DataFrame(final_results)

    def _call_model(self, model_name: str, node_info: Dict) -> int:
        """Invokes chosen model with tool-calling for the answer ID."""
        if self.provider == "Ollama":
            llm = ChatOllama(model=model_name, temperature=0, format="json").with_structured_output(EvalResponse)
        else:
            llm = ChatOpenAI(model=model_name, temperature=0, openai_api_key=self.api_key).with_structured_output(EvalResponse)
        
        prompt = (
            f"Question: {node_info['question']}\n\n"
            f"Choices:\n{node_info['choices']}\n\n"
            "Identify the correct choice index (0, 1, 2, or 3). Respond with JSON matching the required schema."
        )
        
        res = llm.invoke(prompt)
        return int(res.choice_id)

    def generate_visualization(self, results_df):
        """Generates the NetworkX graph plot using a radial shell layout."""
        G = nx.DiGraph()
        color_map = {}
        labels = {}
        
        # 1. Build the graph for positioning
        for _, row in results_df.iterrows():
            node_id = str(row['id'])
            G.add_node(node_id)
            if row['parent_id'] and str(row['parent_id']) != "None":
                G.add_edge(str(row['parent_id']), node_id)
            
            status = row.get('status', 'success')
            color_map[node_id] = 'lightcoral' if status in ['fail', 'critical_failure', 'skipped'] else 'lightblue'
            
            labels[node_id] = (
                f"ID: {node_id}\n"
                f"M: {row['used_model']}"
            )

        colors = [color_map.get(node, 'lightgray') for node in G.nodes()]

        # 2. Calculate shells based on depth
        roots = [n for n, d in G.in_degree() if d == 0]
        shells = [roots]
        
        # Iterate to find subsequent layers
        current_layer = roots
        visited = set(roots)
        while True:
            next_layer = []
            for node in current_layer:
                for neighbor in G.successors(node):
                    if neighbor not in visited:
                        next_layer.append(neighbor)
                        visited.add(neighbor)
            if not next_layer:
                break
            shells.append(next_layer)
            current_layer = next_layer

        # 3. Apply shell layout
        pos = nx.shell_layout(G, shells)

        plt.figure(figsize=(10, 10))
        nx.draw(G, pos, with_labels=True, labels=labels, 
                node_color=colors, node_size=2500, font_size=8, 
                font_weight='bold', font_color='black', 
                edge_color='gray', arrows=True, arrowsize=15)
        
        buf = io.BytesIO()
        plt.savefig(buf, format='png', bbox_inches='tight', dpi=150)
        plt.close()
        buf.seek(0)
        return buf.read()

    def generate_summary(self, df: pd.DataFrame) -> str:
        """Generates textual summary report including failed/skipped nodes."""
        summary = "### 📊 Compression Strategy Summary\n\n"
        
        # Aggregate stats for ALL outcomes (including failures/skips)
        stats = df.groupby('used_model').agg({
            'id': 'count',
            'status': lambda x: (x == 'success').sum(),
            'inference_time': 'mean'
        }).rename(columns={'id': 'Total Count', 'status': 'Successes', 'inference_time': 'Avg Time (s)'})
        
        summary += stats.to_markdown() + "\n\n"
        
        total_q = len(df)
        total_s = (df['status'] == 'success').sum()
        summary += f"**Overall Accuracy:** {total_s/total_q:.1%} ({total_s}/{total_q})"
        
        return summary

    def get_tag_sets(self, df: pd.DataFrame, apply_monotonic: bool = False) -> Dict[str, set]:
        """
        Aggregates unique tags. 
        If apply_monotonic is True: 
        - Pass tags of smaller models are inherited by larger ones.
        - Failure sets are defined by the limits of the largest model.
        """
        tag_sets = {}
        
        def parse_tags(t):
            if isinstance(t, list): return set(t)
            try: return set(json.loads(t))
            except: return set()

        # Identify models in ascending order of capability (based on appearance order)
        all_used_models = df['used_model'].unique().tolist()
        ordered_models = [m for m in all_used_models if m not in ["failed_all", "skipped"] and "skipped" not in m]
        
        # 1. Per-model Pass/Fail sets
        model_pass_data = {}
        model_fail_data = {}

        for model in ordered_models:
            m_df = df[df['used_model'] == model]
            pass_tags = set().union(*(m_df[m_df['status'] == 'success']['tags'].apply(parse_tags)))
            fail_tags = set().union(*(m_df[m_df['status'] == 'fail']['tags'].apply(parse_tags)))
            
            model_pass_data[model] = pass_tags
            model_fail_data[model] = fail_tags

        if apply_monotonic:
            # Cumulative union: Model N inherits all tags from Model N-1
            accumulated_pass = set()
            for model in ordered_models:
                accumulated_pass = accumulated_pass.union(model_pass_data[model])
                tag_sets[f"{model} (Pass)"] = set(accumulated_pass)
                # For monotonic fail, we only care about what the model actually failed
                tag_sets[f"{model} (Fail)"] = model_fail_data[model]
        else:
            for model in ordered_models:
                if model_pass_data[model]: tag_sets[f"{model} (Pass)"] = model_pass_data[model]
                if model_fail_data[model]: tag_sets[f"{model} (Fail)"] = model_fail_data[model]

        # 2. Global Failure sets
        if apply_monotonic and ordered_models:
            # Largest model defines the baseline of 'Failed All'
            largest_model = ordered_models[-1]
            f_all_tags = tag_sets.get(f"{largest_model} (Fail)", set())
            f_all_tags |= tag_sets.get(f"{largest_model} (Pass)", set())
            failed_all_df = df[df['used_model'] == "failed_all"]
            f_all_tags |= set().union(*(failed_all_df['tags'].apply(parse_tags)))
            tag_sets["Failed All"] = f_all_tags
            
            # 'Skipped' inherits everything from 'Failed All' (representing extreme complexity)
            skipped_df = df[df['used_model'].str.contains("skipped", na=False)]
            s_tags = set().union(*(skipped_df['tags'].apply(parse_tags)))
            tag_sets["Skipped"] = s_tags.union(f_all_tags)
        else:
            failed_all_df = df[df['used_model'] == "failed_all"]
            skipped_df = df[df['used_model'].str.contains("skipped", na=False)]
            tag_sets["Failed All"] = set().union(*(failed_all_df['tags'].apply(parse_tags)))
            tag_sets["Skipped"] = set().union(*(skipped_df['tags'].apply(parse_tags)))
        
        return tag_sets

    def plot_tag_venn(self, tag_sets: Dict[str, set], selected_names: List[str]):
        """Generates a Venn diagram for selected tag sets with a separate legend."""
        from matplotlib_venn import venn2, venn3
        import matplotlib.pyplot as plt
        from matplotlib.lines import Line2D
        
        if len(selected_names) < 2:
            return None, "Select at least 2 sets to compare."
        
        fig, ax = plt.subplots(figsize=(10, 8))
        sets = [tag_sets[name] for name in selected_names]
        
        # 1. Create the Venn without labels over the circles
        if len(selected_names) == 2:
            v = venn2(sets, set_labels=["", ""])
        else:
            v = venn3(sets[:3], set_labels=["", "", ""])
            
        # 2. Extract colors for the legend
        # Primary circle IDs: '10' and '01' for 2 sets; '100', '010', '001' for 3 sets
        ids = ['10', '01'] if len(selected_names) == 2 else ['100', '010', '001']
        legend_elements = []
        
        for i, patch_id in enumerate(ids):
            patch = v.get_patch_by_id(patch_id)
            color = patch.get_facecolor() if patch else "gray"
            legend_elements.append(Line2D([0], [0], marker='o', color='w', label=selected_names[i],
                                          markerfacecolor=color, markersize=15))
        
        plt.legend(handles=legend_elements, title="Tag Sets", loc='upper left', bbox_to_anchor=(1, 1))
        plt.title(f"Tag Set Intersections: {', '.join(selected_names)}", pad=20)
        
        # Capture plot
        buf = io.BytesIO()
        plt.savefig(buf, format='png', bbox_inches='tight', dpi=120)
        plt.close()
        buf.seek(0)
        
        # Also return textual intersections for UI display
        intersections_report = f"### 🏷️ Tag Intersections Details ({', '.join(selected_names)})\n\n"
        if len(selected_names) == 2:
            s1, s2 = sets[0], sets[1]
            intersections_report += f"**Only {selected_names[0]}:** {sorted(list(s1 - s2))}\n\n"
            intersections_report += f"**Overlap:** {sorted(list(s1 & s2))}\n\n"
            intersections_report += f"**Only {selected_names[1]}:** {sorted(list(s2 - s1))}\n\n"
        elif len(selected_names) == 3:
            s1, s2, s3 = sets[0], sets[1], sets[2]
            intersections_report += f"**Triple Overlap ({selected_names[0]} & {selected_names[1]} & {selected_names[2]}):** {sorted(list(s1 & s2 & s3))}\n\n"
            intersections_report += f"**Shared ({selected_names[0]} & {selected_names[1]} but NOT {selected_names[2]}):** {sorted(list((s1 & s2) - s3))}\n\n"
            intersections_report += f"**Shared ({selected_names[0]} & {selected_names[2]} but NOT {selected_names[1]}):** {sorted(list((s1 & s3) - s2))}\n\n"
            intersections_report += f"**Shared ({selected_names[1]} & {selected_names[2]} but NOT {selected_names[0]}):** {sorted(list((s2 & s3) - s1))}\n\n"
            intersections_report += f"**Only {selected_names[0]}:** {sorted(list(s1 - s2 - s3))}\n\n"
            intersections_report += f"**Only {selected_names[1]}:** {sorted(list(s2 - s1 - s3))}\n\n"
            intersections_report += f"**Only {selected_names[2]}:** {sorted(list(s3 - s1 - s2))}\n\n"
        
        return buf.read(), intersections_report