601 args = _parse_args()
602 _validate_args(args)
603
604 work_dir = Path(args.workDir).expanduser().resolve()
605 roots_dir = work_dir / "roots"
606 logs_dir = work_dir / "logs"
607 csv_path = work_dir / "edge_threshold_scan.csv"
608 summary_path = work_dir / "summary.json"
609
610
611 roots_dir.mkdir(parents=True, exist_ok=True)
612 logs_dir.mkdir(parents=True, exist_ok=True)
613
614 stages = (
615 ScanStage("coarse", args.coarseEvents, args.coarseStep),
616 ScanStage("medium", args.mediumEvents, args.mediumStep),
617 ScanStage("fine", args.fineEvents, args.fineStep),
618 )
619 rows: list[dict[str, Any]] = []
620 baselines: dict[str, dict[str, Any]] = {}
621 best_by_stage: dict[str, dict[str, Any] | None] = {}
622 stage_notes: dict[str, str] = {}
623
624 def summary(status: str) -> dict[str, Any]:
625 recommended = (
626 best_by_stage.get("fine")
627 or best_by_stage.get("medium")
628 or best_by_stage.get("coarse")
629 )
630 return {
631 "status": status,
632 "inputFile": args.inputFile,
633 "treeName": args.treeName,
634 "workDir": str(work_dir),
635 "recoLauncher": _reco_launcher(args),
636 "edgeModel": args.edgeModel,
637 "mlConfiguration": {
638 "bucketFilterEnabled": args.enableBucketFilter,
639 "filterSegmentsWithoutMlConnections": (
640 args.filterSegmentsWithoutMlConnections
641 ),
642 "bucketModel": args.bucketModelPath,
643 "bucketThreshold": args.bucketThreshold,
644 },
645 "target": {
646 "minimumRelativeEfficiency": args.minRelativeEfficiency,
647 "acceptedWhen": (
648 "edgeTrackEfficiency / noMlTrackEfficiency >= "
649 "minimumRelativeEfficiency"
650 ),
651 },
652 "selection": {
653 "denominator": (
654 "len(TruthMuons_truthSegLinks[i]) > 0 and "
655 "abs(TruthMuons_eta[i]) < 2.5"
656 ),
657 "numerator": (
658 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
659 "len(ActsMuons_pt)"
660 ),
661 "implementation": "PyROOT; equivalent to the notebook's executable code",
662 },
663 "stages": [
664 {"name": stage.name, "nEvents": stage.n_events, "step": stage.step}
665 for stage in stages
666 ],
667 "baselines": baselines,
668 "bestByStage": best_by_stage,
669 "stageNotes": stage_notes,
670 "recommendedThreshold": (
671 None if recommended is None else recommended["threshold"]
672 ),
673 "recommendedResult": recommended,
674 "scanCsv": str(csv_path),
675 }
676
677 def persist(status: str) -> None:
678 _write_csv(rows, csv_path)
679 _write_json(summary(status), summary_path)
680
681 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
682 """Run/reuse one no-ML reference output for this stage."""
683
684 if stage.name in baselines:
685 return baselines[stage.name]
686
687 tag = f"noml_{stage.name}_events{stage.n_events:04d}"
688 out_root = roots_dir / f"{tag}.root"
689 out_log = logs_dir / f"{tag}.log"
690 command = _noml_chain_command(args, n_events=stage.n_events, out_root=out_root)
691 row: dict[str, Any] = {
692 "stage": stage.name,
693 "nEvents": stage.n_events,
694 "mode": "noml",
695 "threshold": "",
696 "rootFile": str(out_root),
697 "log": str(out_log),
698 "command": " ".join(command),
699 }
700
701 if not args.skipExisting or not out_root.is_file():
702 return_code = _run(command, out_log)
703 row["returnCode"] = return_code
704 if return_code != 0:
705 row.update({
706 "status": "failed",
707 "error": f"no-ML reconstruction returned {return_code}",
708 })
709 rows.append(row)
710 persist("failed")
711 raise RuntimeError(
712 f"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
713 f"See {out_log}"
714 )
715 else:
716 row["returnCode"] = "reused"
717
718 if not out_root.is_file():
719 row.update({
720 "status": "missing_output",
721 "error": "no-ML ROOT output is missing after reconstruction",
722 })
723 rows.append(row)
724 persist("failed")
725 raise RuntimeError(
726 f"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
727 )
728
729 try:
730 metrics = _read_notebook_efficiency(out_root, args.treeName)
731 except Exception as error:
732 row.update({"status": "evaluation_failed", "error": str(error)})
733 rows.append(row)
734 persist("failed")
735 raise
736
737 row.update(metrics)
738 row.update({
739 "noMlTruthMuonCount": metrics["truthMuonCount"],
740 "noMlMatchedTruthMuonCount": metrics["matchedTruthMuonCount"],
741 "noMlTrackEfficiency": metrics["trackEfficiency"],
742 "status": "ok",
743 })
744 rows.append(row)
745 baselines[stage.name] = row
746 persist("running")
748 f"[{stage.name:6s} | {stage.n_events:4d} events] "
749 f"no-ML efficiency={metrics['trackEfficiency']:.6f} "
750 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
751 flush=True,
752 )
753 return row
754
755 def evaluate_edge(stage: ScanStage, threshold: float) -> dict[str, Any]:
756 """Run/reuse and compare one edge-classifier threshold point."""
757
758 baseline = evaluate_baseline(stage)
759
760 tag = (
761 f"{stage.name}_events{stage.n_events:04d}_"
762 f"threshold{_format_threshold(threshold)}"
763 )
764 out_root = roots_dir / f"{tag}.root"
765 out_log = logs_dir / f"{tag}.log"
766 command = _edge_chain_command(
767 args,
768 threshold=threshold,
769 n_events=stage.n_events,
770 out_root=out_root,
771 )
772 row: dict[str, Any] = {
773 "stage": stage.name,
774 "nEvents": stage.n_events,
775 "mode": "edge",
776 "threshold": threshold,
777 "minRelativeEfficiency": args.minRelativeEfficiency,
778 "rootFile": str(out_root),
779 "log": str(out_log),
780 "command": " ".join(command),
781 }
782
783 if not args.skipExisting or not out_root.is_file():
784 return_code = _run(command, out_log)
785 row["returnCode"] = return_code
786 if return_code != 0:
787 row.update({
788 "status": "failed",
789 "error": f"edge-classifier reconstruction returned {return_code}",
790 })
791 rows.append(row)
792 persist("failed")
793 raise RuntimeError(
794 f"Reconstruction failed at stage={stage.name}, threshold={threshold} "
795 f"with rc={return_code}. See {out_log}"
796 )
797 else:
798 row["returnCode"] = "reused"
799
800 if not out_root.is_file():
801 row.update({
802 "status": "missing_output",
803 "error": "edge-classifier ROOT output is missing after reconstruction",
804 })
805 rows.append(row)
806 persist("failed")
807 raise RuntimeError(
808 f"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
809 f"{out_root}"
810 )
811
812 try:
813 metrics = _read_notebook_efficiency(out_root, args.treeName)
814 except Exception as error:
815 row.update({"status": "evaluation_failed", "error": str(error)})
816 rows.append(row)
817 persist("failed")
818 raise
819
820 noml_efficiency = float(baseline["trackEfficiency"])
821 if noml_efficiency <= 0.0:
822 row.update({
823 "status": "evaluation_failed",
824 "error": "no-ML baseline has zero track efficiency",
825 })
826 rows.append(row)
827 persist("failed")
828 raise RuntimeError(
829 f"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
830 "relative efficiency."
831 )
832
833 relative_efficiency = float(metrics["trackEfficiency"]) / noml_efficiency
834 row.update(metrics)
835 row.update({
836 "noMlTruthMuonCount": baseline["truthMuonCount"],
837 "noMlMatchedTruthMuonCount": baseline["matchedTruthMuonCount"],
838 "noMlTrackEfficiency": noml_efficiency,
839 "truthCountMatchesNoMl": (
840 int(metrics["truthMuonCount"]) == int(baseline["truthMuonCount"])
841 ),
842 "relativeTrackEfficiency": relative_efficiency,
843 "relativeEfficiencyLoss":
max(0.0, 1.0 - relative_efficiency),
844 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
845 "status": "ok",
846 })
847 rows.append(row)
848 persist("running")
850 f"[{stage.name:6s} | {stage.n_events:4d} events] "
851 f"threshold={threshold: .6f} "
852 f"edge={metrics['trackEfficiency']:.6f} "
853 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']}) "
854 f"relative={relative_efficiency:.6f} "
855 f"{'PASS' if row['passesTarget'] else 'FAIL'}",
856 flush=True,
857 )
858 return row
859
860 def adaptive_scan(
861 stage: ScanStage,
862 start_threshold: float,
863 ) -> tuple[dict[str, Any] | None, str]:
864 """Find the highest passing point, with first-point recovery downward."""
865
866 first = evaluate_edge(stage, start_threshold)
867 if bool(first["passesTarget"]):
868 best = first
869 for threshold in _ascending_thresholds(
870 start_threshold,
871 args.maxThreshold,
872 stage.step,
873 include_start=False,
874 ):
875 candidate = evaluate_edge(stage, threshold)
876 if not bool(candidate["passesTarget"]):
877 return best, "stopped_at_first_below_target"
878 best = candidate
879 return best, "reached_upper_threshold_bound"
880
881
882
883 for threshold in _descending_thresholds(
884 start_threshold,
885 args.minThreshold,
886 stage.step,
887 include_start=False,
888 ):
889 candidate = evaluate_edge(stage, threshold)
890 if bool(candidate["passesTarget"]):
891 return candidate, "recovered_by_lowering_threshold"
892
893 return None, "no_passing_threshold_in_range"
894
895 try:
896 coarse, medium, fine = stages
897
898
899
900
901 coarse_best: dict[str, Any] | None = None
902 coarse_note = "reached_upper_threshold_bound"
903 for threshold in _ascending_thresholds(
904 args.minThreshold,
905 args.maxThreshold,
906 coarse.step,
907 ):
908 candidate = evaluate_edge(coarse, threshold)
909 if not bool(candidate["passesTarget"]):
910 coarse_note = "stopped_at_first_below_target"
911 break
912 coarse_best = candidate
913
914 if coarse_best is None:
915 best_by_stage[coarse.name] = None
916 stage_notes[coarse.name] = "no_passing_threshold_in_range"
917 persist("no_passing_threshold")
918 raise RuntimeError(
919 "No coarse edge threshold met the relative-efficiency target. "
920 "Lower --minRelativeEfficiency or extend --minThreshold."
921 )
922
923 best_by_stage[coarse.name] = coarse_best
924 stage_notes[coarse.name] = coarse_note
925 persist("running")
926
927 medium_best, medium_note = adaptive_scan(
928 medium,
929 float(coarse_best["threshold"]),
930 )
931 best_by_stage[medium.name] = medium_best
932 stage_notes[medium.name] = medium_note
933 if medium_best is None:
934 persist("no_passing_threshold")
935 raise RuntimeError(
936 "No medium-stage edge threshold met the relative-efficiency target."
937 )
938 persist("running")
939
940 fine_best, fine_note = adaptive_scan(
941 fine,
942 float(medium_best["threshold"]),
943 )
944 best_by_stage[fine.name] = fine_best
945 stage_notes[fine.name] = fine_note
946 if fine_best is None:
947 persist("no_passing_threshold")
948 raise RuntimeError(
949 "No fine-stage edge threshold met the relative-efficiency target."
950 )
951
952 except KeyboardInterrupt:
953 persist("interrupted")
954 raise SystemExit(
955 f"Interrupted. Partial results were saved under {work_dir}."
956 )
957 except Exception:
958 persist("failed")
959 raise
960
961 persist("completed")
962 final = summary("completed")
963 print(f
"\nRecommended --edgeThreshold: {final['recommendedThreshold']:.6f}")
964 print(f
"Scan CSV: {csv_path}")
965 print(f
"Summary: {summary_path}")
966
967
void print(char *figname, TCanvas *c1)