Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:53:40.327Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

score.rs

929 lines · 33.4 KB · rust
1use crate::{
2    PredictArgs, PredictionProvider,
3    example::Example,
4    format_prompt::TeacherPrompt,
5    headless::EpAppState,
6    parse_output::parse_prediction_output,
7    predict::run_prediction,
8    progress::{ExampleProgress, Step},
9};
10use anyhow::Context as _;
11use edit_prediction_context::limit_retrieved_context_to_bytes;
12use edit_prediction_metrics::{
13    ActualPredictionCursor, Excerpt, PredictionReversalContext, PredictionScoringInput,
14};
15use gpui::{AppContext as _, AsyncApp};
16use std::fs::File;
17use std::io::BufWriter;
18use std::path::Path;
19use std::sync::Arc;
20use zeta_prompt::{ContextSource, RelatedFile};
21
22pub const EVAL_RELATED_CONTEXT_TOKENS_LIMIT: usize = 4000;
23
24pub async fn run_scoring(
25    example: &mut Example,
26    args: &PredictArgs,
27    app_state: Arc<EpAppState>,
28    example_progress: &ExampleProgress,
29    cx: AsyncApp,
30    allow_missing_predictions: bool,
31    retrieved_context_byte_limit: Option<usize>,
32    context_source_filter: Option<Vec<ContextSource>>,
33) -> anyhow::Result<()> {
34    if !(allow_missing_predictions && args.provider.is_none() && example.predictions.is_empty()) {
35        run_prediction(example, args, app_state, example_progress, cx.clone()).await?;
36    }
37
38    let progress = example_progress.start(Step::Score);
39
40    progress.set_substatus("computing metrics");
41    let example_for_scoring = example.clone();
42    example.score = cx
43        .background_spawn(async move {
44            let prompt_inputs = example_for_scoring
45                .prompt_inputs
46                .as_ref()
47                .context("prompt_inputs is required for scoring - run prediction first or ensure JSON includes prompt_inputs")?;
48            let original_text: &str = prompt_inputs.cursor_excerpt.as_ref();
49            let expected_patches_with_cursors = example_for_scoring
50                .spec
51                .expected_patches_with_cursor_positions();
52
53            let old_editable_region = if let Some(p) = example_for_scoring.prompt.as_ref() {
54                if matches!(
55                    p.provider,
56                    PredictionProvider::Teacher(_, _) | PredictionProvider::TeacherNonBatching(_, _)
57                ) {
58                    Some(
59                        TeacherPrompt::extract_editable_region(&p.input)?
60                            .replace(TeacherPrompt::USER_CURSOR_MARKER, ""),
61                    )
62                } else {
63                    None
64                }
65            } else {
66                None
67            };
68
69            let cursor_path = example_for_scoring.spec.cursor_path.as_ref();
70            let context = context_excerpts(
71                &example_for_scoring,
72                prompt_inputs,
73                retrieved_context_byte_limit,
74                context_source_filter.as_deref(),
75            );
76
77            let prepared_expected_patches = match edit_prediction_metrics::prepare_expected_patches(
78                &expected_patches_with_cursors,
79                original_text,
80                old_editable_region.as_deref(),
81            ) {
82                Ok(prepared_expected_patches) => prepared_expected_patches,
83                Err(_) if !context.is_empty() => expected_patches_with_cursors
84                    .iter()
85                    .map(|(patch, cursor_offset)| edit_prediction_metrics::PreparedExpectedPatch {
86                        patch: patch.clone(),
87                        text: original_text.to_string(),
88                        cursor_editable_region_offset: *cursor_offset,
89                    })
90                    .collect(),
91                Err(error) => {
92                    return Err(error).with_context(|| {
93                        format!(
94                            "Expected patch did not apply for {}",
95                            example_for_scoring.spec.name
96                        )
97                    });
98                }
99            };
100
101            let mut scores = vec![];
102            if allow_missing_predictions && example_for_scoring.predictions.is_empty() {
103                scores.push(edit_prediction_metrics::score_prediction(
104                    PredictionScoringInput {
105                        original_text,
106                        expected_patches: &prepared_expected_patches,
107                        actual_patch: None,
108                        actual_cursor: None,
109                        reversal_context: Some(PredictionReversalContext {
110                            edit_history: &prompt_inputs.events,
111                            excerpt_start_row: prompt_inputs.excerpt_start_row,
112                            cursor_path,
113                        }),
114                        cumulative_logprob: None,
115                        avg_logprob: None,
116                        context: Some(&context),
117                    },
118                ));
119            }
120
121            for prediction in &example_for_scoring.predictions {
122                let actual_patch = prediction.actual_patch.clone().or_else(|| {
123                    parse_prediction_output(
124                        &example_for_scoring,
125                        &prediction.actual_output,
126                        prediction.provider,
127                    )
128                    .ok()
129                    .map(|(patch, _)| patch)
130                });
131
132                let actual_cursor = prediction.actual_cursor.as_ref().map(|cursor| {
133                    ActualPredictionCursor {
134                        row: cursor.row,
135                        editable_region_offset: cursor.editable_region_offset,
136                    }
137                });
138
139                scores.push(edit_prediction_metrics::score_prediction(
140                    PredictionScoringInput {
141                        original_text,
142                        expected_patches: &prepared_expected_patches,
143                        actual_patch: actual_patch.as_deref(),
144                        actual_cursor,
145                        reversal_context: Some(PredictionReversalContext {
146                            edit_history: &prompt_inputs.events,
147                            excerpt_start_row: prompt_inputs.excerpt_start_row,
148                            cursor_path,
149                        }),
150                        cumulative_logprob: prediction.cumulative_logprob,
151                        avg_logprob: prediction.avg_logprob,
152                        context: Some(&context),
153                    },
154                ));
155            }
156
157            anyhow::Ok(scores)
158        })
159        .await?;
160    Ok(())
161}
162
163pub fn run_context_coverage_scoring(
164    example: &mut Example,
165    example_progress: &ExampleProgress,
166    retrieved_context_byte_limit: Option<usize>,
167    context_source_filter: Option<&[ContextSource]>,
168) -> anyhow::Result<()> {
169    let progress = example_progress.start(Step::Score);
170
171    progress.set_substatus("computing context coverage");
172    let prompt_inputs = example
173        .prompt_inputs
174        .as_ref()
175        .context("prompt_inputs is required for context coverage scoring")?;
176    let context = context_excerpts(
177        example,
178        prompt_inputs,
179        retrieved_context_byte_limit,
180        context_source_filter,
181    );
182
183    let editable_context_coverage = example
184        .spec
185        .expected_patches_with_cursor_positions()
186        .iter()
187        .map(|(expected_patch, _)| {
188            edit_prediction_metrics::editable_context_coverage(expected_patch, &context)
189        })
190        .max_by(|left, right| {
191            left.lines_f1
192                .total_cmp(&right.lines_f1)
193                .then_with(|| left.files_f1.total_cmp(&right.files_f1))
194        });
195
196    let mut score = edit_prediction_metrics::PredictionScore::zero();
197    score.editable_context_coverage = editable_context_coverage;
198    example.score = vec![score];
199
200    Ok(())
201}
202
203fn context_excerpts(
204    _example: &Example,
205    prompt_inputs: &zeta_prompt::Zeta2PromptInput,
206    retrieved_context_byte_limit: Option<usize>,
207    context_source_filter: Option<&[ContextSource]>,
208) -> Vec<Excerpt> {
209    let mut context = Vec::new();
210
211    if let Some(excerpt_start_row) = prompt_inputs.excerpt_start_row {
212        let row_count = prompt_inputs.cursor_excerpt.lines().count() as u32;
213
214        context.push(Excerpt {
215            path: prompt_inputs.cursor_path.to_string_lossy().to_string(),
216            row_range: excerpt_start_row..excerpt_start_row.saturating_add(row_count),
217            content: prompt_inputs.cursor_excerpt.to_string(),
218        });
219    }
220
221    if let Some(related_files) = &prompt_inputs.related_files {
222        let related_files = filtered_related_files(related_files, context_source_filter);
223        let related_files = if let Some(max_bytes) = retrieved_context_byte_limit {
224            limit_retrieved_context_to_bytes(&related_files, max_bytes)
225        } else {
226            related_files
227        };
228        for related_file in &related_files {
229            for excerpt in &related_file.excerpts {
230                // First component is a project name which is not present in expected patch, skip it
231                let path = related_file
232                    .path
233                    .iter()
234                    .skip(1)
235                    .collect::<std::path::PathBuf>()
236                    .to_string_lossy()
237                    .to_string();
238                context.push(Excerpt {
239                    path,
240                    row_range: excerpt.row_range.clone(),
241                    content: excerpt.text.to_string(),
242                });
243            }
244        }
245    }
246
247    context
248}
249
250fn filtered_related_files(
251    related_files: &[RelatedFile],
252    context_source_filter: Option<&[ContextSource]>,
253) -> Vec<RelatedFile> {
254    let Some(context_source_filter) = context_source_filter else {
255        return related_files.to_vec();
256    };
257
258    related_files
259        .iter()
260        .filter_map(|related_file| {
261            let excerpts = related_file
262                .excerpts
263                .iter()
264                .filter(|excerpt| context_source_filter.contains(&excerpt.context_source))
265                .cloned()
266                .collect::<Vec<_>>();
267            if excerpts.is_empty() {
268                None
269            } else {
270                Some(RelatedFile {
271                    path: related_file.path.clone(),
272                    max_row: related_file.max_row,
273                    excerpts,
274                    in_open_source_repo: related_file.in_open_source_repo,
275                })
276            }
277        })
278        .collect()
279}
280
281fn retrieved_context_bytes(
282    example: &Example,
283    retrieved_context_byte_limit: Option<usize>,
284    context_source_filter: Option<&[ContextSource]>,
285) -> Option<usize> {
286    let related_files = example.prompt_inputs.as_ref()?.related_files.as_ref()?;
287    let related_files = filtered_related_files(related_files, context_source_filter);
288    let related_files = if let Some(max_bytes) = retrieved_context_byte_limit {
289        limit_retrieved_context_to_bytes(&related_files, max_bytes)
290    } else {
291        related_files
292    };
293    Some(
294        related_files
295            .iter()
296            .flat_map(|file| file.excerpts.iter())
297            .map(|excerpt| excerpt.text.len())
298            .sum::<usize>(),
299    )
300}
301
302pub fn print_report(
303    examples: &[Example],
304    verbose: bool,
305    context_only: bool,
306    retrieved_context_byte_limit: Option<usize>,
307    context_source_filter: Option<&[ContextSource]>,
308) {
309    const MAX_EXAMPLES_DEFAULT: usize = 20;
310    const LINE_WIDTH: usize = 101;
311
312    if context_only {
313        print_context_coverage_report(
314            examples,
315            verbose,
316            retrieved_context_byte_limit,
317            context_source_filter,
318        );
319        return;
320    }
321
322    let separator = "─".repeat(LINE_WIDTH);
323
324    println!("{}", separator);
325    println!(
326        "{:<40} {:>8} {:>5} {:>7} {:>7} {:>7} {:>7} {:>6} {:>5}",
327        "Example", "DeltaChrF", "Brace", "F1", "Revert", "QaRev", "QaConf", "Cursor", "WrgER"
328    );
329    println!("{}", separator);
330
331    let mut patch_inserted_tokens: Vec<usize> = Vec::new();
332    let mut patch_deleted_tokens: Vec<usize> = Vec::new();
333    let mut predictions_with_patch: usize = 0;
334
335    let mut printed_lines: usize = 0;
336    let mut skipped_lines: usize = 0;
337
338    for example in examples {
339        for (score_idx, score) in example.score.iter().enumerate() {
340            let exact_lines = score.exact_lines_counts();
341
342            // Get QA results for this prediction if available
343            let qa_result = example.qa.get(score_idx).and_then(|q| q.as_ref());
344            let qa_reverts_str = qa_result
345                .and_then(|q| q.reverts_edits)
346                .map(|v| if v { "yes" } else { "no" })
347                .unwrap_or("-");
348            let qa_conf_str = qa_result
349                .and_then(|q| q.confidence)
350                .map(|v| format!("{}", v))
351                .unwrap_or("-".to_string());
352
353            // Format wrong editable region metric
354            let wrong_er_str = match score.wrong_editable_region {
355                Some(true) => "✗",
356                Some(false) => "",
357                None => "",
358            };
359
360            // Format cursor metric
361            let cursor_str = match (score.cursor_exact_match, score.cursor_distance) {
362                (Some(true), _) => "✓".to_string(),
363                (Some(false), Some(dist)) => format!("±{}", dist),
364                (Some(false), None) => "✗".to_string(),
365                (None, _) => "-".to_string(),
366            };
367
368            if verbose || printed_lines < MAX_EXAMPLES_DEFAULT {
369                println!(
370                    "{:<40} {:>8.2} {:>5} {:>6.1}% {:>6.1}% {:>7} {:>7} {:>6} {:>5}",
371                    truncate_name(&example.spec.name, 40),
372                    score.delta_chr_f,
373                    score.braces_disbalance,
374                    exact_lines.f1() * 100.0,
375                    score.reversal_ratio * 100.0,
376                    qa_reverts_str,
377                    qa_conf_str,
378                    cursor_str,
379                    wrong_er_str
380                );
381                printed_lines += 1;
382            } else {
383                skipped_lines += 1;
384            }
385
386            // Token change percentiles need the raw per-prediction values, so
387            // they are collected here rather than in `compute_summary`.
388            let has_patch = example
389                .predictions
390                .get(score_idx)
391                .and_then(|p| p.actual_patch.as_ref())
392                .is_some_and(|p| !p.is_empty());
393            if has_patch {
394                predictions_with_patch += 1;
395                patch_inserted_tokens.push(score.inserted_tokens);
396                patch_deleted_tokens.push(score.deleted_tokens);
397            }
398        }
399    }
400
401    if skipped_lines > 0 {
402        println!(
403            "{:<40} (use --verbose to see all {} examples)",
404            format!("... and {} more", skipped_lines),
405            printed_lines + skipped_lines
406        );
407    }
408    println!("{}", separator);
409
410    let summary = compute_summary(
411        examples,
412        retrieved_context_byte_limit,
413        context_source_filter,
414    );
415
416    if summary.total_examples > 0 {
417        let total_scores = summary.total_examples;
418        let format_rate = |rate: Option<f32>, precision: usize| {
419            rate.map(|rate| format!("{:.*}%", precision, rate * 100.0))
420                .unwrap_or_else(|| "-".to_string())
421        };
422        let qa_reverts_str = format_rate(summary.qa_avg_reverts_edits, 1);
423        let qa_conf_str = summary
424            .qa_avg_confidence
425            .map(|confidence| format!("{:.1}", confidence))
426            .unwrap_or_else(|| "-".to_string());
427        let cursor_str = format_rate(summary.cursor_exact_match_rate, 0);
428        let wrong_er_str = format_rate(summary.wrong_editable_region_rate, 2);
429
430        println!(
431            "{:<40} {:>8.2} {:>5.1} {:>6.1}% {:>6.1}% {:>7} {:>7} {:>6} {:>5}",
432            "TOTAL / AVERAGE",
433            summary.avg_delta_chr_f,
434            summary.avg_braces_disbalance,
435            summary.exact_lines_f1 * 100.0,
436            summary.avg_reversal_ratio * 100.0,
437            qa_reverts_str,
438            qa_conf_str,
439            cursor_str,
440            wrong_er_str
441        );
442        println!("{}", separator);
443        println!(
444            "Delta chrF (β={:.1}): TP={}, FP={}, FN={}, P={:.1}%, R={:.1}%",
445            summary.delta_chr_f_beta,
446            summary.delta_chr_f_true_positives,
447            summary.delta_chr_f_false_positives,
448            summary.delta_chr_f_false_negatives,
449            summary.delta_chr_f_precision * 100.0,
450            summary.delta_chr_f_recall * 100.0
451        );
452
453        if let Some(avg_distance) = summary.cursor_avg_distance {
454            println!(
455                "Cursor: {}/{} exact matches ({:.0}%), avg distance: {:.1} bytes",
456                summary.cursor_exact_matches.unwrap_or(0),
457                summary.cursor_total_evaluated.unwrap_or(0),
458                summary.cursor_exact_match_rate.unwrap_or(0.0) * 100.0,
459                avg_distance
460            );
461        }
462
463        if let (Some(count), Some(rate)) = (
464            summary.isolated_whitespace_count,
465            summary.isolated_whitespace_rate,
466        ) {
467            println!(
468                "Isolated whitespace changes: {}/{} ({:.1}%)",
469                count,
470                total_scores,
471                rate * 100.0
472            );
473        }
474
475        if let (Some(avg_kept_rate), Some(evaluated)) =
476            (summary.avg_kept_rate, summary.kept_rate_examples)
477        {
478            println!(
479                "Kept rate: {:.1}% avg ({} evaluated, kept chars: {}, correctly deleted chars: {}, discarded chars: {})",
480                avg_kept_rate * 100.0,
481                evaluated,
482                summary.total_kept_chars.unwrap_or(0),
483                summary.total_correctly_deleted_chars.unwrap_or(0),
484                summary.total_discarded_chars.unwrap_or(0)
485            );
486        }
487        if let (Some(avg_recall_rate), Some(evaluated)) =
488            (summary.avg_recall_rate, summary.recall_rate_examples)
489        {
490            println!(
491                "Recall rate: {:.1}% avg ({} evaluated)",
492                avg_recall_rate * 100.0,
493                evaluated
494            );
495        }
496        if let (Some(avg_bytes), Some(example_count)) = (
497            summary.avg_retrieved_context_bytes,
498            summary.retrieved_context_examples,
499        ) {
500            println!(
501                "Retrieved context size: {:.0} bytes avg ({} examples)",
502                avg_bytes, example_count
503            );
504        }
505
506        print_prf_line(
507            "Editable context lines",
508            summary.editable_context_examples,
509            summary.avg_editable_context_lines_precision,
510            summary.avg_editable_context_lines_recall,
511            summary.avg_editable_context_lines_f1,
512            summary.editable_context_lines_tp,
513            summary.editable_context_lines_fp,
514            summary.editable_context_lines_fn,
515        );
516        print_prf_line(
517            "Editable context files",
518            summary.editable_context_examples,
519            summary.avg_editable_context_files_precision,
520            summary.avg_editable_context_files_recall,
521            summary.avg_editable_context_files_f1,
522            summary.editable_context_files_tp,
523            summary.editable_context_files_fp,
524            summary.editable_context_files_fn,
525        );
526        print_prf_line(
527            "Jump location lines",
528            summary.jump_location_examples,
529            summary.avg_jump_location_lines_precision,
530            summary.avg_jump_location_lines_recall,
531            summary.avg_jump_location_lines_f1,
532            summary.jump_location_lines_tp,
533            summary.jump_location_lines_fp,
534            summary.jump_location_lines_fn,
535        );
536        print_prf_line(
537            "Jump location files",
538            summary.jump_location_examples,
539            summary.avg_jump_location_files_precision,
540            summary.avg_jump_location_files_recall,
541            summary.avg_jump_location_files_f1,
542            summary.jump_location_files_tp,
543            summary.jump_location_files_fp,
544            summary.jump_location_files_fn,
545        );
546
547        // Print token change percentile summary (only for predictions with a patch)
548        if !patch_inserted_tokens.is_empty() {
549            patch_inserted_tokens.sort_unstable();
550            patch_deleted_tokens.sort_unstable();
551            let mut patch_total_tokens: Vec<usize> = patch_inserted_tokens
552                .iter()
553                .zip(patch_deleted_tokens.iter())
554                .map(|(i, d)| i + d)
555                .collect();
556            patch_total_tokens.sort_unstable();
557
558            let patch_rate = predictions_with_patch as f32 / total_scores as f32 * 100.0;
559            println!();
560            println!(
561                "Token changes ({}/{} predictions produced a patch, {:.1}% — table includes only those)",
562                predictions_with_patch, total_scores, patch_rate
563            );
564            println!(
565                "{:<20} {:>8} {:>8} {:>8} {:>8} {:>8}",
566                "", "p25", "p50", "p75", "p90", "p99"
567            );
568            println!("{}", "─".repeat(LINE_WIDTH));
569            println!(
570                "{:<20} {:>8} {:>8} {:>8} {:>8} {:>8}",
571                "Inserted tokens",
572                percentile(&patch_inserted_tokens, 25),
573                percentile(&patch_inserted_tokens, 50),
574                percentile(&patch_inserted_tokens, 75),
575                percentile(&patch_inserted_tokens, 90),
576                percentile(&patch_inserted_tokens, 99),
577            );
578            println!(
579                "{:<20} {:>8} {:>8} {:>8} {:>8} {:>8}",
580                "Deleted tokens",
581                percentile(&patch_deleted_tokens, 25),
582                percentile(&patch_deleted_tokens, 50),
583                percentile(&patch_deleted_tokens, 75),
584                percentile(&patch_deleted_tokens, 90),
585                percentile(&patch_deleted_tokens, 99),
586            );
587            println!(
588                "{:<20} {:>8} {:>8} {:>8} {:>8} {:>8}",
589                "Total tokens",
590                percentile(&patch_total_tokens, 25),
591                percentile(&patch_total_tokens, 50),
592                percentile(&patch_total_tokens, 75),
593                percentile(&patch_total_tokens, 90),
594                percentile(&patch_total_tokens, 99),
595            );
596        }
597    }
598
599    println!("\n");
600}
601
602fn print_context_coverage_report(
603    examples: &[Example],
604    verbose: bool,
605    retrieved_context_byte_limit: Option<usize>,
606    context_source_filter: Option<&[ContextSource]>,
607) {
608    const MAX_EXAMPLES_DEFAULT: usize = 20;
609    const LINE_WIDTH: usize = 120;
610
611    let separator = "─".repeat(LINE_WIDTH);
612    println!("{}", separator);
613    println!(
614        "{:<40} {:>6} {:>6} {:>6} {:>5} {:>5} {:>5} {:>6} {:>6} {:>6} {:>5} {:>5} {:>5}",
615        "Example",
616        "LineP",
617        "LineR",
618        "LineF1",
619        "LTP",
620        "LFP",
621        "LFN",
622        "FileP",
623        "FileR",
624        "FileF1",
625        "FTP",
626        "FFP",
627        "FFN"
628    );
629    println!("{}", separator);
630
631    let mut printed_lines = 0;
632    let mut skipped_lines = 0;
633
634    for example in examples {
635        for score in &example.score {
636            let Some(coverage) = &score.editable_context_coverage else {
637                continue;
638            };
639
640            if verbose || printed_lines < MAX_EXAMPLES_DEFAULT {
641                println!(
642                    "{:<40} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5}",
643                    truncate_name(&example.spec.name, 40),
644                    coverage.lines_precision * 100.0,
645                    coverage.lines_recall * 100.0,
646                    coverage.lines_f1 * 100.0,
647                    coverage.lines_tp,
648                    coverage.lines_fp,
649                    coverage.lines_fn,
650                    coverage.files_precision * 100.0,
651                    coverage.files_recall * 100.0,
652                    coverage.files_f1 * 100.0,
653                    coverage.files_tp,
654                    coverage.files_fp,
655                    coverage.files_fn
656                );
657                printed_lines += 1;
658            } else {
659                skipped_lines += 1;
660            }
661        }
662    }
663
664    if skipped_lines > 0 {
665        println!(
666            "{:<40} (use --verbose to see all {} examples)",
667            format!("... and {} more", skipped_lines),
668            printed_lines + skipped_lines
669        );
670    }
671
672    println!("{}", separator);
673
674    let summary = compute_summary(
675        examples,
676        retrieved_context_byte_limit,
677        context_source_filter,
678    );
679
680    if let Some(total_scores) = summary.editable_context_examples {
681        println!(
682            "{:<40} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5}",
683            "TOTAL / AVERAGE",
684            summary.avg_editable_context_lines_precision.unwrap_or(0.0) * 100.0,
685            summary.avg_editable_context_lines_recall.unwrap_or(0.0) * 100.0,
686            summary.avg_editable_context_lines_f1.unwrap_or(0.0) * 100.0,
687            summary.editable_context_lines_tp.unwrap_or(0),
688            summary.editable_context_lines_fp.unwrap_or(0),
689            summary.editable_context_lines_fn.unwrap_or(0),
690            summary.avg_editable_context_files_precision.unwrap_or(0.0) * 100.0,
691            summary.avg_editable_context_files_recall.unwrap_or(0.0) * 100.0,
692            summary.avg_editable_context_files_f1.unwrap_or(0.0) * 100.0,
693            summary.editable_context_files_tp.unwrap_or(0),
694            summary.editable_context_files_fp.unwrap_or(0),
695            summary.editable_context_files_fn.unwrap_or(0)
696        );
697        println!("{}", separator);
698        println!(
699            "Evaluated editable context coverage for {} examples",
700            total_scores
701        );
702        if let (Some(avg_bytes), Some(example_count)) = (
703            summary.avg_retrieved_context_bytes,
704            summary.retrieved_context_examples,
705        ) {
706            println!(
707                "Retrieved context size: {:.0} bytes avg ({} examples)",
708                avg_bytes, example_count
709            );
710        }
711    }
712
713    println!("\n");
714}
715
716/// Print one "P/R/F1 avg + pooled TP/FP/FN" summary line, or nothing when
717/// the metric was never evaluated.
718fn print_prf_line(
719    label: &str,
720    examples: Option<usize>,
721    precision: Option<f64>,
722    recall: Option<f64>,
723    f1: Option<f64>,
724    true_positives: Option<usize>,
725    false_positives: Option<usize>,
726    false_negatives: Option<usize>,
727) {
728    let (
729        Some(examples),
730        Some(precision),
731        Some(recall),
732        Some(f1),
733        Some(true_positives),
734        Some(false_positives),
735        Some(false_negatives),
736    ) = (
737        examples,
738        precision,
739        recall,
740        f1,
741        true_positives,
742        false_positives,
743        false_negatives,
744    )
745    else {
746        return;
747    };
748    println!(
749        "{}: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})",
750        label,
751        precision * 100.0,
752        recall * 100.0,
753        f1 * 100.0,
754        examples,
755        true_positives,
756        false_positives,
757        false_negatives
758    );
759}
760
761fn percentile(sorted_values: &[usize], p: usize) -> usize {
762    if sorted_values.is_empty() {
763        return 0;
764    }
765    let idx = (p as f64 / 100.0 * (sorted_values.len() as f64 - 1.0)).round() as usize;
766    sorted_values[idx.min(sorted_values.len() - 1)]
767}
768
769fn truncate_name(name: &str, max_len: usize) -> String {
770    if name.len() <= max_len {
771        name.to_string()
772    } else {
773        format!("{}...", &name[..max_len - 3])
774    }
775}
776
777pub type SummaryJson = edit_prediction_metrics::SummaryJson;
778
779pub fn compute_summary(
780    examples: &[Example],
781    retrieved_context_byte_limit: Option<usize>,
782    context_source_filter: Option<&[ContextSource]>,
783) -> SummaryJson {
784    edit_prediction_metrics::compute_summary(examples.iter().flat_map(|example| {
785        let retrieved_context_bytes =
786            retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter);
787        example
788            .score
789            .iter()
790            .enumerate()
791            .map(move |(score_idx, score)| {
792                let qa = example
793                    .qa
794                    .get(score_idx)
795                    .and_then(|qa| qa.as_ref())
796                    .map(|qa| edit_prediction_metrics::QaSummaryData {
797                        reverts_edits: qa.reverts_edits,
798                        confidence: qa.confidence,
799                    });
800                let retrieved_context_bytes = (score_idx == 0)
801                    .then_some(retrieved_context_bytes)
802                    .flatten();
803
804                edit_prediction_metrics::PredictionSummaryInput {
805                    score,
806                    qa,
807                    retrieved_context_bytes,
808                }
809            })
810    }))
811}
812
813pub fn write_summary_json(
814    examples: &[Example],
815    path: &Path,
816    retrieved_context_byte_limit: Option<usize>,
817    context_source_filter: Option<&[ContextSource]>,
818) -> anyhow::Result<()> {
819    let summary = compute_summary(
820        examples,
821        retrieved_context_byte_limit,
822        context_source_filter,
823    );
824    let file = File::create(path)
825        .with_context(|| format!("Failed to create summary JSON file: {}", path.display()))?;
826    let writer = BufWriter::new(file);
827    serde_json::to_writer_pretty(writer, &summary)
828        .with_context(|| format!("Failed to write summary JSON to: {}", path.display()))?;
829    eprintln!("Wrote summary JSON to: {}", path.display());
830    Ok(())
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use edit_prediction::example_spec::ExampleSpec;
837    use edit_prediction_metrics::PredictionScore;
838    use std::path::Path;
839    use zeta_prompt::{ExcerptRanges, RelatedExcerpt, Zeta2PromptInput};
840
841    #[test]
842    fn summary_includes_limited_filtered_retrieved_context_bytes_once_per_example() {
843        let examples = vec![
844            example_with_related_files(
845                Some(vec![RelatedFile {
846                    path: Path::new("project/src/lib.rs").into(),
847                    max_row: 10,
848                    excerpts: vec![
849                        related_excerpt("abcd", 0..1, 0, ContextSource::CurrentFile),
850                        related_excerpt("ignored by source filter", 1..2, 1, ContextSource::Lsp),
851                        related_excerpt("efghij", 2..3, 2, ContextSource::CurrentFile),
852                    ],
853                    in_open_source_repo: false,
854                }]),
855                2,
856            ),
857            example_with_related_files(None, 1),
858        ];
859
860        let summary = compute_summary(&examples, Some(10), Some(&[ContextSource::CurrentFile]));
861
862        assert_eq!(summary.total_examples, 3);
863        assert_eq!(summary.avg_retrieved_context_bytes, Some(10.0));
864        assert_eq!(summary.total_retrieved_context_bytes, Some(10));
865        assert_eq!(summary.retrieved_context_examples, Some(1));
866    }
867
868    fn example_with_related_files(
869        related_files: Option<Vec<RelatedFile>>,
870        score_count: usize,
871    ) -> Example {
872        Example {
873            spec: ExampleSpec {
874                name: "example".to_string(),
875                repository_url: "https://github.com/zed-industries/zed.git".to_string(),
876                revision: "revision".to_string(),
877                tags: Vec::new(),
878                reasoning: None,
879                uncommitted_diff: String::new(),
880                recently_opened_files: Vec::new(),
881                recently_viewed_files: Vec::new(),
882                uncommitted_diff_contains_edit_history: false,
883                cursor_path: Path::new("project/src/main.rs").into(),
884                cursor_position: String::new(),
885                edit_history: String::new(),
886                expected_patches: Vec::new(),
887                rejected_patch: None,
888                telemetry: None,
889                human_feedback: Vec::new(),
890                rating: None,
891            },
892            prompt_inputs: Some(Zeta2PromptInput {
893                cursor_path: Path::new("project/src/main.rs").into(),
894                cursor_excerpt: "".into(),
895                cursor_offset_in_excerpt: 0,
896                excerpt_start_row: None,
897                events: Vec::new(),
898                related_files,
899                active_buffer_diagnostics: Vec::new(),
900                excerpt_ranges: ExcerptRanges::default(),
901                syntax_ranges: None,
902                in_open_source_repo: false,
903                can_collect_data: false,
904                repo_url: None,
905            }),
906            prompt: None,
907            predictions: Vec::new(),
908            score: vec![PredictionScore::zero(); score_count],
909            qa: Vec::new(),
910            zed_version: None,
911            state: None,
912        }
913    }
914
915    fn related_excerpt(
916        text: &str,
917        row_range: std::ops::Range<u32>,
918        order: usize,
919        context_source: ContextSource,
920    ) -> RelatedExcerpt {
921        RelatedExcerpt {
922            row_range,
923            text: text.into(),
924            order,
925            context_source,
926        }
927    }
928}
929
Served at tenant.openagents/omega Member data and write actions are omitted.