"""Compare analyzer observations under semantics-preserving transformations.""" from __future__ import annotations import json from collections.abc import Mapping, Sequence from typing import Any from .stability import jaccard DEFAULT_FIELDS = ( "rule_id", "backend", "confidence", "summary_backed", "cross_tu_corroboration", "uri", "line", "function", ) def _mapped_observation( observation: Mapping[str, Any], relation: Mapping[str, Any] ) -> dict[str, Any]: mapped = dict(observation) uri_map = relation.get("uri_map", {}) function_map = relation.get("function_map", {}) line_delta = relation.get("line_delta", {}) if isinstance(uri_map, dict): mapped["uri"] = uri_map.get(str(mapped.get("uri", "")), mapped.get("uri", "")) if isinstance(function_map, dict): mapped["function"] = function_map.get( str(mapped.get("function", "")), mapped.get("function", "") ) uri = str(mapped.get("uri", "")) if isinstance(line_delta, dict) and uri in line_delta: mapped["line"] = int(mapped.get("line", 0)) + int(line_delta[uri]) return mapped def _key(observation: Mapping[str, Any], fields: Sequence[str]) -> str: selected = {field: observation.get(field) for field in fields} return json.dumps(selected, sort_keys=True, separators=(",", ":")) def compare_metamorphic_findings( baseline: Sequence[Mapping[str, Any]], transformed: Sequence[Mapping[str, Any]], relation: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Apply a declared mapping and compare finding multisets. The relation can map URIs/functions, shift lines, or select identity fields. Findings are compared as multisets so duplicate-alert regressions remain visible instead of collapsing behind set equality. """ relation = relation or {} fields = relation.get("fields", DEFAULT_FIELDS) if not isinstance(fields, (list, tuple)) or not fields or not all( isinstance(field, str) and field for field in fields ): raise ValueError("relation.fields must be a non-empty string array") mapped_baseline = [_mapped_observation(item, relation) for item in baseline] left = sorted(_key(item, fields) for item in mapped_baseline) right = sorted(_key(item, fields) for item in transformed) left_counts: dict[str, int] = {} right_counts: dict[str, int] = {} for key in left: left_counts[key] = left_counts.get(key, 0) + 1 for key in right: right_counts[key] = right_counts.get(key, 0) + 1 missing: list[str] = [] unexpected: list[str] = [] for key in sorted(set(left_counts) | set(right_counts)): delta = left_counts.get(key, 0) - right_counts.get(key, 0) if delta > 0: missing.extend([key] * delta) elif delta < 0: unexpected.extend([key] * -delta) return { "exact": not missing and not unexpected, "baseline_count": len(left), "transformed_count": len(right), "missing": missing, "unexpected": unexpected, "set_jaccard": jaccard(set(left), set(right)), "fields": list(fields), }