diff --git a/AGENTS.md b/AGENTS.md index 766c8781..2bd9dfff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,9 +75,11 @@ input_df RepairWorkflow → _rewritten_text (failing rows only) → output: {text_col}_rewritten, utility_score, leakage_mass, needs_human_review, … -Later, `RewriteWorkflow.evaluate()` can run the non-critical judge steps: +Later, `Anonymizer.evaluate()` runs the non-critical judge steps: + → EntityCoverageWorkflow → entity_coverage, leaked_entities (always) + → FinalJudgeWorkflow → judge_evaluation (always) → DetectionJudgeWorkflow → detection_valid, detection_invalid_entities - → FinalJudgeWorkflow → judge_evaluation + (only when EvaluateConfig.compute_detection_validity=True) ``` Records with no detected entities skip all LLM sub-workflows and pass through with default metrics (utility=1.0, leakage=0.0). diff --git a/docs/concepts/evaluation.md b/docs/concepts/evaluation.md index 1c2e9ec1..afbcbc17 100644 --- a/docs/concepts/evaluation.md +++ b/docs/concepts/evaluation.md @@ -45,7 +45,41 @@ with open("/tmp/preview.pkl", "rb") as f: evaluated = anonymizer.evaluate(loaded) ``` -Four LLM judges run per record: one that scores detection quality and three that score replacement quality (Substitute mode only). +Which judges run depends on the strategy and your `EvaluateConfig`: + +| Judge | When it runs | +|-------|--------------| +| **Entity Coverage** | Always. | +| **Detection Validity** | Only when `EvaluateConfig(compute_detection_validity=True)` — off by default. | +| **Type Fidelity, Attribute Fidelity, Relational Consistency** | Substitute mode only. | + +--- + +### Entity Coverage + +> "Which sensitive values from the original text survived into the anonymized output?" + +Entity coverage is the primary residual-leakage metric and **always runs**. The judge scans the original text independently and reports any in-scope sensitive values that were **not** removed or replaced. A deterministic postprocessing step then drops candidates that are already accounted for by the Anonymizer's final entities (exact, sub-span, or composite matches), so only genuine leaks remain. + +The judge is scoped and contextualized by the same signals used during anonymization: + +- **`entity_labels`** — the detection taxonomy in scope; the judge only reports values whose type falls within it. +- **`data_summary`** — used purely to interpret literal values and their semantic types, never to invent entities absent from the text. +- **`strict_entity_protection`** — (rewrite only) when enabled, the judge also reports inferable/indirect sensitive values, not just literal identifiers. + +| Output column | Type | Description | +|---|---|---| +| `entity_coverage` | `float \| None` | `n_final / (n_final + n_leaked)` — fraction of sensitive values that were removed. `1.0` means nothing leaked; `None` if the judge was unavailable. | +| `leaked_entities` | `list` | Each surviving value with its `value`, `label`, and one-sentence `reasoning`. Empty when nothing leaked. | + +**Special values:** + +| Scenario | `entity_coverage` | `leaked_entities` | +|---|---|---| +| No sensitive values in the original text | `1.0` | `[]` | +| Judge ran and found no surviving values | `1.0` | `[]` | +| Judge ran and found surviving values | 0–1 fraction | populated | +| Judge call failed or returned a malformed response | `None` | `[]` | --- @@ -55,7 +89,7 @@ Four LLM judges run per record: one that scores detection quality and three that > "Are the detected entities actually correct (value, label) pairs in context?" -This judge runs regardless of which replace mode was used. It looks at each detected span and flags: +Detection validity is **opt-in** — it runs only when you pass `Anonymizer.evaluate(..., config=EvaluateConfig(compute_detection_validity=True))`. It is disabled by default and is intended for internal model/threshold experiments rather than as a customer-facing metric. When enabled, it looks at each detected span and flags: - **false_positive** — the span is not actually identifying or sensitive in this context (common word, generic phrase, boilerplate). - **wrong_label** — the span is sensitive but the label sits in a clearly different domain (e.g. a company name labeled `first_name`). Sibling labels within the same broad domain are treated as valid. @@ -138,10 +172,11 @@ For a tabular overview across all records: ```python evaluated.dataframe[ [ - "detection_valid", + "entity_coverage", "type_fidelity_valid", "attribute_fidelity_valid", "relational_consistency_valid", + # "detection_valid" — present only if compute_detection_validity=True ] ] ``` @@ -152,14 +187,15 @@ Use `trace_dataframe` for the full internal trace including raw judge outputs. ### Model roles -All four judges default to `gpt-oss-120b`. Defaults are defined in [`evaluate.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/evaluate.yaml). Override them by passing a `model_configs` YAML to `Anonymizer(model_configs=...)` — see [Models](models.md) for the full override pattern. +The entity coverage judge defaults to `nemotron-super`; the other replace-evaluation judges default to `gpt-oss-120b`. Defaults are defined in [`evaluate.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/evaluate.yaml). Override them by passing a `model_configs` YAML to `Anonymizer(model_configs=...)` — see [Models](models.md) for the full override pattern. -The four roles are `detection_validity_judge`, `replace_type_fidelity_judge`, `replace_attribute_fidelity_judge`, and `replace_relational_consistency_judge`. +The roles are `entity_coverage_judge`, `detection_validity_judge`, `replace_type_fidelity_judge`, `replace_attribute_fidelity_judge`, and `replace_relational_consistency_judge`. ```yaml # my_models.yaml selected_models: evaluate: + entity_coverage_judge: your-model-alias detection_validity_judge: your-model-alias replace_type_fidelity_judge: your-model-alias replace_attribute_fidelity_judge: your-model-alias @@ -174,7 +210,7 @@ Rewrite evaluation has two layers: 1. **Automatic (always runs)** — leakage mass, utility score, weighted leakage rate, and `needs_human_review` are computed as part of every `run()` / `preview()` call. See [Rewrite](rewrite.md) for the repair loop and output columns. -2. **Post-hoc LLM judges (optional)** — call `Anonymizer.evaluate()` on a completed rewrite result to add the entity detection judge and three holistic quality rubrics. +2. **Post-hoc LLM judges (optional)** — call `Anonymizer.evaluate()` on a completed rewrite result to add the entity coverage judge (always), three holistic quality rubrics (always), and the detection validity judge (only with `EvaluateConfig(compute_detection_validity=True)`). ```python from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput, Rewrite @@ -207,9 +243,15 @@ evaluated = anonymizer.evaluate(loaded) --- +### Entity Coverage + +Same judge as in replace mode — see [Entity Coverage](#entity-coverage) above. It **always runs** and emits `entity_coverage` and `leaked_entities`. In rewrite mode the judge additionally honours `strict_entity_protection`: when the rewrite config enables it, inferable/indirect sensitive values are reported as leaks, not just literal identifiers. + +--- + ### Entity Detection Judge -Same judge as in replace mode — see [Entity Detection Judge](#entity-detection-judge) above. In rewrite mode, `detection_valid` is returned as a **0–1 fraction** (the share of detected entities that passed), rather than a boolean. A value of `1.0` means all detections are valid; lower values mean more entities were flagged — the value itself is the fraction that passed. +Same judge as in replace mode — see [Entity Detection Judge](#entity-detection-judge) above, and like there it is **opt-in** via `EvaluateConfig(compute_detection_validity=True)` (off by default). In rewrite mode, `detection_valid` is returned as a **0–1 fraction** (the share of detected entities that passed), rather than a boolean. A value of `1.0` means all detections are valid; lower values mean more entities were flagged — the value itself is the fraction that passed. | Output column | Type | Description | |---|---|---| @@ -282,7 +324,7 @@ The three rubric scores are stored together under the `judge_evaluation` column ### Reading rewrite evaluation results -`display_record()` renders a formatted per-record view that includes the detection validity fraction and all three judge rubrics alongside the rewritten text: +`display_record()` renders a formatted per-record view that includes entity coverage, all three judge rubrics, and (when enabled) the detection validity fraction alongside the rewritten text: ```python evaluated.display_record(0) @@ -291,7 +333,8 @@ evaluated.display_record(0) For a tabular overview across all records: ```python -evaluated.dataframe[["detection_valid", "judge_evaluation"]] +evaluated.dataframe[["entity_coverage", "judge_evaluation"]] +# add "detection_valid" if you evaluated with compute_detection_validity=True ``` Use `trace_dataframe` for the full internal trace including raw judge outputs. @@ -300,12 +343,13 @@ Use `trace_dataframe` for the full internal trace including raw judge outputs. ### Model roles -The rewrite quality judge defaults to `nemotron-30b-thinking`. The detection validity judge shares the `detection_validity_judge` role used by replace evaluation. Defaults are defined in [`evaluate.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/evaluate.yaml). Override them via `model_configs`: +The rewrite quality judge defaults to `nemotron-30b-thinking` and the entity coverage judge to `nemotron-super`. The detection validity judge shares the `detection_validity_judge` role used by replace evaluation. Defaults are defined in [`evaluate.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/evaluate.yaml). Override them via `model_configs`: ```yaml # my_models.yaml selected_models: evaluate: + entity_coverage_judge: your-model-alias detection_validity_judge: your-model-alias rewrite_judge: your-model-alias ``` diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 9a009ddf..f7646e7f 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -26,6 +26,7 @@ export NVIDIA_API_KEY="your-nvidia-api-key" | `gliner-pii-detector` | [`nvidia/gliner-pii`](https://build.nvidia.com/nvidia/gliner-pii) | Entity detection (NER) | | `gpt-oss-120b` | [`openai/gpt-oss-120b`](https://build.nvidia.com/openai/gpt-oss-120b) | Detection validation & augmentation, replacement, replace evaluation, rewriting | | `nemotron-30b-thinking` | [`nvidia/nemotron-3-nano-30b-a3b`](https://build.nvidia.com/nvidia/nemotron-3-nano-30b-a3b) | Latent detection, rewrite evaluation, final judge | +| `nemotron-super` | [`nvidia/nemotron-3-super-v3`](https://build.nvidia.com/nvidia/nemotron-3-super-v3) | Entity coverage evaluation | Each pipeline stage has a **role** mapped to one of these aliases. See the full role list in the default configs: [`detection.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/detection.yaml), [`replace.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/replace.yaml), [`rewrite.yaml`](https://github.com/NVIDIA-NeMo/Anonymizer/blob/main/src/anonymizer/config/default_model_configs/rewrite.yaml). diff --git a/docs/concepts/replace.md b/docs/concepts/replace.md index 19adad6b..c34cf022 100644 --- a/docs/concepts/replace.md +++ b/docs/concepts/replace.md @@ -122,4 +122,4 @@ AnonymizerConfig(replace=Hash(algorithm="sha1", digest_length=8)) ## Evaluating replace output -After running `replace`, you can score the quality of substitutions using LLM-as-judge evaluation. See [Evaluation](evaluation.md) for details on all four judges (detection validity, type fidelity, attribute fidelity, relational consistency) and how to call `Anonymizer.evaluate()`. +After running `replace`, you can score residual leakage and the quality of substitutions using LLM-as-judge evaluation. See [Evaluation](evaluation.md) for details on the always-on entity coverage judge, the three Substitute quality judges (type fidelity, attribute fidelity, relational consistency), the opt-in detection validity judge, and how to call `Anonymizer.evaluate()`. diff --git a/docs/concepts/rewrite.md b/docs/concepts/rewrite.md index 096cbd45..614b3c62 100644 --- a/docs/concepts/rewrite.md +++ b/docs/concepts/rewrite.md @@ -135,9 +135,11 @@ config = AnonymizerConfig( | `weighted_leakage_rate` | Always | Normalized leakage (0.0--1.0) relative to the maximum possible leakage mass. | | `any_high_leaked` | Always | Whether any high-sensitivity entity leaked through. | | `needs_human_review` | Always | Flag for records that may need manual review. | -| `detection_valid` | After `evaluate()` | Fraction of detected entities that passed the detection judge (0.0--1.0); `None` if judge unavailable. | -| `detection_invalid_entities` | After `evaluate()` | Flagged detections with value, label, and one-sentence reasoning. | +| `entity_coverage` | After `evaluate()` | Fraction of sensitive values removed (0.0--1.0); `1.0` if nothing leaked, `None` if judge unavailable. | +| `leaked_entities` | After `evaluate()` | Sensitive values that survived into the rewritten text, each with value, label, and reasoning. | | `judge_evaluation` | After `evaluate()` | Dict with `privacy`, `quality`, and `style` rubric scores and reasoning. | +| `detection_valid` | After `evaluate(config=EvaluateConfig(compute_detection_validity=True))` | Fraction of detected entities that passed the detection judge (0.0--1.0); `None` if judge unavailable. Opt-in, off by default. | +| `detection_invalid_entities` | After `evaluate(config=EvaluateConfig(compute_detection_validity=True))` | Flagged detections with value, label, and one-sentence reasoning. | Use `preview.trace_dataframe` for the full pipeline trace (domain, disposition, QA pairs, repair iterations). @@ -187,4 +189,4 @@ Rewrite uses multiple LLM roles. All default to models in the [default config](m ## Evaluating rewrite output -After running rewrite, you can score detection quality and the holistic rewrite quality using LLM-as-judge evaluation. See [Evaluation](evaluation.md) for details on the detection judge and the three rewrite quality rubrics (privacy, quality, style), and how to call `Anonymizer.evaluate()`. +After running rewrite, you can score residual leakage and the holistic rewrite quality using LLM-as-judge evaluation. See [Evaluation](evaluation.md) for details on the always-on entity coverage judge, the three rewrite quality rubrics (privacy, quality, style), the opt-in detection judge, and how to call `Anonymizer.evaluate()`. diff --git a/skills/anonymizer/SKILL.md b/skills/anonymizer/SKILL.md index 93448973..7e4bf1cf 100644 --- a/skills/anonymizer/SKILL.md +++ b/skills/anonymizer/SKILL.md @@ -36,7 +36,7 @@ regulatory and business context. - **For cross-record consistency** (same value → same replacement everywhere), use `Hash`, not `Substitute`. `Substitute` is consistent within a row only. - **In Replace mode, default to `Substitute`** if the user hasn't specified a strategy. It's the most general-purpose choice and matches the bulk of production usage. - **`Annotate` is for inspection, not production.** Its output keeps the original entity text and is not privacy-safe. Use it during iteration to confirm detection is working, then switch. -- **Evaluation is opt-in and runs as a separate step** (Replace and Rewrite modes). After `preview()` / `run()`, call `anonymizer.evaluate(result)` to score the output with LLM-as-judge. Replace mode: `Substitute` gets four judges (detection validity, type fidelity, relational consistency, attribute fidelity); `Redact` / `Annotate` / `Hash` get detection-validity only. Rewrite mode: detection validity + holistic privacy/quality/style judge. Evaluation is diagnostic — it scores quality, it does not change the anonymized output. +- **Evaluation is opt-in and runs as a separate step** (Replace and Rewrite modes). After `preview()` / `run()`, call `anonymizer.evaluate(result)` to score the output with LLM-as-judge. **Entity coverage always runs** in both modes — it reports residual leaks (`entity_coverage` fraction + `leaked_entities`). On top of that: Replace `Substitute` adds three quality judges (type fidelity, relational consistency, attribute fidelity); Rewrite adds the holistic privacy/quality/style judge. Detection validity is **opt-in** via `EvaluateConfig(compute_detection_validity=True)` (off by default). Evaluation is diagnostic — it scores quality, it does not change the anonymized output. - **Always set `AnonymizerInput.data_summary`**, even briefly. It is the single cheapest quality lever and it improves both detection and rewrite. - **Never claim privacy guarantees.** Anonymizer is best-effort. Outputs may need human review depending on `risk_tolerance`. Tell the user this when you finalize. @@ -49,9 +49,9 @@ regulatory and business context. - **`PrivacyGoal.protect` and `.preserve` must each be 10–1000 chars and at least 3 words.** Be specific (categories, named identifiers, structural facets); avoid generic phrasing like "preserve meaning". - **Validator pool is the only model role with built-in load-spreading.** Set `entity_validator: [a, b, c]` in `models.yaml` if rate limits drop rows. Other roles (rewriter, evaluator, etc.) are single-alias. - **Self-hosted GLiNER:** When detection must not call `build.nvidia.com` (PHI on-prem, air-gapped, latency), run the reference server from a **source checkout** with `python tools/serve_gliner.py`. The server is not installed by `pip install nemo-anonymizer`. Add a provider with `endpoint: http://localhost:8001/v1`, then route `entity_detector` through a `gliner-pii-detector` alias with `provider: local-gliner` and `skip_health_check: true`. Match any custom `--port` or `--host` in the provider endpoint. `model_configs` is a **complete** model pool, not an overlay. Copy `src/anonymizer/config/default_model_configs/models.yaml` and change only the detector entry, keeping `gpt-oss-120b` and `nemotron-30b-thinking`. See [`docs/concepts/self-hosting-gliner.md`](../../docs/concepts/self-hosting-gliner.md) or the [published self-hosting guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/). -- **The evaluation judges use their own model roles** (`detection_validity_judge`, `replace_type_fidelity_judge`, `replace_relational_consistency_judge`, `replace_attribute_fidelity_judge`, `rewrite_judge`), configured in the `evaluate` section of `models.yaml`. They are **not** consumed by `preview()` / `run()`, so a config that anonymizes fine can still fail validation at `evaluate()` if those roles are unset. Defaults ship in `src/anonymizer/config/default_model_configs/evaluate.yaml`. -- **Verdict columns are null when the judge was unavailable** — `None` means "unscored", never a pass. Replace verdict columns (`type_fidelity_valid`, etc.) are `True` / `False` / `None`. Rewrite `detection_valid` is a `0–1` float fraction (or `None` if unscored). Inspect verdicts per record with `evaluated.display_record(i)`. -- **`EvaluateConfig` is an empty placeholder today** — no knobs to set. `anonymizer.evaluate(result)` is the whole API; pass nothing else. +- **The evaluation judges use their own model roles** (`entity_coverage_judge`, `detection_validity_judge`, `replace_type_fidelity_judge`, `replace_relational_consistency_judge`, `replace_attribute_fidelity_judge`, `rewrite_judge`), configured in the `evaluate` section of `models.yaml`. They are **not** consumed by `preview()` / `run()`, so a config that anonymizes fine can still fail validation at `evaluate()` if those roles are unset. Defaults ship in `src/anonymizer/config/default_model_configs/evaluate.yaml` (`entity_coverage_judge` defaults to `nemotron-super`). +- **Verdict columns are null when the judge was unavailable** — `None` means "unscored", never a pass. `entity_coverage` is a `0–1` float (`1.0` = nothing leaked) or `None`; `leaked_entities` is a list of surviving values. Replace verdict columns (`type_fidelity_valid`, etc.) are `True` / `False` / `None`. Rewrite `detection_valid` is a `0–1` float fraction (or `None` if unscored). Inspect verdicts per record with `evaluated.display_record(i)`. +- **`EvaluateConfig` has one knob today: `compute_detection_validity`** (default `False`). Plain `anonymizer.evaluate(result)` runs entity coverage + the mode's quality judges; pass `EvaluateConfig(compute_detection_validity=True)` only to additionally score detection validity (an internal-facing tag-precision metric). # Reference Docs @@ -191,27 +191,33 @@ def main() -> None: # Optional LLM-as-judge evaluation (Replace and Rewrite modes). Opt-in, separate # step — scores quality without changing the anonymized output. - # Replace: Substitute -> 4 judges (detection validity, type fidelity, relational - # consistency, attribute fidelity); Redact/Annotate/Hash -> detection-validity only. - # Rewrite: detection validity + holistic privacy/quality/style judge. + # Both modes: entity_coverage (residual-leak metric) always runs. + # Replace: Substitute adds type fidelity, relational consistency, attribute fidelity. + # Rewrite: adds the holistic privacy/quality/style judge. + # Detection validity is opt-in (EvaluateConfig(compute_detection_validity=True)). # Needs the `evaluate` model roles in models.yaml # (see src/anonymizer/config/default_model_configs/evaluate.yaml). if args.evaluate: result = anonymizer.evaluate(result) df = result.dataframe + # entity_coverage is a 0–1 float (1.0 = nothing leaked); mean is the headline metric. + if "entity_coverage" in df.columns: + scored = int(df["entity_coverage"].notna().sum()) + mean_cov = df["entity_coverage"].mean() + print(f"entity_coverage: mean={mean_cov:.2f} scored={scored}/{len(df)}") if config.replace is not None: for col in ( - "detection_valid", "type_fidelity_valid", "relational_consistency_valid", "attribute_fidelity_valid", + "detection_valid", # present only with compute_detection_validity=True ): if col in df.columns: passed = int(df[col].eq(True).sum()) # None = unscored, never a pass scored = int(df[col].notna().sum()) print(f"{col}: {passed}/{scored} passed ({len(df) - scored} unscored)") else: - # Rewrite: detection_valid is a 0–1 fraction (mean is more informative than pass count). + # Rewrite: detection_valid is a 0–1 fraction (present only when opted in). if "detection_valid" in df.columns: scored = int(df["detection_valid"].notna().sum()) mean_val = df["detection_valid"].mean() diff --git a/src/anonymizer/config/anonymizer_config.py b/src/anonymizer/config/anonymizer_config.py index 6852bdbc..b61711d4 100644 --- a/src/anonymizer/config/anonymizer_config.py +++ b/src/anonymizer/config/anonymizer_config.py @@ -212,5 +212,11 @@ class EvaluateConfig(BaseModel): knobs are introduced. """ - # Intentionally empty for now. New fields land here as evaluation - # configurability is introduced. + compute_detection_validity: bool = False + """Run the tag-precision judge (detection_valid / detection_invalid_entities). + + Disabled by default — intended for internal use during model and threshold + experiments, not a customer-facing metric. When True, adds + ``detection_valid`` and ``detection_invalid_entities`` columns to the + evaluate() output alongside ``entity_coverage``. + """ diff --git a/src/anonymizer/config/default_model_configs/evaluate.yaml b/src/anonymizer/config/default_model_configs/evaluate.yaml index 37ad43db..c92c9507 100644 --- a/src/anonymizer/config/default_model_configs/evaluate.yaml +++ b/src/anonymizer/config/default_model_configs/evaluate.yaml @@ -7,6 +7,7 @@ selected_models: # --- Shared --- + entity_coverage_judge: nemotron-super detection_validity_judge: gpt-oss-120b # --- Replace evaluation --- diff --git a/src/anonymizer/config/default_model_configs/models.yaml b/src/anonymizer/config/default_model_configs/models.yaml index 5559587a..bd2ce0e3 100644 --- a/src/anonymizer/config/default_model_configs/models.yaml +++ b/src/anonymizer/config/default_model_configs/models.yaml @@ -28,3 +28,17 @@ model_configs: temperature: 0.4 top_p: 1.0 timeout: 300 + + - alias: nemotron-super + model: nvidia/nemotron-3-super-v3 + provider: nvidia + inference_parameters: + max_parallel_requests: 16 + max_tokens: 16384 + temperature: 0.3 + top_p: 0.95 + timeout: 300 + extra_body: + reasoning_effort: none + chat_template_kwargs: + enable_thinking: false diff --git a/src/anonymizer/config/models.py b/src/anonymizer/config/models.py index 0dc130d9..041bb69d 100644 --- a/src/anonymizer/config/models.py +++ b/src/anonymizer/config/models.py @@ -101,6 +101,7 @@ class EvaluateModelSelection(BaseModel): output, while ``evaluate(...)`` validates the roles that score it. """ + entity_coverage_judge: str detection_validity_judge: str replace_type_fidelity_judge: str replace_relational_consistency_judge: str diff --git a/src/anonymizer/engine/constants.py b/src/anonymizer/engine/constants.py index 3cb0876a..832da71d 100644 --- a/src/anonymizer/engine/constants.py +++ b/src/anonymizer/engine/constants.py @@ -60,11 +60,16 @@ # Final output COL_FINAL_ENTITIES = "final_entities" -# Replace evaluation: detection-validity judge +# Replace evaluation: detection-validity judge (opt-in internal feature; default off) COL_DETECTION_JUDGE = "_detection_judge" # raw judge output, internal COL_DETECTION_VALID = "detection_valid" # user-facing bool (None if judge unavailable) COL_DETECTION_INVALID_ENTITIES = "detection_invalid_entities" # user-facing list of {value, label, reasoning} +# Replace / rewrite evaluation: entity coverage judge (customer-facing recall metric) +COL_ENTITY_COVERAGE_JUDGE = "_entity_coverage_judge" # raw judge output, internal +COL_ENTITY_COVERAGE = "entity_coverage" # user-facing float | None (0.0–1.0) +COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list of {value, label, reasoning} + # Replace evaluation: type-fidelity judge (Substitute only) COL_TYPE_FIDELITY_JUDGE = "_type_fidelity_judge" # raw judge output, internal COL_TYPE_FIDELITY_VALID = "type_fidelity_valid" # user-facing bool (None if judge unavailable) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py new file mode 100644 index 00000000..a2009cd4 --- /dev/null +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -0,0 +1,589 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import logging +import re +from typing import ClassVar + +import pandas as pd +from data_designer.config.column_configs import LLMTextColumnConfig +from data_designer.config.models import ModelConfig +from pydantic import BaseModel, Field + +from anonymizer.config.models import EvaluateModelSelection +from anonymizer.engine.constants import ( + COL_ENTITIES_BY_VALUE, + COL_ENTITY_COVERAGE, + COL_ENTITY_COVERAGE_JUDGE, + COL_LEAKED_ENTITIES, + COL_TEXT, + DEFAULT_ENTITY_LABELS, + _jinja, +) +from anonymizer.engine.evaluation.judge_base import JudgeResult, _BaseJudgeWorkflow +from anonymizer.engine.ndd.adapter import FailedRecord, NddAdapter +from anonymizer.engine.ndd.model_loader import resolve_model_alias +from anonymizer.engine.prompt_utils import substitute_placeholders +from anonymizer.engine.row_partitioning import ROW_ORDER_COL, merge_and_reorder +from anonymizer.engine.schemas import EntitiesByValueSchema + +logger = logging.getLogger("anonymizer.evaluation.entity_coverage_judge") + +_FINAL_ENTITIES_FOR_COVERAGE_COL = "_final_entities_for_coverage_judge" +_WORD_RE = re.compile(r"\w+", re.UNICODE) +# Grammatical stopwords only — function words that carry no PII and just absorb +# article/preposition noise in a value (e.g. "the Nawabganj" matches "Nawabganj"). +# Deliberately does NOT include generic content descriptors (festival, summit, club, +# conference, …): those turn a named event/org into a quasi-identifier, so ignoring +# them would suppress real leaks (e.g. "Davos Summit" collapsing into "Davos"). +_COVERAGE_IGNORE_TOKENS = frozenset( + { + "a", + "an", + "and", + "at", + "by", + "for", + "from", + "in", + "of", + "on", + "or", + "the", + "to", + "with", + } +) + + +# --------------------------------------------------------------------------- +# Output schema +# --------------------------------------------------------------------------- + + +class LeakedEntity(BaseModel): + value: str = Field(description="Exact text span from the original text that was missed by the anonymizer.") + label: str = Field(description="Entity type label (e.g. first_name, email, phone_number).") + reasoning: str = Field(description="One sentence explaining why this is PII that the anonymizer missed.") + + +class EntityCoverageSchema(BaseModel): + leaked_entities: list[LeakedEntity] = Field( + default_factory=list, + description="All PII entities present in the original text that the anonymizer failed to detect. " + "Empty when the anonymizer caught everything.", + ) + + +# --------------------------------------------------------------------------- +# Prompt +# --------------------------------------------------------------------------- + + +def _entity_type_scope_block(entity_labels: list[str] | None) -> str: + if entity_labels is None: + return "\nEvaluate for all PII and sensitive entity types.\n" + labels_str = ", ".join(entity_labels) + return ( + "\n" + f"Detection was configured to target ONLY these entity types: {labels_str}.\n" + "Only report missed entities that belong to one of these types. " + "Do NOT flag PII of other types as leaked — those were intentionally excluded from detection.\n" + "" + ) + + +def _strict_protection_block(strict_entity_protection: bool) -> str: + if not strict_entity_protection: + return "" + return ( + "\n" + "STRICT PROTECTION MODE IS ENABLED.\n\n" + "Flag ALL entities as leaked if they were not caught — including quasi-identifiers\n" + "and low-risk entities that would normally be given benefit of the doubt.\n" + "Do NOT apply MINIMUM NECESSARY CHANGE reasoning to excuse a missed entity.\n" + "Do NOT excuse a missed entity because its combined re-identification risk is low.\n" + "Any PII span not caught by the anonymizer is a miss in strict mode.\n" + "" + ) + + +def _data_summary_block(data_summary: str | None) -> str: + """Return optional dataset context without changing prompts when absent.""" + if not data_summary or not data_summary.strip(): + return "" + return ( + "\n" + f"{data_summary.strip()}\n" + "Use this context only to interpret literal values and their semantic types. " + "Do not infer or invent entities that are absent from the original text.\n" + "" + ) + + +def _coverage_prompt( + *, entity_labels: list[str] | None, strict_entity_protection: bool, data_summary: str | None = None +) -> str: + entity_scope_block = _entity_type_scope_block(entity_labels) + strict_block = _strict_protection_block(strict_entity_protection) + data_context_section = f"\n\n{_data_summary_block(data_summary)}" if data_summary and data_summary.strip() else "" + + entity_scope_guidance = ( + "- Respect the entity_type_scope: do not flag PII types outside the configured scope as leaked." + if entity_labels is not None + else "" + ) + strict_guidance = ( + "- In strict protection mode, flag every missed entity regardless of its perceived risk level." + if strict_entity_protection + else "" + ) + + active_labels = entity_labels if entity_labels is not None else DEFAULT_ENTITY_LABELS + labels_str = ", ".join(active_labels) + + prompt = f"""You are an exhaustive privacy-entity span extractor. Extract every literal value \ +in the ORIGINAL TEXT whose semantic type is in scope. + + +Work independently from the anonymizer: identify all in-scope direct and quasi-identifiers \ +without deciding whether another system already found or protected them. A deterministic \ +postprocessing step will compare your candidates with the anonymizer final entities. + +Return structured JSON: +- `leaked_entities`: every in-scope candidate with its `value`, `label`, and a short `reasoning`. +- Return an empty list only when the original text contains no in-scope entity values. + + + +These entity types are in scope: {labels_str}. +Check every configured type systematically. Report only concrete values that literally appear \ +in the text, not information that is merely implied. +Quasi-identifiers: combinations of values that together re-identify someone \ +(e.g. job title + employer + city appearing together). Time values (specific timestamps, \ +times of day, schedules) can act as quasi-identifiers when combined with other attributes \ +in the same text — flag them if they appear alongside other identifying information. +{data_context_section} + +{entity_scope_block} + + +Treat each configured label as a semantic entity category. Labels may use compact, compound, \ +or abbreviated names; interpret their intended meaning from the label and the original-text \ +context. Return labels exactly as they appear in the entity_type_scope. + + + +Before returning, scan the entire text label by label and inspect: +- Prose, salutations, signatures, addresses, and contact blocks. +- Tables, bullets, forms, and other key/value or semi-structured content. +- Short or single-token values whose nearby wording or syntax establishes their type. +- Honorifics attached to person names, compact date formats used as dates, and categorical \ +or coded values whose context establishes an in-scope type. + + + +For each candidate, verify ALL of the following: +1. Its value is a literal, non-empty span in the original text. +2. It is an actual assigned or stated value, not a generic field name, heading, instruction, \ +blank placeholder, or category name. +3. Its semantic type matches one of the entity types in scope. +4. Report the complete contiguous span that represents one sensitive value. Preserve all \ +tokens belonging to that value, including multi-token values, while excluding surrounding \ +labels, punctuation, instructions, or boilerplate. +5. Evaluate the value using its original-text context rather than its form or how identifying \ +it appears in isolation. + +In structured text, distinguish a generic category name from a literal category value. A short \ +literal that itself instantiates an in-scope type remains a candidate when it appears in a form \ +row, list item, or signature. Do not merge tokens from unrelated people or fields into one value. + + + +Do NOT flag: +- Text whose in-scope semantic type is not supported by its original-text context. +- Information that is inferable but not literally present in the text. + +Do flag: +- The `value` MUST be a literal substring found in the original text. +- `reasoning` MUST be one sentence explaining which in-scope semantic type the value represents. +- A value that fills the role of a listed sensitive type in context, even when it is + short, a single token, an unfamiliar or foreign-looking word, or resembles an ordinary + word or number. Decide by the value's role in the surrounding text, not by its length, + rarity, or familiarity. (This still excludes pronouns and generic references that only + imply a type — those are not concrete values.) + +{entity_scope_guidance} +{strict_guidance} + + +{strict_block} + + + +<> + + + + +Return ONLY the JSON object that matches the required schema. Do NOT wrap your output in \ +``` or ```json markdown fences. Do NOT include any commentary, reasoning, preamble, or text \ +outside the JSON object. Your entire response must be a single valid JSON object. + +""" + return substitute_placeholders( + prompt, + { + "<>": _jinja(COL_TEXT), + }, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _final_entities_for_coverage(parsed: EntitiesByValueSchema) -> list[dict[str, str]]: + """Flatten EntitiesByValueSchema into one (value, label) row per pair for prompt context.""" + return [{"value": e.value, "label": label} for e in parsed.entities_by_value for label in e.labels] + + +def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: + """Parse raw LLM output into the leaked entity list. + + Returns the list (possibly empty) on success, or None when the payload is + malformed or missing so downstream display renders "judge unavailable". + """ + if raw is None: + return None + if isinstance(raw, BaseModel): + raw = raw.model_dump(mode="python") + if isinstance(raw, str): + raw = _parse_json_object(raw) + if raw is None: + return None + if not isinstance(raw, dict): + return None + try: + parsed = EntityCoverageSchema.model_validate(raw) + except Exception: + return None + return [e.model_dump() for e in parsed.leaked_entities] + + +def _parse_json_object(raw: str) -> dict[str, object] | None: + """Parse a JSON object, tolerating fences or brief surrounding model text.""" + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + decoder = json.JSONDecoder() + for match in re.finditer(r"\{", raw): + try: + parsed, _ = decoder.raw_decode(raw[match.start() :]) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(parsed, dict): + return parsed + return None + return parsed if isinstance(parsed, dict) else None + + +def _coverage_token_list(value: object) -> list[str]: + """Unicode-aware, case-insensitive word tokens (order preserved). + + Uses ``casefold()`` + ``\\w`` so accented and non-Latin scripts tokenize + correctly (e.g. ``José`` -> ``["josé"]``) instead of being dropped or mangled. + """ + return _WORD_RE.findall(str(value).casefold()) + + +def _is_concatenation_of_whole_values(leaked_tokens: list[str], final_token_lists: list[list[str]]) -> bool: + """True when ``leaked_tokens`` segment exactly into a sequence of WHOLE final values. + + This is the composite case: a leak that is the concatenation of adjacent detected + entities (e.g. ``"Nawabganj - 382210"`` == ``"Nawabganj"`` + ``"382210"``). Each + segment must equal a full final-entity value, so a leak whose pieces are only + *partial* tokens of unrelated entities is NOT matched here. + """ + + def consume(start: int) -> bool: + if start == len(leaked_tokens): + return True + for final_tokens in final_token_lists: + end = start + len(final_tokens) + if final_tokens and leaked_tokens[start:end] == final_tokens and consume(end): + return True + return False + + return consume(0) + + +def _core_token_sequence(tokens: list[str]) -> list[str]: + """Tokens in original order with grammatical stopwords (``_COVERAGE_IGNORE_TOKENS``) removed.""" + return [token for token in tokens if token not in _COVERAGE_IGNORE_TOKENS] + + +def _is_contiguous_sublist(needle: list[str], haystack: list[str]) -> bool: + """True when ``needle`` appears as a contiguous, in-order run within ``haystack``.""" + if not needle or len(needle) > len(haystack): + return False + return any(haystack[i : i + len(needle)] == needle for i in range(len(haystack) - len(needle) + 1)) + + +def _is_leaked_value_covered(leaked_value: object, final_values: list[str]) -> bool: + """Return True when a judge-reported leak is already covered by final entities. + + Coverage is decided **per final entity** — never against a pooled bag of tokens + from *all* final entities — so a leak whose pieces come from unrelated entities is + not wrongly suppressed (e.g. ``"John Smith"`` is NOT covered by ``"John Doe"`` + + ``"Jane Smith"``). A leak is covered when either: + + - **subspan** — its (stopword-stripped) tokens appear as a *contiguous, in-order run* + within a single final entity's tokens (``"Mstr"`` in ``"Mstr Marzella"``, + ``"White House"`` in ``"White House Road"``). Contiguity and order are required — + shared tokens alone do not qualify — so ``"Ann Lee"`` is NOT covered by + ``"Lee Ann Boulevard"``. + - **composite** — its tokens are a concatenation of *whole* final-entity values + (``"Nawabganj - 382210"`` == ``"Nawabganj"`` + ``"382210"``). + + Matching is whole-token (``"m"`` != ``"mstr"``) and **value-only** — labels are not + compared. Known limitation: a single-token leak is covered whenever that token equals + a whole token in ANY final entity, regardless of type — e.g. a surname ``"Green"`` is + treated as covered by a detected street ``"Bowling Green Road"``. This mirrors the + intended "bare username covered by a file path that contains it" behavior; fixing it + would require label-aware matching, which is deliberately avoided (judge labels are + free-form), so it is accepted as a tradeoff. + """ + leaked_tokens = _coverage_token_list(leaked_value) + if not leaked_tokens: + return False + + final_token_lists = [tokens for tokens in (_coverage_token_list(value) for value in final_values) if tokens] + if not final_token_lists: + return False + + # Exact match against a single final value. + if any(leaked_tokens == final_tokens for final_tokens in final_token_lists): + return True + + # Subspan: the leak's core tokens appear as a contiguous, in-order run within a single + # final entity (adjacency + order required, not merely a shared set of tokens). + leaked_core = _core_token_sequence(leaked_tokens) + if leaked_core and any( + _is_contiguous_sublist(leaked_core, _core_token_sequence(final_tokens)) for final_tokens in final_token_lists + ): + return True + + # Composite: concatenation of whole final-entity values. + return _is_concatenation_of_whole_values(leaked_tokens, final_token_lists) + + +def _filter_covered_leaked_entities( + leaked_entities: list[dict[str, object]], + final_entities: object, +) -> list[dict[str, object]]: + """Drop judge-reported leaks that are already covered by final entity values.""" + if not isinstance(final_entities, list): + return leaked_entities + + final_values = [str(entity.get("value", "")) for entity in final_entities if isinstance(entity, dict)] + if not final_values: + return leaked_entities + + return [entity for entity in leaked_entities if not _is_leaked_value_covered(entity.get("value", ""), final_values)] + + +def _normalize_literal_text(value: object) -> str: + """Normalize case and whitespace while preserving the literal token sequence.""" + return " ".join(str(value).casefold().split()) + + +def _filter_nonliteral_entities( + entities: list[dict[str, object]], + original_text: object, +) -> list[dict[str, object]]: + """Drop judge-reported values that are not literal spans in the original text.""" + normalized_original = _normalize_literal_text(original_text) + return [ + entity + for entity in entities + if (value := _normalize_literal_text(entity.get("value", ""))) and value in normalized_original + ] + + +def _deduplicate_judge_entities(entities: list[dict[str, object]]) -> list[dict[str, object]]: + """Keep one judge entity per normalized (value, label) pair.""" + deduplicated: list[dict[str, object]] = [] + seen: set[tuple[str, str]] = set() + for entity in entities: + key = ( + _normalize_literal_text(entity.get("value", "")), + _normalize_literal_text(entity.get("label", "")), + ) + if not key[0] or key in seen: + continue + seen.add(key) + deduplicated.append(entity) + return deduplicated + + +# --------------------------------------------------------------------------- +# Workflow +# --------------------------------------------------------------------------- + + +class EntityCoverageWorkflow(_BaseJudgeWorkflow): + """LLM judge that reports entities not covered by Anonymizer final entities. + + The judge independently extracts candidates from the original text and entity-type + scope. Deterministic postprocessing removes nonliteral and already-covered findings. + + Output columns: + ``COL_ENTITY_COVERAGE`` (float|None) — n_final / (n_final + n_leaked) + ``COL_LEAKED_ENTITIES`` (list) — missed entities with value, label, reasoning + """ + + RAW_COL: ClassVar[str] = COL_ENTITY_COVERAGE_JUDGE + VALID_COL: ClassVar[str] = COL_ENTITY_COVERAGE + INVALID_COL: ClassVar[str] = COL_LEAKED_ENTITIES + SCHEMA: ClassVar[type[BaseModel]] = EntityCoverageSchema + VERDICT_FIELD: ClassVar[str] = "leaked_entities" + DEFAULT_PAYLOAD: ClassVar[dict] = {"leaked_entities": []} + MODEL_ROLE: ClassVar[str] = "entity_coverage_judge" + WORKFLOW_NAME: ClassVar[str] = "entity-coverage-judge" + + def __init__( + self, + adapter: NddAdapter, + *, + entity_labels: list[str] | None = None, + strict_entity_protection: bool = False, + data_summary: str | None = None, + ) -> None: + super().__init__(adapter) + self._entity_labels = entity_labels + self._strict_entity_protection = strict_entity_protection + self._data_summary = data_summary + + # ------------------------------------------------------------------ hooks + + def prepare(self, dataframe: pd.DataFrame) -> pd.DataFrame: + working_df = dataframe.copy() + parsed = working_df[COL_ENTITIES_BY_VALUE].apply(EntitiesByValueSchema.from_raw) + working_df[_FINAL_ENTITIES_FOR_COVERAGE_COL] = parsed.apply(_final_entities_for_coverage) + return working_df + + def _passthrough_mask(self, dataframe: pd.DataFrame) -> pd.Series: + # Independent extraction must run even when Anonymizer found no entities. + return pd.Series(False, index=dataframe.index, dtype=bool) + + @classmethod + def _build_prompt(cls) -> str: + return _coverage_prompt(entity_labels=None, strict_entity_protection=False) + + @classmethod + def _extract_invalid(cls, parsed: BaseModel) -> list[dict[str, object]]: + return [e.model_dump() for e in parsed.leaked_entities] + + # ----------------------------------------------------------------- overrides + + def column_config(self, selected_models: EvaluateModelSelection) -> LLMTextColumnConfig: + """Override to inject instance-specific entity_labels and strict_entity_protection.""" + return LLMTextColumnConfig( + name=self.RAW_COL, + prompt=_coverage_prompt( + entity_labels=self._entity_labels, + strict_entity_protection=self._strict_entity_protection, + data_summary=self._data_summary, + ), + model_alias=resolve_model_alias(self.MODEL_ROLE, selected_models), + ) + + def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: + """Validate judge-reported leaks and calculate coverage.""" + out = dataframe.copy() + + coverage_vals: list[float | None] = [] + leaked_lists: list[list[dict]] = [] + + for idx in out.index: + raw = out[self.RAW_COL].loc[idx] if self.RAW_COL in out.columns else None + leaked = _parse_leaked_entities(raw) + if leaked is None: + coverage_vals.append(None) + leaked_lists.append([]) + else: + leaked = _filter_nonliteral_entities(leaked, out[COL_TEXT].loc[idx]) + leaked = _deduplicate_judge_entities(leaked) + final_entities = out[_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx] + leaked = _filter_covered_leaked_entities(leaked, final_entities) + n_final = len(final_entities) if isinstance(final_entities, list) else 0 + total = n_final + len(leaked) + coverage = 1.0 if total == 0 else n_final / total + coverage_vals.append(coverage) + leaked_lists.append(leaked) + + out[self.VALID_COL] = coverage_vals + out[self.INVALID_COL] = leaked_lists + return out + + def run_non_critical( + self, + dataframe: pd.DataFrame, + *, + model_configs: list[ModelConfig], + selected_models: EvaluateModelSelection, + preview_num_records: int | None = None, + ) -> tuple[pd.DataFrame, list[FailedRecord]]: + """Run coverage and annotate ``dataframe`` in-place; never raise. + + Rows the LLM drops get ``entity_coverage=None`` / ``leaked_entities=[]`` + rather than disappearing. On total workflow failure, all rows are defaulted. + Returns ``(annotated_df, failed_records)``. + """ + try: + result = self.evaluate( + dataframe, + model_configs=model_configs, + selected_models=selected_models, + preview_num_records=preview_num_records, + ) + out = dataframe.copy() + for col in (self.RAW_COL, self.VALID_COL, self.INVALID_COL): + if col in result.dataframe.columns: + out[col] = result.dataframe[col].values + return out, result.failed_records + except Exception: + logger.debug("Entity coverage workflow failed; scores may be unavailable.", exc_info=True) + out = dataframe.copy() + out[self.VALID_COL] = None + out[self.INVALID_COL] = [[] for _ in range(len(out))] + return out, [] + + def evaluate( + self, + dataframe: pd.DataFrame, + *, + model_configs: list[ModelConfig], + selected_models: EvaluateModelSelection, + preview_num_records: int | None = None, + ) -> JudgeResult: + """Run leak detection against the supplied final entities.""" + working_df = self.prepare(dataframe) + working_df[ROW_ORDER_COL] = range(len(working_df)) + effective_preview = min(preview_num_records, len(working_df)) if preview_num_records is not None else None + run_result = self._adapter.run_workflow( + working_df, + model_configs=model_configs, + columns=[self.column_config(selected_models)], + workflow_name=self.WORKFLOW_NAME, + preview_num_records=effective_preview, + ) + + judged_df = self.postprocess(run_result.dataframe) + combined = merge_and_reorder(judged_df) + return JudgeResult(dataframe=combined, failed_records=run_result.failed_records) diff --git a/src/anonymizer/engine/ndd/model_loader.py b/src/anonymizer/engine/ndd/model_loader.py index 123c4be9..f1e0c56e 100644 --- a/src/anonymizer/engine/ndd/model_loader.py +++ b/src/anonymizer/engine/ndd/model_loader.py @@ -286,6 +286,7 @@ def validate_model_alias_references( _collect_role(roles_to_check, f"replace.{role}", alias) if check_evaluate: evaluate_roles = selected_models.evaluate.model_dump() + _collect_role(roles_to_check, "evaluate.entity_coverage_judge", evaluate_roles["entity_coverage_judge"]) _collect_role(roles_to_check, "evaluate.detection_validity_judge", evaluate_roles["detection_validity_judge"]) if check_rewrite_judge: _collect_role(roles_to_check, "evaluate.rewrite_judge", evaluate_roles["rewrite_judge"]) diff --git a/src/anonymizer/engine/replace/replace_runner.py b/src/anonymizer/engine/replace/replace_runner.py index f3a95adc..69200406 100644 --- a/src/anonymizer/engine/replace/replace_runner.py +++ b/src/anonymizer/engine/replace/replace_runner.py @@ -22,6 +22,7 @@ COL_REPLACEMENT_MAP, ) from anonymizer.engine.evaluation.detection_judge import DetectionJudgeWorkflow +from anonymizer.engine.evaluation.entity_coverage_judge import EntityCoverageWorkflow from anonymizer.engine.evaluation.replace.attribute_fidelity_judge import AttributeFidelityJudgeWorkflow from anonymizer.engine.evaluation.replace.relational_consistency_judge import RelationalConsistencyJudgeWorkflow from anonymizer.engine.evaluation.replace.type_fidelity_judge import TypeFidelityJudgeWorkflow @@ -115,6 +116,9 @@ def evaluate( model_configs: list[ModelConfig], selected_models: EvaluateModelSelection, preview_num_records: int | None = None, + entity_labels: list[str] | None = None, + compute_detection_validity: bool = False, + data_summary: str | None = None, ) -> ReplacementResult: """Run the LLM evaluation judges on an already-replaced dataframe. @@ -141,10 +145,19 @@ def evaluate( f"missing: {sorted(missing)}. Pass the trace_dataframe from a " f"previous preview()/run() call." ) + # strict_entity_protection is a rewrite-only knob (only on the Rewrite config); replace + # mode has no such setting, so the coverage judge intentionally runs non-strict here. + entity_coverage_judge = EntityCoverageWorkflow( + adapter=self._adapter, # type: ignore[arg-type] + entity_labels=entity_labels, + data_summary=data_summary, + ) failed_records: list[FailedRecord] = [] judged_df = self._run_merged_judges( dataframe, is_substitute=is_substitute, + entity_coverage_judge=entity_coverage_judge, + compute_detection_validity=compute_detection_validity, model_configs=model_configs, selected_models=selected_models, preview_num_records=preview_num_records, @@ -161,6 +174,8 @@ def _run_merged_judges( dataframe: pd.DataFrame, *, is_substitute: bool, + entity_coverage_judge: EntityCoverageWorkflow, + compute_detection_validity: bool, model_configs: list[ModelConfig], selected_models: EvaluateModelSelection, preview_num_records: int | None, @@ -173,7 +188,9 @@ def _run_merged_judges( + apply passthrough defaults). The adapter sees one workflow with N columns and lets DD schedule them in parallel. """ - active = [j for j in [self._detection_judge] if j is not None] + active = [entity_coverage_judge] + if compute_detection_validity and self._detection_judge is not None: + active.append(self._detection_judge) if is_substitute: active.extend( j @@ -220,7 +237,7 @@ def _run_merged_judges( else: judged_df = prepared except Exception: - logger.warning("Replace judges workflow failed; populating defaults for all judges", exc_info=True) + logger.debug("Replace judges workflow failed; evaluation scores may be unavailable.", exc_info=True) judged_df = prepared for judge in active: diff --git a/src/anonymizer/engine/rewrite/rewrite_workflow.py b/src/anonymizer/engine/rewrite/rewrite_workflow.py index 2a118445..8562302c 100644 --- a/src/anonymizer/engine/rewrite/rewrite_workflow.py +++ b/src/anonymizer/engine/rewrite/rewrite_workflow.py @@ -462,7 +462,7 @@ def _run_final_judge( df = _join_judge_columns(df, judge_result.dataframe) return df, judge_result.failed_records except Exception: - logger.warning("Final judge step failed; defaulting to judge_evaluation=None", exc_info=True) + logger.debug("Rewrite judge workflow failed; scores may be unavailable.", exc_info=True) df[COL_JUDGE_EVALUATION] = None return df, [] @@ -478,24 +478,27 @@ def evaluate( selected_models: EvaluateModelSelection, privacy_goal: PrivacyGoal, preview_num_records: int | None = None, + compute_detection_validity: bool = False, ) -> RewriteResult: """Run detection validity judge and holistic judge on a completed rewrite result. Takes the trace dataframe from a prior run() / preview() and appends - COL_DETECTION_VALID, COL_DETECTION_INVALID_ENTITIES, and COL_JUDGE_EVALUATION. + COL_JUDGE_EVALUATION, and optionally COL_DETECTION_VALID / + COL_DETECTION_INVALID_ENTITIES when ``compute_detection_validity=True``. COL_NEEDS_HUMAN_REVIEW is not modified — it was set during run() based on objective metrics and judge scores do not influence it. """ entity_rows, passthrough_rows = split_rows(df, column=COL_ENTITIES_BY_VALUE, predicate=_has_entities) passthrough_rows = passthrough_rows.copy() - if not passthrough_rows.empty: - logger.info( - "%d passthrough row(s) have no detected entities — detection_valid set to 1.0 (trivially valid).", - len(passthrough_rows), - ) - passthrough_rows[COL_DETECTION_VALID] = 1.0 - passthrough_rows[COL_DETECTION_INVALID_ENTITIES] = [[] for _ in range(len(passthrough_rows))] + if compute_detection_validity: + if not passthrough_rows.empty: + logger.info( + "%d passthrough row(s) have no detected entities — detection_valid set to 1.0 (trivially valid).", + len(passthrough_rows), + ) + passthrough_rows[COL_DETECTION_VALID] = 1.0 + passthrough_rows[COL_DETECTION_INVALID_ENTITIES] = [[] for _ in range(len(passthrough_rows))] passthrough_rows[COL_JUDGE_EVALUATION] = None if entity_rows.empty: @@ -504,27 +507,28 @@ def evaluate( all_failed: list[FailedRecord] = [] - # --- Detection validity judge --- - try: - detection_result = self._detection_judge_wf.evaluate( - entity_rows, - model_configs=model_configs, - selected_models=selected_models, - preview_num_records=preview_num_records, - ) - entity_rows = _join_preserving( - entity_rows, - detection_result.dataframe, - defaults={COL_DETECTION_VALID: None, COL_DETECTION_INVALID_ENTITIES: None}, - ) - all_failed.extend(detection_result.failed_records) - # Convert bool all_valid → 0–1 fraction so detection validity sits on the - # same scale as utility_score and leakage_mass in the rewrite scores section. - entity_rows[COL_DETECTION_VALID] = entity_rows.apply(_detection_valid_fraction, axis=1) - except Exception: - logger.warning("Detection judge step failed; defaulting to detection_valid=None.", exc_info=True) - entity_rows[COL_DETECTION_VALID] = None - entity_rows[COL_DETECTION_INVALID_ENTITIES] = None + # --- Detection validity judge (opt-in) --- + if compute_detection_validity: + try: + detection_result = self._detection_judge_wf.evaluate( + entity_rows, + model_configs=model_configs, + selected_models=selected_models, + preview_num_records=preview_num_records, + ) + entity_rows = _join_preserving( + entity_rows, + detection_result.dataframe, + defaults={COL_DETECTION_VALID: None, COL_DETECTION_INVALID_ENTITIES: None}, + ) + all_failed.extend(detection_result.failed_records) + # Convert bool all_valid → 0–1 fraction so detection validity sits on the + # same scale as utility_score and leakage_mass in the rewrite scores section. + entity_rows[COL_DETECTION_VALID] = entity_rows.apply(_detection_valid_fraction, axis=1) + except Exception: + logger.debug("Detection validity judge failed; scores may be unavailable.", exc_info=True) + entity_rows[COL_DETECTION_VALID] = None + entity_rows[COL_DETECTION_INVALID_ENTITIES] = None # --- Holistic judge (privacy / quality / style) --- entity_rows, judge_failed = self._run_final_judge( diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index bcfee0c5..35d7fef1 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -30,9 +30,12 @@ COL_DETECTED_ENTITIES, COL_DETECTION_INVALID_ENTITIES, COL_DETECTION_VALID, + COL_ENTITIES_BY_VALUE, + COL_ENTITY_COVERAGE, COL_FINAL_ENTITIES, COL_JUDGE_EVALUATION, COL_LEAKAGE_MASS, + COL_LEAKED_ENTITIES, COL_NEEDS_HUMAN_REVIEW, COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, COL_RELATIONAL_CONSISTENCY_VALID, @@ -48,6 +51,7 @@ ) from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow from anonymizer.engine.evaluation.detection_judge import DetectionJudgeWorkflow +from anonymizer.engine.evaluation.entity_coverage_judge import EntityCoverageWorkflow from anonymizer.engine.evaluation.replace.attribute_fidelity_judge import AttributeFidelityJudgeWorkflow from anonymizer.engine.evaluation.replace.relational_consistency_judge import RelationalConsistencyJudgeWorkflow from anonymizer.engine.evaluation.replace.type_fidelity_judge import TypeFidelityJudgeWorkflow @@ -63,6 +67,7 @@ from anonymizer.engine.replace.replace_runner import ReplacementWorkflow from anonymizer.engine.resolved_input import ResolvedInput from anonymizer.engine.rewrite.rewrite_workflow import RewriteWorkflow +from anonymizer.engine.schemas import EntitiesByValueSchema from anonymizer.interface.errors import InvalidConfigError from anonymizer.interface.results import AnonymizerResult, PreviewResult from anonymizer.logging import LOG_INDENT, configure_logging, reapply_log_levels @@ -91,6 +96,43 @@ logger = logging.getLogger("anonymizer") +def _has_entities_for_evaluation(raw: object) -> bool: + """Return whether a row should receive entity-dependent evaluation scores.""" + try: + return bool(EntitiesByValueSchema.from_raw(raw).entities_by_value) + except Exception: + # Malformed entity metadata should not hide a missing score. + return True + + +def _log_unavailable_scores( + dataframe: pd.DataFrame, + checks: list[tuple[str, str, pd.Series | None]], + *, + group_label: str, +) -> None: + """Warn for unavailable active-judge scores.""" + unavailable_scores: list[tuple[str, int, int]] = [] + for label, column, expected_mask in checks: + applicable = dataframe if expected_mask is None else dataframe.loc[expected_mask] + applicable_count = len(applicable) + if applicable_count == 0: + continue + unavailable = applicable_count if column not in applicable.columns else int(applicable[column].isna().sum()) + if unavailable: + unavailable_scores.append((label, unavailable, applicable_count)) + else: + logger.debug("%s score available for all %d applicable records.", label.capitalize(), applicable_count) + if len(unavailable_scores) == 1: + label, unavailable, applicable_count = unavailable_scores[0] + logger.warning("%s score unavailable for %d/%d records.", label.capitalize(), unavailable, applicable_count) + elif unavailable_scores: + details = "; ".join( + f"{label}: {unavailable}/{applicable_count}" for label, unavailable, applicable_count in unavailable_scores + ) + logger.warning("%s scores unavailable — %s.", group_label, details) + + def _initialize_logging() -> None: """Run one-time logging setup if the user hasn't already called configure_logging().""" from anonymizer import logging as _logging_mod @@ -326,6 +368,11 @@ def preview( preview_num_records=num_records, replace_method=config.replace, rewrite_config=config.rewrite.privacy_goal if config.rewrite is not None else None, + entity_labels=config.detect.entity_labels, + strict_entity_protection=( + config.rewrite.strict_entity_protection if config.rewrite is not None else False + ), + data_summary=result.data_summary, ) except KeyboardInterrupt: status = TaskStatusEnum.CANCELED @@ -380,39 +427,13 @@ def evaluate( knobs (placeholder today; reserved for metric selection, per-judge model/prompt overrides, etc.). """ - _ = config # placeholder; no knobs to read yet + evaluate_config = config or EvaluateConfig() rewrite_config = getattr(output, "rewrite_config", None) replace_method = getattr(output, "replace_method", None) + text_column = output.resolved_text_column + is_rewrite = rewrite_config is not None - if rewrite_config is not None: - try: - validate_model_alias_references( - self._model_configs, - self._selected_models, - check_rewrite=False, - check_evaluate=True, - check_rewrite_judge=True, - ) - except ValueError as exc: - raise InvalidConfigError(str(exc)) from exc - text_column = output.resolved_text_column - internal_df = _unrename_output_columns(output.trace_dataframe, resolved_text_column=text_column) - rewrite_result = self._rewrite_runner.evaluate( - internal_df, - model_configs=self._model_configs, - selected_models=self._selected_models.evaluate, - privacy_goal=rewrite_config, - ) - renamed_trace = _rename_output_columns(rewrite_result.dataframe, resolved_text_column=text_column) - return AnonymizerResult( - dataframe=_build_user_dataframe(renamed_trace, resolved_text_column=text_column), - trace_dataframe=renamed_trace, - resolved_text_column=text_column, - failed_records=rewrite_result.failed_records, - rewrite_config=rewrite_config, - ) - - if replace_method is None: + if not is_rewrite and replace_method is None: raise ValueError( "Cannot evaluate this output — it has no associated anonymization config. " "Pass an AnonymizerResult / PreviewResult produced by run() / preview(). " @@ -426,29 +447,170 @@ def evaluate( check_substitute=isinstance(replace_method, Substitute), check_rewrite=False, check_evaluate=True, + check_rewrite_judge=is_rewrite, ) except ValueError as exc: raise InvalidConfigError(str(exc)) from exc - text_column = output.resolved_text_column + + entity_labels = getattr(output, "entity_labels", None) + strict_entity_protection = getattr(output, "strict_entity_protection", False) + data_summary = getattr(output, "data_summary", None) + num_records = len(output.trace_dataframe) + mode_name = "rewrite" if is_rewrite else type(replace_method).__name__ + active_judges = ["rewrite_judge", "entity_coverage_judge"] if is_rewrite else ["entity_coverage_judge"] + if evaluate_config.compute_detection_validity: + active_judges.append("detection_validity_judge") + if isinstance(replace_method, Substitute): + active_judges.extend( + [ + "replace_type_fidelity_judge", + "replace_relational_consistency_judge", + "replace_attribute_fidelity_judge", + ] + ) + logger.info("🧪 Running %s evaluation on %d records", mode_name, num_records) + logger.debug( + "evaluation config: mode=%s, text_column=%s, detection_validity=%s, " + "strict_entity_protection=%s, entity_labels=%s, data_summary_provided=%s", + mode_name, + text_column, + evaluate_config.compute_detection_validity, + strict_entity_protection, + len(entity_labels) if entity_labels is not None else "default", + bool(data_summary), + ) + logger.debug("active evaluation judges: %s", ", ".join(active_judges)) + logger.debug("evaluation models: %s", self._selected_models.evaluate) + evaluation_start = time.perf_counter() # trace_dataframe is in user-facing form (e.g., 'biography' instead of # '__nemo_anonymizer_text_input__'). The judge prompts reference the # internal names, so reverse the rename before the DD call and re-apply # it on the result. internal_df = _unrename_output_columns(output.trace_dataframe, resolved_text_column=text_column) - replace_result = self._replace_runner.evaluate( - internal_df, - replace_method=replace_method, - model_configs=self._model_configs, - selected_models=self._selected_models.evaluate, - ) - renamed_trace = _rename_output_columns(replace_result.dataframe, resolved_text_column=text_column) - return AnonymizerResult( - dataframe=_build_user_dataframe(renamed_trace, resolved_text_column=text_column), - trace_dataframe=renamed_trace, - resolved_text_column=text_column, - failed_records=replace_result.failed_records, - replace_method=replace_method, + if is_rewrite: + logger.info(LOG_INDENT + "⚖️ Running rewrite judges") + stage_start = time.perf_counter() + rewrite_result = self._rewrite_runner.evaluate( + internal_df, + model_configs=self._model_configs, + selected_models=self._selected_models.evaluate, + privacy_goal=rewrite_config, + compute_detection_validity=evaluate_config.compute_detection_validity, + ) + entity_mask = ( + rewrite_result.dataframe[COL_ENTITIES_BY_VALUE].apply(_has_entities_for_evaluation) + if COL_ENTITIES_BY_VALUE in rewrite_result.dataframe.columns + else None + ) + rewrite_checks = [("rewrite judge", COL_JUDGE_EVALUATION, entity_mask)] + if evaluate_config.compute_detection_validity: + rewrite_checks.append(("detection validity", COL_DETECTION_VALID, None)) + _log_unavailable_scores( + rewrite_result.dataframe, + rewrite_checks, + group_label="Rewrite evaluation", + ) + logger.info( + LOG_INDENT + "📋 Rewrite judges complete [%.1fs]", + time.perf_counter() - stage_start, + ) + all_failed: list[FailedRecord] = list(rewrite_result.failed_records) + coverage_wf = EntityCoverageWorkflow( + adapter=self._adapter, + entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, + data_summary=data_summary, + ) + logger.info(LOG_INDENT + "🔎 Running entity coverage") + stage_start = time.perf_counter() + judged_df, coverage_failed = coverage_wf.run_non_critical( + rewrite_result.dataframe, + model_configs=self._model_configs, + selected_models=self._selected_models.evaluate, + ) + _log_unavailable_scores( + judged_df, + [("entity coverage", COL_ENTITY_COVERAGE, None)], + group_label="Entity coverage", + ) + logger.info( + LOG_INDENT + "📋 Entity coverage complete [%.1fs]", + time.perf_counter() - stage_start, + ) + all_failed.extend(coverage_failed) + renamed_trace = _rename_output_columns(judged_df, resolved_text_column=text_column) + result = AnonymizerResult( + dataframe=_build_user_dataframe( + renamed_trace, + resolved_text_column=text_column, + compute_detection_validity=evaluate_config.compute_detection_validity, + ), + trace_dataframe=renamed_trace, + resolved_text_column=text_column, + failed_records=all_failed, + rewrite_config=rewrite_config, + entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, + data_summary=data_summary, + ) + else: + logger.info(LOG_INDENT + "⚖️ Running replace judges") + stage_start = time.perf_counter() + replace_result = self._replace_runner.evaluate( + internal_df, + replace_method=replace_method, + model_configs=self._model_configs, + selected_models=self._selected_models.evaluate, + entity_labels=entity_labels, + compute_detection_validity=evaluate_config.compute_detection_validity, + data_summary=data_summary, + ) + replace_checks = [("entity coverage", COL_ENTITY_COVERAGE, None)] + if evaluate_config.compute_detection_validity: + replace_checks.append(("detection validity", COL_DETECTION_VALID, None)) + if isinstance(replace_method, Substitute): + replace_checks.extend( + [ + ("type fidelity", COL_TYPE_FIDELITY_VALID, None), + ("relational consistency", COL_RELATIONAL_CONSISTENCY_VALID, None), + ("attribute fidelity", COL_ATTRIBUTE_FIDELITY_VALID, None), + ] + ) + _log_unavailable_scores( + replace_result.dataframe, + replace_checks, + group_label="Replace evaluation", + ) + logger.info( + LOG_INDENT + "📋 Replace judges complete [%.1fs]", + time.perf_counter() - stage_start, + ) + renamed_trace = _rename_output_columns(replace_result.dataframe, resolved_text_column=text_column) + result = AnonymizerResult( + dataframe=_build_user_dataframe( + renamed_trace, + resolved_text_column=text_column, + compute_detection_validity=evaluate_config.compute_detection_validity, + ), + trace_dataframe=renamed_trace, + resolved_text_column=text_column, + failed_records=replace_result.failed_records, + replace_method=replace_method, + entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, + data_summary=data_summary, + ) + + if result.failed_records: + logger.debug("%d evaluation failed record(s).", len(result.failed_records)) + for failure in result.failed_records: + logger.debug(" %s (%s: %s)", failure.record_id, failure.step, failure.reason) + logger.info( + "🎉 Evaluation complete — %d records processed [%.1fs]", + num_records, + time.perf_counter() - evaluation_start, ) + return result def validate_config(self, config: AnonymizerConfig) -> None: """Validate that the active workflow config is compatible with model selections.""" @@ -638,6 +800,9 @@ def _run_internal_impl( failed_records=all_failures, replace_method=config.replace, rewrite_config=config.rewrite.privacy_goal if config.rewrite is not None else None, + entity_labels=config.detect.entity_labels, + strict_entity_protection=(config.rewrite.strict_entity_protection if config.rewrite is not None else False), + data_summary=data.data_summary, ) def _validate_preflight_config(self, config: AnonymizerConfig) -> None: @@ -869,11 +1034,17 @@ def _unrename_output_columns(df: pd.DataFrame, *, resolved_text_column: str) -> return df.rename(columns=rename_map) -def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column: str) -> pd.DataFrame: +def _build_user_dataframe( + trace_dataframe: pd.DataFrame, + *, + resolved_text_column: str, + compute_detection_validity: bool = False, +) -> pd.DataFrame: """Filter trace dataframe to the public column set for the active mode. Replace: {text_col}, {text_col}_replaced, {text_col}_with_spans, final_entities, - optional judge verdict columns when available + entity_coverage, leaked_entities (always after evaluate()), + detection_valid, detection_invalid_entities (only when compute_detection_validity=True) Rewrite: {text_col}, {text_col}_rewritten, utility_score, leakage_mass, weighted_leakage_rate, any_high_leaked, needs_human_review Detect-only: {text_col}, {text_col}_with_spans, final_entities @@ -890,18 +1061,20 @@ def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column COL_WEIGHTED_LEAKAGE_RATE, COL_ANY_HIGH_LEAKED, COL_NEEDS_HUMAN_REVIEW, - COL_DETECTION_VALID, # only present after evaluate() - COL_DETECTION_INVALID_ENTITIES, # only present after evaluate() COL_JUDGE_EVALUATION, # only present after evaluate() + COL_ENTITY_COVERAGE, # only present after evaluate() + COL_LEAKED_ENTITIES, # only present after evaluate() } + if compute_detection_validity: + allowed |= {COL_DETECTION_VALID, COL_DETECTION_INVALID_ENTITIES} elif f"{text_col}_replaced" in t.columns: allowed = { text_col, f"{text_col}_replaced", f"{text_col}_with_spans", COL_FINAL_ENTITIES, - COL_DETECTION_VALID, - COL_DETECTION_INVALID_ENTITIES, + COL_ENTITY_COVERAGE, + COL_LEAKED_ENTITIES, COL_TYPE_FIDELITY_VALID, COL_TYPE_FIDELITY_INVALID_REPLACEMENTS, COL_RELATIONAL_CONSISTENCY_VALID, @@ -909,6 +1082,8 @@ def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column COL_ATTRIBUTE_FIDELITY_VALID, COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, } + if compute_detection_validity: + allowed |= {COL_DETECTION_VALID, COL_DETECTION_INVALID_ENTITIES} else: allowed = { text_col, diff --git a/src/anonymizer/interface/display.py b/src/anonymizer/interface/display.py index 5cdf53ae..0ae76881 100644 --- a/src/anonymizer/interface/display.py +++ b/src/anonymizer/interface/display.py @@ -19,8 +19,10 @@ COL_DETECTION_INVALID_ENTITIES, COL_DETECTION_VALID, COL_ENTITIES_BY_VALUE, + COL_ENTITY_COVERAGE, COL_FINAL_ENTITIES, COL_JUDGE_EVALUATION, + COL_LEAKED_ENTITIES, COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, COL_RELATIONAL_CONSISTENCY_JUDGE, COL_RELATIONAL_CONSISTENCY_VALID, @@ -113,6 +115,7 @@ def _render_replace_html(row: pd.Series, *, text_col: str, record_index: int | N replaced_entities = _build_replaced_entities_from_map(replacement_map, replaced_text) replaced_html = _render_highlighted_text(replaced_text, replaced_entities) table_html = _render_replacement_table(replacement_map) + entity_coverage_html = _render_entity_coverage_section(row) detection_judge_html = _render_detection_judge_section(row) type_fidelity_section = _render_type_fidelity_section(row, replacement_map) attribute_fidelity_section = _render_attribute_fidelity_section(row) @@ -124,6 +127,7 @@ def _render_replace_html(row: pd.Series, *, text_col: str, record_index: int | N index_label=html.escape(index_label), original_html=original_html, replaced_html=replaced_html, + entity_coverage_html=entity_coverage_html, detection_judge_html=detection_judge_html, type_fidelity_section=type_fidelity_section, attribute_fidelity_section=attribute_fidelity_section, @@ -133,13 +137,14 @@ def _render_replace_html(row: pd.Series, *, text_col: str, record_index: int | N def _render_rewrite_html(row: pd.Series, *, text_col: str, record_index: int | None) -> str: - """Rewrite-mode layout: Original (highlighted), Rewritten, Scores, Entity Disposition.""" + """Rewrite-mode layout: Original (highlighted), Rewritten, Scores, Entity Coverage, Entity Disposition.""" text = str(row.get(text_col, "")) rewritten_text = str(row.get(f"{text_col}_rewritten", "")) entities = _resolve_display_entities(row) original_html = _render_highlighted_text(text, entities) rewritten_html = f"{html.escape(rewritten_text)}" scores_html = _render_scores_section(row, is_rewrite=True) + entity_coverage_html = _render_entity_coverage_section(row, is_rewrite=True) disposition_html = _render_disposition_table(row) index_label = f" (record {record_index})" if record_index is not None else "" @@ -148,6 +153,7 @@ def _render_rewrite_html(row: pd.Series, *, text_col: str, record_index: int | N original_html=original_html, rewritten_html=rewritten_html, scores_html=scores_html, + entity_coverage_html=entity_coverage_html, disposition_html=disposition_html, ) @@ -576,6 +582,74 @@ def _verdict_badge(valid: object, correct: int, total: int) -> tuple[str, str]: return badge, rate_html +def _render_entity_coverage_section(row: pd.Series, *, is_rewrite: bool = False) -> str: + """Render entity coverage. Returns "" when judge has not run. + + Rewrite mode shows a numeric score (e.g. "0.86 — 18/21 (86%)"). + Replace mode shows a Satisfied / Not Satisfied badge. + """ + if COL_ENTITY_COVERAGE not in row.index: + return "" + coverage = row.get(COL_ENTITY_COVERAGE) + leaked_entries = _normalize_invalid_entities(row.get(COL_LEAKED_ENTITIES)) + n_leaked = len(leaked_entries) + n_detected = _count_detected_entity_label_pairs(row) + total = n_detected + n_leaked + + if coverage is None: + summary_html = "Unavailable" + elif is_rewrite: + pct = round(float(coverage) * 100) + covered = total - n_leaked + summary_html = f"{float(coverage):.2f} — {covered}/{total} ({pct}%)" + elif coverage >= 1.0: + pct = 100 + summary_html = f"Satisfied — {total}/{total} ({pct}%)" + else: + covered = total - n_leaked + pct = round(float(coverage) * 100) + summary_html = f"Not Satisfied — {covered}/{total} ({pct}%)" + + header = f"
Entity Coverage: {summary_html}
" + + body = header + if leaked_entries: + rows_html: list[str] = [] + for entry in leaked_entries: + value = html.escape(str(entry.get("value", ""))) + label = html.escape(str(entry.get("label", ""))) + reasoning = html.escape(str(entry.get("reasoning", ""))) + rows_html.append( + "" + f"{value}" + f"{label}" + f"{reasoning}" + "" + ) + body += ( + "
" + f"Show {n_leaked} leaked " + "entity(ies)" + "" + "" + "" + "" + "" + "" + f"{''.join(rows_html)}" + "
ValueLabelReason
" + "
" + ) + + return ( + "
" + '
Entity Coverage
' + f"{body}" + "
" + ) + + def _render_detection_judge_section(row: pd.Series) -> str: """Render the detection-judge verdict plus a drilldown when invalid entities exist.""" if COL_DETECTION_VALID not in row.index: @@ -1108,6 +1182,7 @@ def _normalize_disposition(raw: object) -> list[dict[str, str]]: line-height:1.6;white-space:pre-wrap;padding:12px;border:1px solid currentColor;\ border-radius:6px;opacity:0.85">{replaced_html} + {entity_coverage_html} {detection_judge_html} {type_fidelity_section} {attribute_fidelity_section} @@ -1147,6 +1222,7 @@ def _normalize_disposition(raw: object) -> list[dict[str, str]]: letter-spacing:0.05em;margin-bottom:6px;opacity:0.5">Scores {scores_html} + {entity_coverage_html}
Entity Disposition
diff --git a/src/anonymizer/interface/results.py b/src/anonymizer/interface/results.py index 900653a6..60a88b19 100644 --- a/src/anonymizer/interface/results.py +++ b/src/anonymizer/interface/results.py @@ -64,6 +64,13 @@ class AnonymizerResult(_DisplayMixin): mode was used. Set by ``run()`` / ``preview()``; consumed by ``evaluate()`` to dispatch the rewrite judges. Mutually exclusive with ``replace_method``. + strict_entity_protection: Whether the rewrite ran with strict entity + protection. Set by ``run()`` / ``preview()``; consumed by + ``evaluate()`` so the entity-coverage judge scores in strict mode + (no benefit-of-the-doubt for missed quasi-identifiers). + data_summary: Optional dataset context supplied with the original input. + Preserved for ``evaluate()`` so entity-coverage judging uses the + same context as detection. """ dataframe: pd.DataFrame @@ -72,6 +79,9 @@ class AnonymizerResult(_DisplayMixin): failed_records: list[FailedRecord] replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None + entity_labels: list[str] | None = None + strict_entity_protection: bool = False + data_summary: str | None = None _display_cycle_index: int = field(default=0, init=False, repr=False) def __repr__(self) -> str: @@ -105,6 +115,13 @@ class PreviewResult(_DisplayMixin): rewrite_config: The privacy goal that produced this preview when rewrite mode was used. Set by ``preview()``; consumed by ``evaluate()`` to dispatch the rewrite judges. Mutually exclusive with ``replace_method``. + strict_entity_protection: Whether the rewrite ran with strict entity + protection. Set by ``preview()``; consumed by ``evaluate()`` so the + entity-coverage judge scores in strict mode (no benefit-of-the-doubt + for missed quasi-identifiers). + data_summary: Optional dataset context supplied with the original input. + Preserved for ``evaluate()`` so entity-coverage judging uses the + same context as detection. """ dataframe: pd.DataFrame @@ -114,6 +131,9 @@ class PreviewResult(_DisplayMixin): preview_num_records: int replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None + entity_labels: list[str] | None = None + strict_entity_protection: bool = False + data_summary: str | None = None _display_cycle_index: int = field(default=0, init=False, repr=False) def __repr__(self) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 69f2c6c2..f1179e23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,6 +133,7 @@ def stub_slim_model_selection() -> ModelSelection: repairer="known", ), evaluate=EvaluateModelSelection( + entity_coverage_judge="known", detection_validity_judge="known", replace_type_fidelity_judge="known", replace_relational_consistency_judge="known", diff --git a/tests/engine/test_entity_coverage_judge.py b/tests/engine/test_entity_coverage_judge.py new file mode 100644 index 00000000..6be3a872 --- /dev/null +++ b/tests/engine/test_entity_coverage_judge.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from anonymizer.config.models import EvaluateModelSelection +from anonymizer.engine.evaluation.entity_coverage_judge import ( + EntityCoverageWorkflow, + _coverage_prompt, + _filter_covered_leaked_entities, + _is_leaked_value_covered, + _parse_leaked_entities, +) + + +def test_coverage_prompt_omits_data_summary_context_when_summary_absent() -> None: + without_summary = _coverage_prompt(entity_labels=None, strict_entity_protection=False) + with_blank_summary = _coverage_prompt( + entity_labels=None, + strict_entity_protection=False, + data_summary=" ", + ) + + assert without_summary == with_blank_summary + assert "" not in without_summary + + +def test_coverage_prompt_includes_data_summary_as_interpretive_context() -> None: + prompt = _coverage_prompt( + entity_labels=["first_name"], + strict_entity_protection=False, + data_summary="Customer support transcripts.", + ) + + assert "\nCustomer support transcripts.\n" in prompt + assert "Use this context only to interpret literal values and their semantic types." in prompt + assert "Do not infer or invent entities that are absent from the original text." in prompt + + +def test_filter_covered_leaked_entities_removes_subspans_and_composites() -> None: + detected = [ + {"value": "Mstr Marzella", "label": "givenname"}, + {"value": "Nawabganj", "label": "city"}, + {"value": "382210", "label": "zipcode"}, + {"value": "44 Dunsfold Drive", "label": "street"}, + {"value": "Chihuahuan Desert", "label": "location"}, + {"value": "Annex Building", "label": "place_name"}, + ] + leaked = [ + {"value": "Mstr", "label": "title"}, # subspan of a single final + {"value": "Nawabganj - 382210", "label": "city"}, # composite of two whole finals + {"value": "44", "label": "buildingnum"}, # short subspan + # "Chihuahuan Desert Festival" adds the content token "festival" on top of the + # detected "Chihuahuan Desert" — a named event, so it is a real leak (NOT covered). + {"value": "Chihuahuan Desert Festival", "label": "event"}, + {"value": "m", "label": "sex"}, # short token, not covered + {"value": "Ann", "label": "first_name"}, # partial token of "Annex", not covered + {"value": "uncovered value", "label": "unique_id"}, + ] + + assert _filter_covered_leaked_entities(leaked, detected) == [ + {"value": "Chihuahuan Desert Festival", "label": "event"}, + {"value": "m", "label": "sex"}, + {"value": "Ann", "label": "first_name"}, + {"value": "uncovered value", "label": "unique_id"}, + ] + + +@pytest.mark.parametrize( + ("leaked_value", "final_values"), + [ + ("Mstr", ["Mstr Marzella"]), # subspan of a single final entity + ("the Nawabganj", ["Nawabganj"]), # grammatical stopword ignored + ("44", ["44 Dunsfold Drive"]), # short numeric subspan + ("White House", ["White House Road"]), # contiguous, in-order multi-token subspan + ("Nawabganj - 382210", ["Nawabganj", "382210"]), # composite of whole finals + ("Nawabganj", ["Nawabganj", "382210"]), # exact match against one final + ("José", ["José García"]), # accented subspan (Unicode tokenizer) + ("Zürich", ["Zürich"]), # accented exact match + ], +) +def test_is_leaked_value_covered_true(leaked_value: str, final_values: list[str]) -> None: + assert _is_leaked_value_covered(leaked_value, final_values) is True + + +@pytest.mark.parametrize( + ("leaked_value", "final_values"), + [ + # Cross-entity: pieces come from unrelated final entities -> a real, distinct leak. + ("John Smith", ["John Doe", "Jane Smith"]), + # Reverse / non-contiguous order within a SINGLE final entity: shared tokens are + # NOT enough — order and adjacency are required, so a reversed span is not covered. + ("John Doe", ["Doe John"]), + ("Ann Lee", ["Lee Ann Boulevard"]), + ("John Doe", ["Doe John Memorial Highway"]), + # Content descriptor is NOT ignored: a named event is a distinct leak. + ("Davos Summit", ["Davos"]), + ("Chihuahuan Desert Festival", ["Chihuahuan Desert"]), + # Partial-token substrings must NOT count as covered (no raw substring matching). + ("Ann", ["Annex Building"]), + ("Sara", ["Sarah Connor"]), + ("ana", ["Banana Republic"]), + # Short-token safeguard: a single letter is not covered by a longer token it prefixes. + ("m", ["Mstr Marzella"]), + # Nothing in common. + ("uncovered value", ["Mstr Marzella", "Nawabganj"]), + # No final entities -> nothing can be covered. + ("Alice", []), + ], +) +def test_is_leaked_value_covered_false(leaked_value: str, final_values: list[str]) -> None: + assert _is_leaked_value_covered(leaked_value, final_values) is False + + +def test_filter_covered_leaked_entities_keeps_cross_entity_reconstruction() -> None: + """A leak whose tokens are spread across unrelated final entities is a real leak.""" + detected = [ + {"value": "John Doe", "label": "first_name"}, + {"value": "Jane Smith", "label": "first_name"}, + ] + leaked = [{"value": "John Smith", "label": "first_name"}] + + assert _filter_covered_leaked_entities(leaked, detected) == leaked + + +def test_filter_covered_leaked_entities_passthrough_on_no_final_entities() -> None: + leaked = [{"value": "Alice", "label": "first_name"}] + + assert _filter_covered_leaked_entities(leaked, []) == leaked + assert _filter_covered_leaked_entities(leaked, None) == leaked + + +def test_parse_leaked_entities_accepts_gemma_thought_prefixed_json_fence() -> None: + raw = """thought +```json +{ + "leaked_entities": [ + { + "value": "Alice", + "label": "givenname", + "reasoning": "The given name was not detected." + } + ] +} +```""" + + assert _parse_leaked_entities(raw) == [ + { + "value": "Alice", + "label": "givenname", + "reasoning": "The given name was not detected.", + } + ] + + +def test_coverage_prompt_extracts_independently_before_deterministic_filtering() -> None: + prompt = _coverage_prompt(entity_labels=["sex", "title"], strict_entity_protection=False) + + assert "Work independently from the anonymizer" in prompt + assert "deterministic postprocessing step" in prompt + assert "" not in prompt + assert "_final_entities_for_coverage_judge" not in prompt + + +def test_coverage_prompt_requires_systematic_structured_text_scan() -> None: + prompt = _coverage_prompt(entity_labels=["sex", "title"], strict_entity_protection=False) + + assert "salutations, signatures" in prompt + assert "Tables, bullets, forms" in prompt + assert "Short or single-token values" in prompt + assert "Honorifics attached to person names" in prompt + + +def _stub_evaluate_selection() -> EvaluateModelSelection: + return EvaluateModelSelection( + entity_coverage_judge="nemotron-super", + detection_validity_judge="gpt-oss-120b", + replace_type_fidelity_judge="gpt-oss-120b", + replace_relational_consistency_judge="gpt-oss-120b", + replace_attribute_fidelity_judge="gpt-oss-120b", + rewrite_judge="nemotron-30b-thinking", + ) + + +def test_column_config_builds_prompt_and_resolves_model() -> None: + """Smoke-guard the real prompt-build path: a broken ``_coverage_prompt`` signature + (e.g. a stray required parameter) must fail loudly here, since ``run_non_critical`` + swallows exceptions downstream and would otherwise mask it as ``entity_coverage=None``. + """ + workflow = EntityCoverageWorkflow( + adapter=Mock(), + entity_labels=["sex", "title"], + strict_entity_protection=True, + data_summary="Customer support transcripts.", + ) + + config = workflow.column_config(_stub_evaluate_selection()) + + assert config.model_alias == "nemotron-super" + # Prompt built without error and carries the instance-specific context. + assert isinstance(config.prompt, str) and config.prompt + assert "Customer support transcripts." in config.prompt # data_summary threaded in + assert "sex, title" in config.prompt # entity_labels scope threaded in diff --git a/tests/engine/test_replace_runner.py b/tests/engine/test_replace_runner.py index adc4d970..27ca62b8 100644 --- a/tests/engine/test_replace_runner.py +++ b/tests/engine/test_replace_runner.py @@ -15,9 +15,10 @@ from anonymizer.engine.constants import ( COL_ATTRIBUTE_FIDELITY_JUDGE, COL_ATTRIBUTE_FIDELITY_VALID, - COL_DETECTION_JUDGE, COL_DETECTION_VALID, COL_ENTITIES_BY_VALUE, + COL_ENTITY_COVERAGE, + COL_ENTITY_COVERAGE_JUDGE, COL_FINAL_ENTITIES, COL_RELATIONAL_CONSISTENCY_JUDGE, COL_RELATIONAL_CONSISTENCY_VALID, @@ -167,7 +168,7 @@ def test_evaluate_uses_merged_dd_workflow_for_judges( ) judge_defaults = { - COL_DETECTION_JUDGE: {"all_valid": True, "invalid_entities": []}, + COL_ENTITY_COVERAGE_JUDGE: {"leaked_entities": []}, COL_TYPE_FIDELITY_JUDGE: {"all_valid": True, "invalid_replacements": []}, COL_RELATIONAL_CONSISTENCY_JUDGE: {"all_consistent": True, "relations": []}, COL_ATTRIBUTE_FIDELITY_JUDGE: {"all_valid": True, "entities": []}, @@ -210,9 +211,11 @@ def fake_attach_ids(df: pd.DataFrame) -> pd.DataFrame: call_columns = adapter.run_workflow.call_args.kwargs["columns"] assert {c.name for c in call_columns} == set(judge_defaults) - # And each judge's VALID column ended up on the result, with True (default payload above). + # And each judge's output column ended up on the result. + # Entity coverage is a float (1.0 = full coverage); replace judges use bool True. + assert COL_ENTITY_COVERAGE in result.dataframe.columns + assert result.dataframe[COL_ENTITY_COVERAGE].iloc[0] == 1.0 for col in ( - COL_DETECTION_VALID, COL_TYPE_FIDELITY_VALID, COL_RELATIONAL_CONSISTENCY_VALID, COL_ATTRIBUTE_FIDELITY_VALID, @@ -221,6 +224,55 @@ def fake_attach_ids(df: pd.DataFrame) -> pd.DataFrame: assert bool(result.dataframe[col].iloc[0]) is True +def test_evaluate_threads_entity_labels_and_data_summary_into_coverage_prompt( + stub_model_configs: list[ModelConfig], + stub_evaluate_model_selection: EvaluateModelSelection, +) -> None: + """Replace-mode ``evaluate()`` must forward ``entity_labels`` and ``data_summary`` + all the way into the coverage judge's prompt (the same context the rewrite path + supplies), so the judge scopes and interprets leaks against the run's taxonomy. + """ + saved_trace = pd.DataFrame( + { + COL_TEXT: ["Alice works at Acme"], + COL_FINAL_ENTITIES: [{"entities": []}], + COL_REPLACED_TEXT: ["Maya works at NovaCorp"], + COL_REPLACEMENT_MAP: [{"replacements": []}], + COL_ENTITIES_BY_VALUE: [{"entities_by_value": []}], + } + ) + + def fake_run_workflow(df: pd.DataFrame, *, columns, **_: object) -> WorkflowRunResult: + out = df.copy() + for column in columns: + out[column.name] = [{"leaked_entities": []}] * len(out) + return WorkflowRunResult(dataframe=out, failed_records=[]) + + def fake_attach_ids(df: pd.DataFrame) -> pd.DataFrame: + out = df.copy() + out[RECORD_ID_COLUMN] = [f"id-{i}" for i in range(len(out))] + return out + + adapter = Mock() + adapter.run_workflow.side_effect = fake_run_workflow + adapter._attach_record_ids.side_effect = fake_attach_ids + + runner = ReplacementWorkflow(adapter=adapter) + runner.evaluate( + saved_trace, + replace_method=Redact(), + model_configs=stub_model_configs, + selected_models=stub_evaluate_model_selection, + entity_labels=["first_name", "organization"], + data_summary="Employee HR records.", + ) + + call_columns = adapter.run_workflow.call_args.kwargs["columns"] + coverage_col = next(c for c in call_columns if c.name == COL_ENTITY_COVERAGE_JUDGE) + assert "first_name, organization" in coverage_col.prompt + assert "Employee HR records." in coverage_col.prompt + + def test_evaluate_preserves_all_rows_when_llm_drops_some( stub_model_configs: list[ModelConfig], stub_evaluate_model_selection: EvaluateModelSelection, @@ -256,7 +308,7 @@ def test_evaluate_preserves_all_rows_when_llm_drops_some( ) judge_payload = { - COL_DETECTION_JUDGE: {"all_valid": True, "invalid_entities": []}, + COL_ENTITY_COVERAGE_JUDGE: {"leaked_entities": []}, COL_TYPE_FIDELITY_JUDGE: {"all_valid": True, "invalid_replacements": []}, COL_RELATIONAL_CONSISTENCY_JUDGE: {"all_consistent": True, "relations": []}, COL_ATTRIBUTE_FIDELITY_JUDGE: {"all_valid": True, "entities": []}, @@ -298,10 +350,12 @@ def fake_run_workflow(df: pd.DataFrame, *, columns, **_: object) -> WorkflowRunR # Row count is preserved end-to-end. assert len(result.dataframe) == 2 - # First row got a real verdict. - assert bool(result.dataframe[COL_DETECTION_VALID].iloc[0]) is True + # First row got a real verdict (entity_coverage is a float, replace judges are bool). + assert result.dataframe[COL_ENTITY_COVERAGE].iloc[0] == 1.0 + assert bool(result.dataframe[COL_TYPE_FIDELITY_VALID].iloc[0]) is True # Second row (LLM-dropped) is surfaced as Unavailable, not dropped. - assert result.dataframe[COL_DETECTION_VALID].iloc[1] is None + # Float column: None becomes NaN in pandas, so use pd.isna instead of `is None`. + assert pd.isna(result.dataframe[COL_ENTITY_COVERAGE].iloc[1]) assert result.dataframe[COL_TYPE_FIDELITY_VALID].iloc[1] is None assert result.dataframe[COL_RELATIONAL_CONSISTENCY_VALID].iloc[1] is None assert result.dataframe[COL_ATTRIBUTE_FIDELITY_VALID].iloc[1] is None diff --git a/tests/engine/test_rewrite_workflow.py b/tests/engine/test_rewrite_workflow.py index 9d85f676..6e7a824c 100644 --- a/tests/engine/test_rewrite_workflow.py +++ b/tests/engine/test_rewrite_workflow.py @@ -438,6 +438,7 @@ def test_detection_judge_partial_row_loss_preserves_all_rows( model_configs=stub_model_configs, selected_models=stub_evaluate_model_selection, privacy_goal=_PRIVACY_GOAL, + compute_detection_validity=True, ) assert len(result.dataframe) == 2 @@ -939,6 +940,7 @@ def test_evaluate_produces_detection_valid_column( model_configs=stub_model_configs, selected_models=stub_evaluate_model_selection, privacy_goal=_PRIVACY_GOAL, + compute_detection_validity=True, ) assert COL_DETECTION_VALID in result.dataframe.columns @@ -976,6 +978,7 @@ def test_evaluate_skips_passthrough_rows( model_configs=stub_model_configs, selected_models=stub_evaluate_model_selection, privacy_goal=_PRIVACY_GOAL, + compute_detection_validity=True, ) detection_call_df = wf._detection_judge_wf.evaluate.call_args.args[0] @@ -1010,6 +1013,7 @@ def test_evaluate_passthrough_rows_get_none_judge_defaults( model_configs=stub_model_configs, selected_models=stub_evaluate_model_selection, privacy_goal=_PRIVACY_GOAL, + compute_detection_validity=True, ) passthrough_result = result.dataframe[ diff --git a/tests/interface/test_anonymizer_interface.py b/tests/interface/test_anonymizer_interface.py index f307a609..d5cbd809 100644 --- a/tests/interface/test_anonymizer_interface.py +++ b/tests/interface/test_anonymizer_interface.py @@ -12,12 +12,13 @@ from data_designer.config.models import ModelConfig from anonymizer import RunConfig -from anonymizer.config.anonymizer_config import AnonymizerConfig, AnonymizerInput, Rewrite +from anonymizer.config.anonymizer_config import AnonymizerConfig, AnonymizerInput, EvaluateConfig, Rewrite from anonymizer.config.models import ModelSelection, ReplaceModelSelection from anonymizer.config.replace_strategies import Redact, Substitute from anonymizer.engine.constants import ( COL_DETECTED_ENTITIES, COL_DETECTION_VALID, + COL_ENTITIES_BY_VALUE, COL_FINAL_ENTITIES, COL_JUDGE_EVALUATION, COL_REPLACED_TEXT, @@ -896,15 +897,50 @@ def test_evaluate_rewrite_result_adds_detection_valid(stub_input: AnonymizerInpu "needs_human_review": [False], COL_JUDGE_EVALUATION: [None], COL_DETECTION_VALID: [0.9], + COL_ENTITIES_BY_VALUE: [{}], } ) rewrite_runner.evaluate.return_value = RewriteResult(dataframe=eval_df, failed_records=[]) - evaluated = anonymizer.evaluate(run_result) + evaluated = anonymizer.evaluate(run_result, config=EvaluateConfig(compute_detection_validity=True)) assert COL_DETECTION_VALID in evaluated.dataframe.columns +def test_evaluate_config_detection_validity_defaults_off() -> None: + """Goal 2: detection-validity scoring is opt-in — the config default must be False + so a plain ``EvaluateConfig()`` never triggers the extra detection judge.""" + assert EvaluateConfig().compute_detection_validity is False + + +def test_evaluate_defaults_skip_detection_validity_in_runner_call(stub_input: AnonymizerInput) -> None: + """With the default ``EvaluateConfig()``, evaluate() must forward + ``compute_detection_validity=False`` to the rewrite runner (the judge is not run).""" + config = AnonymizerConfig(rewrite=Rewrite()) + anonymizer, _, _, rewrite_runner = _make_anonymizer() + + run_result = anonymizer.run(config=config, data=stub_input) + + eval_df = pd.DataFrame( + { + COL_TEXT: ["Alice works at Acme"], + COL_REWRITTEN_TEXT: ["Beth works at Globex"], + "utility_score": [0.85], + "leakage_mass": [0.3], + "weighted_leakage_rate": [0.23], + "any_high_leaked": [False], + "needs_human_review": [False], + COL_JUDGE_EVALUATION: [None], + COL_ENTITIES_BY_VALUE: [{}], + } + ) + rewrite_runner.evaluate.return_value = RewriteResult(dataframe=eval_df, failed_records=[]) + + anonymizer.evaluate(run_result, config=EvaluateConfig()) + + assert rewrite_runner.evaluate.call_args.kwargs["compute_detection_validity"] is False + + def test_evaluate_rewrite_raises_without_rewrite_config() -> None: """evaluate() must raise ValueError when result has no rewrite_config and no replace_method.""" anonymizer, _, _, _ = _make_anonymizer() @@ -990,3 +1026,107 @@ def test_evaluate_rewrite_calls_validate_with_check_rewrite_false(stub_input: An "evaluate() on a rewrite result must pass check_rewrite=False to avoid " "requiring rewrite pipeline model aliases that are unused during evaluation" ) + + +# --------------------------------------------------------------------------- +# Tests: strict_entity_protection flows config -> result -> coverage judge +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("strict", [True, False]) +def test_run_persists_strict_entity_protection(stub_input: AnonymizerInput, strict: bool) -> None: + """run() must copy config.rewrite.strict_entity_protection onto the result.""" + config = AnonymizerConfig(rewrite=Rewrite(strict_entity_protection=strict)) + anonymizer, _, _, _ = _make_anonymizer() + + result = anonymizer.run(config=config, data=stub_input) + + assert result.strict_entity_protection is strict + + +@pytest.mark.parametrize("strict", [True, False]) +def test_preview_persists_strict_entity_protection(stub_input: AnonymizerInput, strict: bool) -> None: + """preview() must copy config.rewrite.strict_entity_protection onto the PreviewResult.""" + config = AnonymizerConfig(rewrite=Rewrite(strict_entity_protection=strict)) + anonymizer, _, _, _ = _make_anonymizer() + + preview = anonymizer.preview(config=config, data=stub_input, num_records=1) + + assert preview.strict_entity_protection is strict + + +def test_evaluate_passes_strict_entity_protection_to_coverage_judge(stub_input: AnonymizerInput) -> None: + """evaluate() must forward the result's strict flag into EntityCoverageWorkflow. + + Regression: strict_entity_protection was dropped after run()/preview(), so the + entity-coverage judge always scored in non-strict mode regardless of config. + """ + config = AnonymizerConfig(rewrite=Rewrite(strict_entity_protection=True)) + anonymizer, _, _, rewrite_runner = _make_anonymizer() + + run_result = anonymizer.run(config=config, data=stub_input) + + eval_df = pd.DataFrame( + { + COL_TEXT: ["Alice works at Acme"], + COL_REWRITTEN_TEXT: ["Beth works at Globex"], + "utility_score": [0.85], + "leakage_mass": [0.3], + "weighted_leakage_rate": [0.23], + "any_high_leaked": [False], + "needs_human_review": [False], + COL_JUDGE_EVALUATION: [None], + COL_DETECTION_VALID: [1.0], + } + ) + rewrite_runner.evaluate.return_value = RewriteResult(dataframe=eval_df, failed_records=[]) + + with patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as mock_coverage_wf: + mock_coverage_wf.return_value.run_non_critical.return_value = (eval_df, []) + anonymizer.evaluate(run_result) + + assert mock_coverage_wf.call_args is not None, "EntityCoverageWorkflow was not constructed" + assert mock_coverage_wf.call_args.kwargs["strict_entity_protection"] is True + + +def test_run_and_preview_persist_data_summary(stub_input: AnonymizerInput) -> None: + """run()/preview() must preserve input context for later evaluation.""" + data = stub_input.model_copy(update={"data_summary": "Customer support transcripts."}) + config = AnonymizerConfig(rewrite=Rewrite()) + anonymizer, _, _, _ = _make_anonymizer() + + result = anonymizer.run(config=config, data=data) + preview = anonymizer.preview(config=config, data=data, num_records=1) + + assert result.data_summary == "Customer support transcripts." + assert preview.data_summary == "Customer support transcripts." + + +def test_evaluate_passes_data_summary_to_coverage_judge(stub_input: AnonymizerInput) -> None: + """evaluate() must forward the input summary to EntityCoverageWorkflow.""" + data = stub_input.model_copy(update={"data_summary": "Customer support transcripts."}) + config = AnonymizerConfig(rewrite=Rewrite()) + anonymizer, _, _, rewrite_runner = _make_anonymizer() + run_result = anonymizer.run(config=config, data=data) + + eval_df = pd.DataFrame( + { + COL_TEXT: ["Alice works at Acme"], + COL_REWRITTEN_TEXT: ["Beth works at Globex"], + "utility_score": [0.85], + "leakage_mass": [0.3], + "weighted_leakage_rate": [0.23], + "any_high_leaked": [False], + "needs_human_review": [False], + COL_JUDGE_EVALUATION: [None], + COL_DETECTION_VALID: [1.0], + } + ) + rewrite_runner.evaluate.return_value = RewriteResult(dataframe=eval_df, failed_records=[]) + + with patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as mock_coverage_wf: + mock_coverage_wf.return_value.run_non_critical.return_value = (eval_df, []) + evaluated = anonymizer.evaluate(run_result) + + assert mock_coverage_wf.call_args.kwargs["data_summary"] == "Customer support transcripts." + assert evaluated.data_summary == "Customer support transcripts." diff --git a/tests/interface/test_anonymizer_logging.py b/tests/interface/test_anonymizer_logging.py index 26a52b21..3aaa3edb 100644 --- a/tests/interface/test_anonymizer_logging.py +++ b/tests/interface/test_anonymizer_logging.py @@ -6,22 +6,30 @@ import logging import re from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch import pandas as pd import pytest -from anonymizer.config.anonymizer_config import AnonymizerConfig, AnonymizerInput, Rewrite -from anonymizer.config.replace_strategies import Redact +from anonymizer.config.anonymizer_config import AnonymizerConfig, AnonymizerInput, EvaluateConfig, Rewrite +from anonymizer.config.replace_strategies import Redact, Substitute from anonymizer.engine.constants import ( + COL_ATTRIBUTE_FIDELITY_VALID, COL_DETECTED_ENTITIES, + COL_DETECTION_VALID, + COL_ENTITIES_BY_VALUE, + COL_ENTITY_COVERAGE, COL_FINAL_ENTITIES, + COL_JUDGE_EVALUATION, + COL_RELATIONAL_CONSISTENCY_VALID, COL_REPLACED_TEXT, COL_REWRITTEN_TEXT, COL_TEXT, + COL_TYPE_FIDELITY_VALID, ) from anonymizer.engine.detection.detection_workflow import EntityDetectionResult, EntityDetectionWorkflow -from anonymizer.engine.ndd.adapter import FailedRecord +from anonymizer.engine.evaluation.entity_coverage_judge import EntityCoverageWorkflow +from anonymizer.engine.ndd.adapter import RECORD_ID_COLUMN, FailedRecord, NddAdapter from anonymizer.engine.replace.replace_runner import ReplacementResult, ReplacementWorkflow from anonymizer.engine.rewrite.rewrite_workflow import RewriteResult, RewriteWorkflow from anonymizer.interface.anonymizer import Anonymizer @@ -64,6 +72,9 @@ def _make_logging_anonymizer( ) replace_runner = Mock(spec=ReplacementWorkflow) replace_runner.run.return_value = ReplacementResult(dataframe=_replace_df, failed_records=replace_failures or []) + _replace_eval_df = _replace_df.copy() + _replace_eval_df[COL_ENTITY_COVERAGE] = [1.0, 1.0] + replace_runner.evaluate.return_value = ReplacementResult(dataframe=_replace_eval_df, failed_records=[]) _rewrite_df = pd.DataFrame( { COL_TEXT: ["Alice works at Acme", "Bob likes cats"], @@ -76,6 +87,14 @@ def _make_logging_anonymizer( ) rewrite_runner = Mock(spec=RewriteWorkflow) rewrite_runner.run.return_value = RewriteResult(dataframe=_rewrite_df, failed_records=[]) + _rewrite_eval_df = _rewrite_df.copy() + _rewrite_eval_df[COL_ENTITIES_BY_VALUE] = [ + {"entities_by_value": [{"value": "Alice", "labels": ["first_name"]}]}, + {"entities_by_value": [{"value": "Bob", "labels": ["first_name"]}]}, + ] + _rewrite_eval_df[COL_JUDGE_EVALUATION] = [{"privacy": {"score": "high"}}] * 2 + _rewrite_eval_df[COL_ENTITY_COVERAGE] = [1.0, 1.0] + rewrite_runner.evaluate.return_value = RewriteResult(dataframe=_rewrite_eval_df, failed_records=[]) return Anonymizer( detection_workflow=detection_workflow, replace_runner=replace_runner, @@ -154,6 +173,231 @@ def test_preview_logs_preview_mode(stub_input: AnonymizerInput, caplog: pytest.L assert "👀 Preview mode: 📂 Loaded 2 records" in messages +def test_evaluate_replace_logs_stages(stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(replace=Redact()), data=stub_input) + caplog.clear() + + with caplog.at_level(logging.INFO, logger="anonymizer"): + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "🧪 Running Redact evaluation on 2 records" in messages + assert "⚖️ Running replace judges" in messages + assert "📋 Replace judges complete" in messages + assert re.search(r"Replace judges complete.*\[\d+\.\ds\]", messages), "Replace evaluation timing not found" + assert "🎉 Evaluation complete — 2 records processed" in messages + assert re.search(r"Evaluation complete.*\[\d+\.\ds\]", messages), "Total evaluation timing not found" + + +def test_evaluate_rewrite_logs_stages(stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(rewrite=Rewrite()), data=stub_input) + caplog.clear() + + with ( + patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as coverage_workflow, + caplog.at_level(logging.INFO, logger="anonymizer"), + ): + coverage_workflow.return_value.run_non_critical.return_value = ( + anonymizer._rewrite_runner.evaluate.return_value.dataframe, + [], + ) + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "🧪 Running rewrite evaluation on 2 records" in messages + assert "⚖️ Running rewrite judges" in messages + assert "📋 Rewrite judges complete" in messages + assert re.search(r"Rewrite judges complete.*\[\d+\.\ds\]", messages), "Rewrite evaluation timing not found" + assert "🔎 Running entity coverage" in messages + assert "📋 Entity coverage complete" in messages + assert re.search(r"Entity coverage complete.*\[\d+\.\ds\]", messages), "Coverage timing not found" + assert "🎉 Evaluation complete — 2 records processed" in messages + + +def test_evaluate_rewrite_logs_unavailable_entity_coverage( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(rewrite=Rewrite()), data=stub_input) + coverage_df = anonymizer._rewrite_runner.evaluate.return_value.dataframe.copy() + coverage_df[COL_ENTITY_COVERAGE] = None + caplog.clear() + + with ( + patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as coverage_workflow, + caplog.at_level(logging.INFO, logger="anonymizer"), + ): + coverage_workflow.return_value.run_non_critical.return_value = (coverage_df, []) + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "Entity coverage score unavailable for 2/2 records" in messages + assert "📋 Entity coverage complete" in messages + assert "🎉 Evaluation complete — 2 records processed" in messages + + +def test_evaluate_debug_logs_config_and_failures_without_sensitive_context( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(replace=Redact()), data=stub_input) + run_result.data_summary = "confidential dataset description" + anonymizer._replace_runner.evaluate.return_value = ReplacementResult( + dataframe=anonymizer._replace_runner.evaluate.return_value.dataframe, + failed_records=[FailedRecord(record_id="r1", step="entity-coverage-judge", reason="timeout")], + ) + caplog.clear() + + with caplog.at_level(logging.DEBUG, logger="anonymizer"): + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "evaluation config: mode=Redact" in messages + assert "data_summary_provided=True" in messages + assert "active evaluation judges: entity_coverage_judge" in messages + assert "evaluation models:" in messages + assert "1 evaluation failed record(s)" in messages + assert "r1 (entity-coverage-judge: timeout)" in messages + assert "confidential dataset description" not in messages + + +def test_evaluate_logs_unavailable_scores_for_all_active_replace_judges( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(replace=Substitute()), data=stub_input) + unavailable_df = anonymizer._replace_runner.evaluate.return_value.dataframe.copy() + for column in ( + COL_ENTITY_COVERAGE, + COL_DETECTION_VALID, + COL_TYPE_FIDELITY_VALID, + COL_RELATIONAL_CONSISTENCY_VALID, + COL_ATTRIBUTE_FIDELITY_VALID, + ): + unavailable_df[column] = None + anonymizer._replace_runner.evaluate.return_value = ReplacementResult( + dataframe=unavailable_df, + failed_records=[], + ) + caplog.clear() + + with caplog.at_level(logging.INFO, logger="anonymizer"): + anonymizer.evaluate(run_result, config=EvaluateConfig(compute_detection_validity=True)) + + messages = caplog.text + assert ( + "Replace evaluation scores unavailable — entity coverage: 2/2; detection validity: 2/2; " + "type fidelity: 2/2; relational consistency: 2/2; attribute fidelity: 2/2." + ) in messages + assert "📋 Replace judges complete" in messages + assert "🎉 Evaluation complete — 2 records processed" in messages + + +def test_evaluate_rewrite_warns_when_rewrite_judge_is_unavailable_but_coverage_succeeds( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(rewrite=Rewrite()), data=stub_input) + evaluation_df = anonymizer._rewrite_runner.evaluate.return_value.dataframe.copy() + evaluation_df[COL_JUDGE_EVALUATION] = [{"privacy": {"score": "high"}}, None] + anonymizer._rewrite_runner.evaluate.return_value = RewriteResult( + dataframe=evaluation_df, + failed_records=[FailedRecord(record_id="r2", step="rewrite-final-judge", reason="timeout")], + ) + caplog.clear() + + with ( + patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as coverage_workflow, + caplog.at_level(logging.INFO, logger="anonymizer"), + ): + coverage_workflow.return_value.run_non_critical.return_value = (evaluation_df, []) + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "Rewrite judge score unavailable for 1/2 records" in messages + assert "Entity coverage score unavailable" not in messages + + +def test_evaluate_rewrite_does_not_warn_for_expected_no_entity_passthrough( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(rewrite=Rewrite()), data=stub_input) + evaluation_df = anonymizer._rewrite_runner.evaluate.return_value.dataframe.copy() + evaluation_df[COL_ENTITIES_BY_VALUE] = [ + {"entities_by_value": []}, + {"entities_by_value": [{"value": "Bob", "labels": ["first_name"]}]}, + ] + evaluation_df[COL_JUDGE_EVALUATION] = [None, {"privacy": {"score": "high"}}] + anonymizer._rewrite_runner.evaluate.return_value = RewriteResult(dataframe=evaluation_df, failed_records=[]) + caplog.clear() + + with ( + patch("anonymizer.interface.anonymizer.EntityCoverageWorkflow") as coverage_workflow, + caplog.at_level(logging.INFO, logger="anonymizer"), + ): + coverage_workflow.return_value.run_non_critical.return_value = (evaluation_df, []) + anonymizer.evaluate(run_result) + + assert "Rewrite judge score unavailable" not in caplog.text + + +def test_evaluate_substitute_warns_only_for_the_unavailable_judge( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + anonymizer = _make_logging_anonymizer() + run_result = anonymizer.run(config=AnonymizerConfig(replace=Substitute()), data=stub_input) + evaluation_df = anonymizer._replace_runner.evaluate.return_value.dataframe.copy() + evaluation_df[COL_TYPE_FIDELITY_VALID] = [True, None] + evaluation_df[COL_RELATIONAL_CONSISTENCY_VALID] = [True, True] + evaluation_df[COL_ATTRIBUTE_FIDELITY_VALID] = [True, True] + anonymizer._replace_runner.evaluate.return_value = ReplacementResult(dataframe=evaluation_df, failed_records=[]) + caplog.clear() + + with caplog.at_level(logging.INFO, logger="anonymizer"): + anonymizer.evaluate(run_result) + + messages = caplog.text + assert "Type fidelity score unavailable for 1/2 records" in messages + assert "Replace evaluation scores unavailable" not in messages + assert "Entity coverage score unavailable" not in messages + + +def test_evaluate_caught_replace_workflow_exception_logs_debug_and_public_warning( + stub_input: AnonymizerInput, caplog: pytest.LogCaptureFixture +) -> None: + setup_anonymizer = _make_logging_anonymizer() + run_result = setup_anonymizer.run(config=AnonymizerConfig(replace=Redact()), data=stub_input) + run_result.trace_dataframe[COL_ENTITIES_BY_VALUE] = [ + {"entities_by_value": [{"value": "Alice", "labels": ["first_name"]}]}, + {"entities_by_value": [{"value": "Bob", "labels": ["first_name"]}]}, + ] + + adapter = Mock(spec=NddAdapter) + adapter._attach_record_ids.side_effect = lambda dataframe: dataframe.assign( + **{RECORD_ID_COLUMN: [f"r{i}" for i in range(len(dataframe))]} + ) + adapter.run_workflow.side_effect = RuntimeError("simulated workflow outage") + anonymizer = Anonymizer( + data_designer=Mock(), + replace_runner=ReplacementWorkflow(adapter=adapter), + ) + caplog.clear() + + with ( + patch.object(EntityCoverageWorkflow, "column_config", return_value=Mock()), + caplog.at_level(logging.DEBUG, logger="anonymizer"), + ): + evaluated = anonymizer.evaluate(run_result) + + messages = caplog.text + assert "Replace judges workflow failed; evaluation scores may be unavailable." in messages + assert "Entity coverage score unavailable for 2/2 records" in messages + assert evaluated.trace_dataframe[COL_ENTITY_COVERAGE].isna().all() + + def test_preview_set_preview_num_records_capped(stub_input: AnonymizerInput) -> None: """preview() must propagate preview_num_records so downstream workflows use DataDesigner.preview().""" anonymizer = _make_logging_anonymizer()