diff --git a/.importlinter b/.importlinter new file mode 100644 index 00000000..6e8d170a --- /dev/null +++ b/.importlinter @@ -0,0 +1,47 @@ +[importlinter] +root_package = anonymizer + +# The NDD adapter layer is a leaf boundary: it must never import the engine +# sub-workflows that call it. +[importlinter:contract:ndd-adapter-is-a-leaf] +name = engine.ndd must not import engine sub-workflows +type = forbidden +source_modules = + anonymizer.engine.ndd +forbidden_modules = + anonymizer.engine.detection + anonymizer.engine.replace + anonymizer.engine.rewrite + anonymizer.engine.evaluation + anonymizer.interface.anonymizer + +# User-facing config must stay import-free of the engine and interface. +[importlinter:contract:config-is-lowest-layer] +name = config imports neither engine nor interface +type = forbidden +source_modules = + anonymizer.config +forbidden_modules = + anonymizer.engine + anonymizer.interface + +# Display package layering (active after Group A): the layout module may use +# the coercion/span modules, never the reverse. +[importlinter:contract:display-layering] +name = display internals do not import the layout module +type = forbidden +source_modules = + anonymizer.interface.display.payload_coercion + anonymizer.interface.display.replaced_spans +forbidden_modules = + anonymizer.interface.display.record_html + +# Facade extractions (active after Group B2) must not re-import the facade. +[importlinter:contract:facade-extractions-no-backedge] +name = output_columns / run_telemetry do not import the Anonymizer facade +type = forbidden +source_modules = + anonymizer.interface.output_columns + anonymizer.interface.run_telemetry +forbidden_modules = + anonymizer.interface.anonymizer diff --git a/pyproject.toml b/pyproject.toml index 98c75262..a61df572 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ anonymizer-chunked-validation = "anonymizer.engine.workflow_columns.detection.pl [dependency-groups] dev = [ + "import-linter>=2.13", "pre-commit>=4.0.0,<5", "pytest>=9.0.3,<10", "pytest-cov>=7.0,<8", diff --git a/src/anonymizer/engine/io/reader.py b/src/anonymizer/engine/io/reader.py index 34abc3ae..87b17709 100644 --- a/src/anonymizer/engine/io/reader.py +++ b/src/anonymizer/engine/io/reader.py @@ -44,11 +44,11 @@ def read_input(input_data: AnonymizerInput, *, nrows: int | None = None) -> Reso # Suffixes appended to the user's text column to form per-mode output columns -# (see ``_rename_output_columns`` in ``anonymizer.interface.anonymizer``). +# (see ``rename_output_columns`` in ``anonymizer.interface.output_columns``). _OUTPUT_COLUMN_SUFFIXES: tuple[str, ...] = ("_replaced", "_with_spans", "_rewritten") # Fixed user-facing output column names that don't depend on the text column -# (see ``_build_user_dataframe`` in ``anonymizer.interface.anonymizer``). +# (see ``build_user_dataframe`` in ``anonymizer.interface.output_columns``). _STATIC_OUTPUT_COLUMNS: tuple[str, ...] = ( COL_FINAL_ENTITIES, COL_UTILITY_SCORE, @@ -88,7 +88,7 @@ def _resolve_output_column_collisions(dataframe: pd.DataFrame, *, selected_text_ ``final_entities__input_replaced``) are re-checked against the remaining user columns. That second pass is necessary — without it, an input column that matches a *post-rename* derived name (``final_entities__input_replaced``) - would never be reserved, and ``_rename_output_columns`` would later + would never be reserved, and ``rename_output_columns`` would later overwrite it, producing duplicate user-facing labels. The returned ``ResolvedInput.resolved_text_column`` reflects the final diff --git a/src/anonymizer/engine/ndd/adapter.py b/src/anonymizer/engine/ndd/adapter.py index 4dbc4ae9..3588dbf8 100644 --- a/src/anonymizer/engine/ndd/adapter.py +++ b/src/anonymizer/engine/ndd/adapter.py @@ -1,34 +1,37 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +"""Owns execution of DataDesigner workflows with uniform I/O, record-id tagging, +and missing-record (`FailedRecord`) tracking. +""" + from __future__ import annotations -import importlib import json import logging -import re import tempfile import time import uuid -from collections.abc import Iterator, Mapping -from contextlib import contextmanager -from contextvars import ContextVar from dataclasses import dataclass -from functools import wraps from pathlib import Path from threading import RLock -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard, cast +from typing import TYPE_CHECKING -from data_designer.config.column_configs import CustomColumnConfig, LLMStructuredColumnConfig, LLMTextColumnConfig from data_designer.config.column_types import ColumnConfigT from data_designer.config.config_builder import DataDesignerConfigBuilder from data_designer.config.models import ModelConfig -from data_designer.config.run_config import RunConfig from data_designer.config.seed import SamplingStrategy from data_designer.config.seed_source import LocalFileSeedSource -from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX -from data_designer.config.utils.trace_type import TraceType +from anonymizer.engine.ndd.dd_observability import ( + DataDesignerUsageProbe, + DDMessageTracePlan, + _TaskTrace, + extract_workflow_model_aliases, + record_dd_task_traces, + task_traces_from_result, + temporary_dd_task_trace, +) from anonymizer.interface.errors import AnonymizerWorkflowError from anonymizer.measurement import current_collector, record_ndd_workflow @@ -36,12 +39,13 @@ import pandas as pd from data_designer.interface.data_designer import DataDesigner +__all__ = ["FailedRecord", "NddAdapter", "RECORD_ID_COLUMN", "WorkflowRunResult"] + + logger = logging.getLogger("anonymizer.ndd") + RECORD_ID_COLUMN = "_anonymizer_record_id" -_TRACEABLE_LLM_COLUMN_TYPES = (LLMTextColumnConfig, LLMStructuredColumnConfig) -_MODEL_TRACE_COLUMN: ContextVar[str | None] = ContextVar("anonymizer_dd_model_trace_column", default=None) -_MODEL_TRACE_PURPOSE: ContextVar[str | None] = ContextVar("anonymizer_dd_model_trace_purpose", default=None) @dataclass(frozen=True) @@ -61,209 +65,6 @@ class WorkflowRunResult: failed_records: list[FailedRecord] -@dataclass(frozen=True) -class _NativeTraceColumn: - column_name: str - trace_column_name: str - model_alias: str | None - model_name: str | None - model_provider_name: str | None - - -@dataclass(frozen=True) -class _PrivateFacadeTraceColumn: - column_name: str - - -@dataclass(frozen=True) -class _DDMessageTracePlan: - columns: list[ColumnConfigT] - native_columns: list[_NativeTraceColumn] - private_columns: list[_PrivateFacadeTraceColumn] - unsupported_columns: list[ColumnConfigT] - - @classmethod - def from_columns( - cls, - *, - columns: list[ColumnConfigT], - model_configs: list[ModelConfig], - collector: Any | None, - ) -> _DDMessageTracePlan: - if collector is None or not collector.dd_trace_enabled: - return cls(columns=columns, native_columns=[], private_columns=[], unsupported_columns=[]) - - model_configs_by_alias = {model_config.alias: model_config for model_config in model_configs} - native_columns: list[_NativeTraceColumn] = [] - private_columns: list[_PrivateFacadeTraceColumn] = [] - unsupported_columns: list[ColumnConfigT] = [] - configured_columns: list[ColumnConfigT] = [] - - for column in columns: - if isinstance(column, _TRACEABLE_LLM_COLUMN_TYPES): - configured_columns.append( - cast(ColumnConfigT, column.model_copy(update={"with_trace": cls.trace_type()})) - ) - model_config = model_configs_by_alias.get(column.model_alias) - native_columns.append( - _NativeTraceColumn( - column_name=column.name, - trace_column_name=f"{column.name}{TRACE_COLUMN_POSTFIX}", - model_alias=column.model_alias, - model_name=getattr(model_config, "model", None), - model_provider_name=getattr(model_config, "provider", None), - ) - ) - continue - - if _column_has_private_facade_model_calls(column): - configured_columns.append(_custom_column_with_trace_context(column)) - private_columns.append(_PrivateFacadeTraceColumn(column_name=column.name)) - continue - - unsupported_columns.append(column) - configured_columns.append(column) - - return cls( - columns=configured_columns, - native_columns=native_columns, - private_columns=private_columns, - unsupported_columns=unsupported_columns, - ) - - @staticmethod - def trace_type() -> TraceType: - # Preserve Anonymizer's existing dd_trace=last_message semantics: the trace - # sink records the final prompt message and response separately, while DD's - # native LAST_MESSAGE side effect only keeps the final assistant message. - return TraceType.ALL_MESSAGES - - def record_coverage(self, *, workflow_name: str, collector: Any | None) -> None: - if collector is None or not collector.dd_trace_enabled: - return - - traced_column_names = [column.column_name for column in self.native_columns] + [ - column.column_name for column in self.private_columns - ] - collector.record( - "dd_trace_coverage", - workflow_name=workflow_name, - trace_backend=self.backend, - trace_mode=collector.dd_trace_mode, - native_trace_type=self.trace_type().value, - traced_column_count=len(traced_column_names), - traced_column_names=traced_column_names, - native_trace_column_count=len(self.native_columns), - native_trace_column_names=[column.column_name for column in self.native_columns], - private_trace_column_count=len(self.private_columns), - private_trace_column_names=[column.column_name for column in self.private_columns], - private_trace_backend="anonymizer_private_model_facade" if self.private_columns else None, - private_trace_note=( - "temporary private DataDesigner model registry/facade instrumentation" if self.private_columns else None - ), - unsupported_column_count=len(self.unsupported_columns), - unsupported_column_names=[column.name for column in self.unsupported_columns], - unsupported_column_types=[_column_type_name(column) for column in self.unsupported_columns], - ) - - @property - def backend(self) -> str: - if self.native_columns and self.private_columns: - return "mixed" - if self.private_columns: - return "anonymizer_private_model_facade" - return "data_designer_column" - - def record_and_strip_native_traces( - self, - *, - output_df: pd.DataFrame, - workflow_name: str, - collector: Any | None, - ) -> pd.DataFrame: - if not self.native_columns: - return output_df - - trace_column_names = [column.trace_column_name for column in self.native_columns] - if collector is not None and collector.dd_trace_enabled: - for _, row in output_df.iterrows(): - for trace_column in self.native_columns: - if trace_column.trace_column_name not in output_df.columns: - continue - self._record_native_trace( - trace_column=trace_column, - trace_value=row.get(trace_column.trace_column_name), - workflow_name=workflow_name, - collector=collector, - ) - - existing_trace_columns = [column_name for column_name in trace_column_names if column_name in output_df.columns] - if not existing_trace_columns: - return output_df - return output_df.drop(columns=existing_trace_columns) - - @staticmethod - def _record_native_trace( - *, - trace_column: _NativeTraceColumn, - trace_value: Any, - workflow_name: str, - collector: Any, - ) -> None: - trace_messages = _native_trace_messages(trace_value) - if not trace_messages: - return - collector.record_dd_message_trace( - workflow_name=workflow_name, - trace_source="data_designer_column", - column_name=trace_column.column_name, - trace_column_name=trace_column.trace_column_name, - model_alias=trace_column.model_alias, - model_name=trace_column.model_name, - model_provider_name=trace_column.model_provider_name, - modality="chat", - is_async=None, - status="completed", - error_type=None, - elapsed_sec=None, - messages=_select_native_trace_messages(trace_messages, mode=collector.dd_trace_mode), - response=_native_trace_response(trace_messages), - usage=None, - ) - - -class _TaskTraceLike(Protocol): - column: Any - row_group: Any - row_index: Any - task_type: Any - status: Any - error: Any - dispatched_at: Any - slot_acquired_at: Any - completed_at: Any - - -_TaskTrace = Mapping[str, Any] | _TaskTraceLike - - -class _DDTaskTraceFields(TypedDict): - workflow_name: str - trace_source: Literal["data_designer_scheduler"] - column: Any - row_group: Any - row_index: Any - task_type: Any - status: Any - error_present: bool - dispatched_offset_sec: float | None - slot_acquired_offset_sec: float | None - completed_offset_sec: float | None - queue_wait_sec: float | None - execution_sec: float | None - total_sec: float | None - - class NddAdapter: """Adapter for running NDD workflows with uniform I/O and record tracking.""" @@ -307,7 +108,7 @@ def run_workflow( col_names = [c.name for c in columns] logger.debug("NDD workflow '%s': %d columns %s", workflow_name, len(col_names), col_names) available_model_aliases = [m.alias for m in model_configs] - model_aliases = _extract_workflow_model_aliases(columns) or available_model_aliases + model_aliases = extract_workflow_model_aliases(columns) or available_model_aliases record_count = ( min(preview_num_records, len(workflow_input_df)) if preview_num_records is not None @@ -315,13 +116,13 @@ def run_workflow( ) started = time.perf_counter() collector = current_collector() - trace_plan = _DDMessageTracePlan.from_columns( + trace_plan = DDMessageTracePlan.from_columns( columns=columns, model_configs=model_configs, collector=collector, ) columns = trace_plan.columns - usage_probe = _DataDesignerUsageProbe( + usage_probe = DataDesignerUsageProbe( self._data_designer, enabled=collector is not None, collector=collector, @@ -341,21 +142,21 @@ def run_workflow( task_traces: list[_TaskTrace] = [] try: - with self._run_lock, usage_probe, _temporary_dd_task_trace(self._data_designer, collector=collector): + with self._run_lock, usage_probe, temporary_dd_task_trace(self._data_designer, collector=collector): if preview_num_records is None: run_results = self._data_designer.create( config_builder, num_records=len(workflow_input_df), dataset_name=workflow_name, ) - task_traces = _task_traces_from_result(run_results) + task_traces = task_traces_from_result(run_results) output_df = run_results.load_dataset() else: preview_results = self._data_designer.preview( config_builder, num_records=record_count, ) - task_traces = _task_traces_from_result(preview_results) + task_traces = task_traces_from_result(preview_results) if preview_results.dataset is None: output_df = workflow_input_df.iloc[0:0].copy() else: @@ -397,7 +198,7 @@ def run_workflow( workflow_name=workflow_name, collector=collector, ) - _record_dd_task_traces( + record_dd_task_traces( workflow_name=workflow_name, collector=collector, task_traces=task_traces, @@ -580,576 +381,3 @@ def _detect_missing_records( ) for record_id in missing_ids ] - - -def _extract_workflow_model_aliases(columns: list[ColumnConfigT]) -> list[str]: - aliases: list[str] = [] - for column in columns: - aliases.extend(_as_alias_list(getattr(column, "model_alias", None))) - generator = getattr(column, "generator_function", None) - metadata = getattr(generator, "custom_column_metadata", None) - if isinstance(metadata, dict): - aliases.extend(_as_alias_list(metadata.get("model_aliases"))) - return list(dict.fromkeys(alias for alias in aliases if alias)) - - -def _as_alias_list(raw: Any) -> list[str]: - if raw is None: - return [] - if isinstance(raw, str): - return [raw] - if isinstance(raw, (list, tuple, set)): - return [str(item) for item in raw if item is not None and str(item)] - return [str(raw)] - - -class _DataDesignerUsageProbe: - """Capture DataDesigner model usage from the per-run private ResourceProvider.""" - - def __init__( - self, - data_designer: DataDesigner, - *, - enabled: bool, - collector: Any | None = None, - workflow_name: str | None = None, - private_trace_columns: list[_PrivateFacadeTraceColumn] | None = None, - ) -> None: - self._data_designer = data_designer - self._enabled = enabled - self._collector = collector - self._workflow_name = workflow_name - self._private_trace_column_names = {column.column_name for column in private_trace_columns or []} - self._original_create_resource_provider: Any | None = None - self._resource_providers: list[Any] = [] - self._model_registry_patches: list[tuple[Any, Any]] = [] - self._facade_patches: dict[int, tuple[Any, dict[str, Any]]] = {} - self._private_trace_records: list[dict[str, Any]] = [] - - def __enter__(self) -> _DataDesignerUsageProbe: - if not self._enabled: - return self - - original = getattr(self._data_designer, "_create_resource_provider", None) - if not callable(original): - return self - - self._original_create_resource_provider = original - - def wrapper(*args: Any, **kwargs: Any) -> Any: - resource_provider = original(*args, **kwargs) - self._resource_providers.append(resource_provider) - self._install_private_model_trace(resource_provider) - return resource_provider - - setattr(self._data_designer, "_create_resource_provider", wrapper) - return self - - def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: - self._restore_private_trace_patches() - if self._original_create_resource_provider is not None: - setattr(self._data_designer, "_create_resource_provider", self._original_create_resource_provider) - - def model_usage(self) -> dict[str, Any] | None: - usage: dict[str, Any] = {} - for resource_provider in self._resource_providers: - model_registry = getattr(resource_provider, "model_registry", None) - snapshot = _get_model_usage_snapshot(model_registry) - if not snapshot: - continue - for model_name, stats in snapshot.items(): - usage[str(model_name)] = _model_usage_as_json(stats) - return usage or None - - def flush_private_trace_records(self) -> None: - collector = self._collector - if collector is None: - self._private_trace_records.clear() - return - while self._private_trace_records: - collector.record_dd_message_trace(**self._private_trace_records.pop(0)) - - def _private_trace_enabled(self) -> bool: - return bool( - self._collector is not None - and self._collector.dd_trace_enabled - and self._workflow_name - and self._private_trace_column_names - ) - - def _install_private_model_trace(self, resource_provider: Any) -> None: - if not self._private_trace_enabled(): - return - model_registry = getattr(resource_provider, "model_registry", None) - get_model = getattr(model_registry, "get_model", None) - if not callable(get_model): - return - - def wrapped_get_model(*args: Any, **kwargs: Any) -> Any: - facade = get_model(*args, **kwargs) - self._patch_model_facade(facade) - return facade - - # Temporary private DataDesigner shim: CustomColumnConfig receives - # ModelFacade objects directly and DD does not yet expose a public - # model-call event sink for those calls. - setattr(model_registry, "get_model", wrapped_get_model) - self._model_registry_patches.append((model_registry, get_model)) - - def _patch_model_facade(self, facade: Any) -> None: - facade_id = id(facade) - if facade_id in self._facade_patches: - return - - originals: dict[str, Any] = {} - for method_name in ("completion", "acompletion", "generate", "agenerate"): - method = getattr(facade, method_name, None) - if not callable(method): - continue - originals[method_name] = method - setattr(facade, method_name, self._wrap_facade_method(facade, method_name, method)) - - if originals: - self._facade_patches[facade_id] = (facade, originals) - - def _wrap_facade_method(self, facade: Any, method_name: str, method: Any) -> Any: - if method_name == "acompletion": - return self._wrap_async_completion(facade, method) - if method_name == "completion": - return self._wrap_completion(facade, method) - if method_name == "agenerate": - return self._wrap_async_generate(method) - return self._wrap_generate(method) - - def _wrap_generate(self, method: Any) -> Any: - def wrapper(*args: Any, **kwargs: Any) -> Any: - token = _MODEL_TRACE_PURPOSE.set(_purpose_from_kwargs(kwargs)) - try: - return method(*args, **kwargs) - finally: - _MODEL_TRACE_PURPOSE.reset(token) - - return wrapper - - def _wrap_async_generate(self, method: Any) -> Any: - async def wrapper(*args: Any, **kwargs: Any) -> Any: - token = _MODEL_TRACE_PURPOSE.set(_purpose_from_kwargs(kwargs)) - try: - return await method(*args, **kwargs) - finally: - _MODEL_TRACE_PURPOSE.reset(token) - - return wrapper - - def _wrap_completion(self, facade: Any, method: Any) -> Any: - def wrapper(*args: Any, **kwargs: Any) -> Any: - started = time.perf_counter() - error: Exception | None = None - response: Any = None - try: - response = method(*args, **kwargs) - return response - except Exception as exc: - error = exc - raise - finally: - self._record_private_completion_trace(facade, args, kwargs, started, response, error, is_async=False) - - return wrapper - - def _wrap_async_completion(self, facade: Any, method: Any) -> Any: - async def wrapper(*args: Any, **kwargs: Any) -> Any: - started = time.perf_counter() - error: Exception | None = None - response: Any = None - try: - response = await method(*args, **kwargs) - return response - except Exception as exc: - error = exc - raise - finally: - self._record_private_completion_trace(facade, args, kwargs, started, response, error, is_async=True) - - return wrapper - - def _record_private_completion_trace( - self, - facade: Any, - args: tuple[Any, ...], - kwargs: dict[str, Any], - started: float, - response: Any, - error: Exception | None, - *, - is_async: bool, - ) -> None: - if not self._private_trace_enabled(): - return - column_name = _private_trace_column_name( - column_names=self._private_trace_column_names, - purpose=_purpose_from_kwargs(kwargs) or _MODEL_TRACE_PURPOSE.get(), - ) - if column_name is None: - return - collector = self._collector - if collector is None: - return - self._private_trace_records.append( - _private_completion_trace_fields( - workflow_name=self._workflow_name, - column_name=column_name, - facade=facade, - args=args, - kwargs=kwargs, - response=response, - error=error, - elapsed_sec=time.perf_counter() - started, - is_async=is_async, - trace_mode=collector.dd_trace_mode, - ) - ) - - def _restore_private_trace_patches(self) -> None: - for facade, originals in reversed(list(self._facade_patches.values())): - for method_name, original in originals.items(): - setattr(facade, method_name, original) - self._facade_patches.clear() - - for model_registry, get_model in reversed(self._model_registry_patches): - setattr(model_registry, "get_model", get_model) - self._model_registry_patches.clear() - - -def _get_model_usage_snapshot(model_registry: object) -> Mapping[str, object] | None: - alias_snapshot = _get_model_usage_snapshot_by_alias(model_registry) - if alias_snapshot: - return alias_snapshot - - get_snapshot = getattr(model_registry, "get_model_usage_snapshot", None) - if not callable(get_snapshot): - return None - snapshot = get_snapshot() - if isinstance(snapshot, Mapping): - return snapshot - return None - - -def _get_model_usage_snapshot_by_alias(model_registry: object) -> Mapping[str, object] | None: - models = getattr(model_registry, "_models", None) - if not isinstance(models, Mapping): - return None - - snapshot: dict[str, object] = {} - for model_alias, model_facade in models.items(): - stats = getattr(model_facade, "usage_stats", None) - if stats is None or not getattr(stats, "has_usage", False): - continue - payload = _model_usage_as_json(stats) - if isinstance(payload, Mapping): - payload = { - **payload, - "model_alias": getattr(model_facade, "model_alias", str(model_alias)), - "model_name": getattr(model_facade, "model_name", None), - "model_provider_name": getattr(model_facade, "model_provider_name", None), - } - snapshot[str(model_alias)] = payload - return snapshot or None - - -def _model_usage_as_json(stats: object) -> Any: - model_dump = getattr(stats, "model_dump", None) - if callable(model_dump): - return model_dump(mode="json") - return stats - - -def _purpose_from_kwargs(kwargs: Mapping[str, Any]) -> str | None: - purpose = kwargs.get("purpose") - return purpose if isinstance(purpose, str) and purpose else None - - -def _private_trace_column_name(*, column_names: set[str], purpose: str | None) -> str | None: - context_column = _MODEL_TRACE_COLUMN.get() - if context_column in column_names: - return context_column - - task_column = _runtime_correlation_task_column() - if task_column in column_names: - return task_column - - purpose_column = _column_name_from_purpose(purpose) - if purpose_column in column_names: - return purpose_column - - if len(column_names) == 1: - return next(iter(column_names)) - return None - - -def _runtime_correlation_task_column() -> str | None: - try: - observability = importlib.import_module("data_designer.engine.observability") - except Exception: - return None - - runtime_correlation_provider = getattr(observability, "runtime_correlation_provider", None) - current = getattr(runtime_correlation_provider, "current", None) - if not callable(current): - return None - correlation = current() - task_column = getattr(correlation, "task_column", None) - return task_column if isinstance(task_column, str) and task_column else None - - -def _column_name_from_purpose(purpose: str | None) -> str | None: - if not purpose: - return None - match = re.search(r"column '([^']+)'", purpose) - if match: - return match.group(1) - return None - - -def _model_provider_endpoint(facade: Any) -> str | None: - provider = getattr(facade, "model_provider", None) - endpoint = getattr(provider, "endpoint", None) - return endpoint if isinstance(endpoint, str) and endpoint else None - - -def _private_trace_messages(*, args: tuple[Any, ...], kwargs: Mapping[str, Any]) -> list[dict[str, Any]]: - messages = args[0] if args else kwargs.get("messages") - if isinstance(messages, list): - return [_trace_message(message) for message in messages] - return [] - - -def _private_completion_trace_fields( - *, - workflow_name: str | None, - column_name: str, - facade: Any, - args: tuple[Any, ...], - kwargs: Mapping[str, Any], - response: Any, - error: Exception | None, - elapsed_sec: float, - is_async: bool, - trace_mode: str, -) -> dict[str, Any]: - return { - "workflow_name": workflow_name, - "trace_source": "anonymizer_private_model_facade", - "column_name": column_name, - "trace_column_name": None, - "model_alias": getattr(facade, "model_alias", None), - "model_name": getattr(facade, "model_name", None), - "model_provider_name": getattr(facade, "model_provider_name", None), - "model_provider_endpoint": _model_provider_endpoint(facade), - "modality": "chat", - "is_async": is_async, - "status": "error" if error is not None else "completed", - "error_type": type(error).__name__ if error is not None else None, - "elapsed_sec": elapsed_sec, - "messages": _select_native_trace_messages(_private_trace_messages(args=args, kwargs=kwargs), mode=trace_mode), - "response": _model_trace_response(response), - "usage": _model_trace_usage(response), - } - - -def _model_trace_response(response: Any) -> dict[str, Any] | None: - message = getattr(response, "message", None) - if message is None: - return None - return { - "content": getattr(message, "content", None), - "reasoning_content": getattr(message, "reasoning_content", None), - "tool_calls": _trace_tool_calls(getattr(message, "tool_calls", [])), - } - - -def _model_trace_usage(response: Any) -> Any: - usage = getattr(response, "usage", None) - if usage is None: - return None - model_dump = getattr(usage, "model_dump", None) - if callable(model_dump): - return model_dump(mode="json") - if isinstance(usage, Mapping): - return dict(usage) - fields = ("input_tokens", "output_tokens", "total_tokens", "reasoning_tokens") - payload = {field: getattr(usage, field) for field in fields if getattr(usage, field, None) is not None} - return payload or None - - -@contextmanager -def _temporary_dd_task_trace(data_designer: DataDesigner, *, collector: Any | None) -> Iterator[None]: - if collector is None or not collector.dd_task_trace_enabled: - yield - return - - original_run_config = getattr(data_designer, "run_config", None) - set_run_config = getattr(data_designer, "set_run_config", None) - if original_run_config is None or not callable(set_run_config): - yield - return - - traced_run_config = _run_config_with_async_trace(original_run_config) - set_run_config(traced_run_config) - try: - yield - finally: - set_run_config(original_run_config) - - -def _run_config_with_async_trace(run_config: Any) -> Any: - model_copy = getattr(run_config, "model_copy", None) - if callable(model_copy): - return model_copy(update={"async_trace": True}) - if isinstance(run_config, RunConfig): - return run_config.model_copy(update={"async_trace": True}) - return run_config - - -def _task_traces_from_result(result: Any) -> list[_TaskTrace]: - raw_traces = getattr(result, "task_traces", None) - if raw_traces is None: - return [] - if isinstance(raw_traces, list): - return cast(list[_TaskTrace], raw_traces) - try: - return cast(list[_TaskTrace], list(raw_traces)) - except TypeError: - return [] - - -def _custom_column_with_trace_context(column: CustomColumnConfig) -> ColumnConfigT: - generator = column.generator_function - - @wraps(generator) - def traced_generator(*args: Any, **kwargs: Any) -> Any: - token = _MODEL_TRACE_COLUMN.set(column.name) - try: - return generator(*args, **kwargs) - finally: - _MODEL_TRACE_COLUMN.reset(token) - - traced_generator.custom_column_metadata = getattr(generator, "custom_column_metadata", {}) # type: ignore[attr-defined] - return cast(ColumnConfigT, column.model_copy(update={"generator_function": traced_generator})) - - -def _column_has_private_facade_model_calls(column: ColumnConfigT) -> TypeGuard[CustomColumnConfig]: - return isinstance(column, CustomColumnConfig) and bool(_extract_workflow_model_aliases([column])) - - -def _column_type_name(column: ColumnConfigT) -> str: - column_type = getattr(column, "column_type", None) - return str(column_type) if column_type is not None else type(column).__name__ - - -def _native_trace_messages(value: Any) -> list[dict[str, Any]]: - if value is None or isinstance(value, (str, bytes, Mapping)): - return [] - try: - messages = list(value) - except TypeError: - return [] - return [_trace_message(message) for message in messages] - - -def _select_native_trace_messages(messages: list[dict[str, Any]], *, mode: str) -> list[dict[str, Any]]: - if mode == "all_messages": - return messages - last_prompt = next((message for message in reversed(messages) if message.get("role") != "assistant"), None) - return [last_prompt] if last_prompt is not None else [] - - -def _native_trace_response(messages: list[dict[str, Any]]) -> dict[str, Any] | None: - assistant_message = next((message for message in reversed(messages) if message.get("role") == "assistant"), None) - if assistant_message is None: - return None - return { - "content": assistant_message.get("content"), - "reasoning_content": assistant_message.get("reasoning_content"), - "tool_calls": _trace_tool_calls(assistant_message.get("tool_calls", [])), - } - - -def _trace_message(message: Any) -> dict[str, Any]: - to_dict = getattr(message, "to_dict", None) - if callable(to_dict): - return cast(dict[str, Any], to_dict()) - if isinstance(message, Mapping): - return dict(message) - return {"role": getattr(message, "role", None), "content": getattr(message, "content", None)} - - -def _trace_tool_calls(tool_calls: Any) -> list[Any]: - if isinstance(tool_calls, list): - return [getattr(tool_call, "__dict__", tool_call) for tool_call in tool_calls] - return [] - - -def _record_dd_task_traces(*, workflow_name: str, collector: Any | None, task_traces: list[_TaskTrace]) -> None: - if collector is None or not collector.dd_task_trace_enabled: - return - trace_origin = _task_trace_origin(task_traces) - for task_trace in task_traces: - collector.record_dd_task_trace(**_dd_task_trace_fields(workflow_name, task_trace, trace_origin)) - - -def _dd_task_trace_fields( - workflow_name: str, - task_trace: _TaskTrace, - trace_origin: float | None, -) -> _DDTaskTraceFields: - dispatched_at = _trace_attr(task_trace, "dispatched_at") - slot_acquired_at = _trace_attr(task_trace, "slot_acquired_at") - completed_at = _trace_attr(task_trace, "completed_at") - return { - "workflow_name": workflow_name, - "trace_source": "data_designer_scheduler", - "column": _trace_attr(task_trace, "column"), - "row_group": _trace_attr(task_trace, "row_group"), - "row_index": _trace_attr(task_trace, "row_index"), - "task_type": _trace_attr(task_trace, "task_type"), - "status": _trace_attr(task_trace, "status"), - "error_present": bool(_trace_attr(task_trace, "error")), - "dispatched_offset_sec": _trace_offset(trace_origin, dispatched_at), - "slot_acquired_offset_sec": _trace_offset(trace_origin, slot_acquired_at), - "completed_offset_sec": _trace_offset(trace_origin, completed_at), - "queue_wait_sec": _trace_duration(dispatched_at, slot_acquired_at), - "execution_sec": _trace_duration(slot_acquired_at, completed_at), - "total_sec": _trace_duration(dispatched_at, completed_at), - } - - -def _task_trace_origin(task_traces: list[_TaskTrace]) -> float | None: - dispatch_times: list[float] = [] - for task_trace in task_traces: - dispatched_at = _trace_attr(task_trace, "dispatched_at") - if isinstance(dispatched_at, (int, float)) and dispatched_at > 0: - dispatch_times.append(float(dispatched_at)) - return min(dispatch_times) if dispatch_times else None - - -def _trace_attr(task_trace: _TaskTrace, name: str) -> Any: - if isinstance(task_trace, Mapping): - return cast(Mapping[str, Any], task_trace).get(name) - return getattr(task_trace, name, None) - - -def _trace_offset(origin: float | None, timestamp: Any) -> float | None: - if origin is None or not isinstance(timestamp, (int, float)): - return None - if timestamp <= 0 or timestamp < origin: - return None - return float(timestamp - origin) - - -def _trace_duration(start: Any, end: Any) -> float | None: - if not isinstance(start, (int, float)) or not isinstance(end, (int, float)): - return None - if start <= 0 or end <= 0 or end < start: - return None - return float(end - start) diff --git a/src/anonymizer/engine/ndd/dd_observability.py b/src/anonymizer/engine/ndd/dd_observability.py new file mode 100644 index 00000000..aaa16373 --- /dev/null +++ b/src/anonymizer/engine/ndd/dd_observability.py @@ -0,0 +1,824 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Owns message/task/usage tracing of DataDesigner runs for the measurement +collector, including the temporary private model-facade monkeypatch shims. +""" + +from __future__ import annotations + +import importlib +import re +import time +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from functools import wraps +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard, cast + +from data_designer.config.column_configs import CustomColumnConfig, LLMStructuredColumnConfig, LLMTextColumnConfig +from data_designer.config.column_types import ColumnConfigT +from data_designer.config.models import ModelConfig +from data_designer.config.run_config import RunConfig +from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX +from data_designer.config.utils.trace_type import TraceType + +if TYPE_CHECKING: + import pandas as pd + from data_designer.interface.data_designer import DataDesigner + +__all__ = [ + "DDMessageTracePlan", + "DataDesignerUsageProbe", + "as_alias_list", + "extract_workflow_model_aliases", + "record_dd_task_traces", + "task_traces_from_result", + "temporary_dd_task_trace", +] + + +_TRACEABLE_LLM_COLUMN_TYPES = (LLMTextColumnConfig, LLMStructuredColumnConfig) + + +_MODEL_TRACE_COLUMN: ContextVar[str | None] = ContextVar("anonymizer_dd_model_trace_column", default=None) + + +_MODEL_TRACE_PURPOSE: ContextVar[str | None] = ContextVar("anonymizer_dd_model_trace_purpose", default=None) + + +@dataclass(frozen=True) +class _NativeTraceColumn: + column_name: str + trace_column_name: str + model_alias: str | None + model_name: str | None + model_provider_name: str | None + + +@dataclass(frozen=True) +class _PrivateFacadeTraceColumn: + column_name: str + + +@dataclass(frozen=True) +class DDMessageTracePlan: + columns: list[ColumnConfigT] + native_columns: list[_NativeTraceColumn] + private_columns: list[_PrivateFacadeTraceColumn] + unsupported_columns: list[ColumnConfigT] + + @classmethod + def from_columns( + cls, + *, + columns: list[ColumnConfigT], + model_configs: list[ModelConfig], + collector: Any | None, + ) -> DDMessageTracePlan: + if collector is None or not collector.dd_trace_enabled: + return cls(columns=columns, native_columns=[], private_columns=[], unsupported_columns=[]) + + model_configs_by_alias = {model_config.alias: model_config for model_config in model_configs} + native_columns: list[_NativeTraceColumn] = [] + private_columns: list[_PrivateFacadeTraceColumn] = [] + unsupported_columns: list[ColumnConfigT] = [] + configured_columns: list[ColumnConfigT] = [] + + for column in columns: + if isinstance(column, _TRACEABLE_LLM_COLUMN_TYPES): + configured_columns.append( + cast(ColumnConfigT, column.model_copy(update={"with_trace": cls.trace_type()})) + ) + model_config = model_configs_by_alias.get(column.model_alias) + native_columns.append( + _NativeTraceColumn( + column_name=column.name, + trace_column_name=f"{column.name}{TRACE_COLUMN_POSTFIX}", + model_alias=column.model_alias, + model_name=getattr(model_config, "model", None), + model_provider_name=getattr(model_config, "provider", None), + ) + ) + continue + + if _column_has_private_facade_model_calls(column): + configured_columns.append(_custom_column_with_trace_context(column)) + private_columns.append(_PrivateFacadeTraceColumn(column_name=column.name)) + continue + + unsupported_columns.append(column) + configured_columns.append(column) + + return cls( + columns=configured_columns, + native_columns=native_columns, + private_columns=private_columns, + unsupported_columns=unsupported_columns, + ) + + @staticmethod + def trace_type() -> TraceType: + # Preserve Anonymizer's existing dd_trace=last_message semantics: the trace + # sink records the final prompt message and response separately, while DD's + # native LAST_MESSAGE side effect only keeps the final assistant message. + return TraceType.ALL_MESSAGES + + def record_coverage(self, *, workflow_name: str, collector: Any | None) -> None: + if collector is None or not collector.dd_trace_enabled: + return + + traced_column_names = [column.column_name for column in self.native_columns] + [ + column.column_name for column in self.private_columns + ] + collector.record( + "dd_trace_coverage", + workflow_name=workflow_name, + trace_backend=self.backend, + trace_mode=collector.dd_trace_mode, + native_trace_type=self.trace_type().value, + traced_column_count=len(traced_column_names), + traced_column_names=traced_column_names, + native_trace_column_count=len(self.native_columns), + native_trace_column_names=[column.column_name for column in self.native_columns], + private_trace_column_count=len(self.private_columns), + private_trace_column_names=[column.column_name for column in self.private_columns], + private_trace_backend="anonymizer_private_model_facade" if self.private_columns else None, + private_trace_note=( + "temporary private DataDesigner model registry/facade instrumentation" if self.private_columns else None + ), + unsupported_column_count=len(self.unsupported_columns), + unsupported_column_names=[column.name for column in self.unsupported_columns], + unsupported_column_types=[_column_type_name(column) for column in self.unsupported_columns], + ) + + @property + def backend(self) -> str: + if self.native_columns and self.private_columns: + return "mixed" + if self.private_columns: + return "anonymizer_private_model_facade" + return "data_designer_column" + + def record_and_strip_native_traces( + self, + *, + output_df: pd.DataFrame, + workflow_name: str, + collector: Any | None, + ) -> pd.DataFrame: + if not self.native_columns: + return output_df + + trace_column_names = [column.trace_column_name for column in self.native_columns] + if collector is not None and collector.dd_trace_enabled: + for _, row in output_df.iterrows(): + for trace_column in self.native_columns: + if trace_column.trace_column_name not in output_df.columns: + continue + self._record_native_trace( + trace_column=trace_column, + trace_value=row.get(trace_column.trace_column_name), + workflow_name=workflow_name, + collector=collector, + ) + + existing_trace_columns = [column_name for column_name in trace_column_names if column_name in output_df.columns] + if not existing_trace_columns: + return output_df + return output_df.drop(columns=existing_trace_columns) + + @staticmethod + def _record_native_trace( + *, + trace_column: _NativeTraceColumn, + trace_value: Any, + workflow_name: str, + collector: Any, + ) -> None: + trace_messages = _native_trace_messages(trace_value) + if not trace_messages: + return + collector.record_dd_message_trace( + workflow_name=workflow_name, + trace_source="data_designer_column", + column_name=trace_column.column_name, + trace_column_name=trace_column.trace_column_name, + model_alias=trace_column.model_alias, + model_name=trace_column.model_name, + model_provider_name=trace_column.model_provider_name, + modality="chat", + is_async=None, + status="completed", + error_type=None, + elapsed_sec=None, + messages=_select_native_trace_messages(trace_messages, mode=collector.dd_trace_mode), + response=_native_trace_response(trace_messages), + usage=None, + ) + + +class _TaskTraceLike(Protocol): + column: Any + row_group: Any + row_index: Any + task_type: Any + status: Any + error: Any + dispatched_at: Any + slot_acquired_at: Any + completed_at: Any + + +_TaskTrace = Mapping[str, Any] | _TaskTraceLike + + +class _DDTaskTraceFields(TypedDict): + workflow_name: str + trace_source: Literal["data_designer_scheduler"] + column: Any + row_group: Any + row_index: Any + task_type: Any + status: Any + error_present: bool + dispatched_offset_sec: float | None + slot_acquired_offset_sec: float | None + completed_offset_sec: float | None + queue_wait_sec: float | None + execution_sec: float | None + total_sec: float | None + + +def extract_workflow_model_aliases(columns: list[ColumnConfigT]) -> list[str]: + aliases: list[str] = [] + for column in columns: + aliases.extend(as_alias_list(getattr(column, "model_alias", None))) + generator = getattr(column, "generator_function", None) + metadata = getattr(generator, "custom_column_metadata", None) + if isinstance(metadata, dict): + aliases.extend(as_alias_list(metadata.get("model_aliases"))) + return list(dict.fromkeys(alias for alias in aliases if alias)) + + +def as_alias_list(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, str): + return [raw] + if isinstance(raw, (list, tuple, set)): + return [str(item) for item in raw if item is not None and str(item)] + return [str(raw)] + + +class DataDesignerUsageProbe: + """Capture DataDesigner model usage from the per-run private ResourceProvider.""" + + def __init__( + self, + data_designer: DataDesigner, + *, + enabled: bool, + collector: Any | None = None, + workflow_name: str | None = None, + private_trace_columns: list[_PrivateFacadeTraceColumn] | None = None, + ) -> None: + self._data_designer = data_designer + self._enabled = enabled + self._collector = collector + self._workflow_name = workflow_name + self._private_trace_column_names = {column.column_name for column in private_trace_columns or []} + self._original_create_resource_provider: Any | None = None + self._resource_providers: list[Any] = [] + self._model_registry_patches: list[tuple[Any, Any]] = [] + self._facade_patches: dict[int, tuple[Any, dict[str, Any]]] = {} + self._private_trace_records: list[dict[str, Any]] = [] + + def __enter__(self) -> DataDesignerUsageProbe: + if not self._enabled: + return self + + original = getattr(self._data_designer, "_create_resource_provider", None) + if not callable(original): + return self + + self._original_create_resource_provider = original + + def wrapper(*args: Any, **kwargs: Any) -> Any: + resource_provider = original(*args, **kwargs) + self._resource_providers.append(resource_provider) + self._install_private_model_trace(resource_provider) + return resource_provider + + setattr(self._data_designer, "_create_resource_provider", wrapper) + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self._restore_private_trace_patches() + if self._original_create_resource_provider is not None: + setattr(self._data_designer, "_create_resource_provider", self._original_create_resource_provider) + + def model_usage(self) -> dict[str, Any] | None: + usage: dict[str, Any] = {} + for resource_provider in self._resource_providers: + model_registry = getattr(resource_provider, "model_registry", None) + snapshot = _get_model_usage_snapshot(model_registry) + if not snapshot: + continue + for model_name, stats in snapshot.items(): + usage[str(model_name)] = _model_usage_as_json(stats) + return usage or None + + def flush_private_trace_records(self) -> None: + collector = self._collector + if collector is None: + self._private_trace_records.clear() + return + while self._private_trace_records: + collector.record_dd_message_trace(**self._private_trace_records.pop(0)) + + def _private_trace_enabled(self) -> bool: + return bool( + self._collector is not None + and self._collector.dd_trace_enabled + and self._workflow_name + and self._private_trace_column_names + ) + + def _install_private_model_trace(self, resource_provider: Any) -> None: + if not self._private_trace_enabled(): + return + model_registry = getattr(resource_provider, "model_registry", None) + get_model = getattr(model_registry, "get_model", None) + if not callable(get_model): + return + + def wrapped_get_model(*args: Any, **kwargs: Any) -> Any: + facade = get_model(*args, **kwargs) + self._patch_model_facade(facade) + return facade + + # Temporary private DataDesigner shim: CustomColumnConfig receives + # ModelFacade objects directly and DD does not yet expose a public + # model-call event sink for those calls. + setattr(model_registry, "get_model", wrapped_get_model) + self._model_registry_patches.append((model_registry, get_model)) + + def _patch_model_facade(self, facade: Any) -> None: + facade_id = id(facade) + if facade_id in self._facade_patches: + return + + originals: dict[str, Any] = {} + for method_name in ("completion", "acompletion", "generate", "agenerate"): + method = getattr(facade, method_name, None) + if not callable(method): + continue + originals[method_name] = method + setattr(facade, method_name, self._wrap_facade_method(facade, method_name, method)) + + if originals: + self._facade_patches[facade_id] = (facade, originals) + + def _wrap_facade_method(self, facade: Any, method_name: str, method: Any) -> Any: + if method_name == "acompletion": + return self._wrap_async_completion(facade, method) + if method_name == "completion": + return self._wrap_completion(facade, method) + if method_name == "agenerate": + return self._wrap_async_generate(method) + return self._wrap_generate(method) + + def _wrap_generate(self, method: Any) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> Any: + token = _MODEL_TRACE_PURPOSE.set(_purpose_from_kwargs(kwargs)) + try: + return method(*args, **kwargs) + finally: + _MODEL_TRACE_PURPOSE.reset(token) + + return wrapper + + def _wrap_async_generate(self, method: Any) -> Any: + async def wrapper(*args: Any, **kwargs: Any) -> Any: + token = _MODEL_TRACE_PURPOSE.set(_purpose_from_kwargs(kwargs)) + try: + return await method(*args, **kwargs) + finally: + _MODEL_TRACE_PURPOSE.reset(token) + + return wrapper + + def _wrap_completion(self, facade: Any, method: Any) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> Any: + started = time.perf_counter() + error: Exception | None = None + response: Any = None + try: + response = method(*args, **kwargs) + return response + except Exception as exc: + error = exc + raise + finally: + self._record_private_completion_trace(facade, args, kwargs, started, response, error, is_async=False) + + return wrapper + + def _wrap_async_completion(self, facade: Any, method: Any) -> Any: + async def wrapper(*args: Any, **kwargs: Any) -> Any: + started = time.perf_counter() + error: Exception | None = None + response: Any = None + try: + response = await method(*args, **kwargs) + return response + except Exception as exc: + error = exc + raise + finally: + self._record_private_completion_trace(facade, args, kwargs, started, response, error, is_async=True) + + return wrapper + + def _record_private_completion_trace( + self, + facade: Any, + args: tuple[Any, ...], + kwargs: dict[str, Any], + started: float, + response: Any, + error: Exception | None, + *, + is_async: bool, + ) -> None: + if not self._private_trace_enabled(): + return + column_name = _private_trace_column_name( + column_names=self._private_trace_column_names, + purpose=_purpose_from_kwargs(kwargs) or _MODEL_TRACE_PURPOSE.get(), + ) + if column_name is None: + return + collector = self._collector + if collector is None: + return + self._private_trace_records.append( + _private_completion_trace_fields( + workflow_name=self._workflow_name, + column_name=column_name, + facade=facade, + args=args, + kwargs=kwargs, + response=response, + error=error, + elapsed_sec=time.perf_counter() - started, + is_async=is_async, + trace_mode=collector.dd_trace_mode, + ) + ) + + def _restore_private_trace_patches(self) -> None: + for facade, originals in reversed(list(self._facade_patches.values())): + for method_name, original in originals.items(): + setattr(facade, method_name, original) + self._facade_patches.clear() + + for model_registry, get_model in reversed(self._model_registry_patches): + setattr(model_registry, "get_model", get_model) + self._model_registry_patches.clear() + + +def _get_model_usage_snapshot(model_registry: object) -> Mapping[str, object] | None: + alias_snapshot = _get_model_usage_snapshot_by_alias(model_registry) + if alias_snapshot: + return alias_snapshot + + get_snapshot = getattr(model_registry, "get_model_usage_snapshot", None) + if not callable(get_snapshot): + return None + snapshot = get_snapshot() + if isinstance(snapshot, Mapping): + return snapshot + return None + + +def _get_model_usage_snapshot_by_alias(model_registry: object) -> Mapping[str, object] | None: + models = getattr(model_registry, "_models", None) + if not isinstance(models, Mapping): + return None + + snapshot: dict[str, object] = {} + for model_alias, model_facade in models.items(): + stats = getattr(model_facade, "usage_stats", None) + if stats is None or not getattr(stats, "has_usage", False): + continue + payload = _model_usage_as_json(stats) + if isinstance(payload, Mapping): + payload = { + **payload, + "model_alias": getattr(model_facade, "model_alias", str(model_alias)), + "model_name": getattr(model_facade, "model_name", None), + "model_provider_name": getattr(model_facade, "model_provider_name", None), + } + snapshot[str(model_alias)] = payload + return snapshot or None + + +def _model_usage_as_json(stats: object) -> Any: + model_dump = getattr(stats, "model_dump", None) + if callable(model_dump): + return model_dump(mode="json") + return stats + + +def _purpose_from_kwargs(kwargs: Mapping[str, Any]) -> str | None: + purpose = kwargs.get("purpose") + return purpose if isinstance(purpose, str) and purpose else None + + +def _private_trace_column_name(*, column_names: set[str], purpose: str | None) -> str | None: + context_column = _MODEL_TRACE_COLUMN.get() + if context_column in column_names: + return context_column + + task_column = _runtime_correlation_task_column() + if task_column in column_names: + return task_column + + purpose_column = _column_name_from_purpose(purpose) + if purpose_column in column_names: + return purpose_column + + if len(column_names) == 1: + return next(iter(column_names)) + return None + + +def _runtime_correlation_task_column() -> str | None: + try: + observability = importlib.import_module("data_designer.engine.observability") + except Exception: + return None + + runtime_correlation_provider = getattr(observability, "runtime_correlation_provider", None) + current = getattr(runtime_correlation_provider, "current", None) + if not callable(current): + return None + correlation = current() + task_column = getattr(correlation, "task_column", None) + return task_column if isinstance(task_column, str) and task_column else None + + +def _column_name_from_purpose(purpose: str | None) -> str | None: + if not purpose: + return None + match = re.search(r"column '([^']+)'", purpose) + if match: + return match.group(1) + return None + + +def _model_provider_endpoint(facade: Any) -> str | None: + provider = getattr(facade, "model_provider", None) + endpoint = getattr(provider, "endpoint", None) + return endpoint if isinstance(endpoint, str) and endpoint else None + + +def _private_trace_messages(*, args: tuple[Any, ...], kwargs: Mapping[str, Any]) -> list[dict[str, Any]]: + messages = args[0] if args else kwargs.get("messages") + if isinstance(messages, list): + return [_trace_message(message) for message in messages] + return [] + + +def _private_completion_trace_fields( + *, + workflow_name: str | None, + column_name: str, + facade: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + response: Any, + error: Exception | None, + elapsed_sec: float, + is_async: bool, + trace_mode: str, +) -> dict[str, Any]: + return { + "workflow_name": workflow_name, + "trace_source": "anonymizer_private_model_facade", + "column_name": column_name, + "trace_column_name": None, + "model_alias": getattr(facade, "model_alias", None), + "model_name": getattr(facade, "model_name", None), + "model_provider_name": getattr(facade, "model_provider_name", None), + "model_provider_endpoint": _model_provider_endpoint(facade), + "modality": "chat", + "is_async": is_async, + "status": "error" if error is not None else "completed", + "error_type": type(error).__name__ if error is not None else None, + "elapsed_sec": elapsed_sec, + "messages": _select_native_trace_messages(_private_trace_messages(args=args, kwargs=kwargs), mode=trace_mode), + "response": _model_trace_response(response), + "usage": _model_trace_usage(response), + } + + +def _model_trace_response(response: Any) -> dict[str, Any] | None: + message = getattr(response, "message", None) + if message is None: + return None + return { + "content": getattr(message, "content", None), + "reasoning_content": getattr(message, "reasoning_content", None), + "tool_calls": _trace_tool_calls(getattr(message, "tool_calls", [])), + } + + +def _model_trace_usage(response: Any) -> Any: + usage = getattr(response, "usage", None) + if usage is None: + return None + model_dump = getattr(usage, "model_dump", None) + if callable(model_dump): + return model_dump(mode="json") + if isinstance(usage, Mapping): + return dict(usage) + fields = ("input_tokens", "output_tokens", "total_tokens", "reasoning_tokens") + payload = {field: getattr(usage, field) for field in fields if getattr(usage, field, None) is not None} + return payload or None + + +@contextmanager +def temporary_dd_task_trace(data_designer: DataDesigner, *, collector: Any | None) -> Iterator[None]: + if collector is None or not collector.dd_task_trace_enabled: + yield + return + + original_run_config = getattr(data_designer, "run_config", None) + set_run_config = getattr(data_designer, "set_run_config", None) + if original_run_config is None or not callable(set_run_config): + yield + return + + traced_run_config = _run_config_with_async_trace(original_run_config) + set_run_config(traced_run_config) + try: + yield + finally: + set_run_config(original_run_config) + + +def _run_config_with_async_trace(run_config: Any) -> Any: + model_copy = getattr(run_config, "model_copy", None) + if callable(model_copy): + return model_copy(update={"async_trace": True}) + if isinstance(run_config, RunConfig): + return run_config.model_copy(update={"async_trace": True}) + return run_config + + +def task_traces_from_result(result: Any) -> list[_TaskTrace]: + raw_traces = getattr(result, "task_traces", None) + if raw_traces is None: + return [] + if isinstance(raw_traces, list): + return cast(list[_TaskTrace], raw_traces) + try: + return cast(list[_TaskTrace], list(raw_traces)) + except TypeError: + return [] + + +def _custom_column_with_trace_context(column: CustomColumnConfig) -> ColumnConfigT: + generator = column.generator_function + + @wraps(generator) + def traced_generator(*args: Any, **kwargs: Any) -> Any: + token = _MODEL_TRACE_COLUMN.set(column.name) + try: + return generator(*args, **kwargs) + finally: + _MODEL_TRACE_COLUMN.reset(token) + + traced_generator.custom_column_metadata = getattr(generator, "custom_column_metadata", {}) # type: ignore[attr-defined] + return cast(ColumnConfigT, column.model_copy(update={"generator_function": traced_generator})) + + +def _column_has_private_facade_model_calls(column: ColumnConfigT) -> TypeGuard[CustomColumnConfig]: + return isinstance(column, CustomColumnConfig) and bool(extract_workflow_model_aliases([column])) + + +def _column_type_name(column: ColumnConfigT) -> str: + column_type = getattr(column, "column_type", None) + return str(column_type) if column_type is not None else type(column).__name__ + + +def _native_trace_messages(value: Any) -> list[dict[str, Any]]: + if value is None or isinstance(value, (str, bytes, Mapping)): + return [] + try: + messages = list(value) + except TypeError: + return [] + return [_trace_message(message) for message in messages] + + +def _select_native_trace_messages(messages: list[dict[str, Any]], *, mode: str) -> list[dict[str, Any]]: + if mode == "all_messages": + return messages + last_prompt = next((message for message in reversed(messages) if message.get("role") != "assistant"), None) + return [last_prompt] if last_prompt is not None else [] + + +def _native_trace_response(messages: list[dict[str, Any]]) -> dict[str, Any] | None: + assistant_message = next((message for message in reversed(messages) if message.get("role") == "assistant"), None) + if assistant_message is None: + return None + return { + "content": assistant_message.get("content"), + "reasoning_content": assistant_message.get("reasoning_content"), + "tool_calls": _trace_tool_calls(assistant_message.get("tool_calls", [])), + } + + +def _trace_message(message: Any) -> dict[str, Any]: + to_dict = getattr(message, "to_dict", None) + if callable(to_dict): + return cast(dict[str, Any], to_dict()) + if isinstance(message, Mapping): + return dict(message) + return {"role": getattr(message, "role", None), "content": getattr(message, "content", None)} + + +def _trace_tool_calls(tool_calls: Any) -> list[Any]: + if isinstance(tool_calls, list): + return [getattr(tool_call, "__dict__", tool_call) for tool_call in tool_calls] + return [] + + +def record_dd_task_traces(*, workflow_name: str, collector: Any | None, task_traces: list[_TaskTrace]) -> None: + if collector is None or not collector.dd_task_trace_enabled: + return + trace_origin = _task_trace_origin(task_traces) + for task_trace in task_traces: + collector.record_dd_task_trace(**_dd_task_trace_fields(workflow_name, task_trace, trace_origin)) + + +def _dd_task_trace_fields( + workflow_name: str, + task_trace: _TaskTrace, + trace_origin: float | None, +) -> _DDTaskTraceFields: + dispatched_at = _trace_attr(task_trace, "dispatched_at") + slot_acquired_at = _trace_attr(task_trace, "slot_acquired_at") + completed_at = _trace_attr(task_trace, "completed_at") + return { + "workflow_name": workflow_name, + "trace_source": "data_designer_scheduler", + "column": _trace_attr(task_trace, "column"), + "row_group": _trace_attr(task_trace, "row_group"), + "row_index": _trace_attr(task_trace, "row_index"), + "task_type": _trace_attr(task_trace, "task_type"), + "status": _trace_attr(task_trace, "status"), + "error_present": bool(_trace_attr(task_trace, "error")), + "dispatched_offset_sec": _trace_offset(trace_origin, dispatched_at), + "slot_acquired_offset_sec": _trace_offset(trace_origin, slot_acquired_at), + "completed_offset_sec": _trace_offset(trace_origin, completed_at), + "queue_wait_sec": _trace_duration(dispatched_at, slot_acquired_at), + "execution_sec": _trace_duration(slot_acquired_at, completed_at), + "total_sec": _trace_duration(dispatched_at, completed_at), + } + + +def _task_trace_origin(task_traces: list[_TaskTrace]) -> float | None: + dispatch_times: list[float] = [] + for task_trace in task_traces: + dispatched_at = _trace_attr(task_trace, "dispatched_at") + if isinstance(dispatched_at, (int, float)) and dispatched_at > 0: + dispatch_times.append(float(dispatched_at)) + return min(dispatch_times) if dispatch_times else None + + +def _trace_attr(task_trace: _TaskTrace, name: str) -> Any: + if isinstance(task_trace, Mapping): + return cast(Mapping[str, Any], task_trace).get(name) + return getattr(task_trace, name, None) + + +def _trace_offset(origin: float | None, timestamp: Any) -> float | None: + if origin is None or not isinstance(timestamp, (int, float)): + return None + if timestamp <= 0 or timestamp < origin: + return None + return float(timestamp - origin) + + +def _trace_duration(start: Any, end: Any) -> float | None: + if not isinstance(start, (int, float)) or not isinstance(end, (int, float)): + return None + if start <= 0 or end <= 0 or end < start: + return None + return float(end - start) diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index bcfee0c5..e4684d74 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -1,11 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +"""Owns the `Anonymizer` facade: wiring config to engine workflows and returning results.""" + from __future__ import annotations import logging import os -import re import time import uuid from collections import Counter @@ -24,26 +25,8 @@ ) from anonymizer.config.replace_strategies import Substitute from anonymizer.engine.constants import ( - COL_ANY_HIGH_LEAKED, - COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, - COL_ATTRIBUTE_FIDELITY_VALID, COL_DETECTED_ENTITIES, - COL_DETECTION_INVALID_ENTITIES, - COL_DETECTION_VALID, - COL_FINAL_ENTITIES, - COL_JUDGE_EVALUATION, - COL_LEAKAGE_MASS, - COL_NEEDS_HUMAN_REVIEW, - COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, - COL_RELATIONAL_CONSISTENCY_VALID, - COL_REPLACED_TEXT, - COL_REWRITTEN_TEXT, - COL_TAGGED_TEXT, COL_TEXT, - COL_TYPE_FIDELITY_INVALID_REPLACEMENTS, - COL_TYPE_FIDELITY_VALID, - COL_UTILITY_SCORE, - COL_WEIGHTED_LEAKAGE_RATE, DEFAULT_ENTITY_LABELS, ) from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow @@ -52,7 +35,7 @@ from anonymizer.engine.evaluation.replace.relational_consistency_judge import RelationalConsistencyJudgeWorkflow from anonymizer.engine.evaluation.replace.type_fidelity_judge import TypeFidelityJudgeWorkflow from anonymizer.engine.io.reader import read_input -from anonymizer.engine.ndd.adapter import FailedRecord, NddAdapter +from anonymizer.engine.ndd.adapter import NddAdapter from anonymizer.engine.ndd.model_loader import ( load_default_model_providers, parse_model_configs, @@ -64,7 +47,13 @@ from anonymizer.engine.resolved_input import ResolvedInput from anonymizer.engine.rewrite.rewrite_workflow import RewriteWorkflow from anonymizer.interface.errors import InvalidConfigError +from anonymizer.interface.output_columns import ( + build_user_dataframe, + rename_output_columns, + unrename_output_columns, +) from anonymizer.interface.results import AnonymizerResult, PreviewResult +from anonymizer.interface.run_telemetry import build_anonymizer_event from anonymizer.logging import LOG_INDENT, configure_logging, reapply_log_levels from anonymizer.measurement import ( record_record_metrics, @@ -72,22 +61,19 @@ stage_timer, ) from anonymizer.telemetry import ( - NOT_APPLICABLE, - AnonymizerEvent, TaskEnum, TaskStatusEnum, TelemetryHandler, _telemetry_enabled, - avg_tokens_per_record, - classify_model_host, - collect_model_hosts, - sort_join_aliases, ) if TYPE_CHECKING: import pandas as pd from data_designer.config.config_builder import DataDesignerConfigBuilder +__all__ = ["Anonymizer"] + + logger = logging.getLogger("anonymizer") @@ -396,16 +382,16 @@ def evaluate( 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) + 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) + 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), + 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, @@ -434,16 +420,16 @@ def evaluate( # '__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) + internal_df = unrename_output_columns(output.trace_dataframe, resolved_text_column=text_column) replace_result = self._replace_runner.evaluate( internal_df, replace_method=replace_method, model_configs=self._model_configs, selected_models=self._selected_models.evaluate, ) - renamed_trace = _rename_output_columns(replace_result.dataframe, resolved_text_column=text_column) + 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), trace_dataframe=renamed_trace, resolved_text_column=text_column, failed_records=replace_result.failed_records, @@ -622,7 +608,7 @@ def _run_internal_impl( for f in all_failures: logger.debug(" %s (%s: %s)", f.record_id, f.step, f.reason) text_col = context.resolved_text_column - renamed_trace = _rename_output_columns(final_df, resolved_text_column=text_col) + renamed_trace = rename_output_columns(final_df, resolved_text_column=text_col) logger.info("🎉 Pipeline complete — %d records processed, %d total failures", num_records, len(all_failures)) record_record_metrics( final_df, @@ -632,7 +618,7 @@ def _run_internal_impl( validation_max_entities_per_call=config.detect.validation_max_entities_per_call, ) return AnonymizerResult( - dataframe=_build_user_dataframe(renamed_trace, resolved_text_column=text_col), + dataframe=build_user_dataframe(renamed_trace, resolved_text_column=text_col), trace_dataframe=renamed_trace, resolved_text_column=text_col, failed_records=all_failures, @@ -673,12 +659,14 @@ def _maybe_emit_telemetry( try: if not getattr(config, "emit_telemetry", True): return - # Short-circuit before _build_telemetry_event so we don't pay the + # Short-circuit before build_anonymizer_event so we don't pay the # tiktoken cost on every record when telemetry is globally disabled # via NEMO_TELEMETRY_ENABLED=false. if not _telemetry_enabled(): return - event = self._build_telemetry_event( + event = build_anonymizer_event( + selected_models=self._selected_models, + resolved_providers=self._resolved_providers, task=task, status=status, config=config, @@ -697,77 +685,6 @@ def _maybe_emit_telemetry( except Exception: # noqa: BLE001 - best-effort logger.debug("Failed to emit telemetry event", exc_info=True) - def _build_telemetry_event( - self, - *, - task: TaskEnum, - status: TaskStatusEnum, - config: AnonymizerConfig, - data: AnonymizerInput, - input_df: pd.DataFrame, - result: AnonymizerResult | None, - duration_sec: float, - ) -> AnonymizerEvent: - """Construct an AnonymizerEvent from the current pipeline state.""" - total_records = int(len(input_df)) - failed = list(result.failed_records) if result is not None else [] - failure_count = len(failed) - success_count = max(total_records - failure_count, 0) - - avg_tokens = -1 - if total_records > 0 and COL_TEXT in input_df.columns: - avg_tokens = avg_tokens_per_record(input_df[COL_TEXT].astype(str)) - - transformation_type = _transformation_type_string(config) - rewrite = config.rewrite - substitute = config.replace if isinstance(config.replace, Substitute) else None - - models = _collect_step_models( - selected=self._selected_models, - has_substitute=substitute is not None, - has_rewrite=rewrite is not None, - ) - failure_counts = _collect_failure_counts(failed) - hosts = _resolve_model_hosts(self._resolved_providers) - - return AnonymizerEvent( - task=task, - task_status=status, - job_duration_sec=duration_sec, - num_input_records=total_records, - num_success_records=success_count, - num_failure_records=failure_count, - avg_tokens_per_record=avg_tokens, - transformation_type=transformation_type, - custom_data_summary_provided=bool(data.data_summary), - custom_privacy_goal_provided=_custom_privacy_goal_provided(rewrite), - custom_substitute_instructions_provided=bool(substitute is not None and substitute.instructions), - max_repair_iterations=(rewrite.max_repair_iterations if rewrite is not None else -1), - strict_entity_protection=(rewrite.strict_entity_protection if rewrite is not None else False), - repair_iterations_triggered=_repair_iterations_triggered(failed, rewrite is not None), - entity_detector_model=models["entity_detector"], - entity_validator_model=models["entity_validator"], - entity_augmenter_model=models["entity_augmenter"], - latent_detector_model=models["latent_detector"], - replacement_generator_model=models["replacement_generator"], - domain_classifier_model=models["domain_classifier"], - disposition_analyzer_model=models["disposition_analyzer"], - meaning_extractor_model=models["meaning_extractor"], - qa_generator_model=models["qa_generator"], - rewriter_model=models["rewriter"], - evaluator_model=models["evaluator"], - repairer_model=models["repairer"], - model_hosts=hosts, - entity_detection_failure_count=failure_counts["entity_detection"], - latent_detection_failure_count=failure_counts["latent_detection"], - replace_map_generation_failure_count=failure_counts["replace_map_generation"], - rewrite_pipeline_failure_count=failure_counts["rewrite_pipeline"], - rewrite_evaluate_failure_count=failure_counts["rewrite_evaluate"], - rewrite_repair_failure_count=failure_counts["rewrite_repair"], - rewrite_final_judge_failure_count=failure_counts["rewrite_final_judge"], - unknown_step_failure_count=failure_counts["unknown"], - ) - def _unwrap_entities(raw: object) -> list: if isinstance(raw, dict): @@ -827,218 +744,3 @@ def _resolve_model_providers( if not raw_providers: raise ValueError("model_providers must contain at least one provider.") return [ModelProvider.model_validate(provider) for provider in raw_providers] - - -def _rename_output_columns(df: pd.DataFrame, *, resolved_text_column: str) -> pd.DataFrame: - """Rename internal column names to user-facing names.""" - rename_map: dict[str, str] = {} - if COL_TEXT in df.columns: - rename_map[COL_TEXT] = resolved_text_column - if COL_REPLACED_TEXT in df.columns: - rename_map[COL_REPLACED_TEXT] = f"{resolved_text_column}_replaced" - if COL_TAGGED_TEXT in df.columns: - rename_map[COL_TAGGED_TEXT] = f"{resolved_text_column}_with_spans" - if COL_REWRITTEN_TEXT in df.columns: - rename_map[COL_REWRITTEN_TEXT] = f"{resolved_text_column}_rewritten" - if not rename_map: - return df - return df.rename(columns=rename_map) - - -def _unrename_output_columns(df: pd.DataFrame, *, resolved_text_column: str) -> pd.DataFrame: - """Reverse of :func:`_rename_output_columns`. - - Converts user-facing column names (``biography``, ``biography_replaced``, …) - back to the internal names (``__nemo_anonymizer_text_input__``, …) that the - judges' prompt templates reference. No-op if the dataframe is already in - internal form (``COL_TEXT`` already present). - """ - if COL_TEXT in df.columns: - return df - rename_map: dict[str, str] = {} - if resolved_text_column in df.columns: - rename_map[resolved_text_column] = COL_TEXT - if f"{resolved_text_column}_replaced" in df.columns: - rename_map[f"{resolved_text_column}_replaced"] = COL_REPLACED_TEXT - if f"{resolved_text_column}_with_spans" in df.columns: - rename_map[f"{resolved_text_column}_with_spans"] = COL_TAGGED_TEXT - if f"{resolved_text_column}_rewritten" in df.columns: - rename_map[f"{resolved_text_column}_rewritten"] = COL_REWRITTEN_TEXT - if not rename_map: - return df - return df.rename(columns=rename_map) - - -def _build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column: str) -> 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 - 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 - """ - t = trace_dataframe - text_col = resolved_text_column - - if f"{text_col}_rewritten" in t.columns: - allowed = { - text_col, - f"{text_col}_rewritten", - COL_UTILITY_SCORE, - COL_LEAKAGE_MASS, - 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() - } - elif f"{text_col}_replaced" in t.columns: - allowed = { - text_col, - f"{text_col}_replaced", - f"{text_col}_with_spans", - COL_FINAL_ENTITIES, - COL_DETECTION_VALID, - COL_DETECTION_INVALID_ENTITIES, - COL_TYPE_FIDELITY_VALID, - COL_TYPE_FIDELITY_INVALID_REPLACEMENTS, - COL_RELATIONAL_CONSISTENCY_VALID, - COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, - COL_ATTRIBUTE_FIDELITY_VALID, - COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, - } - else: - allowed = { - text_col, - f"{text_col}_with_spans", - COL_FINAL_ENTITIES, - } - - return t[[col for col in t.columns if col in allowed]].copy() - - -# ----------------------------------------------------------------- telemetry helpers - - -_REWRITE_REPAIR_RE = re.compile(r"^rewrite-repair-(\d+)$") -_REWRITE_EVALUATE_RE = re.compile(r"^rewrite-evaluate-(\d+)$") - - -def _transformation_type_string(config: AnonymizerConfig) -> str: - """Map AnonymizerConfig to the schema's transformationType value. - - Schema accepts exactly one of: ``annotate``, ``redact``, ``hash``, - ``substitute``, ``rewrite``. AnonymizerConfig's validator enforces exactly one - of replace/rewrite, so one of these branches always fires. - """ - if config.rewrite is not None: - return "rewrite" - # The four ReplaceMethodBase subclasses (Annotate, Redact, Hash, Substitute) - # lowercase directly to their schema values. - return type(config.replace).__name__.lower() - - -def _custom_privacy_goal_provided(rewrite: object | None) -> bool: - """Detect whether the user supplied a non-default privacy_goal. - - ``Rewrite.populate_default_privacy_goal`` always populates a default if the - user passed None, so we treat the default protect/preserve text as "not custom". - """ - if rewrite is None or rewrite.privacy_goal is None: # type: ignore[union-attr] - return False - from anonymizer.config.rewrite import DEFAULT_PRESERVE_TEXT, DEFAULT_PROTECT_TEXT - - goal = rewrite.privacy_goal # type: ignore[union-attr] - return goal.protect != DEFAULT_PROTECT_TEXT or goal.preserve != DEFAULT_PRESERVE_TEXT - - -def _collect_step_models( - *, - selected, # ModelSelection - has_substitute: bool, - has_rewrite: bool, -) -> dict[str, str]: - """Project the user's model selection into the schema's step-keyed shape.""" - det = selected.detection - rewrite = selected.rewrite - replace = selected.replace - return { - "entity_detector": det.entity_detector or NOT_APPLICABLE, - "entity_validator": sort_join_aliases(det.entity_validator or []), - "entity_augmenter": det.entity_augmenter or NOT_APPLICABLE, - # latent_detector only runs in rewrite mode - "latent_detector": (det.latent_detector or NOT_APPLICABLE) if has_rewrite else NOT_APPLICABLE, - # replacement_generator only runs in Substitute mode - "replacement_generator": replace.replacement_generator if has_substitute else NOT_APPLICABLE, - # All rewrite-only roles - "domain_classifier": rewrite.domain_classifier if has_rewrite else NOT_APPLICABLE, - "disposition_analyzer": rewrite.disposition_analyzer if has_rewrite else NOT_APPLICABLE, - "meaning_extractor": rewrite.meaning_extractor if has_rewrite else NOT_APPLICABLE, - "qa_generator": rewrite.qa_generator if has_rewrite else NOT_APPLICABLE, - "rewriter": rewrite.rewriter if has_rewrite else NOT_APPLICABLE, - "evaluator": rewrite.evaluator if has_rewrite else NOT_APPLICABLE, - "repairer": rewrite.repairer if has_rewrite else NOT_APPLICABLE, - } - - -def _step_to_field(step: str) -> str: - """Map a FailedRecord.step (workflow_name) to a schema failure-count field key.""" - match step: - case "entity-detection": - return "entity_detection" - case "latent-entity-detection": - return "latent_detection" - case "replace-map-generation": - return "replace_map_generation" - case "rewrite-pipeline": - return "rewrite_pipeline" - case "rewrite-final-judge": - return "rewrite_final_judge" - case _ if _REWRITE_EVALUATE_RE.match(step): - return "rewrite_evaluate" - case _ if _REWRITE_REPAIR_RE.match(step): - return "rewrite_repair" - case _: - return "unknown" - - -def _collect_failure_counts(failed: list[FailedRecord]) -> dict[str, int]: - """Aggregate FailedRecord.step values into per-workflow failure counts.""" - counts = { - "entity_detection": 0, - "latent_detection": 0, - "replace_map_generation": 0, - "rewrite_pipeline": 0, - "rewrite_evaluate": 0, - "rewrite_repair": 0, - "rewrite_final_judge": 0, - "unknown": 0, - } - for fr in failed: - counts[_step_to_field(fr.step)] += 1 - return counts - - -def _repair_iterations_triggered(failed: list[FailedRecord], is_rewrite: bool) -> int: - """Count distinct repair iterations observed in FailedRecord step names. - - Falls back to -1 when the run wasn't a rewrite. Returns 0 when rewrite ran - but no failures surfaced from repair iterations — note that this undercounts - repair iterations that completed without producing FailedRecord entries. A - follow-up could plumb a richer signal up from the rewrite workflow. - """ - if not is_rewrite: - return -1 - iterations: set[int] = set() - for fr in failed: - m = _REWRITE_REPAIR_RE.match(fr.step) - if m: - iterations.add(int(m.group(1))) - return len(iterations) - - -def _resolve_model_hosts(providers: list[ModelProvider]) -> list[str]: - """Sorted, deduplicated list of provider host classifications.""" - return collect_model_hosts([classify_model_host(p) for p in providers]) diff --git a/src/anonymizer/interface/display/__init__.py b/src/anonymizer/interface/display/__init__.py new file mode 100644 index 00000000..15b65a1e --- /dev/null +++ b/src/anonymizer/interface/display/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Render anonymizer result records for notebook display.""" + +from __future__ import annotations + +from anonymizer.interface.display.record_html import render_record_html + +__all__ = ["render_record_html"] diff --git a/src/anonymizer/interface/display/payload_coercion.py b/src/anonymizer/interface/display/payload_coercion.py new file mode 100644 index 00000000..34256480 --- /dev/null +++ b/src/anonymizer/interface/display/payload_coercion.py @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Owns coercion of trace-dataframe cell payloads (JSON strings, Pydantic models, numpy/parquet round-trips) into plain dicts and counts for display.""" + +from __future__ import annotations + +import json + +import pandas as pd + +from anonymizer.engine.constants import ( + COL_ATTRIBUTE_FIDELITY_JUDGE, + COL_ENTITIES_BY_VALUE, + COL_RELATIONAL_CONSISTENCY_JUDGE, + COL_REPLACEMENT_MAP, +) +from anonymizer.engine.schemas import ( + EntitiesByValueSchema, + EntityReplacementMapSchema, +) + +__all__ = [ + "normalize_replacement_map", + "extract_judge_scores", + "extract_all_attribute_entries", + "normalize_attribute_entries", + "extract_all_relations", + "normalize_relations", + "count_detected_entity_label_pairs", + "count_replacement_triples", + "normalize_invalid_entities", + "normalize_disposition", +] + + +def normalize_replacement_map(raw: str | dict | object) -> list[dict[str, str]]: + """Coerce ``_replacement_map`` cell values into a list of ``{original, label, synthetic}`` dicts. + + Cells can arrive as JSON strings, Pydantic models, plain dicts, or after a + parquet round-trip with ``replacements`` wrapped as a ``numpy.ndarray``. + Run the value through ``EntityReplacementMapSchema.model_validate`` so + Pydantic's coercion absorbs numpy shapes, then fall back to a permissive + hand-walk if validation rejects the payload. + """ + if raw is None: + return [] + if hasattr(raw, "model_dump"): + raw = raw.model_dump(mode="python") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if not isinstance(raw, dict): + return [] + try: + parsed = EntityReplacementMapSchema.model_validate(raw) + return [r.model_dump() for r in parsed.replacements] + except Exception: + pass + replacements = raw.get("replacements", []) + if hasattr(replacements, "tolist"): + replacements = replacements.tolist() + if not isinstance(replacements, list): + return [] + result: list[dict[str, str]] = [] + for r in replacements: + if hasattr(r, "model_dump"): + r = r.model_dump() + if isinstance(r, dict): + result.append(r) + return result + + +def extract_judge_scores(raw: object) -> list[tuple[str, int | str]]: + """Extract (name, score) pairs from the judge evaluation column. + + LLMJudgeColumnConfig output is a plain dict keyed by rubric name, each + value carrying ``{"score": , "reasoning": "..."}``. Scores are + returned as-is — callers must not assume int (rewrite mode uses strings). + """ + if not isinstance(raw, dict): + return [] + result: list[tuple[str, int | str]] = [] + for name, value in raw.items(): + if not isinstance(value, dict) or "score" not in value: + continue + score = value["score"] + if score is None: + continue + result.append((str(name), score)) + return result + + +def extract_all_attribute_entries(row: pd.Series) -> list[dict[str, object]]: + """Read the full entities list (passes + fails) from the raw attribute-fidelity column.""" + raw = row.get(COL_ATTRIBUTE_FIDELITY_JUDGE) if COL_ATTRIBUTE_FIDELITY_JUDGE in row.index else None + if raw is None: + return [] + if hasattr(raw, "model_dump"): + raw = raw.model_dump(mode="python") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if not isinstance(raw, dict): + return [] + entities = raw.get("entities", []) + if hasattr(entities, "tolist"): + entities = entities.tolist() + if not isinstance(entities, list): + return [] + out: list[dict[str, object]] = [] + for entry in entities: + if hasattr(entry, "model_dump"): + entry = entry.model_dump() + if isinstance(entry, dict): + out.append(entry) + return out + + +def normalize_attribute_entries(raw: object) -> list[dict[str, object]]: + """Coerce the invalid-entities column into a list of plain dicts.""" + if raw is None: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if hasattr(raw, "tolist"): + raw = raw.tolist() + if not isinstance(raw, list): + return [] + out: list[dict[str, object]] = [] + for entry in raw: + if hasattr(entry, "model_dump"): + entry = entry.model_dump() + if isinstance(entry, dict): + out.append(entry) + return out + + +def extract_all_relations(row: pd.Series) -> list[dict[str, object]]: + """Read the full relations list (passes + fails) from the raw judge column.""" + raw = row.get(COL_RELATIONAL_CONSISTENCY_JUDGE) if COL_RELATIONAL_CONSISTENCY_JUDGE in row.index else None + if raw is None: + return [] + if hasattr(raw, "model_dump"): + raw = raw.model_dump(mode="python") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if not isinstance(raw, dict): + return [] + relations = raw.get("relations", []) + if hasattr(relations, "tolist"): + relations = relations.tolist() + if not isinstance(relations, list): + return [] + out: list[dict[str, object]] = [] + for entry in relations: + if hasattr(entry, "model_dump"): + entry = entry.model_dump() + if isinstance(entry, dict): + out.append(entry) + return out + + +def normalize_relations(raw: object) -> list[dict[str, object]]: + """Coerce the invalid-relations column into a list of plain dicts.""" + if raw is None: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if hasattr(raw, "tolist"): + raw = raw.tolist() + if not isinstance(raw, list): + return [] + out: list[dict[str, object]] = [] + for entry in raw: + if hasattr(entry, "model_dump"): + entry = entry.model_dump() + if isinstance(entry, dict): + out.append(entry) + return out + + +def count_detected_entity_label_pairs(row: pd.Series) -> int: + """Count (value, label) pairs the judge had a chance to evaluate. + + The judge schema flags entities at the (value, label) granularity, so the + denominator for the LLM alignment score is the total number of such pairs in the + deduped entity payload, not the number of unique values. + """ + raw = row.get(COL_ENTITIES_BY_VALUE) if COL_ENTITIES_BY_VALUE in row.index else None + if raw is None: + return 0 + try: + parsed = EntitiesByValueSchema.from_raw(raw) + except Exception: + return 0 + return sum(len(entity.labels) for entity in parsed.entities_by_value) + + +def count_replacement_triples(row: pd.Series, *, fallback: list[dict[str, str]]) -> int: + """Count replacement entries the type-fidelity judge had a chance to evaluate. + + ``normalize_replacement_map`` (used to render the table) rejects shapes + like ``{"replacements": numpy.ndarray(...)}`` from parquet round-trips, + which would silently zero out the success-rate denominator here. Validate + via Pydantic instead so the count matches what the judge actually saw. + """ + raw = row.get(COL_REPLACEMENT_MAP) if COL_REPLACEMENT_MAP in row.index else None + if raw is not None: + if hasattr(raw, "model_dump"): + raw = raw.model_dump(mode="python") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + raw = None + if isinstance(raw, dict): + try: + return len(EntityReplacementMapSchema.model_validate(raw).replacements) + except Exception: + pass + return len(fallback) + + +def normalize_invalid_entities(raw: object) -> list[dict[str, str]]: + """Coerce the invalid-entities column into a list of plain dicts.""" + if raw is None: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + if hasattr(raw, "tolist"): + raw = raw.tolist() + if not isinstance(raw, list): + return [] + out: list[dict[str, str]] = [] + for entry in raw: + if hasattr(entry, "model_dump"): + entry = entry.model_dump() + if isinstance(entry, dict): + out.append(entry) + return out + + +def normalize_disposition(raw: object) -> list[dict[str, str]]: + """Extract disposition entries from the raw column value. + + LLMStructuredColumnConfig output lands as a plain dict keyed by + ``sensitivity_disposition``, each entry being an EntityDispositionSchema dict. + """ + if not isinstance(raw, dict): + return [] + entries = raw.get("sensitivity_disposition", []) + if not isinstance(entries, list): + return [] + return [e for e in entries if isinstance(e, dict)] diff --git a/src/anonymizer/interface/display.py b/src/anonymizer/interface/display/record_html.py similarity index 67% rename from src/anonymizer/interface/display.py rename to src/anonymizer/interface/display/record_html.py index 5cdf53ae..f1f7c057 100644 --- a/src/anonymizer/interface/display.py +++ b/src/anonymizer/interface/display/record_html.py @@ -1,28 +1,24 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +"""Owns the HTML layouts and templates for rendering one anonymizer result record (replace + rewrite modes).""" + from __future__ import annotations import html -import json import logging -import re -from dataclasses import dataclass import pandas as pd from anonymizer.engine.constants import ( COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, - COL_ATTRIBUTE_FIDELITY_JUDGE, COL_ATTRIBUTE_FIDELITY_VALID, COL_DETECTED_ENTITIES, COL_DETECTION_INVALID_ENTITIES, COL_DETECTION_VALID, - COL_ENTITIES_BY_VALUE, COL_FINAL_ENTITIES, COL_JUDGE_EVALUATION, COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, - COL_RELATIONAL_CONSISTENCY_JUDGE, COL_RELATIONAL_CONSISTENCY_VALID, COL_REPLACEMENT_MAP, COL_SENSITIVITY_DISPOSITION, @@ -30,14 +26,33 @@ COL_TYPE_FIDELITY_VALID, ) from anonymizer.engine.schemas import ( - EntitiesByValueSchema, EntitiesSchema, - EntityReplacementMapSchema, EntitySchema, ) +from anonymizer.interface.display.payload_coercion import ( + count_detected_entity_label_pairs, + count_replacement_triples, + extract_all_attribute_entries, + extract_all_relations, + extract_judge_scores, + normalize_attribute_entries, + normalize_disposition, + normalize_invalid_entities, + normalize_relations, + normalize_replacement_map, +) +from anonymizer.interface.display.replaced_spans import ( + build_original_entities_from_map, + build_replaced_entities, + build_replaced_entities_from_map, +) + +__all__ = ["render_record_html"] + logger = logging.getLogger(__name__) + ENTITY_COLORS: list[str] = [ "#dbeafe", # blue "#dcfce7", # green @@ -53,6 +68,7 @@ "#fee2e2", # red ] + LABEL_BORDER_COLORS: list[str] = [ "#3b82f6", "#22c55e", @@ -69,14 +85,6 @@ ] -@dataclass(frozen=True) -class _SyntheticLookupMaps: - by_value_label: dict[tuple[str, str], str] - by_value: dict[str, str] - by_value_label_ci: dict[tuple[str, str], str] - by_value_ci: dict[str, str] - - def _color_for_label(label: str) -> tuple[str, str]: idx = hash(label) % len(ENTITY_COLORS) return ENTITY_COLORS[idx], LABEL_BORDER_COLORS[idx] @@ -101,16 +109,16 @@ def _render_replace_html(row: pd.Series, *, text_col: str, record_index: int | N text = str(row.get(text_col, "")) replaced_text = str(row.get(f"{text_col}_replaced", "")) entities = _resolve_display_entities(row) - replacement_map = _normalize_replacement_map(row.get(COL_REPLACEMENT_MAP, {})) + replacement_map = normalize_replacement_map(row.get(COL_REPLACEMENT_MAP, {})) if not entities and replacement_map: - entities = _build_original_entities_from_map(replacement_map, text) + entities = build_original_entities_from_map(replacement_map, text) original_html = _render_highlighted_text(text, entities) - replaced_entities = _build_replaced_entities(entities, replacement_map, text, replaced_text) + replaced_entities = build_replaced_entities(entities, replacement_map, text, replaced_text) if not replaced_entities and replacement_map: - replaced_entities = _build_replaced_entities_from_map(replacement_map, replaced_text) + 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) detection_judge_html = _render_detection_judge_section(row) @@ -192,158 +200,6 @@ def _render_highlighted_text(text: str, entities: list[EntitySchema]) -> str: return "".join(parts) -def _build_replaced_entities( - original_entities: list[EntitySchema], - replacement_map: list[dict[str, str]], - original_text: str, - replaced_text: str, -) -> list[EntitySchema]: - """Compute entity positions in the replaced text by locating synthetic values directly. - - Instead of replaying cursor arithmetic (which drifts when entity values - differ in case from the replacement map keys), we resolve each entity's - synthetic value and find it in the replaced text by scanning forward. - """ - by_value_label: dict[tuple[str, str], str] = {} - by_value: dict[str, str] = {} - by_value_label_ci: dict[tuple[str, str], str] = {} - by_value_ci: dict[str, str] = {} - for entry in replacement_map: - orig = entry.get("original", "") - label = entry.get("label", "") - synth = entry.get("synthetic", "") - by_value_label[(orig, label)] = synth - by_value[orig] = synth - by_value_label_ci[(orig.lower(), label)] = synth - by_value_ci[orig.lower()] = synth - lookups = _SyntheticLookupMaps( - by_value_label=by_value_label, - by_value=by_value, - by_value_label_ci=by_value_label_ci, - by_value_ci=by_value_ci, - ) - - sorted_entities = sorted(original_entities, key=lambda e: (e.start_position, e.end_position)) - replaced_entities: list[EntitySchema] = [] - original_cursor = 0 - search_from = 0 - - for entity in sorted_entities: - start = entity.start_position - end = entity.end_position - value = entity.value - label = entity.label - if start < original_cursor or end <= start or end > len(original_text): - continue - - synthetic = _resolve_synthetic(value, label, lookups) - original_span = original_text[start:end] - - pos = replaced_text.find(synthetic, search_from) if synthetic else -1 - if pos < 0 and synthetic != original_span: - pos = replaced_text.find(original_span, search_from) - if pos >= 0: - synthetic = original_span - - if pos < 0: - original_cursor = end - continue - - replaced_entities.append( - EntitySchema( - value=replaced_text[pos : pos + len(synthetic)], - label=label, - start_position=pos, - end_position=pos + len(synthetic), - ) - ) - search_from = pos + len(synthetic) - original_cursor = end - - return replaced_entities - - -def _resolve_synthetic( - value: str, - label: str, - lookups: _SyntheticLookupMaps, -) -> str: - """Look up synthetic value with exact-match first, then case-insensitive fallback.""" - result = lookups.by_value_label.get((value, label)) - if result is not None: - return result - result = lookups.by_value.get(value) - if result is not None: - return result - result = lookups.by_value_label_ci.get((value.lower(), label)) - if result is not None: - return result - result = lookups.by_value_ci.get(value.lower()) - if result is not None: - return result - return value - - -def _build_original_entities_from_map( - replacement_map: list[dict[str, str]], - original_text: str, -) -> list[EntitySchema]: - """Build entity positions by finding original values in original text. - - Fallback when _detected_entities is empty but replacement_map exists. - Uses case-insensitive matching to align with how the detection engine - finds entities (e.g. "The Lantern" matching "the Lantern" in text). - """ - result: list[EntitySchema] = [] - for entry in replacement_map: - original = str(entry.get("original", "")) - label = str(entry.get("label", "")) - if not original or not label: - continue - for match in re.finditer(re.escape(original), original_text, flags=re.IGNORECASE): - result.append( - EntitySchema( - value=original_text[match.start() : match.end()], - label=label, - start_position=match.start(), - end_position=match.end(), - ) - ) - return sorted(result, key=lambda e: (e.start_position, e.end_position)) - - -def _build_replaced_entities_from_map( - replacement_map: list[dict[str, str]], - replaced_text: str, -) -> list[EntitySchema]: - """Build entity positions by finding synthetic values in replaced text. - - Fallback when _detected_entities is empty but replacement_map exists (e.g. LLM - replace path where entity format differs). - """ - result: list[EntitySchema] = [] - for entry in replacement_map: - synthetic = str(entry.get("synthetic", "")) - label = str(entry.get("label", "")) - if not synthetic or not label: - continue - start = 0 - while True: - pos = replaced_text.find(synthetic, start) - if pos < 0: - break - result.append( - EntitySchema( - value=synthetic, - label=label, - start_position=pos, - end_position=pos + len(synthetic), - ) - ) - start = pos + len(synthetic) - return sorted(result, key=lambda e: (e.start_position, e.end_position)) - - def _render_replacement_table(replacement_map: list[dict[str, str]]) -> str: if not replacement_map: return "

