Skip to content

Commit 2a49d18

Browse files
committed
Add sanitized evaluation measurement records
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
1 parent 00c7293 commit 2a49d18

5 files changed

Lines changed: 306 additions & 7 deletions

File tree

src/anonymizer/measurement/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
record_stage,
2020
stage_timer,
2121
)
22-
from anonymizer.measurement.records.row import record_record_metrics
22+
from anonymizer.measurement.records.row import record_evaluation_metrics, record_record_metrics
2323
from anonymizer.measurement.session import configured_measurement_session, current_collector, measurement_session
2424

2525
__all__ = [
@@ -35,6 +35,7 @@
3535
"measurement_session",
3636
"record_model_workflow",
3737
"record_ndd_workflow",
38+
"record_evaluation_metrics",
3839
"record_record_metrics",
3940
"record_run_metadata",
4041
"record_stage",

src/anonymizer/measurement/records/row.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,22 @@
44
from __future__ import annotations
55

66
from collections import Counter
7-
from typing import TYPE_CHECKING, Any
7+
from dataclasses import dataclass
8+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
89

9-
from anonymizer.engine.constants import COL_FINAL_ENTITIES
10+
from anonymizer.engine.constants import (
11+
COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES,
12+
COL_ATTRIBUTE_FIDELITY_VALID,
13+
COL_DETECTION_INVALID_ENTITIES,
14+
COL_DETECTION_VALID,
15+
COL_FINAL_ENTITIES,
16+
COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS,
17+
COL_RELATIONAL_CONSISTENCY_VALID,
18+
COL_TYPE_FIDELITY_INVALID_REPLACEMENTS,
19+
COL_TYPE_FIDELITY_VALID,
20+
)
1021
from anonymizer.measurement._coerce import (
22+
_coerce_bool,
1123
_coerce_int,
1224
_count_items,
1325
_count_text_tokens,
@@ -34,6 +46,69 @@
3446
from anonymizer.measurement.collector import MeasurementCollector
3547

3648

49+
_EvaluationBoolField = Literal[
50+
"detection_valid",
51+
"type_fidelity_valid",
52+
"relational_consistency_valid",
53+
"attribute_fidelity_valid",
54+
]
55+
_EvaluationCountField = Literal[
56+
"detection_invalid_entity_count",
57+
"type_fidelity_invalid_replacement_count",
58+
"relational_consistency_invalid_relation_count",
59+
"attribute_fidelity_invalid_entity_count",
60+
]
61+
62+
63+
class _EvaluationRecordFields(TypedDict, total=False):
64+
detection_valid: bool | None
65+
type_fidelity_valid: bool | None
66+
relational_consistency_valid: bool | None
67+
attribute_fidelity_valid: bool | None
68+
detection_invalid_entity_count: int
69+
type_fidelity_invalid_replacement_count: int
70+
relational_consistency_invalid_relation_count: int
71+
attribute_fidelity_invalid_entity_count: int
72+
73+
74+
@dataclass(frozen=True)
75+
class _EvaluationBoolMetric:
76+
source_column: str
77+
output_field: _EvaluationBoolField
78+
79+
80+
@dataclass(frozen=True)
81+
class _EvaluationCountMetric:
82+
source_column: str
83+
output_field: _EvaluationCountField
84+
primary_key: str
85+
86+
87+
_EVALUATION_BOOL_METRICS = (
88+
_EvaluationBoolMetric(COL_DETECTION_VALID, "detection_valid"),
89+
_EvaluationBoolMetric(COL_TYPE_FIDELITY_VALID, "type_fidelity_valid"),
90+
_EvaluationBoolMetric(COL_RELATIONAL_CONSISTENCY_VALID, "relational_consistency_valid"),
91+
_EvaluationBoolMetric(COL_ATTRIBUTE_FIDELITY_VALID, "attribute_fidelity_valid"),
92+
)
93+
94+
_EVALUATION_COUNT_METRICS = (
95+
_EvaluationCountMetric(COL_DETECTION_INVALID_ENTITIES, "detection_invalid_entity_count", "invalid_entities"),
96+
_EvaluationCountMetric(
97+
COL_TYPE_FIDELITY_INVALID_REPLACEMENTS,
98+
"type_fidelity_invalid_replacement_count",
99+
"invalid_replacements",
100+
),
101+
_EvaluationCountMetric(
102+
COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS,
103+
"relational_consistency_invalid_relation_count",
104+
"invalid_relations",
105+
),
106+
_EvaluationCountMetric(
107+
COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, "attribute_fidelity_invalid_entity_count", "entities"
108+
),
109+
)
110+
111+
37112
def record_record_metrics(
38113
dataframe: pd.DataFrame,
39114
*,
@@ -76,6 +151,54 @@ def record_record_metrics(
76151
)
77152

78153

154+
def record_evaluation_metrics(
155+
dataframe: pd.DataFrame,
156+
*,
157+
mode: str,
158+
strategy: str,
159+
text_column: str,
160+
) -> None:
161+
"""Record sanitized per-row LLM-as-judge verdict metrics from an evaluated trace dataframe."""
162+
collector = current_collector()
163+
if collector is None or not collector.record_level:
164+
return
165+
166+
columns = set(dataframe.columns)
167+
if not _has_evaluation_metrics(columns):
168+
return
169+
170+
for row_index, row in dataframe.iterrows():
171+
collector.record(
172+
"evaluation_record",
173+
**_base_record_fields(
174+
collector=collector,
175+
row_index=row_index,
176+
row=row,
177+
text_column=text_column,
178+
mode=mode,
179+
strategy=strategy,
180+
),
181+
**_evaluation_record_fields(row, columns=columns),
182+
)
183+
184+
185+
def _has_evaluation_metrics(columns: set[str]) -> bool:
186+
return any(metric.source_column in columns for metric in _EVALUATION_BOOL_METRICS) or any(
187+
metric.source_column in columns for metric in _EVALUATION_COUNT_METRICS
188+
)
189+
190+
191+
def _evaluation_record_fields(row: pd.Series, *, columns: set[str]) -> _EvaluationRecordFields:
192+
fields: _EvaluationRecordFields = {}
193+
for metric in _EVALUATION_BOOL_METRICS:
194+
if metric.source_column in columns:
195+
fields[metric.output_field] = _coerce_bool(row.get(metric.source_column))
196+
for metric in _EVALUATION_COUNT_METRICS:
197+
if metric.source_column in columns:
198+
fields[metric.output_field] = _count_items(row.get(metric.source_column), primary_key=metric.primary_key)
199+
return fields
200+
201+
79202
def _base_record_fields(
80203
*,
81204
collector: MeasurementCollector,

tests/tools/test_measurement_tools.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import importlib.util
7+
import json
78
import sys
89
from collections.abc import Iterator
910
from contextlib import contextmanager
@@ -934,8 +935,16 @@ def test_benchmark_case_can_run_optional_evaluation(
934935
tmp_path: Path,
935936
) -> None:
936937
tool = load_tool("measurement_benchmark_tool_evaluate", REPO_ROOT / "tools/measurement/run_benchmarks.py")
938+
from anonymizer.interface.results import AnonymizerResult
939+
937940
calls: list[Any] = []
938-
run_result = object()
941+
run_result = AnonymizerResult(
942+
dataframe=pd.DataFrame({"text": ["Alice works at Acme"]}),
943+
trace_dataframe=pd.DataFrame({"text": ["Alice works at Acme"]}),
944+
resolved_text_column="text",
945+
failed_records=[],
946+
replace_method=None,
947+
)
939948

940949
@contextmanager
941950
def fake_measurement_session(_config: Any) -> Iterator[None]:
@@ -974,3 +983,143 @@ def evaluate(self, result: object) -> object:
974983
)
975984

976985
assert calls == [("run", tool.Redact(), "text"), ("evaluate", run_result)]
986+
987+
988+
def test_benchmark_optional_evaluation_records_sanitized_judge_metrics(tmp_path: Path) -> None:
989+
tool = load_tool("measurement_benchmark_tool_evaluate_metrics", REPO_ROOT / "tools/measurement/run_benchmarks.py")
990+
from anonymizer.interface.results import AnonymizerResult
991+
992+
dangerous_values = [
993+
"alice@example.com",
994+
"bob@example.com",
995+
"sk-secret-123",
996+
"replacement-output-secret",
997+
"nested-malformed-secret",
998+
"raw judge prompt",
999+
"raw judge response",
1000+
]
1001+
run_result = AnonymizerResult(
1002+
dataframe=pd.DataFrame({"text": ["Alice has sk-secret-123"]}),
1003+
trace_dataframe=pd.DataFrame({"text": ["Alice has sk-secret-123"]}),
1004+
resolved_text_column="text",
1005+
failed_records=[],
1006+
replace_method=None,
1007+
)
1008+
evaluated_public_columns = {
1009+
"text": ["Alice has sk-secret-123"],
1010+
"text_replaced": ["Avery has replacement-output-secret"],
1011+
"final_entities": [[{"value": "alice@example.com", "label": "email"}]],
1012+
"detection_valid": [False],
1013+
"detection_invalid_entities": [{"invalid_entities": [{"value": "alice@example.com", "label": "email"}]}],
1014+
"type_fidelity_valid": [False],
1015+
"type_fidelity_invalid_replacements": [
1016+
{"invalid_replacements": [{"original": "alice@example.com", "synthetic": "bob@example.com"}]}
1017+
],
1018+
"relational_consistency_valid": [False],
1019+
"relational_consistency_invalid_relations": [{"invalid_relations": [{"reasoning": "raw judge response"}]}],
1020+
"attribute_fidelity_valid": [False],
1021+
"attribute_fidelity_invalid_entities": ['[{"entity": "nested-malformed-secret"}'],
1022+
}
1023+
evaluated_result = AnonymizerResult(
1024+
dataframe=pd.DataFrame(evaluated_public_columns),
1025+
trace_dataframe=pd.DataFrame(
1026+
{
1027+
**evaluated_public_columns,
1028+
"_detection_judge": [
1029+
{
1030+
"prompt": "raw judge prompt",
1031+
"response": "raw judge response",
1032+
"invalid_entities": [{"value": "alice@example.com"}],
1033+
}
1034+
],
1035+
"_type_fidelity_judge": [
1036+
{"invalid_replacements": [{"original": "alice@example.com", "synthetic": "bob@example.com"}]}
1037+
],
1038+
}
1039+
),
1040+
resolved_text_column="text",
1041+
failed_records=[],
1042+
replace_method=None,
1043+
)
1044+
1045+
class FakeAnonymizer:
1046+
def run(self, *, config: Any, data: Any) -> AnonymizerResult:
1047+
return run_result
1048+
1049+
def evaluate(self, result: AnonymizerResult) -> AnonymizerResult:
1050+
assert result is run_result
1051+
return evaluated_result
1052+
1053+
spec = _minimal_benchmark_spec(
1054+
tool,
1055+
suite_id="evaluate-suite",
1056+
configs=[
1057+
tool.ConfigSpec(
1058+
id="substitute",
1059+
replace=tool.ReplaceSpec(strategy=tool.ReplaceKind.substitute),
1060+
evaluate=True,
1061+
)
1062+
],
1063+
)
1064+
_write_text_input(tmp_path, "Alice has sk-secret-123")
1065+
case = _minimal_benchmark_case(tool, suite_id="evaluate-suite", config_id="substitute")
1066+
measurement_path = tmp_path / "raw" / "input__substitute__r000.jsonl"
1067+
1068+
tool._execute_case(
1069+
FakeAnonymizer(),
1070+
spec.workloads[0],
1071+
spec.configs[0],
1072+
raw_path=measurement_path,
1073+
trace_path=None,
1074+
task_trace_path=None,
1075+
case=case,
1076+
spec=spec,
1077+
base_dir=tmp_path,
1078+
dd_trace=tool.DDTraceMode.none,
1079+
)
1080+
1081+
serialized = measurement_path.read_text(encoding="utf-8")
1082+
rows = [json.loads(line) for line in serialized.splitlines()]
1083+
evaluation_rows = [row for row in rows if row["record_type"] == "evaluation_record"]
1084+
1085+
assert len(evaluation_rows) == 1
1086+
assert {
1087+
"record_type": "evaluation_record",
1088+
"mode": "replace",
1089+
"strategy": "Substitute",
1090+
"row_index": 0,
1091+
"detection_valid": False,
1092+
"detection_invalid_entity_count": 1,
1093+
"type_fidelity_valid": False,
1094+
"type_fidelity_invalid_replacement_count": 1,
1095+
"relational_consistency_valid": False,
1096+
"relational_consistency_invalid_relation_count": 1,
1097+
"attribute_fidelity_valid": False,
1098+
"attribute_fidelity_invalid_entity_count": 0,
1099+
}.items() <= evaluation_rows[0].items()
1100+
forbidden_fields = {
1101+
"text",
1102+
"text_replaced",
1103+
"text_with_spans",
1104+
"final_entities",
1105+
"detection_invalid_entities",
1106+
"type_fidelity_invalid_replacements",
1107+
"relational_consistency_invalid_relations",
1108+
"attribute_fidelity_invalid_entities",
1109+
"_detection_judge",
1110+
"_type_fidelity_judge",
1111+
"_relational_consistency_judge",
1112+
"_attribute_fidelity_judge",
1113+
}
1114+
assert forbidden_fields.isdisjoint(evaluation_rows[0])
1115+
for raw_value in dangerous_values:
1116+
assert raw_value not in serialized
1117+
1118+
table_dir = tmp_path / "tables"
1119+
tool.export_measurement_tables(measurement_path, table_dir)
1120+
exported = pd.read_parquet(table_dir / "evaluation_record.parquet")
1121+
exported_text = str(exported.to_json(orient="records"))
1122+
1123+
assert forbidden_fields.isdisjoint(exported.columns)
1124+
for raw_value in dangerous_values:
1125+
assert raw_value not in exported_text

tools/measurement/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ plus `manifest.json`:
2828
- `run.parquet`
2929
- `stage.parquet`
3030
- `record.parquet`
31+
- `evaluation_record.parquet` when replace judge evaluation is enabled
3132
- `ndd_workflow.parquet` when DataDesigner adapter records are present
3233
- `model_workflow.parquet` when direct model workflow records are present
3334

@@ -65,7 +66,8 @@ raw = pd.read_json("benchmark-runs/suite/measurements.jsonl", lines=True)
6566
The measurement system has three layers:
6667

6768
- Instrumentation in Anonymizer emits JSONL records for runs, stages,
68-
DataDesigner workflows, direct model workflows, and per-record safety metrics.
69+
DataDesigner workflows, direct model workflows, per-record safety metrics,
70+
and optional sanitized replace-judge evaluation metrics.
6971
- Benchmark runners create repeatable workloads and write those JSONL records
7072
plus optional sidecars such as detection artifacts and DataDesigner traces.
7173
- Analysis tools convert raw run artifacts into case, group, and model tables.
@@ -243,6 +245,12 @@ Set `evaluate: true` on a replace config when the benchmark should run
243245
same case. This is intentionally replace-only for now; rewrite runs already
244246
perform their internal evaluation/repair loop during `run()`.
245247

248+
When evaluation is enabled, the safe measurement log includes
249+
`evaluation_record` rows with judge verdict booleans and invalid-item counts.
250+
It does not persist the evaluated result dataframe or trace dataframe. Those
251+
dataframes can contain original text, entity values, replacement values, raw
252+
judge outputs, prompts, and model responses.
253+
246254
Before starting a real run, the benchmark runner performs cheap preflight
247255
checks: suite/config parsing, local dataset existence, CSV/Parquet text-column
248256
metadata, provider YAML shape, and active model-alias references. `--dry-run`
@@ -409,3 +417,15 @@ Safety and replacement:
409417
- `replacement_synthetic_original_collision_count`: final entity occurrences
410418
whose original value was reused as a synthetic replacement value elsewhere in
411419
the same record.
420+
421+
Replace judge evaluation:
422+
423+
- `detection_valid`, `type_fidelity_valid`,
424+
`relational_consistency_valid`, and `attribute_fidelity_valid`: per-record
425+
judge verdicts when `evaluate: true` is enabled.
426+
- `detection_invalid_entity_count`,
427+
`type_fidelity_invalid_replacement_count`,
428+
`relational_consistency_invalid_relation_count`, and
429+
`attribute_fidelity_invalid_entity_count`: counts of invalid judge findings.
430+
These fields count structures returned by the judges but do not include raw
431+
values, replacement strings, or judge reasoning text.

0 commit comments

Comments
 (0)