ids2eval 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. ids2eval/__init__.py +0 -0
  2. ids2eval/__main__.py +4 -0
  3. ids2eval/_json_utils.py +24 -0
  4. ids2eval/audit/__init__.py +239 -0
  5. ids2eval/audit/_auc.py +48 -0
  6. ids2eval/audit/_by_class.py +29 -0
  7. ids2eval/audit/_checkpoint.py +74 -0
  8. ids2eval/audit/_fit_score.py +40 -0
  9. ids2eval/audit/_materiality.py +28 -0
  10. ids2eval/audit/_status.py +15 -0
  11. ids2eval/audit/_time.py +14 -0
  12. ids2eval/audit/artifact_sensitivity.py +88 -0
  13. ids2eval/audit/class_distribution.py +57 -0
  14. ids2eval/audit/cross_capture_matrix.py +115 -0
  15. ids2eval/audit/cross_dataset_drift.py +74 -0
  16. ids2eval/audit/data_integrity.py +67 -0
  17. ids2eval/audit/dedup.py +67 -0
  18. ids2eval/audit/feature_auc_ranking.py +125 -0
  19. ids2eval/audit/feature_category_ablation.py +98 -0
  20. ids2eval/audit/flow_group_leakage.py +66 -0
  21. ids2eval/audit/homogeneity.py +160 -0
  22. ids2eval/audit/identity_columns.py +171 -0
  23. ids2eval/audit/known_issues.py +144 -0
  24. ids2eval/audit/label_conflict.py +92 -0
  25. ids2eval/audit/leakage.py +99 -0
  26. ids2eval/audit/near_duplicate_class.py +87 -0
  27. ids2eval/audit/one_rule.py +82 -0
  28. ids2eval/audit/port_protocol_shortcut.py +75 -0
  29. ids2eval/audit/repeated_seed_falsification.py +182 -0
  30. ids2eval/audit/resplit.py +92 -0
  31. ids2eval/audit/result_robustness.py +154 -0
  32. ids2eval/audit/row_order_leakage.py +86 -0
  33. ids2eval/audit/scenario_holdout.py +115 -0
  34. ids2eval/audit/schema_fingerprint.py +52 -0
  35. ids2eval/audit/seed_sensitivity.py +69 -0
  36. ids2eval/audit/synthetic_realism.py +73 -0
  37. ids2eval/audit/temporal_leakage.py +58 -0
  38. ids2eval/audit/temporal_realism.py +81 -0
  39. ids2eval/cli.py +284 -0
  40. ids2eval/config.py +362 -0
  41. ids2eval/dashboard/__init__.py +1 -0
  42. ids2eval/dashboard/__main__.py +3 -0
  43. ids2eval/dashboard/server.py +527 -0
  44. ids2eval/dashboard/static/app.js +1064 -0
  45. ids2eval/dashboard/static/charts.js +366 -0
  46. ids2eval/dashboard/static/index.html +33 -0
  47. ids2eval/dashboard/static/style.css +293 -0
  48. ids2eval/data/__init__.py +3 -0
  49. ids2eval/data/cache.py +99 -0
  50. ids2eval/data/chunked_io.py +387 -0
  51. ids2eval/data/dataset.py +208 -0
  52. ids2eval/data/features.py +95 -0
  53. ids2eval/data/label_grouping.py +38 -0
  54. ids2eval/modeling/__init__.py +3 -0
  55. ids2eval/modeling/benchmark.py +360 -0
  56. ids2eval/modeling/classifiers.py +145 -0
  57. ids2eval/modeling/preprocessing.py +73 -0
  58. ids2eval/reporting/__init__.py +2 -0
  59. ids2eval/reporting/cite.py +57 -0
  60. ids2eval/reporting/compare_datasets.py +103 -0
  61. ids2eval/reporting/drift.py +80 -0
  62. ids2eval/reporting/intervention_manifest.py +83 -0
  63. ids2eval/reporting/recommend.py +402 -0
  64. ids2eval/reporting/run_manager.py +184 -0
  65. ids2eval/reporting/scorecard.py +875 -0
  66. ids2eval/reporting/scorecard_plot.py +162 -0
  67. ids2eval/version_info.py +36 -0
  68. ids2eval-0.2.2.dist-info/METADATA +119 -0
  69. ids2eval-0.2.2.dist-info/RECORD +73 -0
  70. ids2eval-0.2.2.dist-info/WHEEL +5 -0
  71. ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
  72. ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
  73. ids2eval-0.2.2.dist-info/top_level.txt +1 -0
