From c079048ad2f56f8c9808ac9a1e356b16cd54e180 Mon Sep 17 00:00:00 2001 From: memadi Date: Tue, 23 Jun 2026 17:59:51 -0700 Subject: [PATCH 01/23] add a plan for replacing detection validity score Signed-off-by: memadi --- plan/plan.md | 300 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 plan/plan.md diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 00000000..2fe13bc6 --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,300 @@ +# Entity Coverage — Implementation Plan + +## Problem + +Currently `detection_valid` scores **precision** of the detection step. Observed scores are predominantly in the lower ranges (issue #176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** + +Precision complaints are already surfaced in the entity dropdown for manual inspection, making `detection_valid` feel redundant as a headline metric. What's missing is a **recall** signal: a score that answers "of all the PII that existed, how much did we successfully catch?" + +--- + +## Proposed Solution: Entity Coverage + +Replace `detection_valid` with `entity_coverage`: + +``` +entity_coverage = n_entities / (n_entities + n_leaked) +``` + +- **`n_entities`** — entities detected by Anonymizer before anonymization (already available in `COL_ENTITIES_BY_VALUE`) +- **`n_leaked`** — the number of missed identifiers from the original text, detected by the evaluation LLM in the anonymized / rewritten output +- **`leaked_entities`** — the list of missed entities (shown in a dropdown when coverage < 100%) + +The evaluation LLM runs on the **original text** and detects all PII that should have been caught. The delta between what it finds and what Anonymizer already detected gives the leaked entities. + +**Key shift:** current judge asks "were our detections correct?" (precision). New judge asks "did any PII survive?" (recall). + +--- + +## Design Decisions + +### Coverage display differs by mode + +| Mode | Display | Rationale | +|---|---|---| +| **Replace** | Satisfied / Not Satisfied | Binary is sufficient — either all PII was caught or it wasn't | +| **Rewrite** | Float fraction (e.g. `0.87`) | Rewrite already exposes numeric metrics (leakage_mass, utility_score); a fraction fits the pattern | + +A value of `1.0` (or Satisfied) means no PII leaked into the output. Any value below `1.0` (or Not Satisfied) means at least one entity was missed, and `leaked_entities` names them. + +### Passthrough rows + +Rows where Anonymizer detected **zero entities** trivially score `entity_coverage = 1.0` — there was nothing to protect, so nothing could have leaked. The LLM judge is skipped for these rows (same pattern as the current passthrough shortcut in `_BaseJudgeWorkflow`). + +### Prompt starting point + +The entity judge prompt is based on the leakage eval script from the experiments repo. It will be refined as it is tested against real examples. The core task: given the original text, identify all PII — then report any that Anonymizer missed. + +### Reuse `_BaseJudgeWorkflow` + +The new `EntityCoverageWorkflow` extends `_BaseJudgeWorkflow`. The partition-LLM-merge skeleton is identical; only the schema, prompt, and post-process step differ (coverage fraction computed from counts, not a boolean verdict field). `postprocess()` is overridden in the subclass — the base class stays unchanged. + +--- + +## Scope + +- Replaces `detection_valid` and `detection_invalid_entities` in both replace and rewrite `evaluate()` output. +- No new public API symbols beyond new column names (`entity_coverage`, `leaked_entities`) and a renamed model role. +- `COL_DETECTION_VALID` / `COL_DETECTION_INVALID_ENTITIES` constants are retired; new constants added. +- `DetectionJudgeWorkflow` is replaced by `EntityCoverageWorkflow` — the old class is removed, not kept as an alias. + +--- + +## New Column Names + +| Constant | Value | Type | Description | +|---|---|---|---| +| `COL_ENTITY_COVERAGE` | `"entity_coverage"` | `float \| None` | Fraction of entities successfully anonymized; `None` if judge unavailable | +| `COL_LEAKED_ENTITIES` | `"leaked_entities"` | `list` | Each missed entity with `value`, `label`, `reasoning` | +| `COL_ENTITY_COVERAGE_JUDGE` | `"_entity_coverage_judge"` | internal | Raw judge output; internal only | + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/anonymizer/engine/constants.py` | Retire `COL_DETECTION_JUDGE`, `COL_DETECTION_VALID`, `COL_DETECTION_INVALID_ENTITIES`; add `COL_ENTITY_COVERAGE_JUDGE`, `COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES` | +| `src/anonymizer/engine/evaluation/detection_judge.py` | Replace entirely with `entity_coverage_judge.py` — new schema, new prompt, new workflow class | +| `src/anonymizer/engine/evaluation/judge_base.py` | No changes needed — `postprocess()` is overridden in the subclass | +| `src/anonymizer/engine/rewrite/rewrite_workflow.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow` in `evaluate()`; remove `_detection_valid_fraction()`; update column references | +| `src/anonymizer/engine/replace/replace_runner.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow`; update column references | +| `src/anonymizer/interface/anonymizer.py` | Update `_build_user_dataframe` allowed columns for both modes; update model role references | +| `src/anonymizer/interface/display.py` | Render `entity_coverage` as Satisfied/Not Satisfied (replace) or fraction (rewrite); update leaked_entities dropdown | +| `src/anonymizer/config/models.py` | Rename `detection_validity_judge` → `entity_coverage_judge` in `EvaluateModelSelection` | +| `src/anonymizer/config/default_model_configs/evaluate.yaml` | Rename the model role key | +| `docs/concepts/evaluation.md` | Replace Entity Detection Judge section with Entity Coverage section in both replace and rewrite docs | +| `skills/anonymizer/SKILL.md` | Update evaluation tips and output template column references | +| `tests/engine/evaluation/test_detection_judge.py` | Replace with `test_entity_coverage_judge.py` — new schema, prompt, and workflow tests | +| `tests/engine/test_rewrite_workflow.py` | Update column name references; remove `_detection_valid_fraction` tests | +| `tests/engine/test_evaluate.py` | Update column name assertions | +| `tests/interface/test_display.py` | Update rendering assertions for entity coverage | + +--- + +## Step 1 — New constants (`constants.py`) + +Retire the three detection judge constants and add three replacements: + +```python +# Retired (remove): +# COL_DETECTION_JUDGE = "_detection_judge" +# COL_DETECTION_VALID = "detection_valid" +# COL_DETECTION_INVALID_ENTITIES = "detection_invalid_entities" + +# New: +COL_ENTITY_COVERAGE_JUDGE = "_entity_coverage_judge" # internal raw output +COL_ENTITY_COVERAGE = "entity_coverage" # user-facing float | None +COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list +``` + +--- + +## Step 2 — New workflow (`entity_coverage_judge.py`) + +Replace `detection_judge.py` with a new file. Key differences from the old judge: + +### Output schema + +```python +class LeakedEntity(BaseModel): + value: str + label: str + reasoning: str # one sentence: why this is PII that survived anonymization + +class EntityCoverageSchema(BaseModel): + leaked_entities: list[LeakedEntity] + # No all_valid field — coverage is computed numerically from counts +``` + +### Prompt + +Runs against the **original text** (`COL_TEXT`), with the Anonymizer-detected entity list injected as context. Starting from the leakage eval script prompt and refined through testing. + +Core task phrasing: +- "Given the original text, identify all PII present. Report any identifiers that were not already caught by Anonymizer — these are the missed entities." +- Returns an empty list when Anonymizer caught everything. + +### Passthrough condition + +Rows with no detected entities trivially score `entity_coverage = 1.0` — nothing to miss, LLM skipped. + +### Coverage fraction (postprocess override) + +```python +def _compute_coverage(self, n_entities: int, n_leaked: int) -> float: + total = n_entities + n_leaked + return 1.0 if total == 0 else n_entities / total +``` + +Passthrough rows get `entity_coverage = 1.0` and `leaked_entities = []` without calling the LLM. + +### Class declaration + +```python +class EntityCoverageWorkflow(_BaseJudgeWorkflow): + RAW_COL = COL_ENTITY_COVERAGE_JUDGE + VALID_COL = COL_ENTITY_COVERAGE + INVALID_COL = COL_LEAKED_ENTITIES + SCHEMA = EntityCoverageSchema + VERDICT_FIELD = "leaked_entities" + DEFAULT_PAYLOAD = {"leaked_entities": []} + MODEL_ROLE = "entity_coverage_judge" + WORKFLOW_NAME = "entity-coverage-judge" +``` + +`postprocess()` is overridden to compute the float fraction from `n_entities` (from `COL_ENTITIES_BY_VALUE`) and `len(leaked_entities)` rather than storing a boolean verdict. + +> **Note:** `COL_ENTITIES_BY_VALUE` (`_entities_by_value`) is derived from `COL_FINAL_ENTITIES` (`final_entities`) during detection — it groups spans by value with a labels list. The leakage eval script references `final_entities`, which is the same underlying data in a different shape. `COL_ENTITIES_BY_VALUE` is the right source here since it's already what the judge's `prepare()` step and passthrough mask consume. + +> **Note on base class / VALID_COL conflict:** `_BaseJudgeWorkflow` does not write to `VALID_COL` before `postprocess()` runs — the base class `evaluate()` calls `postprocess()` as the only step that touches those columns. Overriding `postprocess()` fully in `EntityCoverageWorkflow` is safe. However, `VALID_COL` on the base class carries an implicit `bool | None` semantic (used by other judges). Since `EntityCoverageWorkflow` writes a `float | None` instead, confirm that no shared display or downstream code reads `VALID_COL` generically and assumes boolean before this is merged. + +--- + +## Step 3 — Wire into replace and rewrite evaluate paths + +### Replace (`replace_runner.py`) + +Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow`. The judge runs on `COL_TEXT` (original text) — no change to the input column needed. Update column references for the new output columns. + +### Rewrite (`rewrite_workflow.py`) + +Same swap. The judge also runs on `COL_TEXT` (original text), same as replace. Update `evaluate()`: + +- Remove `_detection_valid_fraction()` — no longer needed since `entity_coverage` is already a float from `postprocess()`. +- Update the try/except around the judge call to default `entity_coverage = None` and `leaked_entities = None` on failure. + +--- + +## Step 4 — Model role rename (`models.py`, `evaluate.yaml`) + +```python +class EvaluateModelSelection(BaseModel): + entity_coverage_judge: str # replaces detection_validity_judge + replace_type_fidelity_judge: str + replace_relational_consistency_judge: str + replace_attribute_fidelity_judge: str + rewrite_judge: str +``` + +Update `evaluate.yaml` default to rename the key. Update `model_loader.py` validation for the new role name. + +--- + +## Step 5 — Update `_build_user_dataframe` (`anonymizer.py`) + +Swap old column names for new in the allowed sets for both replace and rewrite mode: + +```python +# Replace (was: COL_DETECTION_VALID, COL_DETECTION_INVALID_ENTITIES) +COL_ENTITY_COVERAGE, +COL_LEAKED_ENTITIES, + +# Rewrite (same swap) +COL_ENTITY_COVERAGE, +COL_LEAKED_ENTITIES, +``` + +--- + +## Step 6 — Display (`display.py`) + +`display_record()` renders the coverage column: + +- **Replace:** `entity_coverage == 1.0` → "Satisfied"; anything less → "Not Satisfied" +- **Rewrite:** render as numeric fraction (e.g., `0.87`) + +The `leaked_entities` dropdown replaces `detection_invalid_entities` with the same layout (value, label, reasoning per row). + +--- + +## Step 7 — Docs and skills + +### `docs/concepts/evaluation.md` + +Replace the **Entity Detection Judge** subsection in both Replace and Rewrite sections with an **Entity Coverage** subsection: + +- Explain the recall focus (vs precision of the old judge) +- Document `entity_coverage` type per mode (float in rewrite, Satisfied/Not Satisfied in replace) +- Document `leaked_entities` list shape +- Update the special-values table (passthrough row → `1.0` / Satisfied) +- Update model role name (`entity_coverage_judge`) + +### `skills/anonymizer/SKILL.md` + +- Update the evaluation tips bullet: `entity_coverage_judge` role; `entity_coverage` type per mode; `leaked_entities` dropdown +- Update output template: replace `detection_valid` column references with `entity_coverage` + +--- + +## Step 8 — Tests + +### New test file (`test_entity_coverage_judge.py`) + +``` +test_judge_prompt_runs_on_original_text_column +test_judge_prompt_includes_detected_entity_context +test_entity_coverage_schema_accepts_empty_leaked_list +test_entity_coverage_schema_accepts_leaked_entities +test_coverage_fraction_all_caught # n_leaked=0 → 1.0 +test_coverage_fraction_partial_miss # n_leaked=1, n_entities=3 → 0.75 +test_coverage_fraction_total_miss # n_leaked=n, n_entities=0 → 0.0 +test_evaluate_short_circuits_when_no_entities # passthrough → 1.0, no LLM call +test_evaluate_invokes_adapter_for_rows_with_entities +test_evaluate_merges_entity_and_empty_rows_in_order +test_evaluate_marks_coverage_unavailable_for_malformed_payload +``` + +### Update existing tests + +``` +tests/engine/test_rewrite_workflow.py # rename column refs; remove _detection_valid_fraction tests +tests/engine/test_evaluate.py # column name assertions +tests/interface/test_display.py # entity_coverage rendering +tests/interface/test_anonymizer_interface.py # allowed column set for both modes +``` + +All tests use stub adapters — no real LLM calls. + +--- + +## Implementation Order + +1. Add new constants to `constants.py`; retire old ones +2. Write `entity_coverage_judge.py` — schema, prompt (v1 from leakage_eval script), workflow class, `postprocess()` override +3. Wire into replace evaluate path (`replace_runner.py`) +4. Wire into rewrite evaluate path (`rewrite_workflow.py`); remove `_detection_valid_fraction()` +5. Rename model role in `models.py`, `evaluate.yaml`, `model_loader.py` +6. Update `_build_user_dataframe` allowed columns (`anonymizer.py`) +7. Update `display.py` rendering +8. Update `docs/concepts/evaluation.md` and `skills/anonymizer/SKILL.md` +9. Replace `test_detection_judge.py` with `test_entity_coverage_judge.py`; update all affected tests +10. Run `make format && make typecheck && make test` + +--- + +## Open Questions + +- **Prompt v1:** The leakage_eval script is the starting point. The prompt will provide both the original text and the Anonymizer-detected entity list as context — the detected list is what allows the judge to identify the delta efficiently rather than re-detecting everything from scratch. Removing it would mean the judge can't compute a meaningful delta; keeping it is the right call. +- **`n_entities` denominator:** Currently planned to count `(value, label)` pairs (flattened, same as current detection judge). If the same value has multiple labels, it counts multiple times. Consider whether to count unique values instead to avoid inflating the denominator. +- **Replace mode threshold:** "Satisfied" is defined as `entity_coverage == 1.0` (strict — even one leaked entity flips to Not Satisfied). Confirm this is the right threshold or whether a small tolerance is acceptable. From e7817f782289acbb9154bd9ba4d6ad8a01d0dab5 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 24 Jun 2026 08:26:11 -0700 Subject: [PATCH 02/23] nit Signed-off-by: memadi --- plan/plan.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/plan/plan.md b/plan/plan.md index 2fe13bc6..52eb3e43 100644 --- a/plan/plan.md +++ b/plan/plan.md @@ -2,7 +2,7 @@ ## Problem -Currently `detection_valid` scores **precision** of the detection step. Observed scores are predominantly in the lower ranges (issue #176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** +Currently `detection_valid` scores **precision** of the detected entities. Observed scores are predominantly in the lower ranges (issue #176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** Precision complaints are already surfaced in the entity dropdown for manual inspection, making `detection_valid` feel redundant as a headline metric. What's missing is a **recall** signal: a score that answers "of all the PII that existed, how much did we successfully catch?" @@ -10,20 +10,21 @@ Precision complaints are already surfaced in the entity dropdown for manual insp ## Proposed Solution: Entity Coverage -Replace `detection_valid` with `entity_coverage`: +Replace `detection_valid` with `entity_coverage` (name TBD): ``` -entity_coverage = n_entities / (n_entities + n_leaked) +entity_coverage = n_entities_detected / (n_entities_detected + n_entities_leaked) ``` -- **`n_entities`** — entities detected by Anonymizer before anonymization (already available in `COL_ENTITIES_BY_VALUE`) -- **`n_leaked`** — the number of missed identifiers from the original text, detected by the evaluation LLM in the anonymized / rewritten output +- **`n_entities_detected`** — identifiers detected by Anonymizer in the original text (via the detection pipeline) +- **`n_entities_leaked`** — identifiers missed by Anonymizer in the original text, found by the evaluation LLM - **`leaked_entities`** — the list of missed entities (shown in a dropdown when coverage < 100%) The evaluation LLM runs on the **original text** and detects all PII that should have been caught. The delta between what it finds and what Anonymizer already detected gives the leaked entities. **Key shift:** current judge asks "were our detections correct?" (precision). New judge asks "did any PII survive?" (recall). +**Question TBD**: The evaluation and anonymization currently use the same LLM, and we may need to address potential bias by using a different model for evaluation. --- ## Design Decisions @@ -142,9 +143,9 @@ Rows with no detected entities trivially score `entity_coverage = 1.0` — nothi ### Coverage fraction (postprocess override) ```python -def _compute_coverage(self, n_entities: int, n_leaked: int) -> float: - total = n_entities + n_leaked - return 1.0 if total == 0 else n_entities / total +def _compute_coverage(self, n_entities_detected: int, n_entities_leaked: int) -> float: + total = n_entities_detected + n_entities_leaked + return 1.0 if total == 0 else n_entities_detected / total ``` Passthrough rows get `entity_coverage = 1.0` and `leaked_entities = []` without calling the LLM. @@ -163,7 +164,7 @@ class EntityCoverageWorkflow(_BaseJudgeWorkflow): WORKFLOW_NAME = "entity-coverage-judge" ``` -`postprocess()` is overridden to compute the float fraction from `n_entities` (from `COL_ENTITIES_BY_VALUE`) and `len(leaked_entities)` rather than storing a boolean verdict. +`postprocess()` is overridden to compute the float fraction from `n_entities_detected` (from `COL_ENTITIES_BY_VALUE`) and `len(leaked_entities)` rather than storing a boolean verdict. > **Note:** `COL_ENTITIES_BY_VALUE` (`_entities_by_value`) is derived from `COL_FINAL_ENTITIES` (`final_entities`) during detection — it groups spans by value with a labels list. The leakage eval script references `final_entities`, which is the same underlying data in a different shape. `COL_ENTITIES_BY_VALUE` is the right source here since it's already what the judge's `prepare()` step and passthrough mask consume. @@ -256,9 +257,9 @@ test_judge_prompt_runs_on_original_text_column test_judge_prompt_includes_detected_entity_context test_entity_coverage_schema_accepts_empty_leaked_list test_entity_coverage_schema_accepts_leaked_entities -test_coverage_fraction_all_caught # n_leaked=0 → 1.0 -test_coverage_fraction_partial_miss # n_leaked=1, n_entities=3 → 0.75 -test_coverage_fraction_total_miss # n_leaked=n, n_entities=0 → 0.0 +test_coverage_fraction_all_caught # n_entities_leaked=0 → 1.0 +test_coverage_fraction_partial_miss # n_entities_leaked=1, n_entities_detected=3 → 0.75 +test_coverage_fraction_total_miss # n_entities_leaked=n, n_entities_detected=0 → 0.0 test_evaluate_short_circuits_when_no_entities # passthrough → 1.0, no LLM call test_evaluate_invokes_adapter_for_rows_with_entities test_evaluate_merges_entity_and_empty_rows_in_order @@ -296,5 +297,5 @@ All tests use stub adapters — no real LLM calls. ## Open Questions - **Prompt v1:** The leakage_eval script is the starting point. The prompt will provide both the original text and the Anonymizer-detected entity list as context — the detected list is what allows the judge to identify the delta efficiently rather than re-detecting everything from scratch. Removing it would mean the judge can't compute a meaningful delta; keeping it is the right call. -- **`n_entities` denominator:** Currently planned to count `(value, label)` pairs (flattened, same as current detection judge). If the same value has multiple labels, it counts multiple times. Consider whether to count unique values instead to avoid inflating the denominator. +- **`n_entities_detected` denominator:** Currently planned to count `(value, label)` pairs (flattened, same as current detection judge). If the same value has multiple labels, it counts multiple times. Consider whether to count unique values instead to avoid inflating the denominator. - **Replace mode threshold:** "Satisfied" is defined as `entity_coverage == 1.0` (strict — even one leaked entity flips to Not Satisfied). Confirm this is the right threshold or whether a small tolerance is acceptable. From e4ab7c6d5ec8a401f6208e5cec031ce0843b93e5 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 24 Jun 2026 15:43:23 -0700 Subject: [PATCH 03/23] address feedback Signed-off-by: memadi --- plan/plan.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/plan/plan.md b/plan/plan.md index 52eb3e43..0842e131 100644 --- a/plan/plan.md +++ b/plan/plan.md @@ -24,7 +24,8 @@ The evaluation LLM runs on the **original text** and detects all PII that should **Key shift:** current judge asks "were our detections correct?" (precision). New judge asks "did any PII survive?" (recall). -**Question TBD**: The evaluation and anonymization currently use the same LLM, and we may need to address potential bias by using a different model for evaluation. +**Question TBD:** The evaluation and anonymization currently use the same LLM, and we may need to address potential bias by using a different model for evaluation. Potential models: Nemotron Ultra and Nemotron Super (previous [research log](https://nvidia.atlassian.net/wiki/spaces/SDGR/pages/3597435385/2026-06-09+Entity+Validator+Augmentor+Model+Selection+for+NeMo+Anonymizer) around experimenting these 2 models) + --- ## Design Decisions @@ -136,6 +137,52 @@ Core task phrasing: - "Given the original text, identify all PII present. Report any identifiers that were not already caught by Anonymizer — these are the missed entities." - Returns an empty list when Anonymizer caught everything. +#### Strict entity protection (`Rewrite.strict_entity_protection`) + +Only applies in rewrite mode. When `strict_entity_protection=True`, the rewrite pipeline disallows `leave_as_is`, bans `combined_risk_level: low`, and overrides the MINIMUM NECESSARY CHANGE and quasi-identifier "not automatically protected" principles. The coverage judge must mirror that posture — otherwise a missed quasi-identifier or low-combined-risk entity would be excused by the judge despite the user having demanded full protection. + +The prompt includes a `` block injected when the flag is set (empty string otherwise): + +``` +STRICT PROTECTION MODE IS ENABLED. + +Flag ALL entities as leaked if they were not caught — including quasi-identifiers +and low-risk entities that would normally be given benefit of the doubt. +Do NOT apply MINIMUM NECESSARY CHANGE reasoning to excuse a missed entity. +Do NOT excuse a missed entity because its combined re-identification risk is low. +Any PII span not caught by Anonymizer is a miss in strict mode. +``` + +The block mirrors the equivalent block in `sensitivity_disposition.py` so the two prompts enforce the same standard. In replace mode the call site passes `strict_entity_protection=False` (the field does not exist on `Detect`); the block is omitted and the judge uses its default charitable posture. + +#### Both flags enabled simultaneously + +Both can be active at the same time — there is no config constraint preventing it: + +```python +AnonymizerConfig( + detect=Detect(entity_labels=["first_name", "email_address"]), + rewrite=Rewrite(strict_entity_protection=True), +) +``` + +The two blocks compose cleanly in the judge prompt: the entity type scope narrows *what* the judge looks for, and the strict protection block raises the bar *within* that scope. No contradiction — scope is applied first, strictness within that scope second. The two blocks are independent injections in `prepare()` and do not interfere with each other. + +--- + +#### Entity type scope (`Detect.entity_labels`) + +When the user configures `Detect(entity_labels=[...])`, detection is intentionally limited to those label types. The coverage judge must be scoped to match — otherwise it would flag missed PII of types the user never asked to detect, artificially deflating the score. + +The prompt includes an `` block populated per-run in `prepare()`: + +- **`entity_labels` is `None`** (default): `"Evaluate for all PII and sensitive entity types."` +- **`entity_labels` is set**: `"Detection was configured to target ONLY these entity types: . 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."` + +The `` section includes a corresponding rule: respect the entity_type_scope and do not penalize Anonymizer for types outside it. + +Because `entity_labels` is run-level config (not per-row), the workflow constructor accepts `entity_labels: list[str] | None` and `prepare()` broadcasts it as a constant column (`_entity_type_scope`) across all rows. Call sites (`replace_runner.py`, `rewrite_workflow.py`) pass `config.detect.entity_labels`. + ### Passthrough condition Rows with no detected entities trivially score `entity_coverage = 1.0` — nothing to miss, LLM skipped. @@ -220,10 +267,14 @@ COL_LEAKED_ENTITIES, ## Step 6 — Display (`display.py`) -`display_record()` renders the coverage column: +`display_record()` renders the coverage column as a single line. The format puts the verdict first, then the fraction for scale, then the percentage for readability: + +- **Replace:** `Entity Coverage: Satisfied — 21/21 (100%)` / `Entity Coverage: Not Satisfied — 18/21 (86%)` +- **Rewrite:** `Entity Coverage: 0.86 — 18/21 (86%)` (no Satisfied/Not Satisfied badge — rewrite already uses numeric metrics) -- **Replace:** `entity_coverage == 1.0` → "Satisfied"; anything less → "Not Satisfied" -- **Rewrite:** render as numeric fraction (e.g., `0.87`) +Where the fraction is `n_entities_detected / (n_entities_detected + n_entities_leaked)` and the percentage is derived from it. The fraction gives absolute scale (18/20 and 18/200 look very different); the percentage makes it immediately readable. + +Drop the explanatory subtitle line that the old Detection Judge displayed — entity coverage is self-explanatory and does not need a definition sentence. Drop the `"LLM alignment score:"` label prefix — that framing belonged to the old judge (LLM-vs-LLM alignment), not to a count ratio. The `leaked_entities` dropdown replaces `detection_invalid_entities` with the same layout (value, label, reasoning per row). @@ -294,8 +345,22 @@ All tests use stub adapters — no real LLM calls. --- +## Test Datasets + +The following datasets should be used to validate entity coverage results once the feature is shipped. + +| Dataset | Rows | Text column | Ground-truth annotations | Notes | +|---|---|---|---|---| +| **usage_logs_seed** (`test-data/usage_logs_seed.parquet`) | 20 | `conversation_trace` | None | LLM conversation traces with embedded PII (names, emails, phone numbers, addresses) across business document scenarios; good for sanity-checking coverage on realistic, high-density PII text | +| **openPII_ai4_part1** (`openPII_ai4_part1.csv`) | 1000 | `source_text` | `privacy_mask` (character-offset spans with labels: TITLE, GIVENNAME, SURNAME, EMAIL, TELEPHONENUM, DATE, etc.) | Synthetic PII benchmark with ground-truth spans; use `privacy_mask` as the reference to evaluate whether the judge surfaces the same entities Anonymizer missed | +| **PII_NEW_TESTDATA_part1** (`PII_NEW_TESTDATA_part1.csv`) | 1000 | `text_us` / `text_intl` | `spans_us` / `spans_intl` (character-offset spans with Anonymizer-native labels: first_name, last_name, email, date_of_birth, street_address, bank_routing_number, credit_debit_card, etc.) | Multi-domain synthetic dataset (Life, Finance, etc.) with both US and international text variants and pre-tagged versions; spans use Anonymizer's own label vocabulary, making it the most direct test for coverage scoring | + +Run evaluation in both replace and rewrite modes across all three datasets to confirm `entity_coverage` scores are meaningful and `leaked_entities` lists surface real misses rather than noise. `PII_NEW_TESTDATA_part1` is the highest-signal dataset for this feature because its ground-truth spans use the same label set as Anonymizer's detection pipeline. + +--- + ## Open Questions -- **Prompt v1:** The leakage_eval script is the starting point. The prompt will provide both the original text and the Anonymizer-detected entity list as context — the detected list is what allows the judge to identify the delta efficiently rather than re-detecting everything from scratch. Removing it would mean the judge can't compute a meaningful delta; keeping it is the right call. +- **Prompt v1:** The leakage_eval script is the starting point. The prompt will provide both the original text and the Anonymizer-detected entity list as context — the detected list is what allows the judge to identify the delta efficiently rather than re-detecting everything from scratch. Removing it would mean the judge can't compute a meaningful delta; keeping it is the right call. Once v1 is shipped and results are observed, a follow-up iteration should explore **NER-style prompting** (instructing the judge to perform explicit named-entity recognition passes over the text before computing the delta) as a potential path to improving recall and reducing missed entities. - **`n_entities_detected` denominator:** Currently planned to count `(value, label)` pairs (flattened, same as current detection judge). If the same value has multiple labels, it counts multiple times. Consider whether to count unique values instead to avoid inflating the denominator. - **Replace mode threshold:** "Satisfied" is defined as `entity_coverage == 1.0` (strict — even one leaked entity flips to Not Satisfied). Confirm this is the right threshold or whether a small tolerance is acceptable. From dca7462d549b03e3d6ef101fe3c0614055a8819e Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 24 Jun 2026 16:02:29 -0700 Subject: [PATCH 04/23] add missing url to the plan Signed-off-by: memadi --- plan/plan.md | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/plan/plan.md b/plan/plan.md index 0842e131..4e245dca 100644 --- a/plan/plan.md +++ b/plan/plan.md @@ -2,7 +2,7 @@ ## Problem -Currently `detection_valid` scores **precision** of the detected entities. Observed scores are predominantly in the lower ranges (issue #176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** +Currently `detection_valid` scores **precision** of the detected entities. Observed scores are predominantly in the lower ranges issue [#176](https://github.com/NVIDIA-NeMo/Anonymizer/issues/176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** Precision complaints are already surfaced in the entity dropdown for manual inspection, making `detection_valid` feel redundant as a headline metric. What's missing is a **recall** signal: a score that answers "of all the PII that existed, how much did we successfully catch?" @@ -45,7 +45,7 @@ Rows where Anonymizer detected **zero entities** trivially score `entity_coverag ### Prompt starting point -The entity judge prompt is based on the leakage eval script from the experiments repo. It will be refined as it is tested against real examples. The core task: given the original text, identify all PII — then report any that Anonymizer missed. +The entity judge prompt is based on the [leakage eval script](https://gitlab-master.nvidia.com/sdg-research/anonymizer-experiments/-/blob/lramaswamy/latency-benchmark/experiments/latency-benchmark/scripts/leakage_eval.py?ref_type=heads#L26) from the experiments repo. It will be refined as it is tested against real examples. The core task: given the original text, identify all PII — then report any that Anonymizer missed. ### Reuse `_BaseJudgeWorkflow` @@ -155,21 +155,6 @@ Any PII span not caught by Anonymizer is a miss in strict mode. The block mirrors the equivalent block in `sensitivity_disposition.py` so the two prompts enforce the same standard. In replace mode the call site passes `strict_entity_protection=False` (the field does not exist on `Detect`); the block is omitted and the judge uses its default charitable posture. -#### Both flags enabled simultaneously - -Both can be active at the same time — there is no config constraint preventing it: - -```python -AnonymizerConfig( - detect=Detect(entity_labels=["first_name", "email_address"]), - rewrite=Rewrite(strict_entity_protection=True), -) -``` - -The two blocks compose cleanly in the judge prompt: the entity type scope narrows *what* the judge looks for, and the strict protection block raises the bar *within* that scope. No contradiction — scope is applied first, strictness within that scope second. The two blocks are independent injections in `prepare()` and do not interfere with each other. - ---- - #### Entity type scope (`Detect.entity_labels`) When the user configures `Detect(entity_labels=[...])`, detection is intentionally limited to those label types. The coverage judge must be scoped to match — otherwise it would flag missed PII of types the user never asked to detect, artificially deflating the score. @@ -183,6 +168,19 @@ The `` section includes a corresponding rule: respect the entity_type_ Because `entity_labels` is run-level config (not per-row), the workflow constructor accepts `entity_labels: list[str] | None` and `prepare()` broadcasts it as a constant column (`_entity_type_scope`) across all rows. Call sites (`replace_runner.py`, `rewrite_workflow.py`) pass `config.detect.entity_labels`. +#### Both flags enabled simultaneously + +Both can be active at the same time — there is no config constraint preventing it: + +```python +AnonymizerConfig( + detect=Detect(entity_labels=["first_name", "email_address"]), + rewrite=Rewrite(strict_entity_protection=True), +) +``` + +The two blocks compose cleanly in the judge prompt: the entity type scope narrows *what* the judge looks for, and the strict protection block raises the bar *within* that scope. No contradiction — scope is applied first, strictness within that scope second. The two blocks are independent injections in `prepare()` and do not interfere with each other. + ### Passthrough condition Rows with no detected entities trivially score `entity_coverage = 1.0` — nothing to miss, LLM skipped. From fcb3feb0c5340ce801d5a6f113b027b576365cf3 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 24 Jun 2026 16:12:08 -0700 Subject: [PATCH 05/23] nit Signed-off-by: memadi --- plan/plan.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plan/plan.md b/plan/plan.md index 4e245dca..6a8ae9a7 100644 --- a/plan/plan.md +++ b/plan/plan.md @@ -32,10 +32,12 @@ The evaluation LLM runs on the **original text** and detects all PII that should ### Coverage display differs by mode -| Mode | Display | Rationale | +| Mode | Display format | Example | |---|---|---| -| **Replace** | Satisfied / Not Satisfied | Binary is sufficient — either all PII was caught or it wasn't | -| **Rewrite** | Float fraction (e.g. `0.87`) | Rewrite already exposes numeric metrics (leakage_mass, utility_score); a fraction fits the pattern | +| **Replace** | `Entity Coverage: — n/d (pct%)` | `Entity Coverage: Not Satisfied — 18/21 (86%)` | +| **Rewrite** | `Entity Coverage: — n/d (pct%)` | `Entity Coverage: 0.86 — 18/21 (86%)` | + +Where `n` = `n_entities_detected`, `d` = `n_entities_detected + n_entities_leaked`, and `pct` is derived from the fraction. The fraction gives absolute scale; the percentage makes it immediately readable without mental math. No explanatory subtitle line is shown — entity coverage is self-explanatory. A value of `1.0` (or Satisfied) means no PII leaked into the output. Any value below `1.0` (or Not Satisfied) means at least one entity was missed, and `leaked_entities` names them. From 10def2624e6e3fd98eb091d34130a00b359b38b4 Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 26 Jun 2026 12:31:40 -0700 Subject: [PATCH 06/23] update plan- keep precision add recall Signed-off-by: memadi --- plan/plan.md | 99 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/plan/plan.md b/plan/plan.md index 6a8ae9a7..3ab5a5fd 100644 --- a/plan/plan.md +++ b/plan/plan.md @@ -8,9 +8,20 @@ Precision complaints are already surfaced in the entity dropdown for manual insp --- +## Decision: Precision vs Recall + +`detection_valid` measures **precision** — of the entities we detected, were they correct? `entity_coverage` measures **recall** — of all the PII that existed, how much did we catch? + +For an anonymizer, recall is the user-facing priority: a missed PII entity is a privacy failure, while an over-detection is a minor inconvenience already visible in the entity dropdown. We therefore: + +- **Keep `detection_valid`** as an internal, opt-in feature (`compute_detection_validity=False` by default) — useful for model/threshold experiments during development, not surfaced to end users. +- **Add `entity_coverage`** as the new customer-facing metric — the subject of this plan. + +--- + ## Proposed Solution: Entity Coverage -Replace `detection_valid` with `entity_coverage` (name TBD): +Replace `detection_valid` as the headline evaluate metric with `entity_coverage`: ``` entity_coverage = n_entities_detected / (n_entities_detected + n_entities_leaked) @@ -57,10 +68,11 @@ The new `EntityCoverageWorkflow` extends `_BaseJudgeWorkflow`. The partition-LLM ## Scope -- Replaces `detection_valid` and `detection_invalid_entities` in both replace and rewrite `evaluate()` output. -- No new public API symbols beyond new column names (`entity_coverage`, `leaked_entities`) and a renamed model role. -- `COL_DETECTION_VALID` / `COL_DETECTION_INVALID_ENTITIES` constants are retired; new constants added. -- `DetectionJudgeWorkflow` is replaced by `EntityCoverageWorkflow` — the old class is removed, not kept as an alias. +- Adds `entity_coverage` and `leaked_entities` as the new default customer-facing columns in both replace and rewrite `evaluate()` output. +- Retains `detection_valid` and `detection_invalid_entities` as an opt-in internal feature behind `EvaluateConfig(compute_detection_validity=False)` — not customer-facing, off by default. +- New public API: `EvaluateConfig.compute_detection_validity: bool = False`; new column name constants (`COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES`); new model role `entity_coverage_judge` in `EvaluateModelSelection`. +- `COL_DETECTION_VALID` / `COL_DETECTION_INVALID_ENTITIES` constants and `detection_validity_judge` model role are **kept** — they back the opt-in internal feature. +- `DetectionJudgeWorkflow` is **not removed** — it runs only when `compute_detection_validity=True`. --- @@ -78,18 +90,21 @@ The new `EntityCoverageWorkflow` extends `_BaseJudgeWorkflow`. The partition-LLM | File | Change | |---|---| -| `src/anonymizer/engine/constants.py` | Retire `COL_DETECTION_JUDGE`, `COL_DETECTION_VALID`, `COL_DETECTION_INVALID_ENTITIES`; add `COL_ENTITY_COVERAGE_JUDGE`, `COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES` | -| `src/anonymizer/engine/evaluation/detection_judge.py` | Replace entirely with `entity_coverage_judge.py` — new schema, new prompt, new workflow class | +| `src/anonymizer/engine/constants.py` | Keep `COL_DETECTION_JUDGE`, `COL_DETECTION_VALID`, `COL_DETECTION_INVALID_ENTITIES`; add `COL_ENTITY_COVERAGE_JUDGE`, `COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES` | +| `src/anonymizer/engine/evaluation/entity_coverage_judge.py` | **New file** — `EntityCoverageSchema`, `EntityCoverageWorkflow`, coverage fraction `postprocess()` | +| `src/anonymizer/engine/evaluation/detection_judge.py` | No removal — retained as-is; gated behind `compute_detection_validity` flag in call sites | | `src/anonymizer/engine/evaluation/judge_base.py` | No changes needed — `postprocess()` is overridden in the subclass | | `src/anonymizer/engine/rewrite/rewrite_workflow.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow` in `evaluate()`; remove `_detection_valid_fraction()`; update column references | | `src/anonymizer/engine/replace/replace_runner.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow`; update column references | | `src/anonymizer/interface/anonymizer.py` | Update `_build_user_dataframe` allowed columns for both modes; update model role references | | `src/anonymizer/interface/display.py` | Render `entity_coverage` as Satisfied/Not Satisfied (replace) or fraction (rewrite); update leaked_entities dropdown | -| `src/anonymizer/config/models.py` | Rename `detection_validity_judge` → `entity_coverage_judge` in `EvaluateModelSelection` | -| `src/anonymizer/config/default_model_configs/evaluate.yaml` | Rename the model role key | +| `src/anonymizer/config/anonymizer_config.py` | Add `compute_detection_validity: bool = False` to `EvaluateConfig` | +| `src/anonymizer/config/models.py` | Add `entity_coverage_judge` to `EvaluateModelSelection`; keep `detection_validity_judge` | +| `src/anonymizer/config/default_model_configs/evaluate.yaml` | Add `entity_coverage_judge` key alongside existing `detection_validity_judge` | | `docs/concepts/evaluation.md` | Replace Entity Detection Judge section with Entity Coverage section in both replace and rewrite docs | | `skills/anonymizer/SKILL.md` | Update evaluation tips and output template column references | -| `tests/engine/evaluation/test_detection_judge.py` | Replace with `test_entity_coverage_judge.py` — new schema, prompt, and workflow tests | +| `tests/engine/evaluation/test_detection_judge.py` | Keep as-is — covers the retained internal feature | +| `tests/engine/evaluation/test_entity_coverage_judge.py` | **New file** — new schema, prompt, and workflow tests | | `tests/engine/test_rewrite_workflow.py` | Update column name references; remove `_detection_valid_fraction` tests | | `tests/engine/test_evaluate.py` | Update column name assertions | | `tests/interface/test_display.py` | Update rendering assertions for entity coverage | @@ -98,13 +113,13 @@ The new `EntityCoverageWorkflow` extends `_BaseJudgeWorkflow`. The partition-LLM ## Step 1 — New constants (`constants.py`) -Retire the three detection judge constants and add three replacements: +Keep the existing detection judge constants and add three new ones alongside them: ```python -# Retired (remove): -# COL_DETECTION_JUDGE = "_detection_judge" -# COL_DETECTION_VALID = "detection_valid" -# COL_DETECTION_INVALID_ENTITIES = "detection_invalid_entities" +# Retained (no change — back the opt-in internal feature): +COL_DETECTION_JUDGE = "_detection_judge" +COL_DETECTION_VALID = "detection_valid" +COL_DETECTION_INVALID_ENTITIES = "detection_invalid_entities" # New: COL_ENTITY_COVERAGE_JUDGE = "_entity_coverage_judge" # internal raw output @@ -116,7 +131,7 @@ COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list ## Step 2 — New workflow (`entity_coverage_judge.py`) -Replace `detection_judge.py` with a new file. Key differences from the old judge: +Add a new file alongside the existing `detection_judge.py`. Key design: ### Output schema @@ -223,44 +238,44 @@ class EntityCoverageWorkflow(_BaseJudgeWorkflow): ### Replace (`replace_runner.py`) -Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow`. The judge runs on `COL_TEXT` (original text) — no change to the input column needed. Update column references for the new output columns. +Add `EntityCoverageWorkflow` — always runs in `evaluate()`. Gate `DetectionJudgeWorkflow` behind `evaluate_config.compute_detection_validity`. Both judges run on `COL_TEXT` (original text). Update column references for the new output columns. ### Rewrite (`rewrite_workflow.py`) -Same swap. The judge also runs on `COL_TEXT` (original text), same as replace. Update `evaluate()`: +Same structure. Add `EntityCoverageWorkflow` as the default; gate `DetectionJudgeWorkflow` behind the flag. Update `evaluate()`: - Remove `_detection_valid_fraction()` — no longer needed since `entity_coverage` is already a float from `postprocess()`. - Update the try/except around the judge call to default `entity_coverage = None` and `leaked_entities = None` on failure. --- -## Step 4 — Model role rename (`models.py`, `evaluate.yaml`) +## Step 4 — Model role update (`models.py`, `evaluate.yaml`) ```python class EvaluateModelSelection(BaseModel): - entity_coverage_judge: str # replaces detection_validity_judge + entity_coverage_judge: str # new — always required for evaluate() + detection_validity_judge: str # retained — required only when compute_detection_validity=True replace_type_fidelity_judge: str replace_relational_consistency_judge: str replace_attribute_fidelity_judge: str rewrite_judge: str ``` -Update `evaluate.yaml` default to rename the key. Update `model_loader.py` validation for the new role name. +Add `entity_coverage_judge` to `evaluate.yaml` alongside the existing `detection_validity_judge`. Update `model_loader.py` validation to check `entity_coverage_judge` unconditionally; `detection_validity_judge` is already present in the YAML default so no user burden is added. --- ## Step 5 — Update `_build_user_dataframe` (`anonymizer.py`) -Swap old column names for new in the allowed sets for both replace and rewrite mode: +Add the new columns to the allowed sets for both replace and rewrite mode. Keep the existing detection validity columns — they remain in the allowed set and will appear in output only when `compute_detection_validity=True` produces them: ```python -# Replace (was: COL_DETECTION_VALID, COL_DETECTION_INVALID_ENTITIES) -COL_ENTITY_COVERAGE, -COL_LEAKED_ENTITIES, - -# Rewrite (same swap) -COL_ENTITY_COVERAGE, -COL_LEAKED_ENTITIES, +# Add to both replace and rewrite allowed sets: +COL_ENTITY_COVERAGE, # new — always present after evaluate() +COL_LEAKED_ENTITIES, # new — always present after evaluate() +# Already present (retained): +# COL_DETECTION_VALID — only in output when compute_detection_validity=True +# COL_DETECTION_INVALID_ENTITIES — only in output when compute_detection_validity=True ``` --- @@ -276,7 +291,7 @@ Where the fraction is `n_entities_detected / (n_entities_detected + n_entities_l Drop the explanatory subtitle line that the old Detection Judge displayed — entity coverage is self-explanatory and does not need a definition sentence. Drop the `"LLM alignment score:"` label prefix — that framing belonged to the old judge (LLM-vs-LLM alignment), not to a count ratio. -The `leaked_entities` dropdown replaces `detection_invalid_entities` with the same layout (value, label, reasoning per row). +The `leaked_entities` dropdown is added with the same layout (value, label, reasoning per row). The existing `detection_invalid_entities` dropdown continues to render unchanged when `detection_valid` is present (opt-in enabled). --- @@ -291,11 +306,12 @@ Replace the **Entity Detection Judge** subsection in both Replace and Rewrite se - Document `leaked_entities` list shape - Update the special-values table (passthrough row → `1.0` / Satisfied) - Update model role name (`entity_coverage_judge`) +- Add a brief **Internal metrics** note: `compute_detection_validity=True` enables `detection_valid` for tag-precision signal during model/threshold experiments — one paragraph, not featured ### `skills/anonymizer/SKILL.md` - Update the evaluation tips bullet: `entity_coverage_judge` role; `entity_coverage` type per mode; `leaked_entities` dropdown -- Update output template: replace `detection_valid` column references with `entity_coverage` +- Update output template: add `entity_coverage` column references; do not feature `detection_valid` (internal only) --- @@ -332,16 +348,17 @@ All tests use stub adapters — no real LLM calls. ## Implementation Order -1. Add new constants to `constants.py`; retire old ones -2. Write `entity_coverage_judge.py` — schema, prompt (v1 from leakage_eval script), workflow class, `postprocess()` override -3. Wire into replace evaluate path (`replace_runner.py`) -4. Wire into rewrite evaluate path (`rewrite_workflow.py`); remove `_detection_valid_fraction()` -5. Rename model role in `models.py`, `evaluate.yaml`, `model_loader.py` -6. Update `_build_user_dataframe` allowed columns (`anonymizer.py`) -7. Update `display.py` rendering -8. Update `docs/concepts/evaluation.md` and `skills/anonymizer/SKILL.md` -9. Replace `test_detection_judge.py` with `test_entity_coverage_judge.py`; update all affected tests -10. Run `make format && make typecheck && make test` +1. Add new constants to `constants.py`; keep existing `COL_DETECTION_*` constants +2. Add `compute_detection_validity: bool = False` to `EvaluateConfig` (`anonymizer_config.py`) +3. Write `entity_coverage_judge.py` — schema, prompt (v1 from leakage_eval script), workflow class, `postprocess()` override +4. Wire `EntityCoverageWorkflow` into replace evaluate path (`replace_runner.py`); gate `DetectionJudgeWorkflow` behind the flag +5. Wire `EntityCoverageWorkflow` into rewrite evaluate path (`rewrite_workflow.py`); gate `DetectionJudgeWorkflow` behind the flag; remove `_detection_valid_fraction()` +6. Add `entity_coverage_judge` to `EvaluateModelSelection` and `evaluate.yaml`; keep `detection_validity_judge`; update `model_loader.py` +7. Update `_build_user_dataframe` allowed columns (`anonymizer.py`) +8. Update `display.py` rendering +9. Update `docs/concepts/evaluation.md` and `skills/anonymizer/SKILL.md` +10. Add `test_entity_coverage_judge.py`; keep `test_detection_judge.py`; update all affected existing tests +11. Run `make format && make typecheck && make test` --- From d6ded8422497b643a0672adfe392168d559b3f83 Mon Sep 17 00:00:00 2001 From: memadi Date: Mon, 29 Jun 2026 09:17:49 -0700 Subject: [PATCH 07/23] add entity coverage Signed-off-by: memadi --- src/anonymizer/config/anonymizer_config.py | 10 +- .../default_model_configs/evaluate.yaml | 1 + src/anonymizer/config/models.py | 1 + src/anonymizer/engine/constants.py | 7 +- .../evaluation/entity_coverage_judge.py | 411 ++++++++++++++++++ src/anonymizer/engine/ndd/model_loader.py | 1 + .../engine/replace/replace_runner.py | 15 +- src/anonymizer/interface/anonymizer.py | 98 +++-- src/anonymizer/interface/display.py | 78 +++- src/anonymizer/interface/results.py | 2 + tests/conftest.py | 1 + tests/engine/test_replace_runner.py | 21 +- 12 files changed, 597 insertions(+), 49 deletions(-) create mode 100644 src/anonymizer/engine/evaluation/entity_coverage_judge.py 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..62729c8d 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: gpt-oss-120b detection_validity_judge: gpt-oss-120b # --- Replace evaluation --- 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..a60d45f1 --- /dev/null +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -0,0 +1,411 @@ +# 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 +from typing import ClassVar + +import pandas as pd +from data_designer.config.column_configs import LLMStructuredColumnConfig +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, + _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") + +_ENTITIES_FOR_COVERAGE_COL = "_entities_for_coverage_judge" +_N_ENTITIES_DETECTED_COL = "_n_entities_detected_for_coverage" + + +# --------------------------------------------------------------------------- +# 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 _coverage_prompt( + *, + entity_labels: list[str] | None, + strict_entity_protection: bool, +) -> str: + entity_scope_block = _entity_type_scope_block(entity_labels) + strict_block = _strict_protection_block(strict_entity_protection) + + 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 "" + ) + + prompt = f"""You are a privacy auditor. Given ORIGINAL text and a list of PII ENTITIES that were \ +detected by an anonymization pipeline, identify any direct or quasi-identifiers in the \ +original text that are NOT covered by the detected entities. + + +<> + + + +{{%- if <> %}} +{{%- for entity in <> %}} +- value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} +{{%- endfor %}} +{{%- else %}} +(none detected) +{{%- endif %}} + + +{entity_scope_block} + +{strict_block} + + +An identifier is "covered" (already protected) if: +- Its exact value appears in the detected entities list, OR +- Its value is a substring of any entity value in the list (e.g. a bare username is \ +covered if a full file path containing that username is listed as an entity). + + + +Direct identifiers: person names, email addresses, phone numbers, SSNs, physical \ +addresses, IP addresses, usernames, account IDs, API keys / tokens. +Quasi-identifiers: combinations of values that together re-identify someone \ +(e.g. job title + employer + city appearing together). + + + +Identify every direct or quasi-identifier present in the original text that is NOT covered \ +by the detected entities. These are the "leaked" entities — identifiers that would survive \ +anonymization because the detector missed them. + +Only report findings you are HIGH CONFIDENCE about. + +Return structured JSON: +- `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. +- Return an empty list if the anonymizer covered all identifiers. + + + +Do NOT flag: +- Items that are substrings of, or closely matched by, a listed entity value. +- Generic technical terms, common words, or place names that cannot re-identify a specific individual on their own. +- 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 why this value is not covered by the detected entities. +- If `detected_entities` is empty, scan the full text for any direct or quasi-identifiers. +{entity_scope_guidance} +{strict_guidance} + + + +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), + "<>": _ENTITIES_FOR_COVERAGE_COL, + }, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _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 entities 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): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + 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 _compute_coverage(n_detected: int, n_leaked: int) -> float: + total = n_detected + n_leaked + return 1.0 if total == 0 else n_detected / total + + +# --------------------------------------------------------------------------- +# Workflow +# --------------------------------------------------------------------------- + + +class EntityCoverageWorkflow(_BaseJudgeWorkflow): + """LLM-as-judge that measures recall: what fraction of PII did the anonymizer catch? + + Runs on the **original text** and compares all PII present against what the + anonymizer already detected. The delta gives the leaked (missed) entities. + + Output columns: + ``COL_ENTITY_COVERAGE`` (float|None) — n_detected / (n_detected + 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, + ) -> None: + super().__init__(adapter) + self._entity_labels = entity_labels + self._strict_entity_protection = strict_entity_protection + + # ------------------------------------------------------------------ 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[_ENTITIES_FOR_COVERAGE_COL] = parsed.apply(_entities_for_coverage) + working_df[_N_ENTITIES_DETECTED_COL] = working_df[_ENTITIES_FOR_COVERAGE_COL].apply(len) + return working_df + + def _passthrough_mask(self, dataframe: pd.DataFrame) -> pd.Series: + return dataframe[_ENTITIES_FOR_COVERAGE_COL].apply(lambda items: items is None or len(items) == 0) + + @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) -> LLMStructuredColumnConfig: + """Override to inject instance-specific entity_labels and strict_entity_protection.""" + return LLMStructuredColumnConfig( + name=self.RAW_COL, + prompt=_coverage_prompt( + entity_labels=self._entity_labels, + strict_entity_protection=self._strict_entity_protection, + ), + model_alias=resolve_model_alias(self.MODEL_ROLE, selected_models), + output_format=self.SCHEMA, + ) + + def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: + """Override to write float coverage instead of bool verdict.""" + out = dataframe.copy() + passthrough_mask = self._passthrough_mask(out) + + coverage_vals: list[float | None] = [] + leaked_lists: list[list[dict]] = [] + + for idx in out.index: + if passthrough_mask.loc[idx]: + coverage_vals.append(1.0) + leaked_lists.append([]) + else: + raw = out[self.RAW_COL].loc[idx] if self.RAW_COL in out.columns else None + leaked = _parse_leaked_entities(raw) + n_detected = ( + int(out[_N_ENTITIES_DETECTED_COL].loc[idx]) if _N_ENTITIES_DETECTED_COL in out.columns else 0 + ) + if leaked is None: + coverage_vals.append(None) + leaked_lists.append([]) + else: + coverage_vals.append(_compute_coverage(n_detected, len(leaked))) + leaked_lists.append(leaked) + + out[self.VALID_COL] = coverage_vals + out[self.INVALID_COL] = leaked_lists + if self.RAW_COL in out.columns: + out.loc[passthrough_mask, self.RAW_COL] = [self.DEFAULT_PAYLOAD] * int(passthrough_mask.sum()) + 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.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.warning("Entity coverage step failed; populating defaults", 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: + """Standalone entry point. Overrides base to write float coverage instead of bool.""" + working_df = self.prepare(dataframe) + working_df[ROW_ORDER_COL] = range(len(working_df)) + passthrough_mask = self._passthrough_mask(working_df) + passthrough_rows = working_df[passthrough_mask].copy() + with_content = working_df[~passthrough_mask].copy() + + passthrough_rows[self.RAW_COL] = [self.DEFAULT_PAYLOAD for _ in range(len(passthrough_rows))] + passthrough_rows[self.VALID_COL] = 1.0 + passthrough_rows[self.INVALID_COL] = [[] for _ in range(len(passthrough_rows))] + + if with_content.empty: + combined = merge_and_reorder(passthrough_rows) + return JudgeResult(dataframe=combined, failed_records=[]) + + effective_preview = min(preview_num_records, len(with_content)) if preview_num_records is not None else None + run_result = self._adapter.run_workflow( + with_content, + model_configs=model_configs, + columns=[self.column_config(selected_models)], + workflow_name=self.WORKFLOW_NAME, + preview_num_records=effective_preview, + ) + + judged_df = run_result.dataframe.copy() + coverage_vals: list[float | None] = [] + leaked_lists: list[list[dict]] = [] + for idx in judged_df.index: + raw = judged_df[self.RAW_COL].loc[idx] if self.RAW_COL in judged_df.columns else None + leaked = _parse_leaked_entities(raw) + n_detected = ( + int(judged_df[_N_ENTITIES_DETECTED_COL].loc[idx]) + if _N_ENTITIES_DETECTED_COL in judged_df.columns + else 0 + ) + if leaked is None: + coverage_vals.append(None) + leaked_lists.append([]) + else: + coverage_vals.append(_compute_coverage(n_detected, len(leaked))) + leaked_lists.append(leaked) + judged_df[self.VALID_COL] = coverage_vals + judged_df[self.INVALID_COL] = leaked_lists + + combined = merge_and_reorder(judged_df, passthrough_rows) + 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..1395a01a 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,8 @@ 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, ) -> ReplacementResult: """Run the LLM evaluation judges on an already-replaced dataframe. @@ -141,10 +144,16 @@ def evaluate( f"missing: {sorted(missing)}. Pass the trace_dataframe from a " f"previous preview()/run() call." ) + entity_coverage_judge = EntityCoverageWorkflow( + adapter=self._adapter, # type: ignore[arg-type] + entity_labels=entity_labels, + ) 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 +170,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 +184,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 diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index bcfee0c5..86011653 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -30,9 +30,11 @@ COL_DETECTED_ENTITIES, COL_DETECTION_INVALID_ENTITIES, COL_DETECTION_VALID, + 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 +50,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 @@ -326,6 +329,7 @@ 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, ) except KeyboardInterrupt: status = TaskStatusEnum.CANCELED @@ -380,39 +384,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(). " @@ -429,25 +407,62 @@ def evaluate( ) except ValueError as exc: raise InvalidConfigError(str(exc)) from exc - text_column = output.resolved_text_column + + entity_labels = getattr(output, "entity_labels", None) # 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) + + if is_rewrite: + rewrite_result = self._rewrite_runner.evaluate( + internal_df, + model_configs=self._model_configs, + selected_models=self._selected_models.evaluate, + privacy_goal=rewrite_config, + ) + all_failed: list[FailedRecord] = list(rewrite_result.failed_records) + coverage_wf = EntityCoverageWorkflow( + adapter=self._adapter, + entity_labels=entity_labels, + ) + judged_df, coverage_failed = coverage_wf.run_non_critical( + rewrite_result.dataframe, + model_configs=self._model_configs, + selected_models=self._selected_models.evaluate, + ) + all_failed.extend(coverage_failed) + renamed_trace = _rename_output_columns(judged_df, 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=all_failed, + rewrite_config=rewrite_config, + entity_labels=entity_labels, + ) + 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, ) 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), + 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, ) def validate_config(self, config: AnonymizerConfig) -> None: @@ -638,6 +653,7 @@ 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, ) def _validate_preflight_config(self, config: AnonymizerConfig) -> None: @@ -869,11 +885,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 @@ -893,6 +915,8 @@ def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column 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() } elif f"{text_col}_replaced" in t.columns: allowed = { @@ -900,8 +924,8 @@ def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column 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 +933,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..05d7a6d3 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) 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) -> str: + """Render entity coverage (recall metric) for replace mode. Returns "" when judge has not run.""" + 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) + + if coverage is None: + badge = "Unavailable" + fraction_html = "" + elif coverage >= 1.0: + badge = "Satisfied" + n_detected = _count_detected_entity_label_pairs(row) + total = n_detected + n_leaked + pct = 100 + fraction_html = f" — {total}/{total} ({pct}%)" + else: + badge = "Not Satisfied" + n_detected = _count_detected_entity_label_pairs(row) + total = n_detected + n_leaked + pct = round(coverage * 100) + fraction_html = f" — {n_detected}/{total} ({pct}%)" + + header = ( + "
" + f"Entity Coverage: {badge}{html.escape(fraction_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..9b002d03 100644 --- a/src/anonymizer/interface/results.py +++ b/src/anonymizer/interface/results.py @@ -72,6 +72,7 @@ class AnonymizerResult(_DisplayMixin): failed_records: list[FailedRecord] replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None + entity_labels: list[str] | None = None _display_cycle_index: int = field(default=0, init=False, repr=False) def __repr__(self) -> str: @@ -114,6 +115,7 @@ class PreviewResult(_DisplayMixin): preview_num_records: int replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None + entity_labels: list[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_replace_runner.py b/tests/engine/test_replace_runner.py index adc4d970..0963a016 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, @@ -256,7 +259,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 +301,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 From 5ed4b461b76f626a188f2759201b4023000d0998 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 1 Jul 2026 09:30:23 -0700 Subject: [PATCH 08/23] optional detection validity in rewrite Signed-off-by: memadi --- .../engine/rewrite/rewrite_workflow.py | 62 ++++++++++--------- src/anonymizer/interface/anonymizer.py | 11 +++- src/anonymizer/interface/display.py | 38 ++++++------ tests/engine/test_rewrite_workflow.py | 4 ++ 4 files changed, 64 insertions(+), 51 deletions(-) diff --git a/src/anonymizer/engine/rewrite/rewrite_workflow.py b/src/anonymizer/engine/rewrite/rewrite_workflow.py index 2a118445..e9a7b049 100644 --- a/src/anonymizer/engine/rewrite/rewrite_workflow.py +++ b/src/anonymizer/engine/rewrite/rewrite_workflow.py @@ -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.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 # --- 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 86011653..4ee6689b 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -421,6 +421,7 @@ def evaluate( model_configs=self._model_configs, selected_models=self._selected_models.evaluate, privacy_goal=rewrite_config, + compute_detection_validity=evaluate_config.compute_detection_validity, ) all_failed: list[FailedRecord] = list(rewrite_result.failed_records) coverage_wf = EntityCoverageWorkflow( @@ -435,7 +436,11 @@ def evaluate( all_failed.extend(coverage_failed) renamed_trace = _rename_output_columns(judged_df, resolved_text_column=text_column) return AnonymizerResult( - dataframe=_build_user_dataframe(renamed_trace, resolved_text_column=text_column), + 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, @@ -912,12 +917,12 @@ def _build_user_dataframe( 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, diff --git a/src/anonymizer/interface/display.py b/src/anonymizer/interface/display.py index 05d7a6d3..0ae76881 100644 --- a/src/anonymizer/interface/display.py +++ b/src/anonymizer/interface/display.py @@ -144,7 +144,7 @@ def _render_rewrite_html(row: pd.Series, *, text_col: str, record_index: int | N 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) + 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 "" @@ -582,35 +582,35 @@ def _verdict_badge(valid: object, correct: int, total: int) -> tuple[str, str]: return badge, rate_html -def _render_entity_coverage_section(row: pd.Series) -> str: - """Render entity coverage (recall metric) for replace mode. Returns "" when judge has not run.""" +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: - badge = "Unavailable" - fraction_html = "" + 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: - badge = "Satisfied" - n_detected = _count_detected_entity_label_pairs(row) - total = n_detected + n_leaked pct = 100 - fraction_html = f" — {total}/{total} ({pct}%)" + summary_html = f"Satisfied — {total}/{total} ({pct}%)" else: - badge = "Not Satisfied" - n_detected = _count_detected_entity_label_pairs(row) - total = n_detected + n_leaked - pct = round(coverage * 100) - fraction_html = f" — {n_detected}/{total} ({pct}%)" + covered = total - n_leaked + pct = round(float(coverage) * 100) + summary_html = f"Not Satisfied — {covered}/{total} ({pct}%)" - header = ( - "
" - f"Entity Coverage: {badge}{html.escape(fraction_html)}" - "
" - ) + header = f"
Entity Coverage: {summary_html}
" body = header if leaked_entries: 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[ From 2c5948ff63b38ccdfb44effb184d7c686efb4cf3 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 1 Jul 2026 17:50:38 -0700 Subject: [PATCH 09/23] add changes fro nemotron super test and entity coverage prompt Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 30 ++++++++++++------- tests/interface/test_anonymizer_interface.py | 6 ++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index a60d45f1..c2af58c2 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -8,7 +8,7 @@ from typing import ClassVar import pandas as pd -from data_designer.config.column_configs import LLMStructuredColumnConfig +from data_designer.config.column_configs import LLMTextColumnConfig from data_designer.config.models import ModelConfig from pydantic import BaseModel, Field @@ -19,6 +19,7 @@ COL_ENTITY_COVERAGE_JUDGE, COL_LEAKED_ENTITIES, COL_TEXT, + DEFAULT_ENTITY_LABELS, _jinja, ) from anonymizer.engine.evaluation.judge_base import JudgeResult, _BaseJudgeWorkflow @@ -105,6 +106,9 @@ def _coverage_prompt( else "" ) + active_labels = entity_labels if entity_labels is not None else DEFAULT_ENTITY_LABELS + labels_str = ", ".join(active_labels) + prompt = f"""You are a privacy auditor. Given ORIGINAL text and a list of PII ENTITIES that were \ detected by an anonymization pipeline, identify any direct or quasi-identifiers in the \ original text that are NOT covered by the detected entities. @@ -135,10 +139,11 @@ def _coverage_prompt( -Direct identifiers: person names, email addresses, phone numbers, SSNs, physical \ -addresses, IP addresses, usernames, account IDs, API keys / tokens. +The following entity types are considered sensitive and must be flagged if missed: {labels_str}. Quasi-identifiers: combinations of values that together re-identify someone \ -(e.g. job title + employer + city appearing together). +(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. @@ -168,9 +173,8 @@ def _coverage_prompt( -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 ONLY the JSON object that matches the required schema. Do NOT include any commentary, \ +reasoning, preamble, or text outside the JSON object. """ return substitute_placeholders( @@ -203,8 +207,13 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: if isinstance(raw, BaseModel): raw = raw.model_dump(mode="python") if isinstance(raw, str): + # Strip optional ```json ... ``` fence before parsing so models that + # return fenced or unfenced JSON are both handled. + stripped = raw.strip() + if stripped.startswith("```"): + stripped = stripped.split("\n", 1)[-1].rsplit("```", 1)[0].strip() try: - raw = json.loads(raw) + raw = json.loads(stripped) except (json.JSONDecodeError, ValueError): return None if not isinstance(raw, dict): @@ -279,16 +288,15 @@ def _extract_invalid(cls, parsed: BaseModel) -> list[dict[str, object]]: # ----------------------------------------------------------------- overrides - def column_config(self, selected_models: EvaluateModelSelection) -> LLMStructuredColumnConfig: + def column_config(self, selected_models: EvaluateModelSelection) -> LLMTextColumnConfig: """Override to inject instance-specific entity_labels and strict_entity_protection.""" - return LLMStructuredColumnConfig( + return LLMTextColumnConfig( name=self.RAW_COL, prompt=_coverage_prompt( entity_labels=self._entity_labels, strict_entity_protection=self._strict_entity_protection, ), model_alias=resolve_model_alias(self.MODEL_ROLE, selected_models), - output_format=self.SCHEMA, ) def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: diff --git a/tests/interface/test_anonymizer_interface.py b/tests/interface/test_anonymizer_interface.py index f307a609..3cf92c56 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,11 +897,12 @@ 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 From 277a7a2c77e7bd6a67b91790a6e99e1f3ddf91d8 Mon Sep 17 00:00:00 2001 From: memadi Date: Wed, 8 Jul 2026 17:44:11 -0700 Subject: [PATCH 10/23] include strict entity protection in evaluate Signed-off-by: memadi --- src/anonymizer/interface/anonymizer.py | 10 ++++ src/anonymizer/interface/results.py | 10 ++++ tests/interface/test_anonymizer_interface.py | 61 ++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index 4ee6689b..2de90951 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -330,6 +330,9 @@ def preview( 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 + ), ) except KeyboardInterrupt: status = TaskStatusEnum.CANCELED @@ -409,6 +412,7 @@ def evaluate( raise InvalidConfigError(str(exc)) from exc entity_labels = getattr(output, "entity_labels", None) + strict_entity_protection = getattr(output, "strict_entity_protection", False) # 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 @@ -427,6 +431,7 @@ def evaluate( coverage_wf = EntityCoverageWorkflow( adapter=self._adapter, entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, ) judged_df, coverage_failed = coverage_wf.run_non_critical( rewrite_result.dataframe, @@ -446,6 +451,7 @@ def evaluate( failed_records=all_failed, rewrite_config=rewrite_config, entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, ) replace_result = self._replace_runner.evaluate( @@ -468,6 +474,7 @@ def evaluate( failed_records=replace_result.failed_records, replace_method=replace_method, entity_labels=entity_labels, + strict_entity_protection=strict_entity_protection, ) def validate_config(self, config: AnonymizerConfig) -> None: @@ -659,6 +666,9 @@ def _run_internal_impl( 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 + ), ) def _validate_preflight_config(self, config: AnonymizerConfig) -> None: diff --git a/src/anonymizer/interface/results.py b/src/anonymizer/interface/results.py index 9b002d03..51c29cf4 100644 --- a/src/anonymizer/interface/results.py +++ b/src/anonymizer/interface/results.py @@ -64,6 +64,10 @@ 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). """ dataframe: pd.DataFrame @@ -73,6 +77,7 @@ class AnonymizerResult(_DisplayMixin): replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None entity_labels: list[str] | None = None + strict_entity_protection: bool = False _display_cycle_index: int = field(default=0, init=False, repr=False) def __repr__(self) -> str: @@ -106,6 +111,10 @@ 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). """ dataframe: pd.DataFrame @@ -116,6 +125,7 @@ class PreviewResult(_DisplayMixin): replace_method: ReplaceMethod | None = None rewrite_config: PrivacyGoal | None = None entity_labels: list[str] | None = None + strict_entity_protection: bool = False _display_cycle_index: int = field(default=0, init=False, repr=False) def __repr__(self) -> str: diff --git a/tests/interface/test_anonymizer_interface.py b/tests/interface/test_anonymizer_interface.py index 3cf92c56..cc4739dd 100644 --- a/tests/interface/test_anonymizer_interface.py +++ b/tests/interface/test_anonymizer_interface.py @@ -992,3 +992,64 @@ 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 From 6618369725705cd0c0c629064351e96de686e265 Mon Sep 17 00:00:00 2001 From: memadi Date: Tue, 14 Jul 2026 13:49:05 -0700 Subject: [PATCH 11/23] add post processing for entity coverage Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 184 ++++++++++++++---- tests/engine/test_entity_coverage_judge.py | 98 ++++++++++ 2 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 tests/engine/test_entity_coverage_judge.py diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index c2af58c2..992f8583 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -5,6 +5,7 @@ import json import logging +import re from typing import ClassVar import pandas as pd @@ -31,8 +32,32 @@ logger = logging.getLogger("anonymizer.evaluation.entity_coverage_judge") -_ENTITIES_FOR_COVERAGE_COL = "_entities_for_coverage_judge" -_N_ENTITIES_DETECTED_COL = "_n_entities_detected_for_coverage" +_FINAL_ENTITIES_FOR_COVERAGE_COL = "_final_entities_for_coverage_judge" +_N_FINAL_ENTITIES_FOR_COVERAGE_COL = "_n_final_entities_for_coverage" +_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", + } +) # --------------------------------------------------------------------------- @@ -109,23 +134,23 @@ def _coverage_prompt( active_labels = entity_labels if entity_labels is not None else DEFAULT_ENTITY_LABELS labels_str = ", ".join(active_labels) - prompt = f"""You are a privacy auditor. Given ORIGINAL text and a list of PII ENTITIES that were \ -detected by an anonymization pipeline, identify any direct or quasi-identifiers in the \ -original text that are NOT covered by the detected entities. + prompt = f"""You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ +identify any direct or quasi-identifiers in the original text that are NOT covered by the \ +anonymizer final entities. <> - + {{%- if <> %}} {{%- for entity in <> %}} - value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} {{%- endfor %}} {{%- else %}} -(none detected) +(none) {{%- endif %}} - + {entity_scope_block} @@ -133,7 +158,7 @@ def _coverage_prompt( An identifier is "covered" (already protected) if: -- Its exact value appears in the detected entities list, OR +- Its exact value appears in the anonymizer final entities list, OR - Its value is a substring of any entity value in the list (e.g. a bare username is \ covered if a full file path containing that username is listed as an entity). @@ -148,8 +173,8 @@ def _coverage_prompt( Identify every direct or quasi-identifier present in the original text that is NOT covered \ -by the detected entities. These are the "leaked" entities — identifiers that would survive \ -anonymization because the detector missed them. +by the anonymizer final entities. These are the "leaked" entities — identifiers that would \ +survive anonymization because they are absent from the final entity set. Only report findings you are HIGH CONFIDENCE about. @@ -166,8 +191,8 @@ def _coverage_prompt( Do flag: - The `value` MUST be a literal substring found in the original text. -- `reasoning` MUST be one sentence explaining why this value is not covered by the detected entities. -- If `detected_entities` is empty, scan the full text for any direct or quasi-identifiers. +- `reasoning` MUST be one sentence explaining why this value is not covered by the anonymizer final entities. +- If `anonymizer_final_entities` is empty, scan the full text for any direct or quasi-identifiers. {entity_scope_guidance} {strict_guidance} @@ -181,7 +206,7 @@ def _coverage_prompt( prompt, { "<>": _jinja(COL_TEXT), - "<>": _ENTITIES_FOR_COVERAGE_COL, + "<>": _FINAL_ENTITIES_FOR_COVERAGE_COL, }, ) @@ -191,7 +216,7 @@ def _coverage_prompt( # --------------------------------------------------------------------------- -def _entities_for_coverage(parsed: EntitiesByValueSchema) -> list[dict[str, str]]: +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] @@ -207,13 +232,8 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: if isinstance(raw, BaseModel): raw = raw.model_dump(mode="python") if isinstance(raw, str): - # Strip optional ```json ... ``` fence before parsing so models that - # return fenced or unfenced JSON are both handled. - stripped = raw.strip() - if stripped.startswith("```"): - stripped = stripped.split("\n", 1)[-1].rsplit("```", 1)[0].strip() try: - raw = json.loads(stripped) + raw = json.loads(raw) except (json.JSONDecodeError, ValueError): return None if not isinstance(raw, dict): @@ -225,9 +245,97 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: return [e.model_dump() for e in parsed.leaked_entities] -def _compute_coverage(n_detected: int, n_leaked: int) -> float: - total = n_detected + n_leaked - return 1.0 if total == 0 else n_detected / total +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 _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 (core) tokens are a subset of a *single* final entity's tokens + (``"Mstr"`` ⊂ ``"Mstr Marzella"``, ``"44"`` ⊂ ``"44 Dunsfold Drive"``), or + - **composite** — its tokens are a concatenation of *whole* final-entity values + (``"Nawabganj - 382210"`` == ``"Nawabganj"`` + ``"382210"``). + + Matching is on whole tokens, so a leak only matches a final token it equals + (``"m"`` is not covered by ``"Mstr Marzella"``: ``"m"`` != ``"mstr"``). Grammatical + stopwords (see ``_COVERAGE_IGNORE_TOKENS``) are dropped from the leak's core so + article/preposition noise does not block a subspan match. + """ + 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 of a single final entity. + leaked_core = set(leaked_tokens) - _COVERAGE_IGNORE_TOKENS + if leaked_core and any(leaked_core <= set(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 _compute_coverage(n_final: int, n_leaked: int) -> float: + total = n_final + n_leaked + return 1.0 if total == 0 else n_final / total # --------------------------------------------------------------------------- @@ -242,7 +350,7 @@ class EntityCoverageWorkflow(_BaseJudgeWorkflow): anonymizer already detected. The delta gives the leaked (missed) entities. Output columns: - ``COL_ENTITY_COVERAGE`` (float|None) — n_detected / (n_detected + n_leaked) + ``COL_ENTITY_COVERAGE`` (float|None) — n_final / (n_final + n_leaked) ``COL_LEAKED_ENTITIES`` (list) — missed entities with value, label, reasoning """ @@ -271,12 +379,12 @@ def __init__( 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[_ENTITIES_FOR_COVERAGE_COL] = parsed.apply(_entities_for_coverage) - working_df[_N_ENTITIES_DETECTED_COL] = working_df[_ENTITIES_FOR_COVERAGE_COL].apply(len) + working_df[_FINAL_ENTITIES_FOR_COVERAGE_COL] = parsed.apply(_final_entities_for_coverage) + working_df[_N_FINAL_ENTITIES_FOR_COVERAGE_COL] = working_df[_FINAL_ENTITIES_FOR_COVERAGE_COL].apply(len) return working_df def _passthrough_mask(self, dataframe: pd.DataFrame) -> pd.Series: - return dataframe[_ENTITIES_FOR_COVERAGE_COL].apply(lambda items: items is None or len(items) == 0) + return dataframe[_FINAL_ENTITIES_FOR_COVERAGE_COL].apply(lambda items: items is None or len(items) == 0) @classmethod def _build_prompt(cls) -> str: @@ -314,14 +422,17 @@ def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: else: raw = out[self.RAW_COL].loc[idx] if self.RAW_COL in out.columns else None leaked = _parse_leaked_entities(raw) - n_detected = ( - int(out[_N_ENTITIES_DETECTED_COL].loc[idx]) if _N_ENTITIES_DETECTED_COL in out.columns else 0 + n_final = ( + int(out[_N_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) + if _N_FINAL_ENTITIES_FOR_COVERAGE_COL in out.columns + else 0 ) if leaked is None: coverage_vals.append(None) leaked_lists.append([]) else: - coverage_vals.append(_compute_coverage(n_detected, len(leaked))) + leaked = _filter_covered_leaked_entities(leaked, out[_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) + coverage_vals.append(_compute_coverage(n_final, len(leaked))) leaked_lists.append(leaked) out[self.VALID_COL] = coverage_vals @@ -401,16 +512,19 @@ def evaluate( for idx in judged_df.index: raw = judged_df[self.RAW_COL].loc[idx] if self.RAW_COL in judged_df.columns else None leaked = _parse_leaked_entities(raw) - n_detected = ( - int(judged_df[_N_ENTITIES_DETECTED_COL].loc[idx]) - if _N_ENTITIES_DETECTED_COL in judged_df.columns + n_final = ( + int(judged_df[_N_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) + if _N_FINAL_ENTITIES_FOR_COVERAGE_COL in judged_df.columns else 0 ) if leaked is None: coverage_vals.append(None) leaked_lists.append([]) else: - coverage_vals.append(_compute_coverage(n_detected, len(leaked))) + leaked = _filter_covered_leaked_entities( + leaked, judged_df[_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx] + ) + coverage_vals.append(_compute_coverage(n_final, len(leaked))) leaked_lists.append(leaked) judged_df[self.VALID_COL] = coverage_vals judged_df[self.INVALID_COL] = leaked_lists diff --git a/tests/engine/test_entity_coverage_judge.py b/tests/engine/test_entity_coverage_judge.py new file mode 100644 index 00000000..5188fe61 --- /dev/null +++ b/tests/engine/test_entity_coverage_judge.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest + +from anonymizer.engine.evaluation.entity_coverage_judge import ( + _filter_covered_leaked_entities, + _is_leaked_value_covered, +) + + +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 + ("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"]), + # 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 From 45875472994ba10ae942f3bf0f0963e0ca89c176 Mon Sep 17 00:00:00 2001 From: memadi Date: Tue, 14 Jul 2026 13:57:39 -0700 Subject: [PATCH 12/23] add nemotron ultra as entity coverage judge Signed-off-by: memadi --- .../config/default_model_configs/evaluate.yaml | 2 +- .../config/default_model_configs/models.yaml | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/anonymizer/config/default_model_configs/evaluate.yaml b/src/anonymizer/config/default_model_configs/evaluate.yaml index 62729c8d..7c9c2791 100644 --- a/src/anonymizer/config/default_model_configs/evaluate.yaml +++ b/src/anonymizer/config/default_model_configs/evaluate.yaml @@ -7,7 +7,7 @@ selected_models: # --- Shared --- - entity_coverage_judge: gpt-oss-120b + entity_coverage_judge: nemotron-ultra 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..a4f40430 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-ultra + model: nvidia/nemotron-3-ultra-550b-a55b + provider: nvidia + inference_parameters: + max_parallel_requests: 16 + max_tokens: 16384 + temperature: 0.0 + top_p: 0.95 + timeout: 300 + extra_body: + reasoning_effort: none + chat_template_kwargs: + enable_thinking: false From da98003b3bc8b72e5340bf0b20ea26c64f0fe5f9 Mon Sep 17 00:00:00 2001 From: memadi Date: Thu, 16 Jul 2026 15:22:28 -0700 Subject: [PATCH 13/23] explore entity coverage judge prompt Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 357 ++++++++++++------ 1 file changed, 248 insertions(+), 109 deletions(-) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index 992f8583..d88722ec 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -17,7 +17,9 @@ from anonymizer.engine.constants import ( COL_ENTITIES_BY_VALUE, COL_ENTITY_COVERAGE, + COL_ENTITY_COVERAGE_CANDIDATE_TOTAL, COL_ENTITY_COVERAGE_JUDGE, + COL_ENTITY_COVERAGE_TOTAL, COL_LEAKED_ENTITIES, COL_TEXT, DEFAULT_ENTITY_LABELS, @@ -33,7 +35,6 @@ logger = logging.getLogger("anonymizer.evaluation.entity_coverage_judge") _FINAL_ENTITIES_FOR_COVERAGE_COL = "_final_entities_for_coverage_judge" -_N_FINAL_ENTITIES_FOR_COVERAGE_COL = "_n_final_entities_for_coverage" _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"). @@ -138,70 +139,170 @@ def _coverage_prompt( identify any direct or quasi-identifiers in the original text that are NOT covered by the \ anonymizer final entities. - -<> - - - -{{%- if <> %}} -{{%- for entity in <> %}} -- value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} -{{%- endfor %}} -{{%- else %}} -(none) -{{%- endif %}} - - -{entity_scope_block} + +Identify every direct or quasi-identifier present in the original text that is NOT covered \ +by the anonymizer final entities. These are the "leaked" entities — identifiers that would \ +survive anonymization because they are absent from the final entity set. -{strict_block} +Report every literal candidate whose in-scope semantic type is supported by the original-text context. +Do not require a value to identify someone on its own; direct and quasi-identifiers are both reportable. - -An identifier is "covered" (already protected) if: -- Its exact value appears in the anonymizer final entities list, OR -- Its value is a substring of any entity value in the list (e.g. a bare username is \ -covered if a full file path containing that username is listed as an entity). - +Return structured JSON: +- `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. +- Return an empty list if the anonymizer covered all identifiers. + -The following entity types are considered sensitive and must be flagged if missed: {labels_str}. +These entity types are sensitive and should be flagged when a value of that type is present but \ +not covered by the anonymizer final entities: {labels_str}. +Check each type and report missed values, including ones that are easy to overlook — but report \ +only a concrete identifying value (an actual name, code, date, or attribute value), not a pronoun \ +or a generic reference that merely implies a type. 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. - -Identify every direct or quasi-identifier present in the original text that is NOT covered \ -by the anonymizer final entities. These are the "leaked" entities — identifiers that would \ -survive anonymization because they are absent from the final entity set. +{entity_scope_block} -Only report findings you are HIGH CONFIDENCE about. + +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. + -Return structured JSON: -- `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. -- Return an empty list if the anonymizer covered all identifiers. - + +An identifier is "covered" (already protected) if: +- Its exact value appears in the anonymizer final entities list, OR +- Its complete tokens are contained within one final entity value, OR +- Its value is composed entirely of complete final entity values. +Partial character matches, similar meanings, or shared context alone do not establish coverage. + + + +Before reporting an entity, verify ALL of the following: +1. Its value is a literal, non-empty span in the original text. +2. It is an actual data value, not syntax or metadata such as a field name, heading, \ +form instruction, blank placeholder, document title, or category label. +3. Its semantic type matches one of the entity types in scope. +4. It is not already covered by an anonymizer final entity. +5. 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. +6. Evaluate the value using its original-text context rather than its form or how identifying \ +it appears in isolation. + +In structured or semi-structured text, distinguish: +- Syntax and metadata: field names, headings, instructions, placeholders, category labels, \ +formatting, and other structural text. +- Data: assigned values, cell contents, literals, and user-provided content. +Report only the smallest sensitive data value. Do not report surrounding syntax or metadata \ +unless it independently contains a literal sensitive value in scope. + Do NOT flag: -- Items that are substrings of, or closely matched by, a listed entity value. -- Generic technical terms, common words, or place names that cannot re-identify a specific individual on their own. +- Items that satisfy the coverage_definition. +- 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 why this value is not covered by the anonymizer final entities. - If `anonymizer_final_entities` is empty, scan the full text for any direct or quasi-identifiers. +- 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} + + + +<> + + + +{{%- if <> %}} +{{%- for entity in <> %}} +- value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} +{{%- endfor %}} +{{%- else %}} +(none) +{{%- endif %}} + + + -Return ONLY the JSON object that matches the required schema. Do NOT include any commentary, \ -reasoning, preamble, or text outside the JSON object. +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. """ + + +# prompt = f""" +# You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ +# identify every direct or quasi-identifier in the original text that is NOT covered by the \ +# anonymizer final entities. These are the "leaked" entities. + +# Return structured JSON: +# - `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. +# - Return an empty list if the anonymizer covered all identifiers. +# + +# +# These entity types are sensitive and should be flagged when a value of that type is present but \ +# not covered by the anonymizer final entities: {labels_str}. +# 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. +# + +# {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. +# + +# +# An identifier is "covered" (already protected) if: +# - Its exact value appears in the anonymizer final entities list, OR +# - Its value is covered by a listed entity value or by a combination of listed entity values. +# + +# {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. +# + +# --- +# +# <> +# + +# +# {{%- if <> %}} +# {{%- for entity in <> %}} +# - value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} +# {{%- endfor %}} +# {{%- else %}} +# (none) +# {{%- endif %}} +# +# """ return substitute_placeholders( prompt, { @@ -222,7 +323,7 @@ def _final_entities_for_coverage(parsed: EntitiesByValueSchema) -> list[dict[str def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: - """Parse raw LLM output into the leaked entities list. + """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". @@ -232,9 +333,8 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: if isinstance(raw, BaseModel): raw = raw.model_dump(mode="python") if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): + raw = _parse_json_object(raw) + if raw is None: return None if not isinstance(raw, dict): return None @@ -245,6 +345,28 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: return [e.model_dump() for e in parsed.leaked_entities] +def _parse_judge_entities(raw: object) -> list[dict[str, object]] | None: + """Compatibility alias for experiment notebooks parsing raw judge output.""" + return _parse_leaked_entities(raw) + + +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). @@ -333,9 +455,50 @@ def _filter_covered_leaked_entities( ] -def _compute_coverage(n_final: int, n_leaked: int) -> float: - total = n_final + n_leaked - return 1.0 if total == 0 else n_final / total +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 + + +def _compare_judge_to_final( + judge_entities: list[dict[str, object]], + final_entities: object, +) -> tuple[float, list[dict[str, object]], int]: + """Return independent-judge recall, missed entities, and judge total.""" + deduplicated = _deduplicate_judge_entities(judge_entities) + leaked = _filter_covered_leaked_entities(deduplicated, final_entities) + total = len(deduplicated) + coverage = 1.0 if total == 0 else (total - len(leaked)) / total + return coverage, leaked, total # --------------------------------------------------------------------------- @@ -344,10 +507,10 @@ def _compute_coverage(n_final: int, n_leaked: int) -> float: class EntityCoverageWorkflow(_BaseJudgeWorkflow): - """LLM-as-judge that measures recall: what fraction of PII did the anonymizer catch? + """LLM judge that reports entities not covered by Anonymizer final entities. - Runs on the **original text** and compares all PII present against what the - anonymizer already detected. The delta gives the leaked (missed) entities. + The judge sees the original text, entity-type scope, and final entities. + Deterministic postprocessing removes nonliteral and already-covered findings. Output columns: ``COL_ENTITY_COVERAGE`` (float|None) — n_final / (n_final + n_leaked) @@ -380,11 +543,11 @@ 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) - working_df[_N_FINAL_ENTITIES_FOR_COVERAGE_COL] = working_df[_FINAL_ENTITIES_FOR_COVERAGE_COL].apply(len) return working_df def _passthrough_mask(self, dataframe: pd.DataFrame) -> pd.Series: - return dataframe[_FINAL_ENTITIES_FOR_COVERAGE_COL].apply(lambda items: items is None or len(items) == 0) + # 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: @@ -408,37 +571,39 @@ def column_config(self, selected_models: EvaluateModelSelection) -> LLMTextColum ) def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: - """Override to write float coverage instead of bool verdict.""" + """Validate judge-reported leaks and calculate coverage.""" out = dataframe.copy() - passthrough_mask = self._passthrough_mask(out) coverage_vals: list[float | None] = [] leaked_lists: list[list[dict]] = [] + total_vals: list[int | None] = [] + candidate_total_vals: list[int | None] = [] for idx in out.index: - if passthrough_mask.loc[idx]: - coverage_vals.append(1.0) + 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([]) + total_vals.append(None) + candidate_total_vals.append(None) else: - raw = out[self.RAW_COL].loc[idx] if self.RAW_COL in out.columns else None - leaked = _parse_leaked_entities(raw) - n_final = ( - int(out[_N_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) - if _N_FINAL_ENTITIES_FOR_COVERAGE_COL in out.columns - else 0 - ) - if leaked is None: - coverage_vals.append(None) - leaked_lists.append([]) - else: - leaked = _filter_covered_leaked_entities(leaked, out[_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) - coverage_vals.append(_compute_coverage(n_final, len(leaked))) - leaked_lists.append(leaked) + leaked = _filter_nonliteral_entities(leaked, out[COL_TEXT].loc[idx]) + leaked = _deduplicate_judge_entities(leaked) + candidate_total_vals.append(len(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) + total_vals.append(total) out[self.VALID_COL] = coverage_vals out[self.INVALID_COL] = leaked_lists - if self.RAW_COL in out.columns: - out.loc[passthrough_mask, self.RAW_COL] = [self.DEFAULT_PAYLOAD] * int(passthrough_mask.sum()) + out[COL_ENTITY_COVERAGE_TOTAL] = total_vals + out[COL_ENTITY_COVERAGE_CANDIDATE_TOTAL] = candidate_total_vals return out def run_non_critical( @@ -463,7 +628,13 @@ def run_non_critical( preview_num_records=preview_num_records, ) out = dataframe.copy() - for col in (self.VALID_COL, self.INVALID_COL): + for col in ( + self.RAW_COL, + self.VALID_COL, + self.INVALID_COL, + COL_ENTITY_COVERAGE_TOTAL, + COL_ENTITY_COVERAGE_CANDIDATE_TOTAL, + ): if col in result.dataframe.columns: out[col] = result.dataframe[col].values return out, result.failed_records @@ -472,6 +643,8 @@ def run_non_critical( out = dataframe.copy() out[self.VALID_COL] = None out[self.INVALID_COL] = [[] for _ in range(len(out))] + out[COL_ENTITY_COVERAGE_TOTAL] = None + out[COL_ENTITY_COVERAGE_CANDIDATE_TOTAL] = None return out, [] def evaluate( @@ -482,52 +655,18 @@ def evaluate( selected_models: EvaluateModelSelection, preview_num_records: int | None = None, ) -> JudgeResult: - """Standalone entry point. Overrides base to write float coverage instead of bool.""" + """Run leak detection against the supplied final entities.""" working_df = self.prepare(dataframe) working_df[ROW_ORDER_COL] = range(len(working_df)) - passthrough_mask = self._passthrough_mask(working_df) - passthrough_rows = working_df[passthrough_mask].copy() - with_content = working_df[~passthrough_mask].copy() - - passthrough_rows[self.RAW_COL] = [self.DEFAULT_PAYLOAD for _ in range(len(passthrough_rows))] - passthrough_rows[self.VALID_COL] = 1.0 - passthrough_rows[self.INVALID_COL] = [[] for _ in range(len(passthrough_rows))] - - if with_content.empty: - combined = merge_and_reorder(passthrough_rows) - return JudgeResult(dataframe=combined, failed_records=[]) - - effective_preview = min(preview_num_records, len(with_content)) if preview_num_records is not None else None + effective_preview = min(preview_num_records, len(working_df)) if preview_num_records is not None else None run_result = self._adapter.run_workflow( - with_content, + working_df, model_configs=model_configs, columns=[self.column_config(selected_models)], workflow_name=self.WORKFLOW_NAME, preview_num_records=effective_preview, ) - judged_df = run_result.dataframe.copy() - coverage_vals: list[float | None] = [] - leaked_lists: list[list[dict]] = [] - for idx in judged_df.index: - raw = judged_df[self.RAW_COL].loc[idx] if self.RAW_COL in judged_df.columns else None - leaked = _parse_leaked_entities(raw) - n_final = ( - int(judged_df[_N_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx]) - if _N_FINAL_ENTITIES_FOR_COVERAGE_COL in judged_df.columns - else 0 - ) - if leaked is None: - coverage_vals.append(None) - leaked_lists.append([]) - else: - leaked = _filter_covered_leaked_entities( - leaked, judged_df[_FINAL_ENTITIES_FOR_COVERAGE_COL].loc[idx] - ) - coverage_vals.append(_compute_coverage(n_final, len(leaked))) - leaked_lists.append(leaked) - judged_df[self.VALID_COL] = coverage_vals - judged_df[self.INVALID_COL] = leaked_lists - - combined = merge_and_reorder(judged_df, passthrough_rows) + judged_df = self.postprocess(run_result.dataframe) + combined = merge_and_reorder(judged_df) return JudgeResult(dataframe=combined, failed_records=run_result.failed_records) From b89481b5be64b702786325325546827cbebfd98e Mon Sep 17 00:00:00 2001 From: memadi Date: Thu, 16 Jul 2026 15:26:13 -0700 Subject: [PATCH 14/23] nit Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 121 +++++++++--------- src/anonymizer/interface/anonymizer.py | 4 +- 2 files changed, 59 insertions(+), 66 deletions(-) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index d88722ec..e3231d6f 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -216,7 +216,7 @@ def _coverage_prompt( 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} @@ -246,63 +246,62 @@ def _coverage_prompt( """ - -# prompt = f""" -# You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ -# identify every direct or quasi-identifier in the original text that is NOT covered by the \ -# anonymizer final entities. These are the "leaked" entities. - -# Return structured JSON: -# - `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. -# - Return an empty list if the anonymizer covered all identifiers. -# - -# -# These entity types are sensitive and should be flagged when a value of that type is present but \ -# not covered by the anonymizer final entities: {labels_str}. -# 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. -# - -# {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. -# - -# -# An identifier is "covered" (already protected) if: -# - Its exact value appears in the anonymizer final entities list, OR -# - Its value is covered by a listed entity value or by a combination of listed entity values. -# - -# {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. -# - -# --- -# -# <> -# - -# -# {{%- if <> %}} -# {{%- for entity in <> %}} -# - value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} -# {{%- endfor %}} -# {{%- else %}} -# (none) -# {{%- endif %}} -# -# """ + # prompt = f""" + # You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ + # identify every direct or quasi-identifier in the original text that is NOT covered by the \ + # anonymizer final entities. These are the "leaked" entities. + + # Return structured JSON: + # - `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. + # - Return an empty list if the anonymizer covered all identifiers. + # + + # + # These entity types are sensitive and should be flagged when a value of that type is present but \ + # not covered by the anonymizer final entities: {labels_str}. + # 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. + # + + # {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. + # + + # + # An identifier is "covered" (already protected) if: + # - Its exact value appears in the anonymizer final entities list, OR + # - Its value is covered by a listed entity value or by a combination of listed entity values. + # + + # {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. + # + + # --- + # + # <> + # + + # + # {{%- if <> %}} + # {{%- for entity in <> %}} + # - value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} + # {{%- endfor %}} + # {{%- else %}} + # (none) + # {{%- endif %}} + # + # """ return substitute_placeholders( prompt, { @@ -448,11 +447,7 @@ def _filter_covered_leaked_entities( 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) - ] + 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: diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index 2de90951..d6c17770 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -666,9 +666,7 @@ def _run_internal_impl( 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 - ), + strict_entity_protection=(config.rewrite.strict_entity_protection if config.rewrite is not None else False), ) def _validate_preflight_config(self, config: AnonymizerConfig) -> None: From fddb437c49dd85f5cb7369d657fab6db59235b1b Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 17 Jul 2026 19:47:42 +0000 Subject: [PATCH 15/23] improve entity coverage candidate extraction Separate exhaustive candidate extraction from deterministic coverage filtering and add regression coverage for structured responses and prompt behavior. Signed-off-by: memadi --- src/anonymizer/engine/constants.py | 2 + .../evaluation/entity_coverage_judge.py | 137 ++++-------------- tests/engine/test_entity_coverage_judge.py | 43 ++++++ 3 files changed, 74 insertions(+), 108 deletions(-) diff --git a/src/anonymizer/engine/constants.py b/src/anonymizer/engine/constants.py index 832da71d..3dea4a17 100644 --- a/src/anonymizer/engine/constants.py +++ b/src/anonymizer/engine/constants.py @@ -68,6 +68,8 @@ # 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_ENTITY_COVERAGE_TOTAL = "entity_coverage_total" # final entities + leaked entities +COL_ENTITY_COVERAGE_CANDIDATE_TOTAL = "_entity_coverage_candidate_total" # validated judge candidates COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list of {value, label, reasoning} # Replace evaluation: type-fidelity judge (Substitute only) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index e3231d6f..2129eea5 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -135,29 +135,23 @@ def _coverage_prompt( active_labels = entity_labels if entity_labels is not None else DEFAULT_ENTITY_LABELS labels_str = ", ".join(active_labels) - prompt = f"""You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ -identify any direct or quasi-identifiers in the original text that are NOT covered by the \ -anonymizer final entities. + prompt = f"""You are an exhaustive privacy-entity span extractor. Extract every literal value \ +in the ORIGINAL TEXT whose semantic type is in scope. -Identify every direct or quasi-identifier present in the original text that is NOT covered \ -by the anonymizer final entities. These are the "leaked" entities — identifiers that would \ -survive anonymization because they are absent from the final entity set. - -Report every literal candidate whose in-scope semantic type is supported by the original-text context. -Do not require a value to identify someone on its own; direct and quasi-identifiers are both reportable. +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`: list every missed identifier with its `value`, `label`, and a short `reasoning`. -- Return an empty list if the anonymizer covered all identifiers. +- `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 sensitive and should be flagged when a value of that type is present but \ -not covered by the anonymizer final entities: {labels_str}. -Check each type and report missed values, including ones that are easy to overlook — but report \ -only a concrete identifying value (an actual name, code, date, or attribute value), not a pronoun \ -or a generic reference that merely implies a type. +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 \ @@ -172,45 +166,40 @@ def _coverage_prompt( context. Return labels exactly as they appear in the entity_type_scope. - -An identifier is "covered" (already protected) if: -- Its exact value appears in the anonymizer final entities list, OR -- Its complete tokens are contained within one final entity value, OR -- Its value is composed entirely of complete final entity values. -Partial character matches, similar meanings, or shared context alone do not establish coverage. - + +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. + -Before reporting an entity, verify ALL of the following: +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 data value, not syntax or metadata such as a field name, heading, \ -form instruction, blank placeholder, document title, or category label. +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. It is not already covered by an anonymizer final entity. -5. Report the complete contiguous span that represents one sensitive value. Preserve all \ +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. -6. Evaluate the value using its original-text context rather than its form or how identifying \ +5. Evaluate the value using its original-text context rather than its form or how identifying \ it appears in isolation. -In structured or semi-structured text, distinguish: -- Syntax and metadata: field names, headings, instructions, placeholders, category labels, \ -formatting, and other structural text. -- Data: assigned values, cell contents, literals, and user-provided content. -Report only the smallest sensitive data value. Do not report surrounding syntax or metadata \ -unless it independently contains a literal sensitive value in scope. +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: -- Items that satisfy the coverage_definition. - 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 why this value is not covered by the anonymizer final entities. -- If `anonymizer_final_entities` is empty, scan the full text for any direct or quasi-identifiers. +- `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, @@ -227,16 +216,6 @@ def _coverage_prompt( <> - - -{{%- if <> %}} -{{%- for entity in <> %}} -- value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} -{{%- endfor %}} -{{%- else %}} -(none) -{{%- endif %}} - @@ -245,68 +224,10 @@ def _coverage_prompt( outside the JSON object. Your entire response must be a single valid JSON object. """ - - # prompt = f""" - # You are a privacy auditor. Given ORIGINAL text and a list of ANONYMIZER FINAL ENTITIES, \ - # identify every direct or quasi-identifier in the original text that is NOT covered by the \ - # anonymizer final entities. These are the "leaked" entities. - - # Return structured JSON: - # - `leaked_entities`: list every missed identifier with its `value`, `label`, and a short `reasoning`. - # - Return an empty list if the anonymizer covered all identifiers. - # - - # - # These entity types are sensitive and should be flagged when a value of that type is present but \ - # not covered by the anonymizer final entities: {labels_str}. - # 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. - # - - # {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. - # - - # - # An identifier is "covered" (already protected) if: - # - Its exact value appears in the anonymizer final entities list, OR - # - Its value is covered by a listed entity value or by a combination of listed entity values. - # - - # {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. - # - - # --- - # - # <> - # - - # - # {{%- if <> %}} - # {{%- for entity in <> %}} - # - value="{{{{ entity.value }}}}" | label={{{{ entity.label }}}} - # {{%- endfor %}} - # {{%- else %}} - # (none) - # {{%- endif %}} - # - # """ return substitute_placeholders( prompt, { "<>": _jinja(COL_TEXT), - "<>": _FINAL_ENTITIES_FOR_COVERAGE_COL, }, ) @@ -504,8 +425,8 @@ def _compare_judge_to_final( class EntityCoverageWorkflow(_BaseJudgeWorkflow): """LLM judge that reports entities not covered by Anonymizer final entities. - The judge sees the original text, entity-type scope, and final entities. - Deterministic postprocessing removes nonliteral and already-covered findings. + 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) diff --git a/tests/engine/test_entity_coverage_judge.py b/tests/engine/test_entity_coverage_judge.py index 5188fe61..a6db435d 100644 --- a/tests/engine/test_entity_coverage_judge.py +++ b/tests/engine/test_entity_coverage_judge.py @@ -6,8 +6,10 @@ import pytest from anonymizer.engine.evaluation.entity_coverage_judge import ( + _coverage_prompt, _filter_covered_leaked_entities, _is_leaked_value_covered, + _parse_leaked_entities, ) @@ -96,3 +98,44 @@ def test_filter_covered_leaked_entities_passthrough_on_no_final_entities() -> No 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 From 6f8d2e00bd106c4821accc67da2216a4683388fc Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 17 Jul 2026 16:54:07 -0700 Subject: [PATCH 16/23] add data summary to entity coverage Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 20 ++++++++- .../engine/replace/replace_runner.py | 2 + src/anonymizer/interface/anonymizer.py | 7 +++ src/anonymizer/interface/results.py | 8 ++++ tests/engine/test_entity_coverage_judge.py | 24 +++++++++++ tests/interface/test_anonymizer_interface.py | 43 +++++++++++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index 2129eea5..a344b064 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -113,13 +113,28 @@ def _strict_protection_block(strict_entity_protection: bool) -> str: ) +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." @@ -156,7 +171,7 @@ def _coverage_prompt( (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} @@ -448,10 +463,12 @@ def __init__( *, 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 @@ -482,6 +499,7 @@ def column_config(self, selected_models: EvaluateModelSelection) -> LLMTextColum 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), ) diff --git a/src/anonymizer/engine/replace/replace_runner.py b/src/anonymizer/engine/replace/replace_runner.py index 1395a01a..acc3876a 100644 --- a/src/anonymizer/engine/replace/replace_runner.py +++ b/src/anonymizer/engine/replace/replace_runner.py @@ -118,6 +118,7 @@ def evaluate( 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. @@ -147,6 +148,7 @@ def evaluate( 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( diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index d6c17770..a8915338 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -333,6 +333,7 @@ def preview( 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 @@ -413,6 +414,7 @@ def evaluate( entity_labels = getattr(output, "entity_labels", None) strict_entity_protection = getattr(output, "strict_entity_protection", False) + data_summary = getattr(output, "data_summary", None) # 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 @@ -432,6 +434,7 @@ def evaluate( adapter=self._adapter, entity_labels=entity_labels, strict_entity_protection=strict_entity_protection, + data_summary=data_summary, ) judged_df, coverage_failed = coverage_wf.run_non_critical( rewrite_result.dataframe, @@ -452,6 +455,7 @@ def evaluate( rewrite_config=rewrite_config, entity_labels=entity_labels, strict_entity_protection=strict_entity_protection, + data_summary=data_summary, ) replace_result = self._replace_runner.evaluate( @@ -461,6 +465,7 @@ def evaluate( selected_models=self._selected_models.evaluate, entity_labels=entity_labels, compute_detection_validity=evaluate_config.compute_detection_validity, + data_summary=data_summary, ) renamed_trace = _rename_output_columns(replace_result.dataframe, resolved_text_column=text_column) return AnonymizerResult( @@ -475,6 +480,7 @@ def evaluate( replace_method=replace_method, entity_labels=entity_labels, strict_entity_protection=strict_entity_protection, + data_summary=data_summary, ) def validate_config(self, config: AnonymizerConfig) -> None: @@ -667,6 +673,7 @@ def _run_internal_impl( 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: diff --git a/src/anonymizer/interface/results.py b/src/anonymizer/interface/results.py index 51c29cf4..60a88b19 100644 --- a/src/anonymizer/interface/results.py +++ b/src/anonymizer/interface/results.py @@ -68,6 +68,9 @@ class AnonymizerResult(_DisplayMixin): 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 @@ -78,6 +81,7 @@ class AnonymizerResult(_DisplayMixin): 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: @@ -115,6 +119,9 @@ class PreviewResult(_DisplayMixin): 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 @@ -126,6 +133,7 @@ class PreviewResult(_DisplayMixin): 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/engine/test_entity_coverage_judge.py b/tests/engine/test_entity_coverage_judge.py index a6db435d..f873a905 100644 --- a/tests/engine/test_entity_coverage_judge.py +++ b/tests/engine/test_entity_coverage_judge.py @@ -13,6 +13,30 @@ ) +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"}, diff --git a/tests/interface/test_anonymizer_interface.py b/tests/interface/test_anonymizer_interface.py index cc4739dd..ee1371a3 100644 --- a/tests/interface/test_anonymizer_interface.py +++ b/tests/interface/test_anonymizer_interface.py @@ -1053,3 +1053,46 @@ def test_evaluate_passes_strict_entity_protection_to_coverage_judge(stub_input: 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." From f706a55e131a71a1c941ebb1bb827fc1d9dc99fd Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 17 Jul 2026 18:39:53 -0700 Subject: [PATCH 17/23] add evaluation logs Signed-off-by: memadi --- .../evaluation/entity_coverage_judge.py | 7 +- .../engine/replace/replace_runner.py | 2 +- .../engine/rewrite/rewrite_workflow.py | 4 +- src/anonymizer/interface/anonymizer.py | 178 +++++++++++-- tests/interface/test_anonymizer_logging.py | 252 +++++++++++++++++- 5 files changed, 406 insertions(+), 37 deletions(-) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index a344b064..b9c22c08 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -127,10 +127,7 @@ def _data_summary_block(data_summary: str | None) -> str: def _coverage_prompt( - *, - entity_labels: list[str] | None, - strict_entity_protection: bool, - data_summary: str | None = None, + *, entity_labels: list[str] | None, strict_entity_protection: bool, data_summary: str | None = None, gi ) -> str: entity_scope_block = _entity_type_scope_block(entity_labels) strict_block = _strict_protection_block(strict_entity_protection) @@ -573,7 +570,7 @@ def run_non_critical( out[col] = result.dataframe[col].values return out, result.failed_records except Exception: - logger.warning("Entity coverage step failed; populating defaults", exc_info=True) + 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))] diff --git a/src/anonymizer/engine/replace/replace_runner.py b/src/anonymizer/engine/replace/replace_runner.py index acc3876a..f6260539 100644 --- a/src/anonymizer/engine/replace/replace_runner.py +++ b/src/anonymizer/engine/replace/replace_runner.py @@ -235,7 +235,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 e9a7b049..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, [] @@ -526,7 +526,7 @@ def evaluate( # 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) + 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 diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index a8915338..a8503bb7 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -30,6 +30,7 @@ COL_DETECTED_ENTITIES, COL_DETECTION_INVALID_ENTITIES, COL_DETECTION_VALID, + COL_ENTITIES_BY_VALUE, COL_ENTITY_COVERAGE, COL_FINAL_ENTITIES, COL_JUDGE_EVALUATION, @@ -66,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 @@ -94,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 @@ -415,13 +454,41 @@ def evaluate( 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) - 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, @@ -429,6 +496,23 @@ def 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, @@ -436,14 +520,25 @@ def evaluate( 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) - return AnonymizerResult( + result = AnonymizerResult( dataframe=_build_user_dataframe( renamed_trace, resolved_text_column=text_column, @@ -457,31 +552,64 @@ def evaluate( strict_entity_protection=strict_entity_protection, data_summary=data_summary, ) - - 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, - ) - 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, + 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, - ), - 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, + 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.""" diff --git a/tests/interface/test_anonymizer_logging.py b/tests/interface/test_anonymizer_logging.py index 26a52b21..8a66181b 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() From 474d452148d94c288968dfbbcb7bb97a8635f88b Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 17 Jul 2026 18:50:28 -0700 Subject: [PATCH 18/23] remove plan Signed-off-by: memadi --- plan/plan.md | 383 --------------------------------------------------- 1 file changed, 383 deletions(-) delete mode 100644 plan/plan.md diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index 3ab5a5fd..00000000 --- a/plan/plan.md +++ /dev/null @@ -1,383 +0,0 @@ -# Entity Coverage — Implementation Plan - -## Problem - -Currently `detection_valid` scores **precision** of the detected entities. Observed scores are predominantly in the lower ranges issue [#176](https://github.com/NVIDIA-NeMo/Anonymizer/issues/176), partly because the judge flags over-detection and mislabeling among other issues. But the actual privacy-sensitive question users care about is the opposite: **did any real PII slip through the anonymizer?** - -Precision complaints are already surfaced in the entity dropdown for manual inspection, making `detection_valid` feel redundant as a headline metric. What's missing is a **recall** signal: a score that answers "of all the PII that existed, how much did we successfully catch?" - ---- - -## Decision: Precision vs Recall - -`detection_valid` measures **precision** — of the entities we detected, were they correct? `entity_coverage` measures **recall** — of all the PII that existed, how much did we catch? - -For an anonymizer, recall is the user-facing priority: a missed PII entity is a privacy failure, while an over-detection is a minor inconvenience already visible in the entity dropdown. We therefore: - -- **Keep `detection_valid`** as an internal, opt-in feature (`compute_detection_validity=False` by default) — useful for model/threshold experiments during development, not surfaced to end users. -- **Add `entity_coverage`** as the new customer-facing metric — the subject of this plan. - ---- - -## Proposed Solution: Entity Coverage - -Replace `detection_valid` as the headline evaluate metric with `entity_coverage`: - -``` -entity_coverage = n_entities_detected / (n_entities_detected + n_entities_leaked) -``` - -- **`n_entities_detected`** — identifiers detected by Anonymizer in the original text (via the detection pipeline) -- **`n_entities_leaked`** — identifiers missed by Anonymizer in the original text, found by the evaluation LLM -- **`leaked_entities`** — the list of missed entities (shown in a dropdown when coverage < 100%) - -The evaluation LLM runs on the **original text** and detects all PII that should have been caught. The delta between what it finds and what Anonymizer already detected gives the leaked entities. - -**Key shift:** current judge asks "were our detections correct?" (precision). New judge asks "did any PII survive?" (recall). - -**Question TBD:** The evaluation and anonymization currently use the same LLM, and we may need to address potential bias by using a different model for evaluation. Potential models: Nemotron Ultra and Nemotron Super (previous [research log](https://nvidia.atlassian.net/wiki/spaces/SDGR/pages/3597435385/2026-06-09+Entity+Validator+Augmentor+Model+Selection+for+NeMo+Anonymizer) around experimenting these 2 models) - ---- - -## Design Decisions - -### Coverage display differs by mode - -| Mode | Display format | Example | -|---|---|---| -| **Replace** | `Entity Coverage: — n/d (pct%)` | `Entity Coverage: Not Satisfied — 18/21 (86%)` | -| **Rewrite** | `Entity Coverage: — n/d (pct%)` | `Entity Coverage: 0.86 — 18/21 (86%)` | - -Where `n` = `n_entities_detected`, `d` = `n_entities_detected + n_entities_leaked`, and `pct` is derived from the fraction. The fraction gives absolute scale; the percentage makes it immediately readable without mental math. No explanatory subtitle line is shown — entity coverage is self-explanatory. - -A value of `1.0` (or Satisfied) means no PII leaked into the output. Any value below `1.0` (or Not Satisfied) means at least one entity was missed, and `leaked_entities` names them. - -### Passthrough rows - -Rows where Anonymizer detected **zero entities** trivially score `entity_coverage = 1.0` — there was nothing to protect, so nothing could have leaked. The LLM judge is skipped for these rows (same pattern as the current passthrough shortcut in `_BaseJudgeWorkflow`). - -### Prompt starting point - -The entity judge prompt is based on the [leakage eval script](https://gitlab-master.nvidia.com/sdg-research/anonymizer-experiments/-/blob/lramaswamy/latency-benchmark/experiments/latency-benchmark/scripts/leakage_eval.py?ref_type=heads#L26) from the experiments repo. It will be refined as it is tested against real examples. The core task: given the original text, identify all PII — then report any that Anonymizer missed. - -### Reuse `_BaseJudgeWorkflow` - -The new `EntityCoverageWorkflow` extends `_BaseJudgeWorkflow`. The partition-LLM-merge skeleton is identical; only the schema, prompt, and post-process step differ (coverage fraction computed from counts, not a boolean verdict field). `postprocess()` is overridden in the subclass — the base class stays unchanged. - ---- - -## Scope - -- Adds `entity_coverage` and `leaked_entities` as the new default customer-facing columns in both replace and rewrite `evaluate()` output. -- Retains `detection_valid` and `detection_invalid_entities` as an opt-in internal feature behind `EvaluateConfig(compute_detection_validity=False)` — not customer-facing, off by default. -- New public API: `EvaluateConfig.compute_detection_validity: bool = False`; new column name constants (`COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES`); new model role `entity_coverage_judge` in `EvaluateModelSelection`. -- `COL_DETECTION_VALID` / `COL_DETECTION_INVALID_ENTITIES` constants and `detection_validity_judge` model role are **kept** — they back the opt-in internal feature. -- `DetectionJudgeWorkflow` is **not removed** — it runs only when `compute_detection_validity=True`. - ---- - -## New Column Names - -| Constant | Value | Type | Description | -|---|---|---|---| -| `COL_ENTITY_COVERAGE` | `"entity_coverage"` | `float \| None` | Fraction of entities successfully anonymized; `None` if judge unavailable | -| `COL_LEAKED_ENTITIES` | `"leaked_entities"` | `list` | Each missed entity with `value`, `label`, `reasoning` | -| `COL_ENTITY_COVERAGE_JUDGE` | `"_entity_coverage_judge"` | internal | Raw judge output; internal only | - ---- - -## Files Changed - -| File | Change | -|---|---| -| `src/anonymizer/engine/constants.py` | Keep `COL_DETECTION_JUDGE`, `COL_DETECTION_VALID`, `COL_DETECTION_INVALID_ENTITIES`; add `COL_ENTITY_COVERAGE_JUDGE`, `COL_ENTITY_COVERAGE`, `COL_LEAKED_ENTITIES` | -| `src/anonymizer/engine/evaluation/entity_coverage_judge.py` | **New file** — `EntityCoverageSchema`, `EntityCoverageWorkflow`, coverage fraction `postprocess()` | -| `src/anonymizer/engine/evaluation/detection_judge.py` | No removal — retained as-is; gated behind `compute_detection_validity` flag in call sites | -| `src/anonymizer/engine/evaluation/judge_base.py` | No changes needed — `postprocess()` is overridden in the subclass | -| `src/anonymizer/engine/rewrite/rewrite_workflow.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow` in `evaluate()`; remove `_detection_valid_fraction()`; update column references | -| `src/anonymizer/engine/replace/replace_runner.py` | Swap `DetectionJudgeWorkflow` → `EntityCoverageWorkflow`; update column references | -| `src/anonymizer/interface/anonymizer.py` | Update `_build_user_dataframe` allowed columns for both modes; update model role references | -| `src/anonymizer/interface/display.py` | Render `entity_coverage` as Satisfied/Not Satisfied (replace) or fraction (rewrite); update leaked_entities dropdown | -| `src/anonymizer/config/anonymizer_config.py` | Add `compute_detection_validity: bool = False` to `EvaluateConfig` | -| `src/anonymizer/config/models.py` | Add `entity_coverage_judge` to `EvaluateModelSelection`; keep `detection_validity_judge` | -| `src/anonymizer/config/default_model_configs/evaluate.yaml` | Add `entity_coverage_judge` key alongside existing `detection_validity_judge` | -| `docs/concepts/evaluation.md` | Replace Entity Detection Judge section with Entity Coverage section in both replace and rewrite docs | -| `skills/anonymizer/SKILL.md` | Update evaluation tips and output template column references | -| `tests/engine/evaluation/test_detection_judge.py` | Keep as-is — covers the retained internal feature | -| `tests/engine/evaluation/test_entity_coverage_judge.py` | **New file** — new schema, prompt, and workflow tests | -| `tests/engine/test_rewrite_workflow.py` | Update column name references; remove `_detection_valid_fraction` tests | -| `tests/engine/test_evaluate.py` | Update column name assertions | -| `tests/interface/test_display.py` | Update rendering assertions for entity coverage | - ---- - -## Step 1 — New constants (`constants.py`) - -Keep the existing detection judge constants and add three new ones alongside them: - -```python -# Retained (no change — back the opt-in internal feature): -COL_DETECTION_JUDGE = "_detection_judge" -COL_DETECTION_VALID = "detection_valid" -COL_DETECTION_INVALID_ENTITIES = "detection_invalid_entities" - -# New: -COL_ENTITY_COVERAGE_JUDGE = "_entity_coverage_judge" # internal raw output -COL_ENTITY_COVERAGE = "entity_coverage" # user-facing float | None -COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list -``` - ---- - -## Step 2 — New workflow (`entity_coverage_judge.py`) - -Add a new file alongside the existing `detection_judge.py`. Key design: - -### Output schema - -```python -class LeakedEntity(BaseModel): - value: str - label: str - reasoning: str # one sentence: why this is PII that survived anonymization - -class EntityCoverageSchema(BaseModel): - leaked_entities: list[LeakedEntity] - # No all_valid field — coverage is computed numerically from counts -``` - -### Prompt - -Runs against the **original text** (`COL_TEXT`), with the Anonymizer-detected entity list injected as context. Starting from the leakage eval script prompt and refined through testing. - -Core task phrasing: -- "Given the original text, identify all PII present. Report any identifiers that were not already caught by Anonymizer — these are the missed entities." -- Returns an empty list when Anonymizer caught everything. - -#### Strict entity protection (`Rewrite.strict_entity_protection`) - -Only applies in rewrite mode. When `strict_entity_protection=True`, the rewrite pipeline disallows `leave_as_is`, bans `combined_risk_level: low`, and overrides the MINIMUM NECESSARY CHANGE and quasi-identifier "not automatically protected" principles. The coverage judge must mirror that posture — otherwise a missed quasi-identifier or low-combined-risk entity would be excused by the judge despite the user having demanded full protection. - -The prompt includes a `` block injected when the flag is set (empty string otherwise): - -``` -STRICT PROTECTION MODE IS ENABLED. - -Flag ALL entities as leaked if they were not caught — including quasi-identifiers -and low-risk entities that would normally be given benefit of the doubt. -Do NOT apply MINIMUM NECESSARY CHANGE reasoning to excuse a missed entity. -Do NOT excuse a missed entity because its combined re-identification risk is low. -Any PII span not caught by Anonymizer is a miss in strict mode. -``` - -The block mirrors the equivalent block in `sensitivity_disposition.py` so the two prompts enforce the same standard. In replace mode the call site passes `strict_entity_protection=False` (the field does not exist on `Detect`); the block is omitted and the judge uses its default charitable posture. - -#### Entity type scope (`Detect.entity_labels`) - -When the user configures `Detect(entity_labels=[...])`, detection is intentionally limited to those label types. The coverage judge must be scoped to match — otherwise it would flag missed PII of types the user never asked to detect, artificially deflating the score. - -The prompt includes an `` block populated per-run in `prepare()`: - -- **`entity_labels` is `None`** (default): `"Evaluate for all PII and sensitive entity types."` -- **`entity_labels` is set**: `"Detection was configured to target ONLY these entity types: . 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."` - -The `` section includes a corresponding rule: respect the entity_type_scope and do not penalize Anonymizer for types outside it. - -Because `entity_labels` is run-level config (not per-row), the workflow constructor accepts `entity_labels: list[str] | None` and `prepare()` broadcasts it as a constant column (`_entity_type_scope`) across all rows. Call sites (`replace_runner.py`, `rewrite_workflow.py`) pass `config.detect.entity_labels`. - -#### Both flags enabled simultaneously - -Both can be active at the same time — there is no config constraint preventing it: - -```python -AnonymizerConfig( - detect=Detect(entity_labels=["first_name", "email_address"]), - rewrite=Rewrite(strict_entity_protection=True), -) -``` - -The two blocks compose cleanly in the judge prompt: the entity type scope narrows *what* the judge looks for, and the strict protection block raises the bar *within* that scope. No contradiction — scope is applied first, strictness within that scope second. The two blocks are independent injections in `prepare()` and do not interfere with each other. - -### Passthrough condition - -Rows with no detected entities trivially score `entity_coverage = 1.0` — nothing to miss, LLM skipped. - -### Coverage fraction (postprocess override) - -```python -def _compute_coverage(self, n_entities_detected: int, n_entities_leaked: int) -> float: - total = n_entities_detected + n_entities_leaked - return 1.0 if total == 0 else n_entities_detected / total -``` - -Passthrough rows get `entity_coverage = 1.0` and `leaked_entities = []` without calling the LLM. - -### Class declaration - -```python -class EntityCoverageWorkflow(_BaseJudgeWorkflow): - RAW_COL = COL_ENTITY_COVERAGE_JUDGE - VALID_COL = COL_ENTITY_COVERAGE - INVALID_COL = COL_LEAKED_ENTITIES - SCHEMA = EntityCoverageSchema - VERDICT_FIELD = "leaked_entities" - DEFAULT_PAYLOAD = {"leaked_entities": []} - MODEL_ROLE = "entity_coverage_judge" - WORKFLOW_NAME = "entity-coverage-judge" -``` - -`postprocess()` is overridden to compute the float fraction from `n_entities_detected` (from `COL_ENTITIES_BY_VALUE`) and `len(leaked_entities)` rather than storing a boolean verdict. - -> **Note:** `COL_ENTITIES_BY_VALUE` (`_entities_by_value`) is derived from `COL_FINAL_ENTITIES` (`final_entities`) during detection — it groups spans by value with a labels list. The leakage eval script references `final_entities`, which is the same underlying data in a different shape. `COL_ENTITIES_BY_VALUE` is the right source here since it's already what the judge's `prepare()` step and passthrough mask consume. - -> **Note on base class / VALID_COL conflict:** `_BaseJudgeWorkflow` does not write to `VALID_COL` before `postprocess()` runs — the base class `evaluate()` calls `postprocess()` as the only step that touches those columns. Overriding `postprocess()` fully in `EntityCoverageWorkflow` is safe. However, `VALID_COL` on the base class carries an implicit `bool | None` semantic (used by other judges). Since `EntityCoverageWorkflow` writes a `float | None` instead, confirm that no shared display or downstream code reads `VALID_COL` generically and assumes boolean before this is merged. - ---- - -## Step 3 — Wire into replace and rewrite evaluate paths - -### Replace (`replace_runner.py`) - -Add `EntityCoverageWorkflow` — always runs in `evaluate()`. Gate `DetectionJudgeWorkflow` behind `evaluate_config.compute_detection_validity`. Both judges run on `COL_TEXT` (original text). Update column references for the new output columns. - -### Rewrite (`rewrite_workflow.py`) - -Same structure. Add `EntityCoverageWorkflow` as the default; gate `DetectionJudgeWorkflow` behind the flag. Update `evaluate()`: - -- Remove `_detection_valid_fraction()` — no longer needed since `entity_coverage` is already a float from `postprocess()`. -- Update the try/except around the judge call to default `entity_coverage = None` and `leaked_entities = None` on failure. - ---- - -## Step 4 — Model role update (`models.py`, `evaluate.yaml`) - -```python -class EvaluateModelSelection(BaseModel): - entity_coverage_judge: str # new — always required for evaluate() - detection_validity_judge: str # retained — required only when compute_detection_validity=True - replace_type_fidelity_judge: str - replace_relational_consistency_judge: str - replace_attribute_fidelity_judge: str - rewrite_judge: str -``` - -Add `entity_coverage_judge` to `evaluate.yaml` alongside the existing `detection_validity_judge`. Update `model_loader.py` validation to check `entity_coverage_judge` unconditionally; `detection_validity_judge` is already present in the YAML default so no user burden is added. - ---- - -## Step 5 — Update `_build_user_dataframe` (`anonymizer.py`) - -Add the new columns to the allowed sets for both replace and rewrite mode. Keep the existing detection validity columns — they remain in the allowed set and will appear in output only when `compute_detection_validity=True` produces them: - -```python -# Add to both replace and rewrite allowed sets: -COL_ENTITY_COVERAGE, # new — always present after evaluate() -COL_LEAKED_ENTITIES, # new — always present after evaluate() -# Already present (retained): -# COL_DETECTION_VALID — only in output when compute_detection_validity=True -# COL_DETECTION_INVALID_ENTITIES — only in output when compute_detection_validity=True -``` - ---- - -## Step 6 — Display (`display.py`) - -`display_record()` renders the coverage column as a single line. The format puts the verdict first, then the fraction for scale, then the percentage for readability: - -- **Replace:** `Entity Coverage: Satisfied — 21/21 (100%)` / `Entity Coverage: Not Satisfied — 18/21 (86%)` -- **Rewrite:** `Entity Coverage: 0.86 — 18/21 (86%)` (no Satisfied/Not Satisfied badge — rewrite already uses numeric metrics) - -Where the fraction is `n_entities_detected / (n_entities_detected + n_entities_leaked)` and the percentage is derived from it. The fraction gives absolute scale (18/20 and 18/200 look very different); the percentage makes it immediately readable. - -Drop the explanatory subtitle line that the old Detection Judge displayed — entity coverage is self-explanatory and does not need a definition sentence. Drop the `"LLM alignment score:"` label prefix — that framing belonged to the old judge (LLM-vs-LLM alignment), not to a count ratio. - -The `leaked_entities` dropdown is added with the same layout (value, label, reasoning per row). The existing `detection_invalid_entities` dropdown continues to render unchanged when `detection_valid` is present (opt-in enabled). - ---- - -## Step 7 — Docs and skills - -### `docs/concepts/evaluation.md` - -Replace the **Entity Detection Judge** subsection in both Replace and Rewrite sections with an **Entity Coverage** subsection: - -- Explain the recall focus (vs precision of the old judge) -- Document `entity_coverage` type per mode (float in rewrite, Satisfied/Not Satisfied in replace) -- Document `leaked_entities` list shape -- Update the special-values table (passthrough row → `1.0` / Satisfied) -- Update model role name (`entity_coverage_judge`) -- Add a brief **Internal metrics** note: `compute_detection_validity=True` enables `detection_valid` for tag-precision signal during model/threshold experiments — one paragraph, not featured - -### `skills/anonymizer/SKILL.md` - -- Update the evaluation tips bullet: `entity_coverage_judge` role; `entity_coverage` type per mode; `leaked_entities` dropdown -- Update output template: add `entity_coverage` column references; do not feature `detection_valid` (internal only) - ---- - -## Step 8 — Tests - -### New test file (`test_entity_coverage_judge.py`) - -``` -test_judge_prompt_runs_on_original_text_column -test_judge_prompt_includes_detected_entity_context -test_entity_coverage_schema_accepts_empty_leaked_list -test_entity_coverage_schema_accepts_leaked_entities -test_coverage_fraction_all_caught # n_entities_leaked=0 → 1.0 -test_coverage_fraction_partial_miss # n_entities_leaked=1, n_entities_detected=3 → 0.75 -test_coverage_fraction_total_miss # n_entities_leaked=n, n_entities_detected=0 → 0.0 -test_evaluate_short_circuits_when_no_entities # passthrough → 1.0, no LLM call -test_evaluate_invokes_adapter_for_rows_with_entities -test_evaluate_merges_entity_and_empty_rows_in_order -test_evaluate_marks_coverage_unavailable_for_malformed_payload -``` - -### Update existing tests - -``` -tests/engine/test_rewrite_workflow.py # rename column refs; remove _detection_valid_fraction tests -tests/engine/test_evaluate.py # column name assertions -tests/interface/test_display.py # entity_coverage rendering -tests/interface/test_anonymizer_interface.py # allowed column set for both modes -``` - -All tests use stub adapters — no real LLM calls. - ---- - -## Implementation Order - -1. Add new constants to `constants.py`; keep existing `COL_DETECTION_*` constants -2. Add `compute_detection_validity: bool = False` to `EvaluateConfig` (`anonymizer_config.py`) -3. Write `entity_coverage_judge.py` — schema, prompt (v1 from leakage_eval script), workflow class, `postprocess()` override -4. Wire `EntityCoverageWorkflow` into replace evaluate path (`replace_runner.py`); gate `DetectionJudgeWorkflow` behind the flag -5. Wire `EntityCoverageWorkflow` into rewrite evaluate path (`rewrite_workflow.py`); gate `DetectionJudgeWorkflow` behind the flag; remove `_detection_valid_fraction()` -6. Add `entity_coverage_judge` to `EvaluateModelSelection` and `evaluate.yaml`; keep `detection_validity_judge`; update `model_loader.py` -7. Update `_build_user_dataframe` allowed columns (`anonymizer.py`) -8. Update `display.py` rendering -9. Update `docs/concepts/evaluation.md` and `skills/anonymizer/SKILL.md` -10. Add `test_entity_coverage_judge.py`; keep `test_detection_judge.py`; update all affected existing tests -11. Run `make format && make typecheck && make test` - ---- - -## Test Datasets - -The following datasets should be used to validate entity coverage results once the feature is shipped. - -| Dataset | Rows | Text column | Ground-truth annotations | Notes | -|---|---|---|---|---| -| **usage_logs_seed** (`test-data/usage_logs_seed.parquet`) | 20 | `conversation_trace` | None | LLM conversation traces with embedded PII (names, emails, phone numbers, addresses) across business document scenarios; good for sanity-checking coverage on realistic, high-density PII text | -| **openPII_ai4_part1** (`openPII_ai4_part1.csv`) | 1000 | `source_text` | `privacy_mask` (character-offset spans with labels: TITLE, GIVENNAME, SURNAME, EMAIL, TELEPHONENUM, DATE, etc.) | Synthetic PII benchmark with ground-truth spans; use `privacy_mask` as the reference to evaluate whether the judge surfaces the same entities Anonymizer missed | -| **PII_NEW_TESTDATA_part1** (`PII_NEW_TESTDATA_part1.csv`) | 1000 | `text_us` / `text_intl` | `spans_us` / `spans_intl` (character-offset spans with Anonymizer-native labels: first_name, last_name, email, date_of_birth, street_address, bank_routing_number, credit_debit_card, etc.) | Multi-domain synthetic dataset (Life, Finance, etc.) with both US and international text variants and pre-tagged versions; spans use Anonymizer's own label vocabulary, making it the most direct test for coverage scoring | - -Run evaluation in both replace and rewrite modes across all three datasets to confirm `entity_coverage` scores are meaningful and `leaked_entities` lists surface real misses rather than noise. `PII_NEW_TESTDATA_part1` is the highest-signal dataset for this feature because its ground-truth spans use the same label set as Anonymizer's detection pipeline. - ---- - -## Open Questions - -- **Prompt v1:** The leakage_eval script is the starting point. The prompt will provide both the original text and the Anonymizer-detected entity list as context — the detected list is what allows the judge to identify the delta efficiently rather than re-detecting everything from scratch. Removing it would mean the judge can't compute a meaningful delta; keeping it is the right call. Once v1 is shipped and results are observed, a follow-up iteration should explore **NER-style prompting** (instructing the judge to perform explicit named-entity recognition passes over the text before computing the delta) as a potential path to improving recall and reducing missed entities. -- **`n_entities_detected` denominator:** Currently planned to count `(value, label)` pairs (flattened, same as current detection judge). If the same value has multiple labels, it counts multiple times. Consider whether to count unique values instead to avoid inflating the denominator. -- **Replace mode threshold:** "Satisfied" is defined as `entity_coverage == 1.0` (strict — even one leaked entity flips to Not Satisfied). Confirm this is the right threshold or whether a small tolerance is acceptable. From 2c554cb0af083f1c067b2dda82ce286ac235cd91 Mon Sep 17 00:00:00 2001 From: memadi Date: Fri, 17 Jul 2026 18:56:52 -0700 Subject: [PATCH 19/23] change entity coverage to nemotron super Signed-off-by: memadi --- src/anonymizer/config/default_model_configs/evaluate.yaml | 2 +- src/anonymizer/config/default_model_configs/models.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/anonymizer/config/default_model_configs/evaluate.yaml b/src/anonymizer/config/default_model_configs/evaluate.yaml index 7c9c2791..c92c9507 100644 --- a/src/anonymizer/config/default_model_configs/evaluate.yaml +++ b/src/anonymizer/config/default_model_configs/evaluate.yaml @@ -7,7 +7,7 @@ selected_models: # --- Shared --- - entity_coverage_judge: nemotron-ultra + 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 a4f40430..bd2ce0e3 100644 --- a/src/anonymizer/config/default_model_configs/models.yaml +++ b/src/anonymizer/config/default_model_configs/models.yaml @@ -29,13 +29,13 @@ model_configs: top_p: 1.0 timeout: 300 - - alias: nemotron-ultra - model: nvidia/nemotron-3-ultra-550b-a55b + - alias: nemotron-super + model: nvidia/nemotron-3-super-v3 provider: nvidia inference_parameters: max_parallel_requests: 16 max_tokens: 16384 - temperature: 0.0 + temperature: 0.3 top_p: 0.95 timeout: 300 extra_body: From 07ae62f414b3e08f85d88f7da253241a1705ba8f Mon Sep 17 00:00:00 2001 From: memadi Date: Sat, 18 Jul 2026 17:20:32 -0700 Subject: [PATCH 20/23] update docs Signed-off-by: memadi --- docs/concepts/evaluation.md | 64 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) 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 ``` From dfe3086567c350f6320baa844a9b5c4ca773f97a Mon Sep 17 00:00:00 2001 From: memadi Date: Sat, 18 Jul 2026 17:48:20 -0700 Subject: [PATCH 21/23] update docs Signed-off-by: memadi --- AGENTS.md | 6 ++++-- docs/concepts/models.md | 1 + docs/concepts/replace.md | 2 +- docs/concepts/rewrite.md | 8 +++++--- skills/anonymizer/SKILL.md | 24 +++++++++++++++--------- 5 files changed, 26 insertions(+), 15 deletions(-) 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/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() From f9998323c9844d25edc6b63d9de0b56f5e6169df Mon Sep 17 00:00:00 2001 From: memadi Date: Sat, 18 Jul 2026 17:53:04 -0700 Subject: [PATCH 22/23] nit Signed-off-by: memadi --- src/anonymizer/engine/constants.py | 2 - .../evaluation/entity_coverage_judge.py | 78 ++++++++----------- .../engine/replace/replace_runner.py | 2 + src/anonymizer/interface/anonymizer.py | 6 +- tests/engine/test_entity_coverage_judge.py | 42 ++++++++++ tests/engine/test_replace_runner.py | 49 ++++++++++++ tests/interface/test_anonymizer_interface.py | 34 ++++++++ tests/interface/test_anonymizer_logging.py | 6 +- 8 files changed, 165 insertions(+), 54 deletions(-) diff --git a/src/anonymizer/engine/constants.py b/src/anonymizer/engine/constants.py index 3dea4a17..832da71d 100644 --- a/src/anonymizer/engine/constants.py +++ b/src/anonymizer/engine/constants.py @@ -68,8 +68,6 @@ # 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_ENTITY_COVERAGE_TOTAL = "entity_coverage_total" # final entities + leaked entities -COL_ENTITY_COVERAGE_CANDIDATE_TOTAL = "_entity_coverage_candidate_total" # validated judge candidates COL_LEAKED_ENTITIES = "leaked_entities" # user-facing list of {value, label, reasoning} # Replace evaluation: type-fidelity judge (Substitute only) diff --git a/src/anonymizer/engine/evaluation/entity_coverage_judge.py b/src/anonymizer/engine/evaluation/entity_coverage_judge.py index b9c22c08..a2009cd4 100644 --- a/src/anonymizer/engine/evaluation/entity_coverage_judge.py +++ b/src/anonymizer/engine/evaluation/entity_coverage_judge.py @@ -17,9 +17,7 @@ from anonymizer.engine.constants import ( COL_ENTITIES_BY_VALUE, COL_ENTITY_COVERAGE, - COL_ENTITY_COVERAGE_CANDIDATE_TOTAL, COL_ENTITY_COVERAGE_JUDGE, - COL_ENTITY_COVERAGE_TOTAL, COL_LEAKED_ENTITIES, COL_TEXT, DEFAULT_ENTITY_LABELS, @@ -127,7 +125,7 @@ def _data_summary_block(data_summary: str | None) -> str: def _coverage_prompt( - *, entity_labels: list[str] | None, strict_entity_protection: bool, data_summary: str | None = None, gi + *, 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) @@ -277,11 +275,6 @@ def _parse_leaked_entities(raw: object) -> list[dict[str, object]] | None: return [e.model_dump() for e in parsed.leaked_entities] -def _parse_judge_entities(raw: object) -> list[dict[str, object]] | None: - """Compatibility alias for experiment notebooks parsing raw judge output.""" - return _parse_leaked_entities(raw) - - def _parse_json_object(raw: str) -> dict[str, object] | None: """Parse a JSON object, tolerating fences or brief surrounding model text.""" try: @@ -329,6 +322,18 @@ def consume(start: int) -> bool: 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. @@ -337,15 +342,21 @@ def _is_leaked_value_covered(leaked_value: object, final_values: list[str]) -> b not wrongly suppressed (e.g. ``"John Smith"`` is NOT covered by ``"John Doe"`` + ``"Jane Smith"``). A leak is covered when either: - - **subspan** — its (core) tokens are a subset of a *single* final entity's tokens - (``"Mstr"`` ⊂ ``"Mstr Marzella"``, ``"44"`` ⊂ ``"44 Dunsfold Drive"``), or + - **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 on whole tokens, so a leak only matches a final token it equals - (``"m"`` is not covered by ``"Mstr Marzella"``: ``"m"`` != ``"mstr"``). Grammatical - stopwords (see ``_COVERAGE_IGNORE_TOKENS``) are dropped from the leak's core so - article/preposition noise does not block a subspan match. + 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: @@ -359,9 +370,12 @@ def _is_leaked_value_covered(leaked_value: object, final_values: list[str]) -> b if any(leaked_tokens == final_tokens for final_tokens in final_token_lists): return True - # Subspan of a single final entity. - leaked_core = set(leaked_tokens) - _COVERAGE_IGNORE_TOKENS - if leaked_core and any(leaked_core <= set(final_tokens) for final_tokens in final_token_lists): + # 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. @@ -417,18 +431,6 @@ def _deduplicate_judge_entities(entities: list[dict[str, object]]) -> list[dict[ return deduplicated -def _compare_judge_to_final( - judge_entities: list[dict[str, object]], - final_entities: object, -) -> tuple[float, list[dict[str, object]], int]: - """Return independent-judge recall, missed entities, and judge total.""" - deduplicated = _deduplicate_judge_entities(judge_entities) - leaked = _filter_covered_leaked_entities(deduplicated, final_entities) - total = len(deduplicated) - coverage = 1.0 if total == 0 else (total - len(leaked)) / total - return coverage, leaked, total - - # --------------------------------------------------------------------------- # Workflow # --------------------------------------------------------------------------- @@ -507,8 +509,6 @@ def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: coverage_vals: list[float | None] = [] leaked_lists: list[list[dict]] = [] - total_vals: list[int | None] = [] - candidate_total_vals: list[int | None] = [] for idx in out.index: raw = out[self.RAW_COL].loc[idx] if self.RAW_COL in out.columns else None @@ -516,12 +516,9 @@ def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: if leaked is None: coverage_vals.append(None) leaked_lists.append([]) - total_vals.append(None) - candidate_total_vals.append(None) else: leaked = _filter_nonliteral_entities(leaked, out[COL_TEXT].loc[idx]) leaked = _deduplicate_judge_entities(leaked) - candidate_total_vals.append(len(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 @@ -529,12 +526,9 @@ def postprocess(self, dataframe: pd.DataFrame) -> pd.DataFrame: coverage = 1.0 if total == 0 else n_final / total coverage_vals.append(coverage) leaked_lists.append(leaked) - total_vals.append(total) out[self.VALID_COL] = coverage_vals out[self.INVALID_COL] = leaked_lists - out[COL_ENTITY_COVERAGE_TOTAL] = total_vals - out[COL_ENTITY_COVERAGE_CANDIDATE_TOTAL] = candidate_total_vals return out def run_non_critical( @@ -559,13 +553,7 @@ def run_non_critical( preview_num_records=preview_num_records, ) out = dataframe.copy() - for col in ( - self.RAW_COL, - self.VALID_COL, - self.INVALID_COL, - COL_ENTITY_COVERAGE_TOTAL, - COL_ENTITY_COVERAGE_CANDIDATE_TOTAL, - ): + 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 @@ -574,8 +562,6 @@ def run_non_critical( out = dataframe.copy() out[self.VALID_COL] = None out[self.INVALID_COL] = [[] for _ in range(len(out))] - out[COL_ENTITY_COVERAGE_TOTAL] = None - out[COL_ENTITY_COVERAGE_CANDIDATE_TOTAL] = None return out, [] def evaluate( diff --git a/src/anonymizer/engine/replace/replace_runner.py b/src/anonymizer/engine/replace/replace_runner.py index f6260539..69200406 100644 --- a/src/anonymizer/engine/replace/replace_runner.py +++ b/src/anonymizer/engine/replace/replace_runner.py @@ -145,6 +145,8 @@ 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, diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index a8503bb7..42f5e896 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -487,7 +487,7 @@ def evaluate( # it on the result. internal_df = _unrename_output_columns(output.trace_dataframe, resolved_text_column=text_column) if is_rewrite: - logger.info(LOG_INDENT + "Running rewrite judges") + logger.info(LOG_INDENT + "⚖️ Running rewrite judges") stage_start = time.perf_counter() rewrite_result = self._rewrite_runner.evaluate( internal_df, @@ -520,7 +520,7 @@ def evaluate( strict_entity_protection=strict_entity_protection, data_summary=data_summary, ) - logger.info(LOG_INDENT + "Running entity coverage") + logger.info(LOG_INDENT + "🔎 Running entity coverage") stage_start = time.perf_counter() judged_df, coverage_failed = coverage_wf.run_non_critical( rewrite_result.dataframe, @@ -553,7 +553,7 @@ def evaluate( data_summary=data_summary, ) else: - logger.info(LOG_INDENT + "Running replace judges") + logger.info(LOG_INDENT + "⚖️ Running replace judges") stage_start = time.perf_counter() replace_result = self._replace_runner.evaluate( internal_df, diff --git a/tests/engine/test_entity_coverage_judge.py b/tests/engine/test_entity_coverage_judge.py index f873a905..6be3a872 100644 --- a/tests/engine/test_entity_coverage_judge.py +++ b/tests/engine/test_entity_coverage_judge.py @@ -3,9 +3,13 @@ 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, @@ -72,6 +76,7 @@ def test_filter_covered_leaked_entities_removes_subspans_and_composites() -> Non ("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) @@ -87,6 +92,11 @@ def test_is_leaked_value_covered_true(leaked_value: str, final_values: list[str] [ # 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"]), @@ -163,3 +173,35 @@ def test_coverage_prompt_requires_systematic_structured_text_scan() -> None: 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 0963a016..27ca62b8 100644 --- a/tests/engine/test_replace_runner.py +++ b/tests/engine/test_replace_runner.py @@ -224,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, diff --git a/tests/interface/test_anonymizer_interface.py b/tests/interface/test_anonymizer_interface.py index ee1371a3..d5cbd809 100644 --- a/tests/interface/test_anonymizer_interface.py +++ b/tests/interface/test_anonymizer_interface.py @@ -907,6 +907,40 @@ def test_evaluate_rewrite_result_adds_detection_valid(stub_input: AnonymizerInpu 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() diff --git a/tests/interface/test_anonymizer_logging.py b/tests/interface/test_anonymizer_logging.py index 8a66181b..3aaa3edb 100644 --- a/tests/interface/test_anonymizer_logging.py +++ b/tests/interface/test_anonymizer_logging.py @@ -183,7 +183,7 @@ def test_evaluate_replace_logs_stages(stub_input: AnonymizerInput, caplog: pytes messages = caplog.text assert "🧪 Running Redact evaluation on 2 records" in messages - assert "Running replace judges" 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 @@ -207,10 +207,10 @@ def test_evaluate_rewrite_logs_stages(stub_input: AnonymizerInput, caplog: pytes messages = caplog.text assert "🧪 Running rewrite evaluation on 2 records" in messages - assert "Running rewrite judges" 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 "🔎 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 From 63b3786b279297f324927ba32ce25d689f3c059d Mon Sep 17 00:00:00 2001 From: memadi Date: Mon, 20 Jul 2026 10:06:06 -0700 Subject: [PATCH 23/23] fix: restore check_rewrite_judge validation in evaluate() rewrite path The refactor that unified the replace/rewrite evaluate() code paths dropped check_rewrite_judge=True from validate_model_alias_references, causing a misconfigured rewrite_judge alias to go undetected until LLM calls were made. Pass check_rewrite_judge=is_rewrite to restore pre-refactor behaviour. Signed-off-by: memadi --- src/anonymizer/interface/anonymizer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index 42f5e896..35d7fef1 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -447,6 +447,7 @@ 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