Spaces:
Running
Running
| """Artifact-based Phase 4 validation checks.""" | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Mapping, Optional, Sequence | |
| from prert.phase4.io import ( | |
| load_optional_json, | |
| load_optional_jsonl_rows, | |
| load_phase3_manifest, | |
| resolve_output_path, | |
| ) | |
| LABELS: tuple[str, str, str] = ("user", "system", "organization") | |
| def evaluate_phase4_validation( | |
| artifact_dir: Path, | |
| manifest: Optional[Mapping[str, Any]] = None, | |
| require_bayesian: bool = False, | |
| require_polisis: bool = False, | |
| polisis_advisory: bool = True, | |
| ece_threshold: float = 0.20, | |
| ) -> Dict[str, Any]: | |
| checks: List[Dict[str, Any]] = [] | |
| loaded_manifest: Mapping[str, Any] | |
| if manifest is None: | |
| try: | |
| loaded_manifest = load_phase3_manifest(artifact_dir) | |
| except FileNotFoundError as exc: | |
| _add_check( | |
| checks, | |
| "phase3_manifest_present", | |
| False, | |
| {"reason": str(exc), "path": str(artifact_dir / "phase3_manifest.json")}, | |
| ) | |
| return { | |
| "phase": "phase-4", | |
| "artifact_dir": str(artifact_dir), | |
| "validation": { | |
| "passed": False, | |
| "required": { | |
| "bayesian": require_bayesian, | |
| "polisis": bool(require_polisis), | |
| }, | |
| "advisory": { | |
| "polisis": bool(polisis_advisory and not require_polisis), | |
| }, | |
| "checks": checks, | |
| }, | |
| "summary": { | |
| "source": "", | |
| "model_type": "", | |
| "metrics": {}, | |
| "rows": {}, | |
| }, | |
| } | |
| else: | |
| loaded_manifest = manifest | |
| _add_check( | |
| checks, | |
| "phase3_manifest_present", | |
| str(loaded_manifest.get("phase", "")).strip().lower() == "phase-3", | |
| {"phase": loaded_manifest.get("phase")}, | |
| ) | |
| dataset_manifest = _as_dict(loaded_manifest.get("dataset_manifest")) | |
| inputs = _as_dict(loaded_manifest.get("inputs")) | |
| output_files = _as_dict(loaded_manifest.get("output_files")) | |
| dataset_manifest_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="dataset_manifest", | |
| fallback="dataset_manifest.json", | |
| ) | |
| classifier_metrics_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="classifier_metrics", | |
| fallback="classifier_metrics.json", | |
| ) | |
| validation_predictions_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="validation_predictions", | |
| fallback="validation_predictions.jsonl", | |
| ) | |
| test_predictions_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="test_predictions", | |
| fallback="test_predictions.jsonl", | |
| ) | |
| expected = { | |
| "dataset_manifest": dataset_manifest_path, | |
| "classifier_metrics": classifier_metrics_path, | |
| "validation_predictions": validation_predictions_path, | |
| "test_predictions": test_predictions_path, | |
| } | |
| if require_bayesian: | |
| expected["bayesian_test"] = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="bayesian_test", | |
| fallback="bayesian_risk_test.json", | |
| ) | |
| missing = [key for key, path in expected.items() if not path.exists()] | |
| present = [key for key, path in expected.items() if path.exists()] | |
| _add_check( | |
| checks, | |
| "expected_artifacts_present", | |
| len(missing) == 0, | |
| {"present": present, "missing": missing}, | |
| ) | |
| dataset_manifest_file = load_optional_json(dataset_manifest_path) or {} | |
| classifier_metrics = load_optional_json(classifier_metrics_path) or {} | |
| merged_dataset = dict(dataset_manifest_file) | |
| for key, value in dataset_manifest.items(): | |
| merged_dataset.setdefault(key, value) | |
| merged_metrics = _derive_metrics( | |
| manifest_metrics=_as_dict(loaded_manifest.get("metrics")), | |
| classifier_metrics=classifier_metrics, | |
| ) | |
| overlap = _as_dict(merged_dataset.get("policy_overlap")) | |
| _add_check( | |
| checks, | |
| "policy_leakage_protection", | |
| all(int(overlap.get(key, 1)) == 0 for key in ("train_validation", "train_test", "validation_test")), | |
| { | |
| "train_validation": int(overlap.get("train_validation", -1)), | |
| "train_test": int(overlap.get("train_test", -1)), | |
| "validation_test": int(overlap.get("validation_test", -1)), | |
| }, | |
| ) | |
| _add_check( | |
| checks, | |
| "core_metrics_in_range", | |
| _in_unit_interval(merged_metrics.get("validation_macro_f1")) | |
| and _in_unit_interval(merged_metrics.get("test_macro_f1")) | |
| and _in_unit_interval(merged_metrics.get("validation_accuracy")) | |
| and _in_unit_interval(merged_metrics.get("test_accuracy")), | |
| { | |
| "validation_macro_f1": merged_metrics.get("validation_macro_f1"), | |
| "test_macro_f1": merged_metrics.get("test_macro_f1"), | |
| "validation_accuracy": merged_metrics.get("validation_accuracy"), | |
| "test_accuracy": merged_metrics.get("test_accuracy"), | |
| }, | |
| ) | |
| validation_rows = load_optional_jsonl_rows(validation_predictions_path) or [] | |
| test_rows = load_optional_jsonl_rows(test_predictions_path) or [] | |
| validation_target = int(_as_dict(_as_dict(merged_dataset.get("splits")).get("validation")).get("rows", -1)) | |
| test_target = int(_as_dict(_as_dict(merged_dataset.get("splits")).get("test")).get("rows", -1)) | |
| count_pass = True | |
| if validation_target >= 0: | |
| count_pass = count_pass and validation_target == len(validation_rows) | |
| if test_target >= 0: | |
| count_pass = count_pass and test_target == len(test_rows) | |
| _add_check( | |
| checks, | |
| "prediction_counts_match_manifest", | |
| count_pass, | |
| { | |
| "validation_expected": validation_target, | |
| "validation_actual": len(validation_rows), | |
| "test_expected": test_target, | |
| "test_actual": len(test_rows), | |
| }, | |
| ) | |
| schema_ok, schema_details = _validate_prediction_rows(validation_rows, test_rows) | |
| _add_check(checks, "prediction_row_schema", schema_ok, schema_details) | |
| prob_ok, prob_details = _validate_probability_mass(validation_rows + test_rows) | |
| _add_check(checks, "prediction_probability_mass", prob_ok, prob_details, required=False) | |
| executed_at = str(_as_dict(loaded_manifest.get("execution_metadata")).get("executed_at", "")) | |
| _add_check( | |
| checks, | |
| "manifest_timestamp_utc", | |
| bool(executed_at) and executed_at.endswith("Z"), | |
| {"executed_at": executed_at}, | |
| required=False, | |
| ) | |
| calibration_test_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="calibration_test", | |
| fallback="calibration_test.json", | |
| ) | |
| calibration_test = load_optional_json(calibration_test_path) | |
| ece = _resolve_ece(merged_metrics, calibration_test) | |
| if ece is not None: | |
| _add_check( | |
| checks, | |
| "calibration_ece_target", | |
| ece <= float(ece_threshold), | |
| {"ece": ece, "threshold": float(ece_threshold)}, | |
| required=False, | |
| ) | |
| bootstrap_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="bootstrap_ci_test", | |
| fallback="bootstrap_ci_test.json", | |
| ) | |
| bootstrap_payload = load_optional_json(bootstrap_path) | |
| if bootstrap_payload is not None: | |
| boot_ok, boot_details = _validate_bootstrap_intervals(bootstrap_payload) | |
| _add_check(checks, "bootstrap_intervals_valid", boot_ok, boot_details, required=False) | |
| threshold_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="threshold_sweep_test", | |
| fallback="threshold_sweep_test.json", | |
| ) | |
| threshold_payload = load_optional_json(threshold_path) | |
| if threshold_payload is not None: | |
| threshold_ok, threshold_details = _validate_threshold_sweep(threshold_payload) | |
| _add_check(checks, "threshold_sweep_valid", threshold_ok, threshold_details, required=False) | |
| bayesian_test_path = resolve_output_path( | |
| artifact_dir, | |
| output_files, | |
| key="bayesian_test", | |
| fallback="bayesian_risk_test.json", | |
| ) | |
| bayesian_payload = load_optional_json(bayesian_test_path) | |
| if require_bayesian: | |
| bayes_ok, bayes_details = _validate_bayesian_evidence(bayesian_payload) | |
| _add_check(checks, "bayesian_evidence_available", bayes_ok, bayes_details) | |
| elif bayesian_payload is not None: | |
| bayes_ok, bayes_details = _validate_bayesian_evidence(bayesian_payload) | |
| _add_check(checks, "bayesian_evidence_available", bayes_ok, bayes_details, required=False) | |
| source = _resolve_dataset_source(loaded_manifest, merged_dataset) | |
| if require_polisis: | |
| has_polisis = "polisis" in source.lower() | |
| _add_check( | |
| checks, | |
| "polisis_source_required", | |
| has_polisis, | |
| {"source": source}, | |
| ) | |
| elif polisis_advisory: | |
| has_polisis = "polisis" in source.lower() | |
| _add_check( | |
| checks, | |
| "polisis_source_advisory", | |
| has_polisis, | |
| {"source": source}, | |
| required=False, | |
| ) | |
| imbalance_ok, imbalance_details = _check_class_balance(merged_dataset) | |
| _add_check(checks, "class_balance_distribution", imbalance_ok, imbalance_details, required=False) | |
| required_checks = [check for check in checks if bool(check.get("required", True))] | |
| passed = all(bool(check["passed"]) for check in required_checks) | |
| summary = { | |
| "source": source, | |
| "model_type": str(inputs.get("model_type", loaded_manifest.get("model_summary", {}).get("model_type", ""))), | |
| "metrics": { | |
| "validation_macro_f1": merged_metrics.get("validation_macro_f1"), | |
| "test_macro_f1": merged_metrics.get("test_macro_f1"), | |
| "validation_accuracy": merged_metrics.get("validation_accuracy"), | |
| "test_accuracy": merged_metrics.get("test_accuracy"), | |
| "bayesian_primary_score": merged_metrics.get("bayesian_primary_score"), | |
| "calibration_test_ece": ece, | |
| }, | |
| "rows": { | |
| "validation_predictions": len(validation_rows), | |
| "test_predictions": len(test_rows), | |
| }, | |
| } | |
| return { | |
| "phase": "phase-4", | |
| "artifact_dir": str(artifact_dir), | |
| "validation": { | |
| "passed": passed, | |
| "required": { | |
| "bayesian": require_bayesian, | |
| "polisis": bool(require_polisis), | |
| }, | |
| "advisory": { | |
| "polisis": bool(polisis_advisory and not require_polisis), | |
| }, | |
| "checks": checks, | |
| }, | |
| "summary": summary, | |
| } | |
| def _derive_metrics(manifest_metrics: Mapping[str, Any], classifier_metrics: Mapping[str, Any]) -> Dict[str, Optional[float]]: | |
| validation = _as_dict(classifier_metrics.get("validation")) | |
| test = _as_dict(classifier_metrics.get("test")) | |
| bayesian = _as_dict(classifier_metrics.get("bayesian")) | |
| return { | |
| "validation_macro_f1": _to_optional_float( | |
| manifest_metrics.get("validation_macro_f1", validation.get("macro_f1")) | |
| ), | |
| "test_macro_f1": _to_optional_float(manifest_metrics.get("test_macro_f1", test.get("macro_f1"))), | |
| "validation_accuracy": _to_optional_float( | |
| manifest_metrics.get("validation_accuracy", validation.get("accuracy")) | |
| ), | |
| "test_accuracy": _to_optional_float(manifest_metrics.get("test_accuracy", test.get("accuracy"))), | |
| "bayesian_primary_score": _to_optional_float( | |
| manifest_metrics.get("bayesian_primary_score", bayesian.get("primary_score")) | |
| ), | |
| "calibration_test_ece": _to_optional_float(_as_dict(classifier_metrics.get("measurement_targets")).get("calibration", {}).get("test_ece")), | |
| } | |
| def _resolve_ece(metrics: Mapping[str, Optional[float]], calibration_payload: Optional[Mapping[str, Any]]) -> Optional[float]: | |
| metric_ece = metrics.get("calibration_test_ece") | |
| if metric_ece is not None: | |
| return float(metric_ece) | |
| if calibration_payload is None: | |
| return None | |
| overall = _as_dict(calibration_payload.get("overall")) | |
| return _to_optional_float(overall.get("ece")) | |
| def _validate_prediction_rows( | |
| validation_rows: Sequence[Mapping[str, Any]], | |
| test_rows: Sequence[Mapping[str, Any]], | |
| ) -> tuple[bool, Dict[str, Any]]: | |
| required_fields = {"example_id", "policy_uid", "actual_label", "predicted_label", "confidence"} | |
| checked = 0 | |
| invalid_examples: List[str] = [] | |
| invalid_labels = 0 | |
| for row in [*validation_rows, *test_rows]: | |
| checked += 1 | |
| missing = [name for name in required_fields if name not in row] | |
| if missing: | |
| invalid_examples.append(str(row.get("example_id", f"row-{checked}"))) | |
| continue | |
| actual = str(row.get("actual_label", "")).strip().lower() | |
| predicted = str(row.get("predicted_label", "")).strip().lower() | |
| if actual not in LABELS or predicted not in LABELS: | |
| invalid_labels += 1 | |
| return len(invalid_examples) == 0 and invalid_labels == 0, { | |
| "rows_checked": checked, | |
| "missing_field_rows": invalid_examples[:10], | |
| "invalid_label_rows": invalid_labels, | |
| } | |
| def _validate_probability_mass(rows: Sequence[Mapping[str, Any]]) -> tuple[bool, Dict[str, Any]]: | |
| inspected = 0 | |
| invalid = 0 | |
| missing_probabilities = 0 | |
| max_delta = 0.0 | |
| for row in rows: | |
| probabilities = row.get("probabilities") | |
| if not isinstance(probabilities, dict): | |
| missing_probabilities += 1 | |
| continue | |
| values: List[float] = [] | |
| for label in LABELS: | |
| value = probabilities.get(label) | |
| if value is None: | |
| continue | |
| maybe_float = _to_optional_float(value) | |
| if maybe_float is None or maybe_float < 0.0 or maybe_float > 1.0: | |
| invalid += 1 | |
| maybe_float = None | |
| if maybe_float is not None: | |
| values.append(maybe_float) | |
| if not values: | |
| invalid += 1 | |
| continue | |
| inspected += 1 | |
| delta = abs(sum(values) - 1.0) | |
| max_delta = max(max_delta, delta) | |
| if delta > 0.01: | |
| invalid += 1 | |
| if inspected == 0: | |
| return True, { | |
| "rows_with_probabilities": inspected, | |
| "invalid_rows": invalid, | |
| "rows_without_probabilities": missing_probabilities, | |
| "max_probability_sum_delta": round(float(max_delta), 6), | |
| "reason": "probabilities_not_available_in_predictions", | |
| } | |
| passed = invalid == 0 | |
| return passed, { | |
| "rows_with_probabilities": inspected, | |
| "invalid_rows": invalid, | |
| "rows_without_probabilities": missing_probabilities, | |
| "max_probability_sum_delta": round(float(max_delta), 6), | |
| } | |
| def _validate_bootstrap_intervals(payload: Mapping[str, Any]) -> tuple[bool, Dict[str, Any]]: | |
| metrics = _as_dict(payload.get("metrics")) | |
| checked = 0 | |
| invalid = 0 | |
| for metric_name, metric_payload in metrics.items(): | |
| metric_dict = _as_dict(metric_payload) | |
| interval = _as_dict(metric_dict.get("interval_95")) | |
| lower = _to_optional_float(interval.get("lower")) | |
| upper = _to_optional_float(interval.get("upper")) | |
| center = _to_optional_float(metric_dict.get("mean")) | |
| if lower is None or upper is None: | |
| continue | |
| checked += 1 | |
| if lower > upper: | |
| invalid += 1 | |
| continue | |
| if center is not None and not (lower <= center <= upper): | |
| invalid += 1 | |
| continue | |
| if lower < 0.0 or upper > 1.0: | |
| invalid += 1 | |
| continue | |
| return invalid == 0, { | |
| "metrics_checked": checked, | |
| "invalid_intervals": invalid, | |
| } | |
| def _validate_threshold_sweep(payload: Mapping[str, Any]) -> tuple[bool, Dict[str, Any]]: | |
| by_label = _as_dict(payload.get("by_label")) | |
| series_count = 0 | |
| invalid_points = 0 | |
| for _, points in by_label.items(): | |
| if not isinstance(points, list): | |
| continue | |
| for point in points: | |
| if not isinstance(point, dict): | |
| invalid_points += 1 | |
| continue | |
| series_count += 1 | |
| for key in ("precision", "recall", "f1"): | |
| value = _to_optional_float(point.get(key)) | |
| if value is None or value < 0.0 or value > 1.0: | |
| invalid_points += 1 | |
| return invalid_points == 0 and series_count > 0, { | |
| "points_checked": series_count, | |
| "invalid_points": invalid_points, | |
| } | |
| def _validate_bayesian_evidence(payload: Optional[Mapping[str, Any]]) -> tuple[bool, Dict[str, Any]]: | |
| if payload is None: | |
| return False, {"reason": "bayesian_payload_missing"} | |
| levels = _as_dict(payload.get("levels")) | |
| evidence_count = 0 | |
| contributor_count = 0 | |
| for level_payload in levels.values(): | |
| level_dict = _as_dict(level_payload) | |
| evidence_count += int(level_dict.get("evidence_count", 0)) | |
| contributors = level_dict.get("top_contributors", []) | |
| if isinstance(contributors, list): | |
| contributor_count += len(contributors) | |
| return evidence_count > 0 and contributor_count > 0, { | |
| "total_evidence": evidence_count, | |
| "contributors": contributor_count, | |
| } | |
| def _check_class_balance(dataset_manifest: Mapping[str, Any]) -> tuple[bool, Dict[str, Any]]: | |
| distribution = _as_dict(dataset_manifest.get("class_distribution")) | |
| total = sum(int(value) for value in distribution.values()) | |
| if total <= 0: | |
| return False, {"reason": "class_distribution_missing"} | |
| fractions = { | |
| label: round(float(int(distribution.get(label, 0)) / total), 6) | |
| for label in LABELS | |
| } | |
| min_fraction = min(fractions.values()) | |
| return min_fraction >= 0.05, { | |
| "fractions": fractions, | |
| "minimum_fraction_threshold": 0.05, | |
| } | |
| def _resolve_dataset_source(manifest: Mapping[str, Any], dataset_manifest: Mapping[str, Any]) -> str: | |
| source = str(dataset_manifest.get("source", "")).strip() | |
| if source: | |
| return source | |
| inputs = _as_dict(manifest.get("inputs")) | |
| labeled = str(inputs.get("labeled_input_path", "")).strip() | |
| if labeled: | |
| return f"labeled::{Path(labeled).name}" | |
| polisis = str(inputs.get("polisis_root", "")).strip() | |
| if polisis: | |
| profile = str(inputs.get("polisis_input_set", "normalized")).strip() or "normalized" | |
| return f"polisis::{profile}" | |
| input_set = str(inputs.get("input_set", "")).strip() or "unknown" | |
| return f"opp115::{input_set}" | |
| def _in_unit_interval(value: Any) -> bool: | |
| numeric = _to_optional_float(value) | |
| if numeric is None: | |
| return False | |
| return 0.0 <= numeric <= 1.0 | |
| def _to_optional_float(value: Any) -> Optional[float]: | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| def _add_check( | |
| checks: List[Dict[str, Any]], | |
| name: str, | |
| passed: bool, | |
| details: Mapping[str, Any], | |
| required: bool = True, | |
| ) -> None: | |
| checks.append( | |
| { | |
| "name": name, | |
| "required": bool(required), | |
| "passed": bool(passed), | |
| "details": dict(details), | |
| } | |
| ) | |
| def _as_dict(value: Any) -> Dict[str, Any]: | |
| return value if isinstance(value, dict) else {} | |