ids2eval/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """CLI entrypoint: config -> dataset split -> audit -> preprocessing -> benchmark.
2
+
3
+ python -m ids2eval --config my_config.yaml (same as: ids2eval run --config ...)
4
+ python -m ids2eval cite path/to/run_dir
5
+ python -m ids2eval validate-config --config my_config.yaml
6
+ python -m ids2eval compare-datasets a.yaml b.yaml
7
+ python -m ids2eval recommend path/to/run_dir
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import pandas as pd
19
+ import yaml
20
+
21
+ from ._json_utils import json_default
22
+ from .audit import STRUCTURAL_CHECKS, _checkpoint, run_audit
23
+ from .config import load_config
24
+ from .data import cache, dataset
25
+ from .data.label_grouping import apply_attack_type_mapping
26
+ from .modeling.benchmark import run_benchmark
27
+ from .reporting import cite, compare_datasets, drift, intervention_manifest, recommend, run_manager, scorecard
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ COMMANDS = ("run", "cite", "validate-config", "compare-datasets", "recommend")
32
+
33
+
34
+ _json_default = json_default
35
+
36
+
37
+ def _write_findings(path: Path, findings: list[dict]) -> None:
38
+ path.write_text(json.dumps(findings, indent=2, default=_json_default))
39
+ logger.info("Audit report written to %s", path)
40
+ for f in findings:
41
+ logger.info("[%s] %s: %s", f["status"].upper(), f["check"], f["summary"])
42
+
43
+
44
+ def parse_args(argv=None) -> argparse.Namespace:
45
+ argv = list(sys.argv[1:] if argv is None else argv)
46
+ # No subcommand given (the original, still-supported invocation) - default to "run"
47
+ # so `ids2eval --config x.yaml` keeps working exactly as before.
48
+ if not argv or argv[0] not in COMMANDS:
49
+ argv = ["run", *argv]
50
+
51
+ parser = argparse.ArgumentParser(description="IDS2Eval: audit + benchmark an IDS dataset")
52
+ subparsers = parser.add_subparsers(dest="command", required=True)
53
+
54
+ run_parser = subparsers.add_parser("run", help="Run the audit and/or benchmark on a config")
55
+ run_parser.add_argument("--config", required=True, help="Path to a YAML config file")
56
+ run_parser.add_argument("--skip-audit", action="store_true", help="Skip the data-quality audit checks")
57
+ run_parser.add_argument("--skip-benchmark", action="store_true", help="Skip classifier benchmarking")
58
+
59
+ cite_parser = subparsers.add_parser("cite", help="Print a citation for a completed run's scorecard")
60
+ cite_parser.add_argument("run_dir", help="A run directory, or its scorecard.json directly")
61
+
62
+ validate_parser = subparsers.add_parser(
63
+ "validate-config", help="Check a config file for errors without loading any data"
64
+ )
65
+ validate_parser.add_argument("--config", required=True, help="Path to a YAML config file")
66
+
67
+ compare_parser = subparsers.add_parser(
68
+ "compare-datasets", help="Check whether two configs load equivalent data"
69
+ )
70
+ compare_parser.add_argument("config_a", help="Path to the first config file")
71
+ compare_parser.add_argument("config_b", help="Path to the second config file")
72
+
73
+ recommend_parser = subparsers.add_parser(
74
+ "recommend", help="Suggest structural config fixes for a completed run's flagged/warned checks"
75
+ )
76
+ recommend_parser.add_argument("run_dir", help="A completed run directory")
77
+ recommend_parser.add_argument(
78
+ "--config", help="Apply patches onto this config file instead of the run's fully-resolved one"
79
+ )
80
+ recommend_parser.add_argument(
81
+ "--apply", action="store_true", help="Merge every recommendation that has a patch and print the result"
82
+ )
83
+ recommend_parser.add_argument("--output", help="Write the patched config here instead of stdout (needs --apply)")
84
+
85
+ return parser.parse_args(argv)
86
+
87
+
88
+ def _run_recommend(args: argparse.Namespace) -> None:
89
+ recs, resolved_cfg = recommend.recommendations_for_run(args.run_dir)
90
+ print(recommend.render_report(recs))
91
+ if not args.apply:
92
+ return
93
+
94
+ base_cfg = yaml.safe_load(Path(args.config).read_text()) if args.config else resolved_cfg
95
+ patchable_ids = [r["id"] for r in recs if r["patch"]]
96
+ patched = recommend.apply_recommendations(base_cfg, recs, patchable_ids)
97
+ text = yaml.safe_dump(patched, sort_keys=False)
98
+ if args.output:
99
+ Path(args.output).write_text(text)
100
+ print(f"\nPatched config written to {args.output}")
101
+ else:
102
+ print("\n--- patched config ---")
103
+ print(text)
104
+
105
+
106
+ def main(argv=None) -> None:
107
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
108
+ args = parse_args(argv)
109
+
110
+ if args.command == "cite":
111
+ print(cite.citation_bibtex(cite.load_scorecard(args.run_dir)))
112
+ return
113
+ if args.command == "validate-config":
114
+ load_config(args.config) # raises ValueError with every problem found, if any
115
+ print(f"{args.config}: valid")
116
+ return
117
+ if args.command == "compare-datasets":
118
+ print(compare_datasets.render_report(compare_datasets.compare(args.config_a, args.config_b)))
119
+ return
120
+ if args.command == "recommend":
121
+ return _run_recommend(args)
122
+
123
+ cfg = load_config(args.config)
124
+
125
+ output_dir = Path(cfg["output"]["dir"])
126
+ output_dir.mkdir(parents=True, exist_ok=True)
127
+
128
+ logger.info("Loading dataset '%s'", cfg["dataset"]["name"])
129
+ cached = cache.try_load(cfg)
130
+ if cached is not None:
131
+ train_df, test_df = cached
132
+ else:
133
+ train_df, test_df = dataset.load_split(cfg)
134
+ # Applied before audit/benchmarking so both see the collapsed
135
+ # categories consistently, not just the final benchmark stage.
136
+ train_df, test_df = apply_attack_type_mapping(train_df, test_df, cfg)
137
+ dataset.validate_loaded(train_df, test_df, cfg)
138
+ cache.save(train_df, test_df, cfg)
139
+ logger.info("Split: train=%d rows, test=%d rows", len(train_df), len(test_df))
140
+
141
+ # A load-phase crash has no run_dir to report status into - there's no
142
+ # run to speak of yet, not even a failed one, until loading succeeds.
143
+ run_dir = run_manager.create_run_dir(output_dir)
144
+ run_manager.write_environment_info(run_dir)
145
+ run_manager.write_resolved_config(run_dir, cfg)
146
+ fingerprint = run_manager.write_dataset_fingerprint(run_dir, train_df, test_df, cfg)
147
+ logger.info("Run artifacts: %s", run_dir)
148
+
149
+ # Stable paths (unlike run_dir, which is fresh per timestamp) so a crash-and-rerun of
150
+ # the same output.dir finds the checkpoint a previous, incomplete run left behind.
151
+ # Separate files per pass: most checks aren't in STRUCTURAL_CHECKS and legitimately
152
+ # produce a DIFFERENT result before vs. after dedup (that's the reason both passes
153
+ # exist), so a single shared checkpoint keyed only by check name would wrongly let
154
+ # the after-pass reuse the before-pass's result for any of them.
155
+ checkpoint_dir = output_dir / ".checkpoint" if cfg["audit"]["checkpoint"] else None
156
+ checkpoint_path_before = str(checkpoint_dir / "before.json") if checkpoint_dir else None
157
+ checkpoint_path_after = str(checkpoint_dir / "after.json") if checkpoint_dir else None
158
+
159
+ stage = "audit_before"
160
+ try:
161
+ if not args.skip_audit:
162
+ # Must run before dedup. dedup_check reports duplication already
163
+ # present in the split, and dataset.dedup() would remove it first.
164
+ stage = "audit_before"
165
+ logger.info("Stage: audit (before dedup)")
166
+ findings_before = run_audit(train_df, test_df, cfg, checkpoint_path=checkpoint_path_before)
167
+ _write_findings(run_dir / "audit_report_before.json", findings_before)
168
+
169
+ if cfg["preprocessing"]["dedup"]:
170
+ stage = "dedup"
171
+ logger.info("Stage: dedup")
172
+ train_df, test_df, dedup_stats = dataset.dedup(train_df, test_df, cfg)
173
+ logger.info("Dedup: %s", dedup_stats)
174
+
175
+ if not args.skip_audit:
176
+ # Full suite again on the cleaned data, by explicit choice, so a
177
+ # claim like "12% duplicate leakage before, 0% after" is backed
178
+ # by two real runs rather than assumed from the dedup stats alone.
179
+ # STRUCTURAL_CHECKS are skipped here and copied from the before-pass
180
+ # instead: their result can't depend on dedup (known_issue_lookup
181
+ # looks at dataset.name, schema_fingerprint_check at column names,
182
+ # resplit_falsification reloads the raw data itself), so recomputing
183
+ # them - resplit_falsification's two RandomForest fits included -
184
+ # would be pure wasted work, not a second real measurement.
185
+ stage = "audit_after"
186
+ logger.info("Stage: audit (after dedup)")
187
+ recomputed = {
188
+ f["check"]: f for f in run_audit(
189
+ train_df, test_df, cfg, skip=STRUCTURAL_CHECKS, checkpoint_path=checkpoint_path_after,
190
+ )
191
+ }
192
+ reused = {f["check"]: f for f in findings_before if f["check"] in STRUCTURAL_CHECKS}
193
+ # dict order follows findings_before's schema order, not recompute order.
194
+ findings_after = [(recomputed | reused)[f["check"]] for f in findings_before]
195
+ _write_findings(run_dir / "audit_report_after.json", findings_after)
196
+
197
+ if not args.skip_audit:
198
+ # Reflects whichever findings describe the data actually shipped
199
+ # in this run - the post-dedup pass if dedup ran, otherwise the
200
+ # only pass there was.
201
+ stage = "scorecard"
202
+ final_findings = findings_after if cfg["preprocessing"]["dedup"] else findings_before
203
+ final_audit_stage = "after" if cfg["preprocessing"]["dedup"] else "before"
204
+
205
+ manifest = intervention_manifest.build_manifest(final_findings)
206
+ if manifest:
207
+ manifest_path = run_dir / "intervention_manifest.json"
208
+ manifest_path.write_text(json.dumps(manifest, indent=2, default=_json_default))
209
+ logger.info(
210
+ "Intervention manifest written to %s (%d intervention(s))",
211
+ manifest_path, len(manifest),
212
+ )
213
+
214
+ sc = scorecard.build_scorecard(
215
+ final_findings, final_audit_stage, cfg, fingerprint,
216
+ findings_before=findings_before if final_audit_stage == "after" else None,
217
+ )
218
+
219
+ previous_run_dir = drift.find_previous_run(output_dir, run_dir)
220
+ if previous_run_dir is not None:
221
+ comparison = drift.compare(sc, previous_run_dir)
222
+ if comparison is not None:
223
+ sc["previous_run_comparison"] = comparison
224
+ if comparison["changed_checks"]:
225
+ logger.info(
226
+ "Compared to %s: %d check(s) changed status",
227
+ comparison["previous_run"], len(comparison["changed_checks"]),
228
+ )
229
+
230
+ has_plot = cfg["output"]["write_scorecard_plot"]
231
+ if has_plot:
232
+ from .reporting import scorecard_plot
233
+ scorecard_plot.render(sc, final_findings, run_dir / "scorecard.pdf", run_dir / "scorecard.png")
234
+ logger.info("Scorecard plot written to %s / .png", run_dir / "scorecard.pdf")
235
+
236
+ run_manager.write_scorecard(
237
+ run_dir, sc, scorecard.render_markdown(sc, has_plot=has_plot), scorecard.render_html(sc)
238
+ )
239
+ logger.info("Scorecard: %s -> %s", sc["overall_status"], run_dir / "scorecard.json")
240
+
241
+ if cfg["output"]["save_preprocessed"]:
242
+ stage = "save_preprocessed"
243
+ fmt = cfg["output"]["format"]
244
+ for name, df in [("train", train_df), ("test", test_df)]:
245
+ path = run_dir / f"{name}.{fmt}"
246
+ if fmt == "parquet":
247
+ df.to_parquet(path, index=False)
248
+ else:
249
+ df.to_csv(path, index=False)
250
+ logger.info("Wrote %s", path)
251
+
252
+ if not args.skip_benchmark:
253
+ stage = "benchmark"
254
+ logger.info("Stage: benchmark")
255
+ results_df, extras = run_benchmark(train_df, test_df, cfg)
256
+ results_path = run_dir / "benchmark_results.csv"
257
+ results_df.to_csv(results_path, index=False)
258
+ per_class_path = run_dir / "per_class_metrics.csv"
259
+ pd.DataFrame(extras["per_class_metrics"]).to_csv(per_class_path, index=False)
260
+ details_path = run_dir / "benchmark_details.json"
261
+ details_path.write_text(json.dumps(extras, indent=2, default=_json_default))
262
+ logger.info("Benchmark results written to %s", results_path)
263
+ logger.info("Per-class precision/recall/f1 written to %s", per_class_path)
264
+ logger.info("Per-classifier detail (confusion matrix, per-class report, "
265
+ "feature importance, best params) written to %s", details_path)
266
+ logger.info("\n%s", results_df.to_string(index=False))
267
+
268
+ run_manager.write_run_status(run_dir, status="completed")
269
+ if checkpoint_dir:
270
+ _checkpoint.clear(checkpoint_path_before)
271
+ _checkpoint.clear(checkpoint_path_after)
272
+ try:
273
+ checkpoint_dir.rmdir()
274
+ except OSError:
275
+ pass # not empty or already gone - fine, nothing left to clean up here
276
+ except Exception as e:
277
+ run_manager.write_run_status(run_dir, status="failed", failed_stage=stage, error=str(e))
278
+ raise
279
+ finally:
280
+ run_manager.cleanup_old_runs(output_dir, cfg["output"]["keep_runs"])
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
ids2eval/config.py ADDED
@@ -0,0 +1,362 @@
1
+ """Load and validate IDS2Eval YAML configs against configs/schema.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ VALID_CLASSIFIERS = {
12
+ "RandomForest", "XGBoost", "DecisionTree", "NaiveBayes",
13
+ "LogisticRegression", "KNN", "SVM", "LDA", "Stacking", "Voting",
14
+ "ExtraTrees", "HistGradientBoosting", "AdaBoost", "MLP",
15
+ }
16
+ NO_TUNE_CLASSIFIERS = {"NaiveBayes", "Stacking", "Voting"}
17
+
18
+ VALID_SAMPLING = {"none", "smote", "smoteenn", "enn", "random_undersample"}
19
+ VALID_SCALING = {"none", "standard", "minmax", "robust"}
20
+ VALID_SPLIT_MODE = {"random", "grouped"}
21
+ VALID_CALIBRATION = {"none", "platt", "isotonic"}
22
+ VALID_OUTPUT_FORMAT = {"parquet", "csv"}
23
+
24
+ # Which checks support audit.materiality_thresholds overrides, and the shape each one
25
+ # expects: a single number for a binary flag/ok check, or the two named tiers for a
26
+ # check with a three-tier ok/warning/flag scale. Kept here as the one place that has to
27
+ # stay in sync with which checks actually call ids2eval.audit._materiality.threshold().
28
+ MATERIALITY_SINGLE_CHECKS = {
29
+ "resplit_falsification", "cross_dataset_drift_check", "repeated_seed_falsification_check",
30
+ "homogeneity_test",
31
+ }
32
+ MATERIALITY_TIERED_CHECKS = {
33
+ "result_robustness_check", "cross_capture_matrix_check",
34
+ "scenario_holdout_falsification", "feature_category_ablation_check",
35
+ }
36
+
37
+ DEFAULTS: dict[str, Any] = {
38
+ "random_seed": 0, # every random_state/seed in the pipeline (split, reservoir
39
+ # sampling, SMOTE/etc., classifier init, benchmark subsampling)
40
+ # derives from this one value, so a run is reproducible from
41
+ # resolved_config.json alone rather than 6 scattered constants
42
+ "dataset": {
43
+ "name": None,
44
+ "raw_files": [],
45
+ "train_file": None,
46
+ "test_file": None,
47
+ "column_names": [],
48
+ "split_ratio": 0.8,
49
+ "split_mode": "random",
50
+ "group_columns": [],
51
+ "chunk_size": None,
52
+ "max_rows": None,
53
+ },
54
+ "schema": {
55
+ "label_column": None,
56
+ "attack_category_column": None,
57
+ "drop_columns": [],
58
+ "id_like_columns": [],
59
+ "timestamp_column": None,
60
+ "flow_id_columns": [],
61
+ "scenario_column": None,
62
+ "feature_categories": {},
63
+ },
64
+ "label_grouping": {
65
+ "attack_type_mapping": {},
66
+ },
67
+ "preprocessing": {
68
+ "dedup": True,
69
+ "scaling": "standard",
70
+ "sampling": {"binary": "none", "type": "none"},
71
+ },
72
+ "audit": {
73
+ "dedup_check": True,
74
+ "label_conflict_check": True,
75
+ "near_duplicate_class_check": True,
76
+ "leakage_screen": True,
77
+ "one_rule_check": True,
78
+ "feature_auc_ranking_check": True,
79
+ "identity_column_flag": True,
80
+ "port_protocol_shortcut_check": True,
81
+ "temporal_leakage_check": True,
82
+ "temporal_realism_check": True,
83
+ "flow_group_leakage_check": True,
84
+ "row_order_leakage_check": True,
85
+ "homogeneity_test": True,
86
+ "resplit_falsification": True,
87
+ "scenario_holdout_falsification": True,
88
+ "class_distribution_report": True,
89
+ "low_cardinality_warning": True,
90
+ "schema_fingerprint_check": True,
91
+ "data_integrity_check": True,
92
+ "synthetic_realism_check": False,
93
+ "cross_dataset_drift_check": False,
94
+ "cross_capture_matrix_check": False,
95
+ "feature_category_ablation_check": False,
96
+ "artifact_sensitivity_check": False,
97
+ "result_robustness_check": False,
98
+ "known_issue_lookup": False,
99
+ "seed_sensitivity_check": False,
100
+ "repeated_seed_falsification_check": False,
101
+ # Number of independent seeds repeated_seed_falsification_check refits under;
102
+ # each additional seed is a full pair of RandomForest refits on the whole raw
103
+ # dataset, real, non-negligible compute on a large dataset, hence opt-in above.
104
+ "repeated_seed_count": 10,
105
+ "reference_dataset": None,
106
+ # Per-check materiality thresholds, frozen and user-visible rather than a buried
107
+ # module constant. Empty by default: each check keeps its own already-calibrated
108
+ # default (see the check's own module) unless overridden here by check name. A
109
+ # single number for a binary flag/ok check (e.g. resplit_falsification: 0.01), or
110
+ # {"warning": x, "flag": y} for a check with a three-tier ok/warning/flag scale
111
+ # (e.g. result_robustness_check). Deliberately per-check, not one global value:
112
+ # resplit_falsification's 0.01 and feature_category_ablation_check's 0.30 measure
113
+ # very different things (any session-leakage cost vs. expected ablation cost) and
114
+ # were each calibrated to their own check's scale.
115
+ "materiality_thresholds": {},
116
+ # Off by default: running checks concurrently means several may hold their own
117
+ # encoded feature matrix/RandomForest fit in memory at the same time instead of
118
+ # one at a time, raising peak memory versus the sequential default - a real
119
+ # concern on large-scale runs sized against sequential peak usage. Opt in once a
120
+ # run's VM has memory headroom to spare; max_parallel_checks caps how many run
121
+ # at once so this can't silently spike to running all ~28 checks simultaneously.
122
+ "parallel_checks": False,
123
+ "max_parallel_checks": 4,
124
+ # Off by default: persists each check's finding (and, for
125
+ # repeated_seed_falsification_check, per-seed progress) to
126
+ # output.dir/.checkpoint/ as it's computed, so a crash partway through a
127
+ # long full-scale run only redoes what hadn't finished yet on the next
128
+ # run with the same output.dir, rather than starting over. Cleared
129
+ # automatically once a run completes successfully. Mutually exclusive
130
+ # with parallel_checks: concurrent checks writing to one checkpoint
131
+ # file would race.
132
+ "checkpoint": False,
133
+ },
134
+ "classifiers": {
135
+ "list": "all",
136
+ "tuning": False,
137
+ "calibration": "none",
138
+ "hyperparameters": {},
139
+ "search_space": {},
140
+ },
141
+ "output": {
142
+ "dir": "./output",
143
+ "format": "parquet",
144
+ "save_preprocessed": True,
145
+ "keep_runs": 10,
146
+ "write_scorecard_plot": False,
147
+ },
148
+ }
149
+
150
+
151
+ def _deep_merge(base: dict, override: dict) -> dict:
152
+ merged = copy.deepcopy(base)
153
+ for key, value in override.items():
154
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
155
+ merged[key] = _deep_merge(merged[key], value)
156
+ else:
157
+ merged[key] = value
158
+ return merged
159
+
160
+
161
+ def apply_patch(cfg: dict, patch: dict) -> dict:
162
+ """Deep-merge a partial config (e.g. a reporting.recommend patch) onto an
163
+ already-resolved config. A list in `patch` replaces the corresponding
164
+ list in `cfg` wholesale, it is never concatenated.
165
+ """
166
+ return _deep_merge(cfg, patch)
167
+
168
+
169
+ def load_config(path: str | Path) -> dict[str, Any]:
170
+ try:
171
+ with open(path) as f:
172
+ user_cfg = yaml.safe_load(f) or {}
173
+ except FileNotFoundError:
174
+ raise ValueError(
175
+ f"Config file not found: {path}. See configs/README.md in the repo for every "
176
+ "field, or the Quick start section of README.md for a minimal example."
177
+ ) from None
178
+ cfg = _deep_merge(DEFAULTS, user_cfg)
179
+ validate_config(cfg)
180
+ return cfg
181
+
182
+
183
+ def validate_config(cfg: dict[str, Any]) -> None:
184
+ errors: list[str] = []
185
+
186
+ if not isinstance(cfg["random_seed"], int) or isinstance(cfg["random_seed"], bool):
187
+ errors.append("random_seed must be an integer")
188
+
189
+ dataset = cfg["dataset"]
190
+ has_raw = bool(dataset["raw_files"])
191
+ has_presplit = bool(dataset["train_file"] and dataset["test_file"])
192
+ if not dataset["name"]:
193
+ errors.append("dataset.name is required")
194
+ if has_raw == has_presplit:
195
+ errors.append(
196
+ "dataset must specify exactly one of raw_files, or train_file+test_file together"
197
+ )
198
+ split_ratio = dataset["split_ratio"]
199
+ if not isinstance(split_ratio, (int, float)) or isinstance(split_ratio, bool) or not 0 < split_ratio < 1:
200
+ errors.append("dataset.split_ratio must be a number strictly between 0 and 1")
201
+ if dataset["split_mode"] not in VALID_SPLIT_MODE:
202
+ errors.append(f"dataset.split_mode must be one of {sorted(VALID_SPLIT_MODE)}")
203
+ if dataset["split_mode"] == "grouped" and not dataset["group_columns"]:
204
+ errors.append("dataset.group_columns is required when split_mode is 'grouped'")
205
+ column_names = dataset["column_names"]
206
+ if column_names:
207
+ if not isinstance(column_names, list) or not all(isinstance(c, str) for c in column_names):
208
+ errors.append("dataset.column_names must be a list of strings")
209
+ elif len(column_names) != len(set(column_names)):
210
+ dupes = sorted({c for c in column_names if column_names.count(c) > 1})
211
+ errors.append(f"dataset.column_names has duplicate name(s): {dupes}")
212
+ if dataset["chunk_size"] is not None and dataset["chunk_size"] <= 0:
213
+ errors.append("dataset.chunk_size must be a positive integer")
214
+ if dataset["max_rows"] is not None:
215
+ if dataset["max_rows"] <= 0:
216
+ errors.append("dataset.max_rows must be a positive integer")
217
+ if not dataset["chunk_size"]:
218
+ errors.append(
219
+ "dataset.max_rows requires dataset.chunk_size to be set. Reservoir "
220
+ "sampling still has to read the file in chunks to sample it"
221
+ )
222
+
223
+ if not cfg["schema"]["label_column"]:
224
+ errors.append("schema.label_column is required")
225
+ if cfg["label_grouping"]["attack_type_mapping"] and not cfg["schema"]["attack_category_column"]:
226
+ errors.append(
227
+ "label_grouping.attack_type_mapping requires schema.attack_category_column "
228
+ "to be set, otherwise there's no column for it to apply to"
229
+ )
230
+
231
+ scaling = cfg["preprocessing"]["scaling"]
232
+ scalings = scaling if isinstance(scaling, list) else [scaling]
233
+ invalid_scaling = [s for s in scalings if s not in VALID_SCALING]
234
+ if invalid_scaling:
235
+ errors.append(
236
+ f"preprocessing.scaling has invalid entries {invalid_scaling}; "
237
+ f"each must be one of {sorted(VALID_SCALING)}"
238
+ )
239
+ if not scalings:
240
+ errors.append("preprocessing.scaling must not be an empty list")
241
+ for stage, algo in cfg["preprocessing"]["sampling"].items():
242
+ algos = algo if isinstance(algo, list) else [algo]
243
+ invalid = [a for a in algos if a not in VALID_SAMPLING]
244
+ if invalid:
245
+ errors.append(
246
+ f"preprocessing.sampling.{stage} has invalid entries {invalid}; "
247
+ f"each must be one of {sorted(VALID_SAMPLING)}"
248
+ )
249
+ if not algos:
250
+ errors.append(f"preprocessing.sampling.{stage} must not be an empty list")
251
+
252
+ audit = cfg["audit"]
253
+ # known_issue_lookup is a pure curated-table lookup keyed on dataset.name,
254
+ # unlike the other two v2 checks, it needs no second dataset to compare against.
255
+ reference_needing_checks = ["synthetic_realism_check", "cross_dataset_drift_check"]
256
+ if any(audit[check] for check in reference_needing_checks) and not audit["reference_dataset"]:
257
+ errors.append(
258
+ f"audit.reference_dataset is required when any of {reference_needing_checks} is true"
259
+ )
260
+ # repeated_seed_falsification_check shares resplit_falsification's exact precondition:
261
+ # it's the same random-vs-grouped comparison, just repeated across seeds.
262
+ for check_name in ("resplit_falsification", "repeated_seed_falsification_check"):
263
+ if audit[check_name] and not (has_raw and dataset["group_columns"]):
264
+ if not has_raw:
265
+ errors.append(
266
+ f"audit.{check_name} needs dataset.raw_files, not a pre-split "
267
+ "train_file/test_file pair (it builds its own independent random-vs-"
268
+ f"grouped comparison split from the raw data). Set "
269
+ f"audit.{check_name}: false if this dataset has no "
270
+ "session-identifying columns to group by."
271
+ )
272
+ else:
273
+ errors.append(
274
+ f"audit.{check_name} needs dataset.group_columns set (e.g. "
275
+ "source/destination IP and port, or whatever identifies a session in "
276
+ f"this dataset) to build its grouped-split comparison. Set "
277
+ f"audit.{check_name}: false instead if this dataset has no "
278
+ "such columns, see guide/checks.md for what the check does."
279
+ )
280
+
281
+ n_seeds = audit["repeated_seed_count"]
282
+ if isinstance(n_seeds, bool) or not isinstance(n_seeds, int) or n_seeds < 1:
283
+ errors.append("audit.repeated_seed_count must be a positive integer")
284
+
285
+ if not isinstance(audit["parallel_checks"], bool):
286
+ errors.append("audit.parallel_checks must be true or false")
287
+ max_parallel = audit["max_parallel_checks"]
288
+ if isinstance(max_parallel, bool) or not isinstance(max_parallel, int) or max_parallel < 1:
289
+ errors.append("audit.max_parallel_checks must be a positive integer")
290
+
291
+ if not isinstance(audit["checkpoint"], bool):
292
+ errors.append("audit.checkpoint must be true or false")
293
+ if audit["checkpoint"] and audit["parallel_checks"]:
294
+ errors.append(
295
+ "audit.checkpoint and audit.parallel_checks cannot both be true: "
296
+ "concurrent checks writing to one checkpoint file would race"
297
+ )
298
+
299
+ thresholds = audit["materiality_thresholds"]
300
+ known_threshold_checks = MATERIALITY_SINGLE_CHECKS | MATERIALITY_TIERED_CHECKS
301
+ unknown_threshold_checks = set(thresholds) - known_threshold_checks
302
+ if unknown_threshold_checks:
303
+ errors.append(
304
+ f"audit.materiality_thresholds has entries for {sorted(unknown_threshold_checks)}, "
305
+ f"which don't support a threshold override; valid keys are {sorted(known_threshold_checks)}"
306
+ )
307
+ for check in MATERIALITY_SINGLE_CHECKS & set(thresholds):
308
+ if isinstance(thresholds[check], bool) or not isinstance(thresholds[check], (int, float)):
309
+ errors.append(
310
+ f"audit.materiality_thresholds.{check} must be a single number "
311
+ f"(this check has one flag/ok threshold, not separate warning/flag tiers)"
312
+ )
313
+ for check in MATERIALITY_TIERED_CHECKS & set(thresholds):
314
+ value = thresholds[check]
315
+ if not isinstance(value, dict) or set(value) != {"warning", "flag"}:
316
+ errors.append(
317
+ f'audit.materiality_thresholds.{check} must be a dict with exactly the keys '
318
+ f'"warning" and "flag" (this check has a three-tier ok/warning/flag scale)'
319
+ )
320
+ elif not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value.values()):
321
+ errors.append(f"audit.materiality_thresholds.{check}'s warning and flag values must be numbers")
322
+ elif value["warning"] >= value["flag"]:
323
+ errors.append(
324
+ f"audit.materiality_thresholds.{check}.warning ({value['warning']}) must be "
325
+ f"less than .flag ({value['flag']})"
326
+ )
327
+
328
+ classifiers = cfg["classifiers"]
329
+ clf_list = classifiers["list"]
330
+ if clf_list != "all":
331
+ unknown = set(clf_list) - VALID_CLASSIFIERS
332
+ if unknown:
333
+ errors.append(
334
+ f"classifiers.list has unknown entries {sorted(unknown)}; "
335
+ f"valid options are {sorted(VALID_CLASSIFIERS)}"
336
+ )
337
+ unknown_hp = set(classifiers["hyperparameters"]) - VALID_CLASSIFIERS
338
+ if unknown_hp:
339
+ errors.append(f"classifiers.hyperparameters has unknown classifier names {sorted(unknown_hp)}")
340
+ unknown_ss = set(classifiers["search_space"]) - VALID_CLASSIFIERS
341
+ if unknown_ss:
342
+ errors.append(f"classifiers.search_space has unknown classifier names {sorted(unknown_ss)}")
343
+ if classifiers["calibration"] not in VALID_CALIBRATION:
344
+ errors.append(f"classifiers.calibration must be one of {sorted(VALID_CALIBRATION)}")
345
+
346
+ if cfg["output"]["format"] not in VALID_OUTPUT_FORMAT:
347
+ errors.append(f"output.format must be one of {sorted(VALID_OUTPUT_FORMAT)}")
348
+ keep_runs = cfg["output"]["keep_runs"]
349
+ if keep_runs is not None and keep_runs <= 0:
350
+ errors.append("output.keep_runs must be a positive integer, or null to keep all runs")
351
+
352
+ if cfg["output"]["write_scorecard_plot"]:
353
+ try:
354
+ import matplotlib # noqa: F401
355
+ except ImportError:
356
+ errors.append(
357
+ "output.write_scorecard_plot is true but matplotlib isn't installed. "
358
+ 'run: pip install "ids2eval[plots]"'
359
+ )
360
+
361
+ if errors:
362
+ raise ValueError("Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
@@ -0,0 +1 @@
1
+ """IDS2Eval dashboard - a local web UI. Entry point: ids2eval.dashboard.server.main."""
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()