No replacement map available.

" @@ -374,45 +230,6 @@ def _render_replacement_table(replacement_map: list[dict[str, str]]) -> str: ) -def _normalize_replacement_map(raw: str | dict | object) -> list[dict[str, str]]: - """Coerce ``_replacement_map`` cell values into a list of ``{original, label, synthetic}`` dicts. - - Cells can arrive as JSON strings, Pydantic models, plain dicts, or after a - parquet round-trip with ``replacements`` wrapped as a ``numpy.ndarray``. - Run the value through ``EntityReplacementMapSchema.model_validate`` so - Pydantic's coercion absorbs numpy shapes, then fall back to a permissive - hand-walk if validation rejects the payload. - """ - if raw is None: - return [] - if hasattr(raw, "model_dump"): - raw = raw.model_dump(mode="python") - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if not isinstance(raw, dict): - return [] - try: - parsed = EntityReplacementMapSchema.model_validate(raw) - return [r.model_dump() for r in parsed.replacements] - except Exception: - pass - replacements = raw.get("replacements", []) - if hasattr(replacements, "tolist"): - replacements = replacements.tolist() - if not isinstance(replacements, list): - return [] - result: list[dict[str, str]] = [] - for r in replacements: - if hasattr(r, "model_dump"): - r = r.model_dump() - if isinstance(r, dict): - result.append(r) - return result - - def _render_scores_section(row: pd.Series, *, is_rewrite: bool = False) -> str: """Render scores in up to three rows: objective metrics | detection validity | judge.""" section_rows: list[str] = [] @@ -459,7 +276,7 @@ def _render_scores_section(row: pd.Series, *, is_rewrite: bool = False) -> str: ) details_html = "" if float(detection_valid) < 1.0: - invalid_entries = _normalize_invalid_entities(row.get(COL_DETECTION_INVALID_ENTITIES)) + invalid_entries = normalize_invalid_entities(row.get(COL_DETECTION_INVALID_ENTITIES)) if invalid_entries: rows_html: list[str] = [] for entry in invalid_entries: @@ -497,7 +314,7 @@ def _render_scores_section(row: pd.Series, *, is_rewrite: bool = False) -> str: # --- Row 3: judge scores with highlighted criterion names --- judge_raw = row.get(COL_JUDGE_EVALUATION) - judge_scores = _extract_judge_scores(judge_raw) + judge_scores = extract_judge_scores(judge_raw) if isinstance(judge_raw, dict) and not judge_scores: logger.warning( "Judge evaluation present but produced no scores (unexpected shape: %s)", type(judge_raw).__name__ @@ -522,26 +339,6 @@ def _render_scores_section(row: pd.Series, *, is_rewrite: bool = False) -> str: return "
" + "".join(section_rows) + "
" -def _extract_judge_scores(raw: object) -> list[tuple[str, int | str]]: - """Extract (name, score) pairs from the judge evaluation column. - - LLMJudgeColumnConfig output is a plain dict keyed by rubric name, each - value carrying ``{"score": , "reasoning": "..."}``. Scores are - returned as-is — callers must not assume int (rewrite mode uses strings). - """ - if not isinstance(raw, dict): - return [] - result: list[tuple[str, int | str]] = [] - for name, value in raw.items(): - if not isinstance(value, dict) or "score" not in value: - continue - score = value["score"] - if score is None: - continue - result.append((str(name), score)) - return result - - def _verdict_badge(valid: object, correct: int, total: int) -> tuple[str, str]: """Return (badge_html, rate_html) for the tri-state verdict. @@ -581,8 +378,8 @@ def _render_detection_judge_section(row: pd.Series) -> str: if COL_DETECTION_VALID not in row.index: return "" valid = row.get(COL_DETECTION_VALID) - invalid_entries = _normalize_invalid_entities(row.get(COL_DETECTION_INVALID_ENTITIES)) - total = _count_detected_entity_label_pairs(row) + invalid_entries = normalize_invalid_entities(row.get(COL_DETECTION_INVALID_ENTITIES)) + total = count_detected_entity_label_pairs(row) correct = max(total - len(invalid_entries), 0) badge, rate_html = _verdict_badge(valid, correct, total) @@ -641,8 +438,8 @@ def _render_type_fidelity_section(row: pd.Series, replacement_map: list[dict[str if COL_TYPE_FIDELITY_VALID not in row.index: return "" valid = row.get(COL_TYPE_FIDELITY_VALID) - invalid_entries = _normalize_invalid_entities(row.get(COL_TYPE_FIDELITY_INVALID_REPLACEMENTS)) - total = _count_replacement_triples(row, fallback=replacement_map) + invalid_entries = normalize_invalid_entities(row.get(COL_TYPE_FIDELITY_INVALID_REPLACEMENTS)) + total = count_replacement_triples(row, fallback=replacement_map) correct = max(total - len(invalid_entries), 0) badge, rate_html = _verdict_badge(valid, correct, total) @@ -703,10 +500,10 @@ def _render_attribute_fidelity_section(row: pd.Series) -> str: if COL_ATTRIBUTE_FIDELITY_VALID not in row.index: return "" valid = row.get(COL_ATTRIBUTE_FIDELITY_VALID) - all_entries = _extract_all_attribute_entries(row) + all_entries = extract_all_attribute_entries(row) invalid_count = sum(1 for e in all_entries if not bool(e.get("passes", False))) if not all_entries: - fallback_invalid = _normalize_attribute_entries(row.get(COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES)) + fallback_invalid = normalize_attribute_entries(row.get(COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES)) invalid_count = len(fallback_invalid) all_entries = [{**entry, "passes": False} for entry in fallback_invalid] total = len(all_entries) @@ -779,56 +576,6 @@ def _render_attribute_entries_table(entries: list[dict[str, object]]) -> str: ) -def _extract_all_attribute_entries(row: pd.Series) -> list[dict[str, object]]: - """Read the full entities list (passes + fails) from the raw attribute-fidelity column.""" - raw = row.get(COL_ATTRIBUTE_FIDELITY_JUDGE) if COL_ATTRIBUTE_FIDELITY_JUDGE in row.index else None - if raw is None: - return [] - if hasattr(raw, "model_dump"): - raw = raw.model_dump(mode="python") - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if not isinstance(raw, dict): - return [] - entities = raw.get("entities", []) - if hasattr(entities, "tolist"): - entities = entities.tolist() - if not isinstance(entities, list): - return [] - out: list[dict[str, object]] = [] - for entry in entities: - if hasattr(entry, "model_dump"): - entry = entry.model_dump() - if isinstance(entry, dict): - out.append(entry) - return out - - -def _normalize_attribute_entries(raw: object) -> list[dict[str, object]]: - """Coerce the invalid-entities column into a list of plain dicts.""" - if raw is None: - return [] - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if hasattr(raw, "tolist"): - raw = raw.tolist() - if not isinstance(raw, list): - return [] - out: list[dict[str, object]] = [] - for entry in raw: - if hasattr(entry, "model_dump"): - entry = entry.model_dump() - if isinstance(entry, dict): - out.append(entry) - return out - - def _render_relational_consistency_section(row: pd.Series) -> str: """Render the relational-consistency verdict for Substitute runs. @@ -839,12 +586,12 @@ def _render_relational_consistency_section(row: pd.Series) -> str: if COL_RELATIONAL_CONSISTENCY_VALID not in row.index: return "" valid = row.get(COL_RELATIONAL_CONSISTENCY_VALID) - all_relations = _extract_all_relations(row) + all_relations = extract_all_relations(row) invalid_count = sum(1 for r in all_relations if not bool(r.get("passes", False))) # Fall back to the invalid-relations column when the raw output is missing, # so the LLM alignment score still surfaces "at least this many failures". if not all_relations: - fallback_invalid = _normalize_relations(row.get(COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS)) + fallback_invalid = normalize_relations(row.get(COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS)) invalid_count = len(fallback_invalid) all_relations = [{**entry, "passes": False} for entry in fallback_invalid] total = len(all_relations) @@ -919,124 +666,10 @@ def _render_relations_table(relations: list[dict[str, object]]) -> str: ) -def _extract_all_relations(row: pd.Series) -> list[dict[str, object]]: - """Read the full relations list (passes + fails) from the raw judge column.""" - raw = row.get(COL_RELATIONAL_CONSISTENCY_JUDGE) if COL_RELATIONAL_CONSISTENCY_JUDGE in row.index else None - if raw is None: - return [] - if hasattr(raw, "model_dump"): - raw = raw.model_dump(mode="python") - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if not isinstance(raw, dict): - return [] - relations = raw.get("relations", []) - if hasattr(relations, "tolist"): - relations = relations.tolist() - if not isinstance(relations, list): - return [] - out: list[dict[str, object]] = [] - for entry in relations: - if hasattr(entry, "model_dump"): - entry = entry.model_dump() - if isinstance(entry, dict): - out.append(entry) - return out - - -def _normalize_relations(raw: object) -> list[dict[str, object]]: - """Coerce the invalid-relations column into a list of plain dicts.""" - if raw is None: - return [] - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if hasattr(raw, "tolist"): - raw = raw.tolist() - if not isinstance(raw, list): - return [] - out: list[dict[str, object]] = [] - for entry in raw: - if hasattr(entry, "model_dump"): - entry = entry.model_dump() - if isinstance(entry, dict): - out.append(entry) - return out - - -def _count_detected_entity_label_pairs(row: pd.Series) -> int: - """Count (value, label) pairs the judge had a chance to evaluate. - - The judge schema flags entities at the (value, label) granularity, so the - denominator for the LLM alignment score is the total number of such pairs in the - deduped entity payload, not the number of unique values. - """ - raw = row.get(COL_ENTITIES_BY_VALUE) if COL_ENTITIES_BY_VALUE in row.index else None - if raw is None: - return 0 - try: - parsed = EntitiesByValueSchema.from_raw(raw) - except Exception: - return 0 - return sum(len(entity.labels) for entity in parsed.entities_by_value) - - -def _count_replacement_triples(row: pd.Series, *, fallback: list[dict[str, str]]) -> int: - """Count replacement entries the type-fidelity judge had a chance to evaluate. - - ``_normalize_replacement_map`` (used to render the table) rejects shapes - like ``{"replacements": numpy.ndarray(...)}`` from parquet round-trips, - which would silently zero out the success-rate denominator here. Validate - via Pydantic instead so the count matches what the judge actually saw. - """ - raw = row.get(COL_REPLACEMENT_MAP) if COL_REPLACEMENT_MAP in row.index else None - if raw is not None: - if hasattr(raw, "model_dump"): - raw = raw.model_dump(mode="python") - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - raw = None - if isinstance(raw, dict): - try: - return len(EntityReplacementMapSchema.model_validate(raw).replacements) - except Exception: - pass - return len(fallback) - - -def _normalize_invalid_entities(raw: object) -> list[dict[str, str]]: - """Coerce the invalid-entities column into a list of plain dicts.""" - if raw is None: - return [] - if isinstance(raw, str): - try: - raw = json.loads(raw) - except (json.JSONDecodeError, ValueError): - return [] - if hasattr(raw, "tolist"): - raw = raw.tolist() - if not isinstance(raw, list): - return [] - out: list[dict[str, str]] = [] - for entry in raw: - if hasattr(entry, "model_dump"): - entry = entry.model_dump() - if isinstance(entry, dict): - out.append(entry) - return out - - def _render_disposition_table(row: pd.Series) -> str: """Render entity disposition table from _sensitivity_disposition column.""" raw = row.get(COL_SENSITIVITY_DISPOSITION) - entries = _normalize_disposition(raw) + entries = normalize_disposition(raw) if not entries: return "" @@ -1069,24 +702,6 @@ def _render_disposition_table(row: pd.Series) -> str: ) -def _normalize_disposition(raw: object) -> list[dict[str, str]]: - """Extract disposition entries from the raw column value. - - LLMStructuredColumnConfig output lands as a plain dict keyed by - ``sensitivity_disposition``, each entry being an EntityDispositionSchema dict. - """ - if not isinstance(raw, dict): - return [] - entries = raw.get("sensitivity_disposition", []) - if not isinstance(entries, list): - return [] - return [e for e in entries if isinstance(e, dict)] - - -# --------------------------------------------------------------------------- -# Templates -# --------------------------------------------------------------------------- - _REPLACE_TEMPLATE = """\
@@ -1121,6 +736,7 @@ def _normalize_disposition(raw: object) -> list[dict[str, str]]:
""" + _REWRITE_TEMPLATE = """\
diff --git a/src/anonymizer/interface/display/replaced_spans.py b/src/anonymizer/interface/display/replaced_spans.py new file mode 100644 index 00000000..e9a9b974 --- /dev/null +++ b/src/anonymizer/interface/display/replaced_spans.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Owns reconstruction of entity span positions in original and replaced text when detection output and the replacement map disagree or are missing.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from anonymizer.engine.schemas import ( + EntitySchema, +) + +__all__ = [ + "build_original_entities_from_map", + "build_replaced_entities", + "build_replaced_entities_from_map", +] + + +@dataclass(frozen=True) +class _SyntheticLookupMaps: + by_value_label: dict[tuple[str, str], str] + by_value: dict[str, str] + by_value_label_ci: dict[tuple[str, str], str] + by_value_ci: dict[str, str] + + +def build_replaced_entities( + original_entities: list[EntitySchema], + replacement_map: list[dict[str, str]], + original_text: str, + replaced_text: str, +) -> list[EntitySchema]: + """Compute entity positions in the replaced text by locating synthetic values directly. + + Instead of replaying cursor arithmetic (which drifts when entity values + differ in case from the replacement map keys), we resolve each entity's + synthetic value and find it in the replaced text by scanning forward. + """ + by_value_label: dict[tuple[str, str], str] = {} + by_value: dict[str, str] = {} + by_value_label_ci: dict[tuple[str, str], str] = {} + by_value_ci: dict[str, str] = {} + for entry in replacement_map: + orig = entry.get("original", "") + label = entry.get("label", "") + synth = entry.get("synthetic", "") + by_value_label[(orig, label)] = synth + by_value[orig] = synth + by_value_label_ci[(orig.lower(), label)] = synth + by_value_ci[orig.lower()] = synth + lookups = _SyntheticLookupMaps( + by_value_label=by_value_label, + by_value=by_value, + by_value_label_ci=by_value_label_ci, + by_value_ci=by_value_ci, + ) + + sorted_entities = sorted(original_entities, key=lambda e: (e.start_position, e.end_position)) + replaced_entities: list[EntitySchema] = [] + original_cursor = 0 + search_from = 0 + + for entity in sorted_entities: + start = entity.start_position + end = entity.end_position + value = entity.value + label = entity.label + if start < original_cursor or end <= start or end > len(original_text): + continue + + synthetic = _resolve_synthetic(value, label, lookups) + original_span = original_text[start:end] + + pos = replaced_text.find(synthetic, search_from) if synthetic else -1 + if pos < 0 and synthetic != original_span: + pos = replaced_text.find(original_span, search_from) + if pos >= 0: + synthetic = original_span + + if pos < 0: + original_cursor = end + continue + + replaced_entities.append( + EntitySchema( + value=replaced_text[pos : pos + len(synthetic)], + label=label, + start_position=pos, + end_position=pos + len(synthetic), + ) + ) + search_from = pos + len(synthetic) + original_cursor = end + + return replaced_entities + + +def _resolve_synthetic( + value: str, + label: str, + lookups: _SyntheticLookupMaps, +) -> str: + """Look up synthetic value with exact-match first, then case-insensitive fallback.""" + result = lookups.by_value_label.get((value, label)) + if result is not None: + return result + result = lookups.by_value.get(value) + if result is not None: + return result + result = lookups.by_value_label_ci.get((value.lower(), label)) + if result is not None: + return result + result = lookups.by_value_ci.get(value.lower()) + if result is not None: + return result + return value + + +def build_original_entities_from_map( + replacement_map: list[dict[str, str]], + original_text: str, +) -> list[EntitySchema]: + """Build entity positions by finding original values in original text. + + Fallback when _detected_entities is empty but replacement_map exists. + Uses case-insensitive matching to align with how the detection engine + finds entities (e.g. "The Lantern" matching "the Lantern" in text). + """ + result: list[EntitySchema] = [] + for entry in replacement_map: + original = str(entry.get("original", "")) + label = str(entry.get("label", "")) + if not original or not label: + continue + for match in re.finditer(re.escape(original), original_text, flags=re.IGNORECASE): + result.append( + EntitySchema( + value=original_text[match.start() : match.end()], + label=label, + start_position=match.start(), + end_position=match.end(), + ) + ) + return sorted(result, key=lambda e: (e.start_position, e.end_position)) + + +def build_replaced_entities_from_map( + replacement_map: list[dict[str, str]], + replaced_text: str, +) -> list[EntitySchema]: + """Build entity positions by finding synthetic values in replaced text. + + Fallback when _detected_entities is empty but replacement_map exists (e.g. LLM + replace path where entity format differs). + """ + result: list[EntitySchema] = [] + for entry in replacement_map: + synthetic = str(entry.get("synthetic", "")) + label = str(entry.get("label", "")) + if not synthetic or not label: + continue + start = 0 + while True: + pos = replaced_text.find(synthetic, start) + if pos < 0: + break + result.append( + EntitySchema( + value=synthetic, + label=label, + start_position=pos, + end_position=pos + len(synthetic), + ) + ) + start = pos + len(synthetic) + return sorted(result, key=lambda e: (e.start_position, e.end_position)) diff --git a/src/anonymizer/interface/output_columns.py b/src/anonymizer/interface/output_columns.py new file mode 100644 index 00000000..8006de7f --- /dev/null +++ b/src/anonymizer/interface/output_columns.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Owns the mapping between internal `COL_*` column names and the user-facing +output dataframe (rename, un-rename, and the public column allowlist per mode). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from anonymizer.engine.constants import ( + COL_ANY_HIGH_LEAKED, + COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, + COL_ATTRIBUTE_FIDELITY_VALID, + COL_DETECTION_INVALID_ENTITIES, + COL_DETECTION_VALID, + COL_FINAL_ENTITIES, + COL_JUDGE_EVALUATION, + COL_LEAKAGE_MASS, + COL_NEEDS_HUMAN_REVIEW, + COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, + COL_RELATIONAL_CONSISTENCY_VALID, + COL_REPLACED_TEXT, + COL_REWRITTEN_TEXT, + COL_TAGGED_TEXT, + COL_TEXT, + COL_TYPE_FIDELITY_INVALID_REPLACEMENTS, + COL_TYPE_FIDELITY_VALID, + COL_UTILITY_SCORE, + COL_WEIGHTED_LEAKAGE_RATE, +) + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["build_user_dataframe", "rename_output_columns", "unrename_output_columns"] + + +def rename_output_columns(df: pd.DataFrame, *, resolved_text_column: str) -> pd.DataFrame: + """Rename internal column names to user-facing names.""" + rename_map: dict[str, str] = {} + if COL_TEXT in df.columns: + rename_map[COL_TEXT] = resolved_text_column + if COL_REPLACED_TEXT in df.columns: + rename_map[COL_REPLACED_TEXT] = f"{resolved_text_column}_replaced" + if COL_TAGGED_TEXT in df.columns: + rename_map[COL_TAGGED_TEXT] = f"{resolved_text_column}_with_spans" + if COL_REWRITTEN_TEXT in df.columns: + rename_map[COL_REWRITTEN_TEXT] = f"{resolved_text_column}_rewritten" + if not rename_map: + return df + return df.rename(columns=rename_map) + + +def unrename_output_columns(df: pd.DataFrame, *, resolved_text_column: str) -> pd.DataFrame: + """Reverse of :func:`rename_output_columns`. + + Converts user-facing column names (``biography``, ``biography_replaced``, …) + back to the internal names (``__nemo_anonymizer_text_input__``, …) that the + judges' prompt templates reference. No-op if the dataframe is already in + internal form (``COL_TEXT`` already present). + """ + if COL_TEXT in df.columns: + return df + rename_map: dict[str, str] = {} + if resolved_text_column in df.columns: + rename_map[resolved_text_column] = COL_TEXT + if f"{resolved_text_column}_replaced" in df.columns: + rename_map[f"{resolved_text_column}_replaced"] = COL_REPLACED_TEXT + if f"{resolved_text_column}_with_spans" in df.columns: + rename_map[f"{resolved_text_column}_with_spans"] = COL_TAGGED_TEXT + if f"{resolved_text_column}_rewritten" in df.columns: + rename_map[f"{resolved_text_column}_rewritten"] = COL_REWRITTEN_TEXT + if not rename_map: + return df + return df.rename(columns=rename_map) + + +def build_user_dataframe(trace_dataframe: pd.DataFrame, *, resolved_text_column: str) -> 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 + 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 + """ + t = trace_dataframe + text_col = resolved_text_column + + if f"{text_col}_rewritten" in t.columns: + allowed = { + text_col, + f"{text_col}_rewritten", + COL_UTILITY_SCORE, + COL_LEAKAGE_MASS, + 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() + } + elif f"{text_col}_replaced" in t.columns: + allowed = { + text_col, + f"{text_col}_replaced", + f"{text_col}_with_spans", + COL_FINAL_ENTITIES, + COL_DETECTION_VALID, + COL_DETECTION_INVALID_ENTITIES, + COL_TYPE_FIDELITY_VALID, + COL_TYPE_FIDELITY_INVALID_REPLACEMENTS, + COL_RELATIONAL_CONSISTENCY_VALID, + COL_RELATIONAL_CONSISTENCY_INVALID_RELATIONS, + COL_ATTRIBUTE_FIDELITY_VALID, + COL_ATTRIBUTE_FIDELITY_INVALID_ENTITIES, + } + else: + allowed = { + text_col, + f"{text_col}_with_spans", + COL_FINAL_ENTITIES, + } + + return t[[col for col in t.columns if col in allowed]].copy() diff --git a/src/anonymizer/interface/run_telemetry.py b/src/anonymizer/interface/run_telemetry.py new file mode 100644 index 00000000..eee9b43e --- /dev/null +++ b/src/anonymizer/interface/run_telemetry.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Owns construction of the per-run `AnonymizerEvent` telemetry payload from a +finished pipeline run. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from data_designer.config.models import ModelProvider + +from anonymizer.config.anonymizer_config import ( + AnonymizerConfig, + AnonymizerInput, +) +from anonymizer.config.models import ModelSelection +from anonymizer.config.replace_strategies import Substitute +from anonymizer.engine.constants import ( + COL_TEXT, +) +from anonymizer.engine.ndd.adapter import FailedRecord +from anonymizer.interface.results import AnonymizerResult +from anonymizer.telemetry import ( + NOT_APPLICABLE, + AnonymizerEvent, + TaskEnum, + TaskStatusEnum, + avg_tokens_per_record, + classify_model_host, + collect_model_hosts, + sort_join_aliases, +) + +if TYPE_CHECKING: + import pandas as pd + +__all__ = ["build_anonymizer_event"] + + +def build_anonymizer_event( + *, + selected_models: ModelSelection, + resolved_providers: list[ModelProvider], + task: TaskEnum, + status: TaskStatusEnum, + config: AnonymizerConfig, + data: AnonymizerInput, + input_df: pd.DataFrame, + result: AnonymizerResult | None, + duration_sec: float, +) -> AnonymizerEvent: + """Construct an AnonymizerEvent from the current pipeline state.""" + total_records = int(len(input_df)) + failed = list(result.failed_records) if result is not None else [] + failure_count = len(failed) + success_count = max(total_records - failure_count, 0) + + avg_tokens = -1 + if total_records > 0 and COL_TEXT in input_df.columns: + avg_tokens = avg_tokens_per_record(input_df[COL_TEXT].astype(str)) + + transformation_type = _transformation_type_string(config) + rewrite = config.rewrite + substitute = config.replace if isinstance(config.replace, Substitute) else None + + models = _collect_step_models( + selected=selected_models, + has_substitute=substitute is not None, + has_rewrite=rewrite is not None, + ) + failure_counts = _collect_failure_counts(failed) + hosts = _resolve_model_hosts(resolved_providers) + + return AnonymizerEvent( + task=task, + task_status=status, + job_duration_sec=duration_sec, + num_input_records=total_records, + num_success_records=success_count, + num_failure_records=failure_count, + avg_tokens_per_record=avg_tokens, + transformation_type=transformation_type, + custom_data_summary_provided=bool(data.data_summary), + custom_privacy_goal_provided=_custom_privacy_goal_provided(rewrite), + custom_substitute_instructions_provided=bool(substitute is not None and substitute.instructions), + max_repair_iterations=(rewrite.max_repair_iterations if rewrite is not None else -1), + strict_entity_protection=(rewrite.strict_entity_protection if rewrite is not None else False), + repair_iterations_triggered=_repair_iterations_triggered(failed, rewrite is not None), + entity_detector_model=models["entity_detector"], + entity_validator_model=models["entity_validator"], + entity_augmenter_model=models["entity_augmenter"], + latent_detector_model=models["latent_detector"], + replacement_generator_model=models["replacement_generator"], + domain_classifier_model=models["domain_classifier"], + disposition_analyzer_model=models["disposition_analyzer"], + meaning_extractor_model=models["meaning_extractor"], + qa_generator_model=models["qa_generator"], + rewriter_model=models["rewriter"], + evaluator_model=models["evaluator"], + repairer_model=models["repairer"], + model_hosts=hosts, + entity_detection_failure_count=failure_counts["entity_detection"], + latent_detection_failure_count=failure_counts["latent_detection"], + replace_map_generation_failure_count=failure_counts["replace_map_generation"], + rewrite_pipeline_failure_count=failure_counts["rewrite_pipeline"], + rewrite_evaluate_failure_count=failure_counts["rewrite_evaluate"], + rewrite_repair_failure_count=failure_counts["rewrite_repair"], + rewrite_final_judge_failure_count=failure_counts["rewrite_final_judge"], + unknown_step_failure_count=failure_counts["unknown"], + ) + + +_REWRITE_REPAIR_RE = re.compile(r"^rewrite-repair-(\d+)$") + + +_REWRITE_EVALUATE_RE = re.compile(r"^rewrite-evaluate-(\d+)$") + + +def _transformation_type_string(config: AnonymizerConfig) -> str: + """Map AnonymizerConfig to the schema's transformationType value. + + Schema accepts exactly one of: ``annotate``, ``redact``, ``hash``, + ``substitute``, ``rewrite``. AnonymizerConfig's validator enforces exactly one + of replace/rewrite, so one of these branches always fires. + """ + if config.rewrite is not None: + return "rewrite" + # The four ReplaceMethodBase subclasses (Annotate, Redact, Hash, Substitute) + # lowercase directly to their schema values. + return type(config.replace).__name__.lower() + + +def _custom_privacy_goal_provided(rewrite: object | None) -> bool: + """Detect whether the user supplied a non-default privacy_goal. + + ``Rewrite.populate_default_privacy_goal`` always populates a default if the + user passed None, so we treat the default protect/preserve text as "not custom". + """ + if rewrite is None or rewrite.privacy_goal is None: # type: ignore[union-attr] + return False + from anonymizer.config.rewrite import DEFAULT_PRESERVE_TEXT, DEFAULT_PROTECT_TEXT + + goal = rewrite.privacy_goal # type: ignore[union-attr] + return goal.protect != DEFAULT_PROTECT_TEXT or goal.preserve != DEFAULT_PRESERVE_TEXT + + +def _collect_step_models( + *, + selected: ModelSelection, + has_substitute: bool, + has_rewrite: bool, +) -> dict[str, str]: + """Project the user's model selection into the schema's step-keyed shape.""" + det = selected.detection + rewrite = selected.rewrite + replace = selected.replace + return { + "entity_detector": det.entity_detector or NOT_APPLICABLE, + "entity_validator": sort_join_aliases(det.entity_validator or []), + "entity_augmenter": det.entity_augmenter or NOT_APPLICABLE, + # latent_detector only runs in rewrite mode + "latent_detector": (det.latent_detector or NOT_APPLICABLE) if has_rewrite else NOT_APPLICABLE, + # replacement_generator only runs in Substitute mode + "replacement_generator": replace.replacement_generator if has_substitute else NOT_APPLICABLE, + # All rewrite-only roles + "domain_classifier": rewrite.domain_classifier if has_rewrite else NOT_APPLICABLE, + "disposition_analyzer": rewrite.disposition_analyzer if has_rewrite else NOT_APPLICABLE, + "meaning_extractor": rewrite.meaning_extractor if has_rewrite else NOT_APPLICABLE, + "qa_generator": rewrite.qa_generator if has_rewrite else NOT_APPLICABLE, + "rewriter": rewrite.rewriter if has_rewrite else NOT_APPLICABLE, + "evaluator": rewrite.evaluator if has_rewrite else NOT_APPLICABLE, + "repairer": rewrite.repairer if has_rewrite else NOT_APPLICABLE, + } + + +def _step_to_field(step: str) -> str: + """Map a FailedRecord.step (workflow_name) to a schema failure-count field key.""" + match step: + case "entity-detection": + return "entity_detection" + case "latent-entity-detection": + return "latent_detection" + case "replace-map-generation": + return "replace_map_generation" + case "rewrite-pipeline": + return "rewrite_pipeline" + case "rewrite-final-judge": + return "rewrite_final_judge" + case _ if _REWRITE_EVALUATE_RE.match(step): + return "rewrite_evaluate" + case _ if _REWRITE_REPAIR_RE.match(step): + return "rewrite_repair" + case _: + return "unknown" + + +def _collect_failure_counts(failed: list[FailedRecord]) -> dict[str, int]: + """Aggregate FailedRecord.step values into per-workflow failure counts.""" + counts = { + "entity_detection": 0, + "latent_detection": 0, + "replace_map_generation": 0, + "rewrite_pipeline": 0, + "rewrite_evaluate": 0, + "rewrite_repair": 0, + "rewrite_final_judge": 0, + "unknown": 0, + } + for fr in failed: + counts[_step_to_field(fr.step)] += 1 + return counts + + +def _repair_iterations_triggered(failed: list[FailedRecord], is_rewrite: bool) -> int: + """Count distinct repair iterations observed in FailedRecord step names. + + Falls back to -1 when the run wasn't a rewrite. Returns 0 when rewrite ran + but no failures surfaced from repair iterations — note that this undercounts + repair iterations that completed without producing FailedRecord entries. A + follow-up could plumb a richer signal up from the rewrite workflow. + """ + if not is_rewrite: + return -1 + iterations: set[int] = set() + for fr in failed: + m = _REWRITE_REPAIR_RE.match(fr.step) + if m: + iterations.add(int(m.group(1))) + return len(iterations) + + +def _resolve_model_hosts(providers: list[ModelProvider]) -> list[str]: + """Sorted, deduplicated list of provider host classifications.""" + return collect_model_hosts([classify_model_host(p) for p in providers]) diff --git a/tests/engine/test_ndd_adapter.py b/tests/engine/test_ndd_adapter.py index e7e6c99f..df22b1f4 100644 --- a/tests/engine/test_ndd_adapter.py +++ b/tests/engine/test_ndd_adapter.py @@ -13,8 +13,8 @@ from data_designer.config.models import ModelConfig from data_designer.interface.data_designer import DataDesigner -from anonymizer.engine.ndd import adapter as ndd_adapter from anonymizer.engine.ndd.adapter import RECORD_ID_COLUMN, NddAdapter +from anonymizer.engine.ndd.dd_observability import as_alias_list from anonymizer.interface.errors import AnonymizerWorkflowError _FORBIDDEN_BACKEND_STRINGS = ("Data Designer", "DataDesigner", "data_designer", "DD") @@ -62,7 +62,7 @@ def _make_columns() -> list[ColumnConfigT]: def test_as_alias_list_drops_none_items_before_stringifying() -> None: - assert ndd_adapter._as_alias_list(["validator", None, "", 0]) == ["validator", "0"] + assert as_alias_list(["validator", None, "", 0]) == ["validator", "0"] def test_attach_record_ids_adds_deterministic_ids() -> None: diff --git a/tests/interface/test_display.py b/tests/interface/test_display.py index 7f156be1..8922de38 100644 --- a/tests/interface/test_display.py +++ b/tests/interface/test_display.py @@ -20,13 +20,11 @@ from anonymizer.engine.rewrite.final_judge import PRIVACY_RUBRIC, QUALITY_RUBRIC, STYLE_RUBRIC from anonymizer.engine.schemas import EntitiesSchema, EntitySchema from anonymizer.engine.schemas.rewrite import EntityDispositionSchema, SensitivityDispositionSchema -from anonymizer.interface.display import ( - _build_replaced_entities, - _extract_judge_scores, - _normalize_replacement_map, - _render_highlighted_text, - _verdict_badge, - render_record_html, +from anonymizer.interface.display import render_record_html +from anonymizer.interface.display.payload_coercion import extract_judge_scores, normalize_replacement_map +from anonymizer.interface.display.record_html import _render_highlighted_text, _verdict_badge +from anonymizer.interface.display.replaced_spans import ( + build_replaced_entities, ) from anonymizer.interface.results import PreviewResult @@ -109,7 +107,7 @@ def test_replaced_entities_tracks_shifted_positions() -> None: {"original": "Alice", "label": "first_name", "synthetic": "Maya"}, {"original": "Acme", "label": "organization", "synthetic": "NovaCorp"}, ] - result = _build_replaced_entities( + result = build_replaced_entities( original_entities, replacement_map, "Alice works at Acme", @@ -125,30 +123,30 @@ def test_replaced_entities_tracks_shifted_positions() -> None: def test_replaced_entities_empty_map_uses_original_values() -> None: original_entities = [_entity("Alice", "first_name", 0, 5)] - result = _build_replaced_entities(original_entities, [], "Alice works", "Alice works") + result = build_replaced_entities(original_entities, [], "Alice works", "Alice works") assert len(result) == 1 assert result[0].value == "Alice" def test_normalize_replacement_map_from_dict() -> None: raw = {"replacements": [{"original": "Alice", "label": "first_name", "synthetic": "Maya"}]} - result = _normalize_replacement_map(raw) + result = normalize_replacement_map(raw) assert len(result) == 1 assert result[0]["synthetic"] == "Maya" def test_normalize_replacement_map_from_json_string() -> None: raw = '{"replacements": [{"original": "Alice", "label": "first_name", "synthetic": "Maya"}]}' - result = _normalize_replacement_map(raw) + result = normalize_replacement_map(raw) assert len(result) == 1 def test_normalize_replacement_map_invalid_json_returns_empty() -> None: - assert _normalize_replacement_map("bad json {{{") == [] + assert normalize_replacement_map("bad json {{{") == [] def test_normalize_replacement_map_non_dict_returns_empty() -> None: - assert _normalize_replacement_map([1, 2, 3]) == [] + assert normalize_replacement_map([1, 2, 3]) == [] def test_verdict_badge_satisfied_when_all_correct_and_valid_true() -> None: @@ -428,11 +426,11 @@ def test_render_record_html_mask_strategy_labels_are_distinct() -> None: def test_build_original_entities_from_map_case_insensitive() -> None: """Fallback map scanning finds entities regardless of case.""" - from anonymizer.interface.display import _build_original_entities_from_map + from anonymizer.interface.display.replaced_spans import build_original_entities_from_map replacement_map = [{"original": "The Lantern", "label": "company_name"}] text = "She works at the Lantern daily" - result = _build_original_entities_from_map(replacement_map, text) + result = build_original_entities_from_map(replacement_map, text) assert len(result) == 1 assert result[0].value == "the Lantern" assert result[0].start_position == 13 @@ -477,7 +475,7 @@ def test_build_replaced_entities_no_drift_with_case_mismatch() -> None: ] replaced_text = "Leila works at The Ember in Boulder. The Ember is her home. Diego visits." - result = _build_replaced_entities(original_entities, replacement_map, original_text, replaced_text) + result = build_replaced_entities(original_entities, replacement_map, original_text, replaced_text) assert len(result) == 5 for entity in result: actual = replaced_text[entity.start_position : entity.end_position] @@ -500,7 +498,7 @@ def test_build_replaced_entities_no_drift_when_entity_absent_from_map() -> None: ] replaced_text = "Contact Sofia and Carlos at NovaCorp" - result = _build_replaced_entities(original_entities, replacement_map, original_text, replaced_text) + result = build_replaced_entities(original_entities, replacement_map, original_text, replaced_text) assert len(result) == 3 for entity in result: actual = replaced_text[entity.start_position : entity.end_position] @@ -588,7 +586,7 @@ def test_render_record_html_rewrite_mode_nan_judge_column_does_not_warn( COL_JUDGE_EVALUATION: np.nan, } ) - with caplog.at_level(logging.WARNING, logger="anonymizer.interface.display"): + with caplog.at_level(logging.WARNING, logger="anonymizer.interface.display.record_html"): render_record_html(row, record_index=0) assert not any("Judge evaluation present but produced no scores" in rec.message for rec in caplog.records) @@ -609,7 +607,7 @@ def test_render_record_html_rewrite_mode_malformed_judge_dict_warns( COL_JUDGE_EVALUATION: {"unexpected_key": "no score field here"}, } ) - with caplog.at_level(logging.WARNING, logger="anonymizer.interface.display"): + with caplog.at_level(logging.WARNING, logger="anonymizer.interface.display.record_html"): render_record_html(row, record_index=0) assert any("Judge evaluation present but produced no scores" in rec.message for rec in caplog.records) @@ -670,7 +668,7 @@ def test_render_record_html_replace_mode_unchanged_when_no_rewritten_column() -> # --------------------------------------------------------------------------- -# Tests: _extract_judge_scores +# Tests: extract_judge_scores # --------------------------------------------------------------------------- @@ -680,14 +678,14 @@ def test_extract_judge_scores_returns_string_scores() -> None: "quality": {"score": "medium", "reasoning": "ok"}, "style": {"score": "low", "reasoning": "rough"}, } - result = _extract_judge_scores(raw) + result = extract_judge_scores(raw) assert result == [("privacy", "high"), ("quality", "medium"), ("style", "low")] def test_extract_judge_scores_categorical_not_silently_empty() -> None: """String scores must not be silently dropped (old int() cast raised ValueError).""" raw = {"privacy": {"score": "high", "reasoning": "..."}} - result = _extract_judge_scores(raw) + result = extract_judge_scores(raw) assert len(result) == 1 assert result[0] == ("privacy", "high") diff --git a/uv.lock b/uv.lock index f54f492c..64cdb69f 100644 --- a/uv.lock +++ b/uv.lock @@ -1201,6 +1201,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] +[[package]] +name = "grimp" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/06/dd37dd3e282e90856215ea406415ba0c18cb77cf3150ffa47bd1137c8daf/grimp-3.15-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cc467bdf890b26545989994c5f91d99fb2b911bbc09d48cebf906082fb2c01d1", size = 2140928, upload-time = "2026-07-03T12:08:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b0/9329b8df4916cd60a1b358e527fcb9239604a1eca88483deb68ab95d9d5a/grimp-3.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:571d62c2f2e264ed83a9b283b0c1e322902ea3af8a9ad20d9f7cc5be3049be25", size = 2097876, upload-time = "2026-07-03T12:08:41.571Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/4352eb0a6549faefeaae1514f2994f6d9148b12db10e49d94935109cd4cd/grimp-3.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0c36369902389e080f708560621f29e2b64957a6bfa5afb06c13b74105039d6", size = 2262242, upload-time = "2026-07-03T12:07:33Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a7ddecd677891070277f92e71bc41c10c423a3317eb412f9636b4cbb32e3/grimp-3.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44a3a576bb0c5d0754108f0b6cedbeaee66a123112c71a8f4796656c9e52c5cd", size = 2196892, upload-time = "2026-07-03T12:07:43.574Z" }, + { url = "https://files.pythonhosted.org/packages/27/e2/8a7502011e324902853df7daecc3d6d651464920aad93a1a7ad9bd833105/grimp-3.15-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b833af82a197371c0b0b69ec600413d9b50109fec2f8e1e1317b1c226a3e07", size = 2348919, upload-time = "2026-07-03T12:08:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/0570ff9ea16c8280de2e84b52006dd588b208716e73794cb332c43d1eb72/grimp-3.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6167c3d6e30ac8ff0a32260edf51fad5c22ec77b04e85a8a0042c1ba78bf6846", size = 2609501, upload-time = "2026-07-03T12:07:53.841Z" }, + { url = "https://files.pythonhosted.org/packages/33/14/34482976316834848df5eb68ec2283c13f84bfab12b21b8c11e4f3442e77/grimp-3.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6c18582eb87e148da11ad6549ec006186460ed29959afb15e093c34696d0134", size = 2329567, upload-time = "2026-07-03T12:08:04.663Z" }, + { url = "https://files.pythonhosted.org/packages/78/1b/2803b1234cf5e663c00b9fb2318a93b8db3831942ff053276234fa6731ab/grimp-3.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c941ed53672f9d136f898bf41557062dc4ed4c4fd10c88e47499e7db1c436ec", size = 2277972, upload-time = "2026-07-03T12:08:29.175Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd25b4a61851db6ca82d4007e1981d9832dff7c34c7b02a975e0bf3da89f/grimp-3.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4d46417bd6fffa56814b1cf8ffe756fe761d8e5eb691f1d7aaa8a0aba37af05a", size = 2440215, upload-time = "2026-07-03T12:08:56.046Z" }, + { url = "https://files.pythonhosted.org/packages/7c/44/d065613d4cbdc108c33db5978351303cfcedfa2f6d55dcff432c722b3568/grimp-3.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1f39ca69733dc9cc2f4e7c6647ebbb9a85e1b615b6b808187b0fc6dc6f3a9354", size = 2471902, upload-time = "2026-07-03T12:09:06.502Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/1eb96d3b2f61d04a5bfe0502216e248cfa0dfe34697cf56ab74e762983d7/grimp-3.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dcbea03a4502291b6014530267a6447365557a7870524202034a9ceac4f26fdd", size = 2509411, upload-time = "2026-07-03T12:09:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/89a69f121848b2324403ace3e3487c0074bf1257507b743fbebd281d270f/grimp-3.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c88386fac08a96933387d4058ef246b191872f7bd75a01c8655b70c8d1a0433a", size = 2519817, upload-time = "2026-07-03T12:09:26.999Z" }, + { url = "https://files.pythonhosted.org/packages/6a/18/9916c71ad9039e74148d9d3b8da4b6cf87900290edc0e7be1a891c840b66/grimp-3.15-cp311-cp311-win32.whl", hash = "sha256:788e1f019052fd6b4dc24ed9892de4f0f24e8e845bd01d146ee549515f70a9bc", size = 1856929, upload-time = "2026-07-03T12:09:46.611Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/6b349a4aee939b52a244c87e49f584ecad7ca4db67aea70d2d49c7709c26/grimp-3.15-cp311-cp311-win_amd64.whl", hash = "sha256:bfe2d28a94eb97db739382c4d377c8c014020eb6cc1599bcf950c9a2a60fce9d", size = 1988289, upload-time = "2026-07-03T12:09:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/311bd40c02d61eee0182cb8c9b6ded37d42bed9a334e5ba4dacbe1c4c997/grimp-3.15-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6db0b30683612be4571b6b6208e1b39b77faa54566c5ca4f086d64cc783e4864", size = 2144799, upload-time = "2026-07-03T12:08:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/86e941cc26bd3419b5e2abb8b48fa35b850e5508f14e033f3ce28bfce608/grimp-3.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e731a1f8a192a802e04d4018d6cce9f4a12ce48b6b73f43014bd4e0a5d04a8e9", size = 2090802, upload-time = "2026-07-03T12:08:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/6c/09/bcb4e380596e533ef9ebb3f164ad3cc113fde62318ca5af71a27886b1612/grimp-3.15-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb607ee3e16440fc59ec2b49eef1b6dbbe701af9e99990b6491e6f120bd60b5", size = 2255870, upload-time = "2026-07-03T12:07:37.207Z" }, + { url = "https://files.pythonhosted.org/packages/c3/84/361cebbd7b14ce15d93bfb65e2b0ff30287252ffa6ab0b7fe08e029eae6c/grimp-3.15-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0492b9f1120176146d81e51aa0691aa6c004cdf5192ab309a221f9b223dedfc", size = 2193359, upload-time = "2026-07-03T12:07:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d6/c4dd785e149c3c66494822c8b46f0a36cbdf5a5400eeb2172e0a8b029d0f/grimp-3.15-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bb3dbb0471179e732400fdf31c8c8d0299c88d13dd3a3bbe77ce54fb3f1b545", size = 2346350, upload-time = "2026-07-03T12:08:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/4b/470922cb9d0a4b0436bb298801f770c98c6953374ff84e545dd7a196aa66/grimp-3.15-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b578512dbaee7eba2900d8078eaf1f7f78aa7616c609b0849b8403012ffddda", size = 2604959, upload-time = "2026-07-03T12:07:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/061dd80f5f0abb3ac53b29ade25ffac3e464e6853e8ce31dc6c6a33a06a9/grimp-3.15-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a550a14cd2f8123fb06814b705f3af093fa1728b244f21ebcccc8d0da0f851", size = 2327185, upload-time = "2026-07-03T12:08:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/32281e7b5fcd5622f4666b36003b9931ca0e112f2955f09458f198a30f65/grimp-3.15-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db5ebb94ec356eaa5242145c993bc84c4b52d0d2c6980dfcd12b22d51c5b2c46", size = 2273241, upload-time = "2026-07-03T12:08:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/b4f6ae15484541decbe4f12cf39a4a769352cb06332e428cc8e64aed4a32/grimp-3.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e03331418d7fee746e2447ecf5296986e6f094ef15fd25125efad2328844edf7", size = 2433114, upload-time = "2026-07-03T12:09:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/a2/7bdc628435a1e908aa53b351ce031a6134efd18245c4a5a4c28e1e6d19aa/grimp-3.15-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4cc91cb69e73e7899feb6ce73747f4dc092c2bfe2653f6cba69a398cd1dfc6ea", size = 2467235, upload-time = "2026-07-03T12:09:10.677Z" }, + { url = "https://files.pythonhosted.org/packages/5d/18/77853f5693d607d5f49c7e3cd58e482ef8362ea9cf86d0fcb108e4168195/grimp-3.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a848d5b4b656c1e3a28cce0d399f2790ecbeb2eeb78bb49896018614c87219f3", size = 2506552, upload-time = "2026-07-03T12:09:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/a094d7dd7115e8764e8069d5d3a44045c333b41d98a5746e99ec712b4b18/grimp-3.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:208ef56397cabfab52864b3d8a461fb5015a056fede1be4587e4453b13aa8c2f", size = 2514255, upload-time = "2026-07-03T12:09:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/013c640df50968b5d25802a5f01b157514d4e0ccd48c37c63947610a0e05/grimp-3.15-cp314-cp314-win32.whl", hash = "sha256:74d32fae3d222888f6b61579998043579dd810ed948785896e552906cbfdfcb7", size = 1856182, upload-time = "2026-07-03T12:09:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/648a1d77dfc5c2fd24fbdca0a45ee1c24669d981d12652424f5c34e3352c/grimp-3.15-cp314-cp314-win_amd64.whl", hash = "sha256:6b36e179d485c797e7fa234803620af752039fa27a8c7e3182333d7c96041f70", size = 1985451, upload-time = "2026-07-03T12:09:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/fd88ea799d181c22ab47c6cb328e7be3e196c8395444af89fd8da401cf6b/grimp-3.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9107d1037ee6e250ab7a15e1b758e0af76c841c19838b3cc688885a0b3817b5", size = 2253182, upload-time = "2026-07-03T12:07:39.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/d9/25377ba259772edfa97e35e265d89eb89b966a82652967c8479902741067/grimp-3.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b88975b2882edc55e8946640f7b36dfb83cce1303cff5f8528c3f4819d7fbb07", size = 2191822, upload-time = "2026-07-03T12:07:48.721Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/cae8ca43e05aa5217ca2e17ffbda77e86cb215c1a9a03592c110c0162f27/grimp-3.15-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fe01a5393e415e3d99b22c7dc6c6a9cc1b633714707d1c6b56ecbaccc2132f5", size = 2344413, upload-time = "2026-07-03T12:08:21.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/32/feea8fec71e62394013865314854ccb3d7bb54f226564fd267e6662f509a/grimp-3.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd4d70a0de010a9b452b59326a00574f7488dd1333d003df624114e5e00e877e", size = 2602856, upload-time = "2026-07-03T12:08:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/46ccdeb698398264d774273a9b8d9b013c8d23127d05371489b22dcf3ea5/grimp-3.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:204beed673ff43ab4ebe52ff4efd29b80025ec777136a62109afb69792d7fae0", size = 2326095, upload-time = "2026-07-03T12:08:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/69/ae/f98c2c964a850ab80341d02576ef8681e75178f893ba2c73dc03e6563925/grimp-3.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602be339f8a4d368d71758814e5505200cf665818eb0f9a2cc74d71ecc8cf1c", size = 2274375, upload-time = "2026-07-03T12:08:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/9355cdb28fab458fb8defca6406de303d78af617aa0d8c9ae5253b4e5a8b/grimp-3.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:077c27a1e6ff3baf23a8caaa1d74cbbac27024b069956dd6ca5fc3acd43ddb4b", size = 2430307, upload-time = "2026-07-03T12:09:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/edfa7f8064c1a3502fe4aba56c07bd122e94e329188939d52c02bd7e85b6/grimp-3.15-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5516fc38899f4396eff68e6b1997310b4de2d0155e3fad9f545e666c993985b3", size = 2464014, upload-time = "2026-07-03T12:09:12.185Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c2/153b5cc3106814504b4195c7d4c178d85732d35b8895185a02112b8f9a03/grimp-3.15-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dbe8e14cc61af4eea8e7ed6f2108828101f57fe86273d5b61cff044b69e6c6b5", size = 2502010, upload-time = "2026-07-03T12:09:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3f/ae4ba51cf484030e3d15b3304eb7ab9039fe91fb35ff5e90d60fde135bff/grimp-3.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1763b7b48ed6c9e6de838d0d64f19510a392235266cf2240c9be9cc25ca7c54b", size = 2514787, upload-time = "2026-07-03T12:09:33Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5f/0fb2d2bc9fe6bce899947802c94531c24e581c715db19216b941c1b2422f/grimp-3.15-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:116c3a6dba61bd302919b82cb5d603f5977e321d80d7de3c7a1265357ab9dd66", size = 2345635, upload-time = "2026-07-03T12:08:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/05d859a62ed9282f43a0f0962da230c46a2eb3d3ecb5b39117b3b4888405/grimp-3.15-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc2e5065d35047729e41a55c9c3eb99810cf322fe3b3e89fa126f306ce6b3b5", size = 2273647, upload-time = "2026-07-03T12:08:36.139Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/f726316f54e29da4f24d545d6299e1ea0accfbbf52729077fd4a619de055/grimp-3.15-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b38327673df49203cff0ebcbbf078999a80f0b11bb654760059f6f00f56f64d6", size = 2344080, upload-time = "2026-07-03T12:08:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/523a8f969f0a0b0a8ce43fbe8bc7a04e90f52724efc85f3305f1e07ae626/grimp-3.15-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965b37dad2f73164a103381c9fd1255bb3b2f6021ca2f38ffe70dd00fdc0fc55", size = 2275041, upload-time = "2026-07-03T12:08:37.582Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/562e56ba1abecc40169aa40fbf62ba5dcda0f56d953b53eeeb5e43535fb2/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:391bca4c4f1c4dc58f10fadd8e4bba1ca1dbf848a5adb4e66907258135aa0b26", size = 2263708, upload-time = "2026-07-03T12:07:40.836Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6a/2fe608f847630675b06a4e635024771b897f4ea9d1c0d3063f83ed1b7fc8/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08f7e663a465b524c5c0e121e6dd759f282ce35f406bc95bd68deda63a604361", size = 2198790, upload-time = "2026-07-03T12:07:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/49/8365239abbe735d8e103dc19f485e57383f8a435a0ae3b2ba7576e2d8bb7/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5625a037e9fc04c6371effb213efb827d225f5a64e04bff5f448adf180193da9", size = 2352309, upload-time = "2026-07-03T12:08:26.377Z" }, + { url = "https://files.pythonhosted.org/packages/5b/76/c1679a9eb0ac4ac2fc943fdcf47c1d59a200fd8daac3db6bd72c63e45cf9/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4651d29d4ee69bd5a1d9aa963ac73e420da1386f0a2263b8acbc0ae495fda12c", size = 2611854, upload-time = "2026-07-03T12:08:01.912Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cf/56e47ded5188088ad0b9455cd74182c536a2f6fed29f1d4c0d7b20617b5d/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f73dcb7fb94b3a450dea3a95d63e179a715f315e2e0ff5b78aedf8b3dad8707c", size = 2331437, upload-time = "2026-07-03T12:08:12.758Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c8/47cb1dcf0819506915bb5a20dff70bddd518199808c67e1aba730a2fedbc/grimp-3.15-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:228e044bc458ee840ab20e1839efbecdecdd151d22ae3902a0f03aa94de772df", size = 2279755, upload-time = "2026-07-03T12:08:38.982Z" }, + { url = "https://files.pythonhosted.org/packages/38/90/6202e5fa8ffd3648b2fbcd1d6028c6224a278f557073cc3f8094f0a01cd5/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bbaa9053652d29733ff88277b554e716e5acc40b37d12841c972779196d2c0ef", size = 2441806, upload-time = "2026-07-03T12:09:03.85Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b6/2d82c6b6161169b0cb87ccb746e3a590c5a9fb54cb7c59d6c657ed731394/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:12fd9e675e6fcf633a5d14f6cbeee4b667a3d3307c91653ccc1a5aef03f53ec6", size = 2472941, upload-time = "2026-07-03T12:09:13.481Z" }, + { url = "https://files.pythonhosted.org/packages/b7/36/fb2906ed60288b236867829f9b3ced558a2725225ec0ed2354a7124a0474/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:0012653561d80b4e4972ac246deb569051a1fbf3880d620a9475e7159722bbab", size = 2513392, upload-time = "2026-07-03T12:09:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d2/ec5f501d9623fe8eb2d517ab7528708b672dba51322a931515b58b659f6f/grimp-3.15-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e438ae00dfd554c6262e92c945ec233c5e311cc99fdb0d602c6063b4f5fc3d9c", size = 2522530, upload-time = "2026-07-03T12:09:34.518Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1330,6 +1418,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "import-linter" +version = "2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -2487,6 +2590,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "import-linter" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -2529,6 +2633,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "import-linter", specifier = ">=2.13" }, { name = "pre-commit", specifier = ">=4.0.0,<5" }, { name = "pytest", specifier = ">=9.0.3,<10" }, { name = "pytest-cov", specifier = ">=7.0,<8" },