455 args = _parse_args()
456 _validate_args(args)
457
458 work_dir = Path(args.workDir).expanduser().resolve()
459 roots_dir = work_dir / "roots"
460 logs_dir = work_dir / "logs"
461 csv_path = work_dir / "bucket_threshold_scan.csv"
462 summary_path = work_dir / "summary.json"
463
464
465 roots_dir.mkdir(parents=True, exist_ok=True)
466 logs_dir.mkdir(parents=True, exist_ok=True)
467
468 stages = (
469 ScanStage("coarse", args.coarseEvents, args.coarseStep),
470 ScanStage("medium", args.mediumEvents, args.mediumStep),
471 ScanStage("fine", args.fineEvents, args.fineStep),
472 )
473 rows: list[dict[str, Any]] = []
474 baselines: dict[str, dict[str, Any]] = {}
475 best_by_stage: dict[str, dict[str, Any] | None] = {}
476 stage_notes: dict[str, str] = {}
477
478 def summary(status: str) -> dict[str, Any]:
479 recommended = (
480 best_by_stage.get("fine")
481 or best_by_stage.get("medium")
482 or best_by_stage.get("coarse")
483 )
484 return {
485 "status": status,
486 "inputFile": args.inputFile,
487 "treeName": args.treeName,
488 "workDir": str(work_dir),
489 "recoLauncher": _reco_launcher(args),
490 "target": {
491 "minimumRelativeEfficiency": args.minRelativeEfficiency,
492 "acceptedWhen": (
493 "bucketTrackEfficiency / noMlTrackEfficiency >= "
494 "minimumRelativeEfficiency"
495 ),
496 },
497 "selection": {
498 "denominator": (
499 "len(TruthMuons_truthSegLinks[i]) > 0 and "
500 "abs(TruthMuons_eta[i]) < 2.5"
501 ),
502 "numerator": (
503 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
504 "len(ActsMuons_pt)"
505 ),
506 "implementation": "PyROOT; equivalent to the notebook's executable code",
507 },
508 "stages": [
509 {"name": stage.name, "nEvents": stage.n_events, "step": stage.step}
510 for stage in stages
511 ],
512 "baselines": baselines,
513 "bestByStage": best_by_stage,
514 "stageNotes": stage_notes,
515 "recommendedThreshold": (
516 None if recommended is None else recommended["threshold"]
517 ),
518 "recommendedResult": recommended,
519 "scanCsv": str(csv_path),
520 }
521
522 def persist(status: str) -> None:
523 _write_csv(rows, csv_path)
524 _write_json(summary(status), summary_path)
525
526 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
527 """Run/reuse one no-ML reference output for this stage."""
528
529 if stage.name in baselines:
530 return baselines[stage.name]
531
532 tag = f"noml_{stage.name}_events{stage.n_events:04d}"
533 out_root = roots_dir / f"{tag}.root"
534 out_log = logs_dir / f"{tag}.log"
535 command = _noml_chain_command(args, n_events=stage.n_events, out_root=out_root)
536 row: dict[str, Any] = {
537 "stage": stage.name,
538 "nEvents": stage.n_events,
539 "mode": "noml",
540 "threshold": "",
541 "rootFile": str(out_root),
542 "log": str(out_log),
543 "command": " ".join(command),
544 }
545
546 if not args.skipExisting or not out_root.is_file():
547 return_code = _run(command, out_log)
548 row["returnCode"] = return_code
549 if return_code != 0:
550 row.update({
551 "status": "failed",
552 "error": f"no-ML reconstruction returned {return_code}",
553 })
554 rows.append(row)
555 persist("failed")
556 raise RuntimeError(
557 f"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
558 f"See {out_log}"
559 )
560 else:
561 row["returnCode"] = "reused"
562
563 if not out_root.is_file():
564 row.update({
565 "status": "missing_output",
566 "error": "no-ML ROOT output is missing after reconstruction",
567 })
568 rows.append(row)
569 persist("failed")
570 raise RuntimeError(
571 f"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
572 )
573
574 try:
575 metrics = _read_notebook_efficiency(out_root, args.treeName)
576 except Exception as error:
577 row.update({"status": "evaluation_failed", "error": str(error)})
578 rows.append(row)
579 persist("failed")
580 raise
581
582 row.update(metrics)
583 row.update({
584 "noMlTruthMuonCount": metrics["truthMuonCount"],
585 "noMlMatchedTruthMuonCount": metrics["matchedTruthMuonCount"],
586 "noMlTrackEfficiency": metrics["trackEfficiency"],
587 "status": "ok",
588 })
589 rows.append(row)
590 baselines[stage.name] = row
591 persist("running")
593 f"[{stage.name:6s} | {stage.n_events:4d} events] "
594 f"no-ML efficiency={metrics['trackEfficiency']:.6f} "
595 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
596 flush=True,
597 )
598 return row
599
600 def evaluate_bucket(stage: ScanStage, threshold: float) -> dict[str, Any]:
601 """Run/reuse and compare one bucket-filter threshold point."""
602
603 baseline = evaluate_baseline(stage)
604
605 tag = (
606 f"{stage.name}_events{stage.n_events:04d}_"
607 f"threshold{_format_threshold(threshold)}"
608 )
609 out_root = roots_dir / f"{tag}.root"
610 out_log = logs_dir / f"{tag}.log"
611 command = _bucket_chain_command(
612 args,
613 threshold=threshold,
614 n_events=stage.n_events,
615 out_root=out_root,
616 )
617 row: dict[str, Any] = {
618 "stage": stage.name,
619 "nEvents": stage.n_events,
620 "mode": "bucket",
621 "threshold": threshold,
622 "minRelativeEfficiency": args.minRelativeEfficiency,
623 "rootFile": str(out_root),
624 "log": str(out_log),
625 "command": " ".join(command),
626 }
627
628 if not args.skipExisting or not out_root.is_file():
629 return_code = _run(command, out_log)
630 row["returnCode"] = return_code
631 if return_code != 0:
632 row.update({
633 "status": "failed",
634 "error": f"bucket reconstruction returned {return_code}",
635 })
636 rows.append(row)
637 persist("failed")
638 raise RuntimeError(
639 f"Reconstruction failed at stage={stage.name}, threshold={threshold} "
640 f"with rc={return_code}. See {out_log}"
641 )
642 else:
643 row["returnCode"] = "reused"
644
645 if not out_root.is_file():
646 row.update({
647 "status": "missing_output",
648 "error": "bucket ROOT output is missing after reconstruction",
649 })
650 rows.append(row)
651 persist("failed")
652 raise RuntimeError(
653 f"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
654 f"{out_root}"
655 )
656
657 try:
658 metrics = _read_notebook_efficiency(out_root, args.treeName)
659 except Exception as error:
660 row.update({"status": "evaluation_failed", "error": str(error)})
661 rows.append(row)
662 persist("failed")
663 raise
664
665 noml_efficiency = float(baseline["trackEfficiency"])
666 if noml_efficiency <= 0.0:
667 row.update({
668 "status": "evaluation_failed",
669 "error": "no-ML baseline has zero track efficiency",
670 })
671 rows.append(row)
672 persist("failed")
673 raise RuntimeError(
674 f"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
675 "relative efficiency."
676 )
677
678 relative_efficiency = float(metrics["trackEfficiency"]) / noml_efficiency
679 row.update(metrics)
680 row.update({
681 "noMlTruthMuonCount": baseline["truthMuonCount"],
682 "noMlMatchedTruthMuonCount": baseline["matchedTruthMuonCount"],
683 "noMlTrackEfficiency": noml_efficiency,
684 "truthCountMatchesNoMl": (
685 int(metrics["truthMuonCount"]) == int(baseline["truthMuonCount"])
686 ),
687 "relativeTrackEfficiency": relative_efficiency,
688 "relativeEfficiencyLoss":
max(0.0, 1.0 - relative_efficiency),
689 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
690 "status": "ok",
691 })
692 rows.append(row)
693 persist("running")
695 f"[{stage.name:6s} | {stage.n_events:4d} events] "
696 f"threshold={threshold: .6f} "
697 f"bucket={metrics['trackEfficiency']:.6f} "
698 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']}) "
699 f"relative={relative_efficiency:.6f} "
700 f"{'PASS' if row['passesTarget'] else 'FAIL'}",
701 flush=True,
702 )
703 return row
704
705 def adaptive_scan(
706 stage: ScanStage,
707 start_threshold: float,
708 ) -> tuple[dict[str, Any] | None, str]:
709 """Find the highest passing point, with first-point recovery downward."""
710
711 first = evaluate_bucket(stage, start_threshold)
712 if bool(first["passesTarget"]):
713 best = first
714 for threshold in _ascending_thresholds(
715 start_threshold,
716 args.maxThreshold,
717 stage.step,
718 include_start=False,
719 ):
720 candidate = evaluate_bucket(stage, threshold)
721 if not bool(candidate["passesTarget"]):
722 return best, "stopped_at_first_below_target"
723 best = candidate
724 return best, "reached_upper_threshold_bound"
725
726
727
728 for threshold in _descending_thresholds(
729 start_threshold,
730 args.minThreshold,
731 stage.step,
732 include_start=False,
733 ):
734 candidate = evaluate_bucket(stage, threshold)
735 if bool(candidate["passesTarget"]):
736 return candidate, "recovered_by_lowering_threshold"
737
738 return None, "no_passing_threshold_in_range"
739
740 try:
741 coarse, medium, fine = stages
742
743
744
745
746 coarse_best: dict[str, Any] | None = None
747 coarse_note = "reached_upper_threshold_bound"
748 for threshold in _ascending_thresholds(
749 args.minThreshold,
750 args.maxThreshold,
751 coarse.step,
752 ):
753 candidate = evaluate_bucket(coarse, threshold)
754 if not bool(candidate["passesTarget"]):
755 coarse_note = "stopped_at_first_below_target"
756 break
757 coarse_best = candidate
758
759 if coarse_best is None:
760 best_by_stage[coarse.name] = None
761 stage_notes[coarse.name] = "no_passing_threshold_in_range"
762 persist("no_passing_threshold")
763 raise RuntimeError(
764 "No coarse threshold met the relative-efficiency target. "
765 "Lower --minRelativeEfficiency or extend --minThreshold."
766 )
767
768 best_by_stage[coarse.name] = coarse_best
769 stage_notes[coarse.name] = coarse_note
770 persist("running")
771
772 medium_best, medium_note = adaptive_scan(
773 medium,
774 float(coarse_best["threshold"]),
775 )
776 best_by_stage[medium.name] = medium_best
777 stage_notes[medium.name] = medium_note
778 if medium_best is None:
779 persist("no_passing_threshold")
780 raise RuntimeError(
781 "No medium-stage threshold met the relative-efficiency target."
782 )
783 persist("running")
784
785 fine_best, fine_note = adaptive_scan(
786 fine,
787 float(medium_best["threshold"]),
788 )
789 best_by_stage[fine.name] = fine_best
790 stage_notes[fine.name] = fine_note
791 if fine_best is None:
792 persist("no_passing_threshold")
793 raise RuntimeError(
794 "No fine-stage threshold met the relative-efficiency target."
795 )
796
797 except KeyboardInterrupt:
798 persist("interrupted")
799 raise SystemExit(
800 f"Interrupted. Partial results were saved under {work_dir}."
801 )
802 except Exception:
803 persist("failed")
804 raise
805
806 persist("completed")
807 final = summary("completed")
808 print(f
"\nRecommended --score-threshold: {final['recommendedThreshold']:.6f}")
809 print(f
"Scan CSV: {csv_path}")
810 print(f
"Summary: {summary_path}")
811
812
void print(char *figname, TCanvas *c1)