Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """Numeric metrics for predicted vs gold cascade chains. | |
| The judge route in ``src/eval/evaluator.py`` produces qualitative grades | |
| (evidence_level / timing_alignment) by reading the news; this module | |
| takes the orthogonal route — compare the predicted chain directly | |
| against a reference (gold) chain and produce precision / recall / F1. | |
| The gold chain is the test-event cascade chain extracted by step 03 of | |
| the data pipeline (``data/processed/cascade_chains/{event_id}.json``). | |
| That chain was extracted with full news as input; the predictor only | |
| sees event metadata + RAG-retrieved historical chains, so the | |
| information gap is large enough that the gold side serves as a | |
| defensible reference. | |
| Bumping ``ALGORITHM_VERSION`` invalidates every cached gold evaluation | |
| via ``GoldEvaluator``'s fingerprint. Do this whenever the matching | |
| algorithm or any default threshold changes in a way that would alter | |
| prior numeric results. | |
| """ | |
| from __future__ import annotations | |
| from typing import Protocol | |
| import numpy as np | |
| from src.models.schemas import CascadeChain, CascadeNode, NodeMatch, NodeMatchCandidate | |
| ALGORITHM_VERSION = "v10" # v0.6 issue A — BFS predictor re-tune; v9 caches were produced under BFS knobs calibrated for v0.4 retrieval distribution and must regenerate | |
| DEFAULT_COSINE_THRESHOLD = 0.35 # was 0.5 (v2); recalibrated for new gold's named-entity density | |
| class SupportsEmbedTexts(Protocol): | |
| def embed_texts(self, texts: list[str]) -> list[list[float]]: ... | |
| def match_nodes( | |
| predicted: list[CascadeNode], | |
| gold: list[CascadeNode], | |
| embedder: SupportsEmbedTexts, | |
| threshold: float = DEFAULT_COSINE_THRESHOLD, | |
| ) -> list[NodeMatch]: | |
| """Greedy node alignment with a hard same-domain filter. | |
| Returns the list of accepted matches only. Thin wrapper around | |
| ``match_nodes_with_diagnostics`` that drops the candidate list. | |
| """ | |
| matches, _ = match_nodes_with_diagnostics( | |
| predicted, gold, embedder, threshold=threshold | |
| ) | |
| return matches | |
| def match_nodes_with_diagnostics( | |
| predicted: list[CascadeNode], | |
| gold: list[CascadeNode], | |
| embedder: SupportsEmbedTexts, | |
| threshold: float = DEFAULT_COSINE_THRESHOLD, | |
| ) -> tuple[list[NodeMatch], list[NodeMatchCandidate]]: | |
| """Same matching as ``match_nodes`` but also surfaces every same-domain pair. | |
| For every (predicted, gold) pair where the domains match, compute the | |
| cosine similarity between description embeddings. Sort candidates by | |
| similarity, then greedily accept pairs whose similarity ≥ threshold | |
| and where neither side has been claimed yet. | |
| Returns a tuple ``(matches, candidates)`` where ``matches`` is the | |
| accepted alignment (same as ``match_nodes``), and ``candidates`` is | |
| every same-domain pair (including those below threshold or crowded | |
| out by greedy selection), sorted by descending cosine. Used by | |
| --dump-match-debug to support offline threshold sweeps. | |
| Both inputs typically have ≤40 nodes (extraction cap), so the | |
| O(P·G) pairing is fine; Hungarian assignment is in the backlog. | |
| """ | |
| if not predicted or not gold: | |
| return [], [] | |
| p_texts = [n.description for n in predicted] | |
| g_texts = [n.description for n in gold] | |
| # Single batched call — cheaper than per-pair embed. | |
| p_vecs = np.array(embedder.embed_texts(p_texts), dtype=float) | |
| g_vecs = np.array(embedder.embed_texts(g_texts), dtype=float) | |
| # sentence-transformers returns L2-normalized vectors when | |
| # normalize_embeddings=True (Embedder default), so dot product == cosine. | |
| # Renormalize defensively to keep this module independent of caller. | |
| p_vecs = _l2_normalize(p_vecs) | |
| g_vecs = _l2_normalize(g_vecs) | |
| sim = p_vecs @ g_vecs.T # shape (P, G) | |
| # Collect ALL same-domain pairs first (no threshold filter) so the | |
| # diagnostics output can show the full distribution, including the | |
| # near-misses that --threshold sweeps are interested in. | |
| all_pairs: list[tuple[float, int, int]] = [] | |
| for i, p_node in enumerate(predicted): | |
| for j, g_node in enumerate(gold): | |
| if p_node.domain != g_node.domain: | |
| continue # hard same-domain filter — applies to diag output too | |
| score = float(sim[i, j]) | |
| all_pairs.append((score, i, j)) | |
| all_pairs.sort(key=lambda x: x[0], reverse=True) | |
| matched_p: set[int] = set() | |
| matched_g: set[int] = set() | |
| matches: list[NodeMatch] = [] | |
| accepted_pairs: set[tuple[int, int]] = set() | |
| for score, i, j in all_pairs: | |
| if score < threshold: | |
| break # candidates are sorted desc; nothing further can pass | |
| if i in matched_p or j in matched_g: | |
| continue | |
| matched_p.add(i) | |
| matched_g.add(j) | |
| accepted_pairs.add((i, j)) | |
| matches.append( | |
| NodeMatch( | |
| p_id=predicted[i].id, | |
| g_id=gold[j].id, | |
| cosine=round(score, 4), | |
| severity_match=(predicted[i].severity == gold[j].severity), | |
| ) | |
| ) | |
| candidates = [ | |
| NodeMatchCandidate( | |
| p_id=predicted[i].id, | |
| g_id=gold[j].id, | |
| cosine=round(score, 4), | |
| severity_match=(predicted[i].severity == gold[j].severity), | |
| accepted=((i, j) in accepted_pairs), | |
| ) | |
| for score, i, j in all_pairs | |
| ] | |
| return matches, candidates | |
| def compute_metrics( | |
| predicted: CascadeChain, | |
| gold: CascadeChain, | |
| matches: list[NodeMatch], | |
| ) -> dict: | |
| """Aggregate node-level matches into the per-event metric dict. | |
| Keys: | |
| - precision, recall, f1: node-level (matched / total) | |
| - domain_jaccard: |P_dom ∩ G_dom| / |P_dom ∪ G_dom| | |
| - severity_match_rate: fraction of matches with identical severity | |
| (None when no matches) | |
| """ | |
| p_total = len(predicted.cascade_events) | |
| g_total = len(gold.cascade_events) | |
| m = len(matches) | |
| precision = m / p_total if p_total else 0.0 | |
| recall = m / g_total if g_total else 0.0 | |
| f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 | |
| p_domains = {n.domain for n in predicted.cascade_events} | |
| g_domains = {n.domain for n in gold.cascade_events} | |
| union = p_domains | g_domains | |
| domain_jaccard = ( | |
| len(p_domains & g_domains) / len(union) if union else 0.0 | |
| ) | |
| severity_match_rate = ( | |
| sum(1 for x in matches if x.severity_match) / m if m else None | |
| ) | |
| return { | |
| "precision": round(precision, 4), | |
| "recall": round(recall, 4), | |
| "f1": round(f1, 4), | |
| "domain_jaccard": round(domain_jaccard, 4), | |
| "severity_match_rate": ( | |
| round(severity_match_rate, 4) if severity_match_rate is not None else None | |
| ), | |
| } | |
| def _l2_normalize(arr: np.ndarray) -> np.ndarray: | |
| """Row-wise L2 normalization, safe against zero rows.""" | |
| norms = np.linalg.norm(arr, axis=1, keepdims=True) | |
| norms[norms == 0] = 1.0 | |
| return arr / norms | |
| # v0.5 issue #65 — domain-bag (category-level) matching. | |
| # Drops the description-cosine threshold from match_nodes_with_diagnostics; | |
| # pairs pred and gold within each domain by multiset min count. | |
| # Defended in technical_report/v0.5/metric_refactor_report.md §2. | |
| # Sentinel for the cosine field on domain-bag NodeMatch. Cosine is in | |
| # [0, 1] for L2-normalized embeddings, so -1.0 is unambiguously "no | |
| # embedding similarity was computed" and JSON-serializes cleanly | |
| # (unlike NaN, which round-trips to null and fails pydantic float | |
| # validation on reload). | |
| _DOMAIN_BAG_COSINE_SENTINEL: float = -1.0 | |
| def match_nodes_domain_bag( | |
| predicted: list[CascadeNode], | |
| gold: list[CascadeNode], | |
| ) -> list[NodeMatch]: | |
| """Domain-bag matching — pair pred and gold nodes within each domain | |
| by multiset min, without requiring description cosine similarity. | |
| For each domain present in both predicted and gold, this emits | |
| ``min(pred_count_d, gold_count_d)`` NodeMatch objects. The 1-to-1 | |
| constraint is preserved (each pred / gold node appears in at most | |
| one match), but the cosine threshold is dropped. | |
| Severity_match flag is computed by greedy multiset alignment per | |
| severity bucket: pairs that share severity within a domain are | |
| paired first; remaining nodes are then paired across severities | |
| (severity_match=False for these). The cosine field is set to | |
| ``_DOMAIN_BAG_COSINE_SENTINEL`` (-1.0) since no embedding | |
| similarity is computed here. | |
| """ | |
| from collections import defaultdict | |
| pred_by_dom: dict[str, list[CascadeNode]] = defaultdict(list) | |
| gold_by_dom: dict[str, list[CascadeNode]] = defaultdict(list) | |
| for n in predicted: | |
| pred_by_dom[n.domain].append(n) | |
| for n in gold: | |
| gold_by_dom[n.domain].append(n) | |
| matches: list[NodeMatch] = [] | |
| domains = set(pred_by_dom) & set(gold_by_dom) | |
| for d in sorted(domains): | |
| ps = pred_by_dom[d] | |
| gs = gold_by_dom[d] | |
| sev_pred: dict[str, list[CascadeNode]] = defaultdict(list) | |
| sev_gold: dict[str, list[CascadeNode]] = defaultdict(list) | |
| for n in ps: | |
| sev_pred[n.severity].append(n) | |
| for n in gs: | |
| sev_gold[n.severity].append(n) | |
| used_p_ids: set[str] = set() | |
| used_g_ids: set[str] = set() | |
| # Pass 1: pair within same severity (severity_match=True) | |
| for sev in sorted(set(sev_pred) | set(sev_gold)): | |
| k = min(len(sev_pred[sev]), len(sev_gold[sev])) | |
| for i in range(k): | |
| pn = sev_pred[sev][i] | |
| gn = sev_gold[sev][i] | |
| used_p_ids.add(pn.id) | |
| used_g_ids.add(gn.id) | |
| matches.append( | |
| NodeMatch( | |
| p_id=pn.id, g_id=gn.id, | |
| cosine=_DOMAIN_BAG_COSINE_SENTINEL, | |
| severity_match=True, | |
| ) | |
| ) | |
| # Pass 2: cross-severity leftover (up to multiset min) | |
| leftover_p = [n for n in ps if n.id not in used_p_ids] | |
| leftover_g = [n for n in gs if n.id not in used_g_ids] | |
| for pn, gn in zip(leftover_p, leftover_g): | |
| matches.append( | |
| NodeMatch( | |
| p_id=pn.id, g_id=gn.id, | |
| cosine=_DOMAIN_BAG_COSINE_SENTINEL, | |
| severity_match=(pn.severity == gn.severity), | |
| ) | |
| ) | |
| return matches | |
| def compute_metrics_domain_bag( | |
| matches: list[NodeMatch], | |
| predicted: list[CascadeNode], | |
| gold: list[CascadeNode], | |
| ) -> tuple[float, float, float, float, float | None]: | |
| """Compute (precision, recall, f1, domain_jaccard, severity_match_rate) | |
| for domain-bag matches. severity_match_rate is None when no matches. | |
| """ | |
| m = len(matches) | |
| p_count = len(predicted) | |
| g_count = len(gold) | |
| precision = m / p_count if p_count else 0.0 | |
| recall = m / g_count if g_count else 0.0 | |
| f1 = ( | |
| 2 * precision * recall / (precision + recall) | |
| if (precision + recall) else 0.0 | |
| ) | |
| pred_doms = {n.domain for n in predicted} | |
| gold_doms = {n.domain for n in gold} | |
| union = pred_doms | gold_doms | |
| domain_jaccard = ( | |
| len(pred_doms & gold_doms) / len(union) if union else 0.0 | |
| ) | |
| sev_rate = ( | |
| sum(1 for x in matches if x.severity_match) / m if m else None | |
| ) | |
| return (precision, recall, f1, domain_jaccard, sev_rate) | |
| # v0.7 issue A — bootstrap CI telemetry. | |
| # Spec: docs/superpowers/specs/2026-06-07-v07-bootstrap-ci-design.md | |
| # CI is reported as additional AGGREGATE telemetry; it does NOT enter the | |
| # KEEP/ROLLBACK acceptance panel (amendment §3.1 ±0.005 tolerance stays | |
| # authoritative). See spec §1.2 for the framing decision. | |
| def bootstrap_macro_ci( | |
| per_event_values: list[float | None], | |
| n_resamples: int = 1000, | |
| confidence: float = 0.95, | |
| seed: int = 42, | |
| ) -> dict[str, float | int]: | |
| """Event-level bootstrap CI for a single macro-averaged metric. | |
| Resamples ``per_event_values`` with replacement ``n_resamples`` times, | |
| takes the mean of each resample, and returns the requested-confidence | |
| percentile interval over those means. ``None`` entries are dropped | |
| before resampling; ``n`` in the returned dict reflects survivors. | |
| Returns a dict with keys: ``mean``, ``ci_low``, ``ci_high``, ``n``, | |
| ``n_resamples``, ``confidence``. | |
| Raises ``ValueError`` when no non-None values remain — silent NaN | |
| masks a real programmer error (e.g. an empty evaluable-event set). | |
| Degenerate inputs (n=1 or zero variance) collapse the CI onto the | |
| point estimate; this is well-defined and exercised by the test suite. | |
| """ | |
| filtered = [float(v) for v in per_event_values if v is not None] | |
| if not filtered: | |
| raise ValueError( | |
| "per_event_values must contain at least one non-None entry" | |
| ) | |
| arr = np.asarray(filtered, dtype=float) | |
| if arr.size == 1: | |
| v = float(arr[0]) | |
| return { | |
| "mean": v, | |
| "ci_low": v, | |
| "ci_high": v, | |
| "n": 1, | |
| "n_resamples": int(n_resamples), | |
| "confidence": float(confidence), | |
| } | |
| rng = np.random.default_rng(seed) | |
| samples = rng.choice(arr, size=(n_resamples, arr.size), replace=True) | |
| means = samples.mean(axis=1) | |
| alpha = (1.0 - confidence) / 2.0 | |
| lo, hi = np.percentile(means, [100.0 * alpha, 100.0 * (1.0 - alpha)]) | |
| return { | |
| "mean": float(arr.mean()), | |
| "ci_low": float(lo), | |
| "ci_high": float(hi), | |
| "n": int(arr.size), | |
| "n_resamples": int(n_resamples), | |
| "confidence": float(confidence), | |
| } | |
| def aggregate_with_ci( | |
| evaluations: "list[GoldEvaluation]", | |
| metrics_to_ci: tuple[str, ...] = ( | |
| "category_recall", | |
| "category_severity_match_rate", | |
| "cosine_f1", | |
| "category_f1", | |
| ), | |
| bootstrap_seed: int = 42, | |
| outlier_event_ids: set[str] | None = None, | |
| ) -> dict[str, dict]: | |
| """Compute mean + bootstrap CI for each macro metric across an | |
| evaluation set, grouping by event first and averaging across seeds. | |
| Pipeline: | |
| 1. Drop evaluations whose event_id is in ``outlier_event_ids``. | |
| 2. Group surviving evaluations by event_id; for each metric build | |
| the list of seed-level values. | |
| 3. Per event, average across seeds → one float per event. | |
| 4. Feed the per-event means into ``bootstrap_macro_ci``. | |
| Returns a dict ``{metric_name: ci_dict}`` where each ``ci_dict`` is | |
| the output of ``bootstrap_macro_ci`` plus two extra keys: | |
| ``per_event_values`` (event_id → seed-mean) and | |
| ``per_event_seed_values`` (event_id → list of seed values). | |
| Cosine F1 is stored on the GoldEvaluation schema as ``f1`` (legacy | |
| name); the alias map below resolves that without making callers care. | |
| Caches missing a metric (e.g. category_severity_match_rate is None | |
| for pre-v0.5 caches) contribute no value for that metric. A metric | |
| with zero contributing events is omitted from the output and logged. | |
| """ | |
| import logging | |
| from collections import defaultdict | |
| log = logging.getLogger(__name__) | |
| outlier_set = outlier_event_ids or set() | |
| # GoldEvaluation stores cosine F1 under the legacy `f1` attribute name. | |
| # All other metric names match their attribute names 1:1. | |
| metric_attr_map = { | |
| "cosine_f1": "f1", | |
| "category_f1": "category_f1", | |
| "category_recall": "category_recall", | |
| "category_severity_match_rate": "category_severity_match_rate", | |
| "category_precision": "category_precision", | |
| "precision": "precision", | |
| "recall": "recall", | |
| "domain_jaccard": "domain_jaccard", | |
| } | |
| grouped: dict[str, dict[str, list[float]]] = defaultdict( | |
| lambda: defaultdict(list) | |
| ) | |
| for ev in evaluations: | |
| if ev.event_id in outlier_set: | |
| continue | |
| for m in metrics_to_ci: | |
| attr = metric_attr_map.get(m, m) | |
| v = getattr(ev, attr, None) | |
| if v is None: | |
| continue | |
| grouped[ev.event_id][m].append(float(v)) | |
| out: dict[str, dict] = {} | |
| for m in metrics_to_ci: | |
| per_event_seed_values = { | |
| eid: per_metric[m] | |
| for eid, per_metric in grouped.items() | |
| if per_metric.get(m) | |
| } | |
| if not per_event_seed_values: | |
| log.warning( | |
| "aggregate_with_ci: no evaluable events for metric '%s'", m | |
| ) | |
| continue | |
| per_event_means = { | |
| eid: sum(vs) / len(vs) | |
| for eid, vs in per_event_seed_values.items() | |
| } | |
| ci = bootstrap_macro_ci( | |
| list(per_event_means.values()), | |
| seed=bootstrap_seed, | |
| ) | |
| ci["per_event_values"] = per_event_means | |
| ci["per_event_seed_values"] = per_event_seed_values | |
| out[m] = ci | |
| return out | |