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.
- ids2eval/__init__.py +0 -0
- ids2eval/__main__.py +4 -0
- ids2eval/_json_utils.py +24 -0
- ids2eval/audit/__init__.py +239 -0
- ids2eval/audit/_auc.py +48 -0
- ids2eval/audit/_by_class.py +29 -0
- ids2eval/audit/_checkpoint.py +74 -0
- ids2eval/audit/_fit_score.py +40 -0
- ids2eval/audit/_materiality.py +28 -0
- ids2eval/audit/_status.py +15 -0
- ids2eval/audit/_time.py +14 -0
- ids2eval/audit/artifact_sensitivity.py +88 -0
- ids2eval/audit/class_distribution.py +57 -0
- ids2eval/audit/cross_capture_matrix.py +115 -0
- ids2eval/audit/cross_dataset_drift.py +74 -0
- ids2eval/audit/data_integrity.py +67 -0
- ids2eval/audit/dedup.py +67 -0
- ids2eval/audit/feature_auc_ranking.py +125 -0
- ids2eval/audit/feature_category_ablation.py +98 -0
- ids2eval/audit/flow_group_leakage.py +66 -0
- ids2eval/audit/homogeneity.py +160 -0
- ids2eval/audit/identity_columns.py +171 -0
- ids2eval/audit/known_issues.py +144 -0
- ids2eval/audit/label_conflict.py +92 -0
- ids2eval/audit/leakage.py +99 -0
- ids2eval/audit/near_duplicate_class.py +87 -0
- ids2eval/audit/one_rule.py +82 -0
- ids2eval/audit/port_protocol_shortcut.py +75 -0
- ids2eval/audit/repeated_seed_falsification.py +182 -0
- ids2eval/audit/resplit.py +92 -0
- ids2eval/audit/result_robustness.py +154 -0
- ids2eval/audit/row_order_leakage.py +86 -0
- ids2eval/audit/scenario_holdout.py +115 -0
- ids2eval/audit/schema_fingerprint.py +52 -0
- ids2eval/audit/seed_sensitivity.py +69 -0
- ids2eval/audit/synthetic_realism.py +73 -0
- ids2eval/audit/temporal_leakage.py +58 -0
- ids2eval/audit/temporal_realism.py +81 -0
- ids2eval/cli.py +284 -0
- ids2eval/config.py +362 -0
- ids2eval/dashboard/__init__.py +1 -0
- ids2eval/dashboard/__main__.py +3 -0
- ids2eval/dashboard/server.py +527 -0
- ids2eval/dashboard/static/app.js +1064 -0
- ids2eval/dashboard/static/charts.js +366 -0
- ids2eval/dashboard/static/index.html +33 -0
- ids2eval/dashboard/static/style.css +293 -0
- ids2eval/data/__init__.py +3 -0
- ids2eval/data/cache.py +99 -0
- ids2eval/data/chunked_io.py +387 -0
- ids2eval/data/dataset.py +208 -0
- ids2eval/data/features.py +95 -0
- ids2eval/data/label_grouping.py +38 -0
- ids2eval/modeling/__init__.py +3 -0
- ids2eval/modeling/benchmark.py +360 -0
- ids2eval/modeling/classifiers.py +145 -0
- ids2eval/modeling/preprocessing.py +73 -0
- ids2eval/reporting/__init__.py +2 -0
- ids2eval/reporting/cite.py +57 -0
- ids2eval/reporting/compare_datasets.py +103 -0
- ids2eval/reporting/drift.py +80 -0
- ids2eval/reporting/intervention_manifest.py +83 -0
- ids2eval/reporting/recommend.py +402 -0
- ids2eval/reporting/run_manager.py +184 -0
- ids2eval/reporting/scorecard.py +875 -0
- ids2eval/reporting/scorecard_plot.py +162 -0
- ids2eval/version_info.py +36 -0
- ids2eval-0.2.2.dist-info/METADATA +119 -0
- ids2eval-0.2.2.dist-info/RECORD +73 -0
- ids2eval-0.2.2.dist-info/WHEEL +5 -0
- ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
- ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
- ids2eval-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Scaling and class-balancing, driven by preprocessing.* config.
|
|
2
|
+
|
|
3
|
+
Sampling is fit on the training split only and never applied to test:
|
|
4
|
+
test sets stay at their real-world class distribution so metrics
|
|
5
|
+
reflect real deployment, matching the IDS project's balancer.py design.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from imblearn.combine import SMOTEENN
|
|
15
|
+
from imblearn.over_sampling import SMOTE
|
|
16
|
+
from imblearn.under_sampling import EditedNearestNeighbours, RandomUnderSampler
|
|
17
|
+
from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler
|
|
18
|
+
|
|
19
|
+
from ..data import features
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
SCALERS = {"standard": StandardScaler, "minmax": MinMaxScaler, "robust": RobustScaler}
|
|
24
|
+
SAMPLERS = {
|
|
25
|
+
"smote": lambda seed: SMOTE(random_state=seed),
|
|
26
|
+
"smoteenn": lambda seed: SMOTEENN(random_state=seed),
|
|
27
|
+
"enn": lambda seed: EditedNearestNeighbours(), # deterministic, no randomness to seed
|
|
28
|
+
"random_undersample": lambda seed: RandomUnderSampler(random_state=seed),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def scale_features(
|
|
33
|
+
train_df: pd.DataFrame, test_df: pd.DataFrame, cfg: dict
|
|
34
|
+
) -> tuple[np.ndarray, np.ndarray, list[str]]:
|
|
35
|
+
cols = features.feature_columns(train_df, cfg)
|
|
36
|
+
x_train_raw, x_test_raw = features.encode_aligned(train_df, test_df, cols)
|
|
37
|
+
|
|
38
|
+
method = cfg["preprocessing"]["scaling"]
|
|
39
|
+
if isinstance(method, list):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"preprocessing.scaling is a list ({method}) - scale_features takes a "
|
|
42
|
+
"single strategy; use run_benchmark for multi-strategy comparison"
|
|
43
|
+
)
|
|
44
|
+
if method == "none":
|
|
45
|
+
return x_train_raw.to_numpy(), x_test_raw.to_numpy(), cols
|
|
46
|
+
|
|
47
|
+
scaler = SCALERS[method]()
|
|
48
|
+
x_train = scaler.fit_transform(x_train_raw)
|
|
49
|
+
x_test = scaler.transform(x_test_raw)
|
|
50
|
+
return x_train, x_test, cols
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def apply_sampling(x_train: np.ndarray, y_train, cfg: dict, stage: str) -> tuple[np.ndarray, np.ndarray]:
|
|
54
|
+
algo = cfg["preprocessing"]["sampling"][stage]
|
|
55
|
+
if isinstance(algo, list):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"preprocessing.sampling.{stage} is a list ({algo}) - apply_sampling takes a "
|
|
58
|
+
"single strategy; use run_benchmark for multi-strategy comparison"
|
|
59
|
+
)
|
|
60
|
+
y_train = np.asarray(y_train)
|
|
61
|
+
if algo == "none":
|
|
62
|
+
return x_train, y_train
|
|
63
|
+
|
|
64
|
+
sampler = SAMPLERS[algo](cfg["random_seed"])
|
|
65
|
+
try:
|
|
66
|
+
return sampler.fit_resample(x_train, y_train)
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Sampling '%s' failed for stage '%s' (%s), likely a class too small "
|
|
70
|
+
"for this sampler's neighbor count. Falling back to unsampled data.",
|
|
71
|
+
algo, stage, e,
|
|
72
|
+
)
|
|
73
|
+
return x_train, y_train
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Prints a citation block for a completed run's scorecard.
|
|
2
|
+
|
|
3
|
+
Everything needed already lives in scorecard.json (dataset name, tool
|
|
4
|
+
version and commit, schema version, verdict, generated timestamp, and
|
|
5
|
+
the dataset_fingerprint content hashes): this reads that one file and
|
|
6
|
+
reformats it, it computes nothing new.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import scorecard as _scorecard
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bibtex_key(dataset_name: str, train_content_hash: str) -> str:
|
|
19
|
+
slug = re.sub(r"[^a-z0-9]+", "_", dataset_name.lower()).strip("_")
|
|
20
|
+
return f"ids2eval_{slug}_{train_content_hash[:8]}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_scorecard(run_dir: str | Path) -> dict:
|
|
24
|
+
path = Path(run_dir)
|
|
25
|
+
if path.is_dir():
|
|
26
|
+
path = path / "scorecard.json"
|
|
27
|
+
if not path.exists():
|
|
28
|
+
raise FileNotFoundError(f"No scorecard.json found at or under {run_dir}")
|
|
29
|
+
return _scorecard.upgrade(json.loads(path.read_text()))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def citation_bibtex(scorecard: dict) -> str:
|
|
33
|
+
fp = scorecard["dataset_fingerprint"]
|
|
34
|
+
year = scorecard["generated_at"][:4]
|
|
35
|
+
key = _bibtex_key(scorecard["dataset_name"], fp["train_content_hash"])
|
|
36
|
+
commit = scorecard["ids2eval_git_commit"]
|
|
37
|
+
verdict = scorecard["overall_status"].replace("_", " ")
|
|
38
|
+
|
|
39
|
+
# commit is only known for a git checkout (version_info.py shells out to
|
|
40
|
+
# git); a pip install from a downloaded zip - no .git directory at all -
|
|
41
|
+
# has no commit to report, and "commit unknown" reads worse in a
|
|
42
|
+
# citation than just not mentioning a commit at all.
|
|
43
|
+
howpublished = f"IDS2Eval v{scorecard['ids2eval_version']} (scorecard schema {scorecard['scorecard_schema_version']})"
|
|
44
|
+
if commit:
|
|
45
|
+
howpublished += f", commit {commit}"
|
|
46
|
+
|
|
47
|
+
fields = {
|
|
48
|
+
"title": f"IDS2Eval scorecard for {scorecard['dataset_name']}",
|
|
49
|
+
"howpublished": howpublished,
|
|
50
|
+
"note": (
|
|
51
|
+
f"{verdict}; train content hash {fp['train_content_hash']}; "
|
|
52
|
+
f"test content hash {fp['test_content_hash']}; generated {scorecard['generated_at']}"
|
|
53
|
+
),
|
|
54
|
+
"year": year,
|
|
55
|
+
}
|
|
56
|
+
body = ",\n".join(f" {name} = {{{value}}}" for name, value in fields.items())
|
|
57
|
+
return f"@misc{{{key},\n{body}\n}}"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Two-dataset equivalence comparison.
|
|
2
|
+
|
|
3
|
+
Answers a question no single-dataset audit can: are these two configs,
|
|
4
|
+
both claiming to load "the same" dataset (an official release and a
|
|
5
|
+
third-party mirror, say), actually equivalent? Loads both, computes
|
|
6
|
+
each one's fingerprint (run_manager.compute_dataset_fingerprint), and
|
|
7
|
+
compares schema, row counts, label distribution, and content.
|
|
8
|
+
|
|
9
|
+
Not a run_audit check: this compares two DATASETS, not one dataset's
|
|
10
|
+
train against its own test, and needs two configs rather than one, a
|
|
11
|
+
different enough shape to be its own CLI subcommand
|
|
12
|
+
(`ids2eval compare-datasets a.yaml b.yaml`) rather than an audit
|
|
13
|
+
finding. Each dataset's own train/test split can use a different
|
|
14
|
+
split_ratio or split_mode, so a train-vs-train, test-vs-test content
|
|
15
|
+
hash comparison alone could report a mismatch that's really just a
|
|
16
|
+
different split of identical underlying data; a combined (train+test
|
|
17
|
+
concatenated) hash catches that case, alongside the split-specific
|
|
18
|
+
hashes for detail.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from ..config import load_config
|
|
26
|
+
from ..data import dataset
|
|
27
|
+
from . import run_manager
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _combined_label_counts(fingerprint: dict) -> dict:
|
|
31
|
+
counts: dict[str, int] = {}
|
|
32
|
+
for source in ("train_class_distribution", "test_class_distribution"):
|
|
33
|
+
for label, count in fingerprint[source].items():
|
|
34
|
+
counts[label] = counts.get(label, 0) + count
|
|
35
|
+
return counts
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compare(config_path_a: str, config_path_b: str) -> dict:
|
|
39
|
+
cfg_a = load_config(config_path_a)
|
|
40
|
+
cfg_b = load_config(config_path_b)
|
|
41
|
+
train_a, test_a = dataset.load_split(cfg_a)
|
|
42
|
+
train_b, test_b = dataset.load_split(cfg_b)
|
|
43
|
+
|
|
44
|
+
fp_a = run_manager.compute_dataset_fingerprint(train_a, test_a, cfg_a)
|
|
45
|
+
fp_b = run_manager.compute_dataset_fingerprint(train_b, test_b, cfg_b)
|
|
46
|
+
|
|
47
|
+
combined_hash_a = run_manager.content_hash_unordered(pd.concat([train_a, test_a], ignore_index=True))
|
|
48
|
+
combined_hash_b = run_manager.content_hash_unordered(pd.concat([train_b, test_b], ignore_index=True))
|
|
49
|
+
|
|
50
|
+
labels_a = _combined_label_counts(fp_a)
|
|
51
|
+
labels_b = _combined_label_counts(fp_b)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"dataset_a": cfg_a["dataset"]["name"],
|
|
55
|
+
"dataset_b": cfg_b["dataset"]["name"],
|
|
56
|
+
"schema_match": set(fp_a["columns"]) == set(fp_b["columns"]),
|
|
57
|
+
"columns_only_in_a": sorted(set(fp_a["columns"]) - set(fp_b["columns"])),
|
|
58
|
+
"columns_only_in_b": sorted(set(fp_b["columns"]) - set(fp_a["columns"])),
|
|
59
|
+
"row_count_a": fp_a["train_rows"] + fp_a["test_rows"],
|
|
60
|
+
"row_count_b": fp_b["train_rows"] + fp_b["test_rows"],
|
|
61
|
+
"row_count_match": (fp_a["train_rows"] + fp_a["test_rows"]) == (fp_b["train_rows"] + fp_b["test_rows"]),
|
|
62
|
+
"label_distribution_a": labels_a,
|
|
63
|
+
"label_distribution_b": labels_b,
|
|
64
|
+
"label_distribution_match": labels_a == labels_b,
|
|
65
|
+
"combined_content_match": combined_hash_a == combined_hash_b,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_report(result: dict) -> str:
|
|
70
|
+
def verdict(matched: bool, hard: bool = False) -> str:
|
|
71
|
+
return "PASS" if matched else ("FAIL" if hard else "WARN")
|
|
72
|
+
|
|
73
|
+
lines = [
|
|
74
|
+
f"Dataset A: {result['dataset_a']}",
|
|
75
|
+
f"Dataset B: {result['dataset_b']}",
|
|
76
|
+
"",
|
|
77
|
+
f"Schema match {verdict(result['schema_match'], hard=True)}",
|
|
78
|
+
f"Row count match {verdict(result['row_count_match'])}",
|
|
79
|
+
f"Label distribution {verdict(result['label_distribution_match'])}",
|
|
80
|
+
f"Combined content {verdict(result['combined_content_match'], hard=True)}",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
if not result["schema_match"]:
|
|
84
|
+
if result["columns_only_in_a"]:
|
|
85
|
+
lines.append(f" columns only in A: {result['columns_only_in_a']}")
|
|
86
|
+
if result["columns_only_in_b"]:
|
|
87
|
+
lines.append(f" columns only in B: {result['columns_only_in_b']}")
|
|
88
|
+
if not result["row_count_match"]:
|
|
89
|
+
lines.append(f" A: {result['row_count_a']:,} rows, B: {result['row_count_b']:,} rows")
|
|
90
|
+
if not result["label_distribution_match"]:
|
|
91
|
+
lines.append(f" A: {result['label_distribution_a']}")
|
|
92
|
+
lines.append(f" B: {result['label_distribution_b']}")
|
|
93
|
+
|
|
94
|
+
# combined_content_match, when true, already means the two datasets' full row
|
|
95
|
+
# sets are identical, which implies matching row counts and label distribution
|
|
96
|
+
# too - those two fields stay useful for diagnosing WHERE a mismatch is when
|
|
97
|
+
# combined_content_match is false, not as a separate verdict branch here.
|
|
98
|
+
lines.append("")
|
|
99
|
+
if result["schema_match"] and result["combined_content_match"]:
|
|
100
|
+
lines.append("Equivalent: same schema, row counts, label distribution, and content.")
|
|
101
|
+
else:
|
|
102
|
+
lines.append("Likely non-equivalent dataset versions.")
|
|
103
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Compares this run's scorecard against the previous run in the same
|
|
2
|
+
output.dir, so a dataset-quality change between two runs of the same
|
|
3
|
+
config over time is visible without a person diffing two JSON files by
|
|
4
|
+
hand.
|
|
5
|
+
|
|
6
|
+
Every check here treats each run as fully independent: there is no
|
|
7
|
+
mechanism to notice that a source file was quietly swapped, or that a
|
|
8
|
+
dataset's duplication rate crept up between one export and the next.
|
|
9
|
+
Deequ and whylogs (see general ML data-quality tooling) both track
|
|
10
|
+
quality metrics across runs against a metrics repository rather than
|
|
11
|
+
only within one; this is a minimal version of that idea, comparing
|
|
12
|
+
against the immediately preceding run rather than a full history.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import run_manager
|
|
22
|
+
from . import scorecard as _scorecard
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_previous_run(output_dir: Path, current_run_dir: Path) -> Path | None:
|
|
28
|
+
"""The most recent other run directory in output_dir that has a
|
|
29
|
+
scorecard.json, or None if there isn't one (a fresh output_dir, or
|
|
30
|
+
every prior run failed before the scorecard stage).
|
|
31
|
+
"""
|
|
32
|
+
runs_dir = output_dir / run_manager.RUNS_DIRNAME
|
|
33
|
+
if not runs_dir.is_dir():
|
|
34
|
+
return None
|
|
35
|
+
candidates = sorted(
|
|
36
|
+
p for p in runs_dir.iterdir()
|
|
37
|
+
if p.is_dir() and p != current_run_dir and (p / "scorecard.json").is_file()
|
|
38
|
+
)
|
|
39
|
+
return candidates[-1] if candidates else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _status_by_check(scorecard: dict) -> dict[str, str]:
|
|
43
|
+
if scorecard.get("checks"):
|
|
44
|
+
return {r["check"]: (r["after"] or r["before"])["status"] for r in scorecard["checks"]}
|
|
45
|
+
return {f["check"]: f["status"] for f in scorecard.get("findings", [])}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compare(scorecard: dict, previous_run_dir: Path) -> dict | None:
|
|
49
|
+
"""None if the previous run's scorecard.json can't be read - a missing
|
|
50
|
+
comparison is reported as "no previous run" rather than crashing the
|
|
51
|
+
current run over an unrelated one's leftover state.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
previous = _scorecard.upgrade(json.loads((previous_run_dir / "scorecard.json").read_text()))
|
|
55
|
+
except (OSError, ValueError) as e:
|
|
56
|
+
logger.warning("Could not read previous run's scorecard at %s: %s", previous_run_dir, e)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
previous_status = _status_by_check(previous)
|
|
60
|
+
current_status = _status_by_check(scorecard)
|
|
61
|
+
changed_checks = [
|
|
62
|
+
{"check": check, "previous_status": previous_status[check], "current_status": status}
|
|
63
|
+
for check, status in current_status.items()
|
|
64
|
+
if check in previous_status and previous_status[check] != status
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
previous_fp = previous.get("dataset_fingerprint", {})
|
|
68
|
+
current_fp = scorecard["dataset_fingerprint"]
|
|
69
|
+
fingerprint_changed = (
|
|
70
|
+
previous_fp.get("train_content_hash") != current_fp.get("train_content_hash")
|
|
71
|
+
or previous_fp.get("test_content_hash") != current_fp.get("test_content_hash")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"previous_run": previous_run_dir.name,
|
|
76
|
+
"previous_generated_at": previous.get("generated_at"),
|
|
77
|
+
"previous_verdict": previous.get("overall_status"),
|
|
78
|
+
"fingerprint_changed": fingerprint_changed,
|
|
79
|
+
"changed_checks": changed_checks,
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Intervention manifest: reformats the checks that already ARE an
|
|
2
|
+
intervention (they refit under an altered condition and compare, rather
|
|
3
|
+
than measuring a static property of the data) into one structured record
|
|
4
|
+
per intervention, machine-readable, matching this project's own falsification
|
|
5
|
+
methodology directly rather than the ok/warning/flag scorecard's summary view.
|
|
6
|
+
|
|
7
|
+
This is a re-projection of findings IDS²Eval already computes, not a new
|
|
8
|
+
detection-and-selection engine: every check listed in INTERVENTION_CHECKS
|
|
9
|
+
always runs (or doesn't) according to audit.<check_name> in the config, the
|
|
10
|
+
same as every other check, rather than being dynamically triggered by a
|
|
11
|
+
prior finding's specific value. Building that latter, fuller "detect a
|
|
12
|
+
threat, then choose which intervention to run" rule engine is real,
|
|
13
|
+
larger, separate work this module does not attempt; what it does do is give
|
|
14
|
+
every intervention that DID run a citable, uniformly-shaped record.
|
|
15
|
+
|
|
16
|
+
Each record's four sections follow the shape:
|
|
17
|
+
finding: the check name, its status, and its own details dict as
|
|
18
|
+
evidence (heterogeneous by check, e.g. resplit_falsification's
|
|
19
|
+
random/grouped accuracy vs. feature_category_ablation_check's
|
|
20
|
+
per-category drop; kept as-is rather than forced into one
|
|
21
|
+
fixed evidence shape, which would lose information)
|
|
22
|
+
intervention: what was done (a fixed, human-readable label per check)
|
|
23
|
+
and that it was performed (always true here, since the
|
|
24
|
+
record only exists for a check that actually ran)
|
|
25
|
+
consequence: the same details dict again, from the intervention's own
|
|
26
|
+
perspective - the "before" and "after" of a resplit-style
|
|
27
|
+
check are the same two numbers as its "evidence"
|
|
28
|
+
conclusion: "material" / "investigated_not_material" / "inconclusive",
|
|
29
|
+
reusing the check's own explicit `material` field
|
|
30
|
+
(True/False/None) when it has one (repeated_seed_falsification_
|
|
31
|
+
check), otherwise derived from its ok/warning/flag status
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
# check name -> human-readable intervention label.
|
|
37
|
+
INTERVENTION_CHECKS = {
|
|
38
|
+
"resplit_falsification": "session/time-grouped resplit",
|
|
39
|
+
"repeated_seed_falsification_check": "session/time-grouped resplit, repeated across seeds",
|
|
40
|
+
"scenario_holdout_falsification": "held out one entire scenario from training",
|
|
41
|
+
"cross_capture_matrix_check": "trained on each scenario, tested on every other",
|
|
42
|
+
"feature_category_ablation_check": "removed one declared feature category and refit",
|
|
43
|
+
"cross_dataset_drift_check": "trained here, evaluated on an independent reference dataset",
|
|
44
|
+
"result_robustness_check": "refit under each buildable condition (split, dedup, identity columns)",
|
|
45
|
+
"artifact_sensitivity_check": "reshuffled one declared identity/timestamp column's values and re-predicted",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_STATUS_TO_CONCLUSION = {"flag": "material", "warning": "inconclusive", "ok": "investigated_not_material"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _conclusion_status(finding: dict) -> str:
|
|
52
|
+
material = finding.get("details", {}).get("material")
|
|
53
|
+
if material is True:
|
|
54
|
+
return "material"
|
|
55
|
+
if material is False:
|
|
56
|
+
return "investigated_not_material"
|
|
57
|
+
if material is None and "material" in finding.get("details", {}):
|
|
58
|
+
return "inconclusive" # explicit tri-state field said "can't tell", not just absent
|
|
59
|
+
return _STATUS_TO_CONCLUSION[finding["status"]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_manifest(findings: list[dict]) -> list[dict]:
|
|
63
|
+
"""One record per finding in `findings` whose check is in INTERVENTION_CHECKS
|
|
64
|
+
AND actually ran, not a no-op. Every check here returns `"details": {}` for its
|
|
65
|
+
no-op case (schema.scenario_column/reference_dataset/etc. not configured, or no
|
|
66
|
+
candidate columns present) - confirmed real against a live run where
|
|
67
|
+
scenario_holdout_falsification's no-op ("no schema.scenario_column configured")
|
|
68
|
+
would otherwise have shown up as intervention.performed: true, a real bug this
|
|
69
|
+
empty-details check exists specifically to prevent from recurring. A
|
|
70
|
+
not_applicable finding is skipped the same way, whatever its details hold.
|
|
71
|
+
"""
|
|
72
|
+
records = []
|
|
73
|
+
for finding in findings:
|
|
74
|
+
check = finding["check"]
|
|
75
|
+
if check not in INTERVENTION_CHECKS or finding["status"] == "not_applicable" or not finding.get("details"):
|
|
76
|
+
continue
|
|
77
|
+
records.append({
|
|
78
|
+
"finding": {"check": check, "status": finding["status"], "evidence": finding.get("details", {})},
|
|
79
|
+
"intervention": {"type": check, "description": INTERVENTION_CHECKS[check], "performed": True},
|
|
80
|
+
"consequence": finding.get("details", {}),
|
|
81
|
+
"conclusion": {"status": _conclusion_status(finding)},
|
|
82
|
+
})
|
|
83
|
+
return records
|