Source code for iguanas.rule_registry

from __future__ import annotations

import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import polars as pl


[docs] class RuleRegistry: """Store, version, and compare named rule snapshots. Each snapshot records the rule list, optional metrics, optional metadata, and a UTC timestamp. The registry can be persisted to/from a JSON file for cross-session use, or kept in-memory only. Parameters ---------- path : str | Path | None, default=None Path to a JSON file used for persistence. If the file already exists it is loaded on construction. If ``None``, the registry is in-memory only and snapshots are lost when the object is garbage-collected. Examples -------- >>> registry = RuleRegistry("rules.json") >>> registry.save("v1", rules=['(X["age"] > 30)']) >>> registry.list() ['v1'] >>> entry = registry.load("v1") >>> entry["rules"] ['(X["age"] > 30)'] """ def __init__(self, path: str | Path | None = None) -> None: self._path: Path | None = Path(path) if path is not None else None self._registry: dict[str, dict[str, Any]] = {} if self._path is not None and self._path.exists(): self._load_from_disk() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def save( self, name: str, rules: list[str], metrics: pl.DataFrame | None = None, metadata: dict[str, Any] | None = None, ) -> None: """Save a named ruleset snapshot. Overwrites any existing snapshot with the same name. Parameters ---------- name : str Snapshot identifier. rules : list[str] Rule expression strings to store. metrics : pl.DataFrame | None, default=None Optional metrics DataFrame (e.g. from :func:`~iguanas.metrics.compute_metrics`). Stored internally as a list of dicts for JSON compatibility. metadata : dict | None, default=None Arbitrary key-value metadata (e.g. threshold settings, dataset description, experiment notes). """ self._registry[name] = { "rules": rules, "metrics": metrics.to_dicts() if metrics is not None else None, "metadata": metadata or {}, "saved_at": datetime.now(tz=UTC).isoformat(), } if self._path is not None: self._dump_to_disk()
[docs] def load(self, name: str) -> dict[str, Any]: """Load a saved snapshot by name. Parameters ---------- name : str Snapshot name to retrieve. Returns ------- dict Dict with keys: - ``"rules"`` — list of rule expression strings - ``"metrics"`` — ``pl.DataFrame`` if metrics were saved, else ``None`` - ``"metadata"`` — dict of metadata - ``"saved_at"`` — ISO-8601 UTC timestamp string Raises ------ KeyError If no snapshot with the given name exists. """ if name not in self._registry: raise KeyError(f"No snapshot named {name!r}. Available: {self.list()}") entry = self._registry[name].copy() if entry["metrics"] is not None: entry["metrics"] = pl.DataFrame(entry["metrics"]) return entry
[docs] def list(self) -> list[str]: """Return a sorted list of all snapshot names.""" return sorted(self._registry)
[docs] def delete(self, name: str) -> None: """Delete a snapshot by name. Parameters ---------- name : str Snapshot to remove. Raises ------ KeyError If no snapshot with the given name exists. """ if name not in self._registry: raise KeyError(f"No snapshot named {name!r}. Available: {self.list()}") del self._registry[name] if self._path is not None: self._dump_to_disk()
[docs] def compare( self, name_a: str, name_b: str, metric_cols: list[str] | None = None, ) -> pl.DataFrame: """Compare saved metrics of two snapshots side by side. Parameters ---------- name_a : str Name of the first snapshot. name_b : str Name of the second snapshot. metric_cols : list[str] | None, default=None Metric columns to include. When ``None``, all columns present in both snapshots (excluding ``"rule"``) are used. Returns ------- pl.DataFrame One row per rule found in either snapshot. Metric columns are suffixed with ``_{name_a}`` and ``_{name_b}``. Rules absent from one snapshot produce null values for its columns. Raises ------ ValueError If either snapshot was saved without a metrics DataFrame. KeyError If either snapshot name does not exist. """ entry_a = self.load(name_a) entry_b = self.load(name_b) if entry_a["metrics"] is None: raise ValueError(f"Snapshot {name_a!r} has no saved metrics.") if entry_b["metrics"] is None: raise ValueError(f"Snapshot {name_b!r} has no saved metrics.") m_a: pl.DataFrame = entry_a["metrics"] m_b: pl.DataFrame = entry_b["metrics"] if metric_cols is None: metric_cols = [c for c in m_a.columns if c != "rule" and c in m_b.columns] m_a = m_a.select(["rule"] + [c for c in metric_cols if c in m_a.columns]) m_b = m_b.select(["rule"] + [c for c in metric_cols if c in m_b.columns]) m_a = m_a.rename({c: f"{c}_{name_a}" for c in m_a.columns if c != "rule"}) m_b = m_b.rename({c: f"{c}_{name_b}" for c in m_b.columns if c != "rule"}) return m_a.join(m_b, on="rule", how="full", coalesce=True).sort("rule")
# ------------------------------------------------------------------ # Persistence helpers # ------------------------------------------------------------------ def _dump_to_disk(self) -> None: assert self._path is not None self._path.write_text(json.dumps(self._registry, indent=2, default=str)) def _load_from_disk(self) -> None: assert self._path is not None self._registry = json.loads(self._path.read_text())
[docs] def filter_rule_pairs_by_overlap( R: pl.DataFrame, min_overlap: float = 0.0, max_overlap: float = 1.0, ) -> pl.DataFrame: """Return rule pairs whose Jaccard overlap falls within ``[min_overlap, max_overlap]``. Jaccard similarity is defined as *(samples flagged by both) / (samples flagged by either)*. The two bounds let you slice any region of the overlap spectrum: - **Disjoint pairs** (never co-fire): ``max_overlap=0.0`` - **Near-disjoint pairs**: ``max_overlap=0.1`` - **All pairs**: defaults ``min_overlap=0.0, max_overlap=1.0`` - **Redundant pairs** (near-identical): ``min_overlap=0.9`` Parameters ---------- R : pl.DataFrame Boolean DataFrame of rule predictions (columns = rules, rows = samples). min_overlap : float, default=0.0 Lower Jaccard bound (inclusive). Pairs with ``jaccard < min_overlap`` are excluded. max_overlap : float, default=1.0 Upper Jaccard bound (inclusive). Pairs with ``jaccard > max_overlap`` are excluded. Returns ------- pl.DataFrame Matching rule pairs with columns: ``rule_a``, ``rule_b``, ``jaccard``, ``flagged_by_both``, ``flagged_by_either``. Sorted by ``jaccard`` ascending. Returns an empty DataFrame with the correct schema when no pairs match. Examples -------- >>> import polars as pl >>> R = pl.DataFrame({ ... "rule_A": [True, True, False, False], ... "rule_B": [False, False, True, True], # disjoint from rule_A ... "rule_C": [True, False, True, False], ... }) >>> filter_rule_pairs_by_overlap(R, max_overlap=0.0) # only disjoint pairs shape: (1, 5) # rule_A vs rule_B >>> filter_rule_pairs_by_overlap(R, min_overlap=0.3) # only overlapping pairs """ _EMPTY_SCHEMA = { "rule_a": pl.String, "rule_b": pl.String, "jaccard": pl.Float64, "flagged_by_both": pl.Int64, "flagged_by_either": pl.Int64, } rules = R.columns if len(rules) < 2: return pl.DataFrame(schema=_EMPTY_SCHEMA) records: list[dict[str, Any]] = [] for i, rule_a in enumerate(rules): for rule_b in rules[i + 1 :]: a = R[rule_a] b = R[rule_b] both = int((a & b).sum()) either = int((a | b).sum()) jaccard = both / either if either > 0 else 0.0 if min_overlap <= jaccard <= max_overlap: records.append( { "rule_a": rule_a, "rule_b": rule_b, "jaccard": jaccard, "flagged_by_both": both, "flagged_by_either": either, } ) if not records: return pl.DataFrame(schema=_EMPTY_SCHEMA) return pl.DataFrame(records).sort("jaccard")