From 0f60ec8af933ffaed0e18558c0c3cc06d10cb54a Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Wed, 21 Oct 2026 18:00:52 +0000 Subject: [PATCH 1/5] refactor: run the Substitute map opaque and delete the native request path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitute's replacement map now runs on DataDesigner's own transport, retries and scheduling on every route, as it already did when the model skipped its health check and as 0.4.0 does. The graph accounts the map like Redact, Annotate, Hash and Rewrite: one opaque invocation with unknown physical requests. Rows, failed records, retries and cancellation follow DataDesigner. With no native map left, the per-request bridge has no consumer: - delete the native map module, the request bridge and physical transport, and the kernel's native transport acquisition and branches; - delete the facade-unreachable qualified detection/Redact profile and the Redact assessment it published, which were the bridge's other consumer; - drop the request_bridge keyword from the adapter and detection workflow, so caller detectors no longer receive it (0.4.0 never passed it); - keep only the source-frame capability checks of the DataDesigner binding. Tests that pinned per-request receipts now assert an empty request inventory; tests of the deleted profile, bridge and native transport go. Measured (src): 16 files, +52 / -1774, net -1722. Tests: 16 files, +83 / -2283. Docs: 1 file, +7 / -26. Gate: full suite pandas 2.3.3 and 3.0.6 5555 passed each; make format-check and make typecheck clean; parity corpus vs main f4f2b73d 0 BLOCKING (both pandas), R0 0 BLOCKING. 🤖 Generated with Claude Code Co-Authored-By: Claude Signed-off-by: Aaron Gonzales --- docs/concepts/graph-sdk.md | 33 +- .../engine/detection/_graph_invocation.py | 55 +- .../engine/detection/_graph_profile.py | 683 +---------- .../engine/detection/_opaque_redact.py | 9 - .../engine/detection/_semantic_detection.py | 17 +- .../engine/detection/detection_workflow.py | 5 - .../engine/detection/run_arguments.py | 2 +- src/anonymizer/engine/harness/kernel.py | 213 +--- src/anonymizer/engine/ndd/_graph_requests.py | 508 +------- src/anonymizer/engine/ndd/_route.py | 12 - src/anonymizer/engine/ndd/adapter.py | 8 +- .../engine/replace/_graph_redact.py | 78 +- .../engine/replace/_native_map_requests.py | 213 ---- .../engine/replace/_semantic_map.py | 7 - .../engine/replace/_semantic_map_profile.py | 12 - .../engine/rewrite/_semantic_evaluation.py | 3 +- src/anonymizer/interface/anonymizer.py | 1 - tests/engine/test_all_replace_graph.py | 13 +- tests/engine/test_detection_count_policy.py | 206 ---- .../test_detection_graph_publication.py | 1019 +---------------- tests/engine/test_detection_redact_config.py | 115 +- .../engine/test_detection_semantic_control.py | 1 - tests/engine/test_detection_source_frame.py | 131 +-- .../test_graph_requests_capabilities.py | 291 +---- .../test_opaque_redact_frame_acquisition.py | 209 ---- .../test_publication_source_admission.py | 2 +- tests/engine/test_route_resolution.py | 43 - .../engine/test_substitute_lineage_compat.py | 26 +- tests/interface/test_dd_capability_abort.py | 120 +- .../test_dd_interrupt_and_retry_compat.py | 15 +- .../test_detector_signature_compat.py | 1 - tests/interface/test_rewrite_graph.py | 2 +- .../interface/test_substitute_map_requests.py | 172 +-- 33 files changed, 142 insertions(+), 4083 deletions(-) delete mode 100644 src/anonymizer/engine/replace/_native_map_requests.py delete mode 100644 tests/engine/test_detection_count_policy.py delete mode 100644 tests/engine/test_opaque_redact_frame_acquisition.py diff --git a/docs/concepts/graph-sdk.md b/docs/concepts/graph-sdk.md index fb0bffc0..61f44512 100644 --- a/docs/concepts/graph-sdk.md +++ b/docs/concepts/graph-sdk.md @@ -120,9 +120,7 @@ whole frame through `NddAdapter.run_workflow()`, with the existing DataDesigner retry, chunk scheduling and provider configuration. Authenticated source positions, typed spans and canonical text artifacts support the existing `PublishedFrame`; there is no inference from text, indexes or row order. This path makes no -Protected claim and does not use the incomplete qualified request bridge. -The qualified development profile remains available for its explicit supported -controls; public execution does not fall back to it after an error. +Protected claim. The ordinary profile reserves at most three semantic artifacts per selected source: initial text, final spans and transformed text. Span membership is bounded @@ -362,29 +360,12 @@ and repeated observer cancellation. Physical transports, classification, Start, terminal facts and publication still execute on the invocation loop. A worker submission is not a physical request. Cancellation before worker entry submits nothing; cancellation after physical entry preserves the first terminal and -usable late settlement while draining local work. The DataDesigner physical -transport cancels its in-flight HTTP exchange when asked to stop; the provider -gives no stop evidence, so such a request is recorded as lost. - -The Substitute map's DataDesigner bridge declares retry follow-ups. DataDesigner -0.9.4 keeps its own retry policy: its request executor retries 502/503/504 and -connection errors with backoff, and its scheduler re-runs a task that failed -with a 5xx, 429, timeout or connection error in later salvage rounds. Each of -those attempts is a separate leaf with its own dispatch and settlement, linked to -the failed predecessor as a `retry` child. A refused connection settles as -`rejected_before_acceptance`; any other transport error leaves the provider side -unknown. Each record admits its parser attempts plus -`(RetryConfig.max_retries + 1) * (salvage_max_rounds + 1) - 1` retries as -accounted leaves, which covers DataDesigner's bounded 5xx, timeout and -connection policy: exhausting it drops that record, as in 0.4.0. DataDesigner -never drops a rate-limited (429) task and keeps retrying it; attempts beyond the -admitted leaves run as DataDesigner-owned requests outside graph request -accounting, and the bridge logs their count. This is honest only because the -Substitute invocation already declares no request upper bound and admits no hard -request budget; a run that selects one could not use this path. DataDesigner runs that the graph does not -bridge (for example Rewrite's stages) are cancelled through DataDesigner's own -scheduler cancellation when the owning invocation is cancelled, as a Ctrl-C does -in 0.4.0. The caller owns +usable late settlement while draining local work. + +The Substitute map, like every other facade stage, runs on DataDesigner's own +transport, retry policy and scheduling, as in 0.4.0; the graph accounts it as one +opaque invocation with unknown physical requests. A Ctrl-C cancels DataDesigner's +runs through DataDesigner's own scheduler cancellation, as in 0.4.0. The caller owns the pool and borrowed loop; the executor neither closes nor replaces them. The pool must be open at service admission. These are live execution resources, not stateless handles: this invocation drains its retained worker and children without waiting diff --git a/src/anonymizer/engine/detection/_graph_invocation.py b/src/anonymizer/engine/detection/_graph_invocation.py index 45e0fbe5..34eb496d 100644 --- a/src/anonymizer/engine/detection/_graph_invocation.py +++ b/src/anonymizer/engine/detection/_graph_invocation.py @@ -3,8 +3,7 @@ """Whole-frame detection/Redact consumer of an admitted graph invocation. Source frames always bind authenticated positions and typed span/text outputs. -An optional physical bridge selects the separately qualified development path; -ordinary public execution leaves DataDesigner's retry and scheduling intact. +DataDesigner keeps its own transport, retries and scheduling. """ from __future__ import annotations @@ -21,16 +20,13 @@ from anonymizer.config.models import DetectionModelSelection from anonymizer.engine.constants import COL_FINAL_ENTITIES, COL_REPLACED_TEXT from anonymizer.engine.detection.artifacts import ENTITY_SPANS_TYPE, EntitySpansValue -from anonymizer.engine.detection.chunked_validation import validation_chunks from anonymizer.engine.detection.detection_workflow import ( - _DEFAULT_VALIDATION_MAX_ENTITIES_PER_CALL, EntityDetectionWorkflow, ) from anonymizer.engine.detection.postprocess import EntitySpan from anonymizer.engine.detection.run_arguments import detection_run_arguments from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests, LocalCompleted, LocalFailure from anonymizer.engine.graph_sdk._graph_values import ArtifactRef, DatumId -from anonymizer.engine.graph_sdk._workflow_values import ArtifactType from anonymizer.engine.graph_sdk.capabilities import FrozenConfig from anonymizer.engine.graph_sdk.requests import ( AssociationInput, @@ -38,11 +34,8 @@ PortArtifact, SemanticAssociation, TextArtifactValue, - TextCollectionItem, - TextCollectionValue, ) from anonymizer.engine.harness.frames import FramePublication, InvocationFrame -from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport, NddRequestBridge from anonymizer.engine.replace._graph_redact import local_strategy from anonymizer.engine.replace.strategies import apply_local_replace_strategy from anonymizer.engine.schemas import EntitiesSchema @@ -59,23 +52,15 @@ class DetectionRedactInvocation: model_configs: list[ModelConfig] selected_models: DetectionModelSelection redact_configuration: FrozenConfig - physical: NddPhysicalTransport | None - slots: Mapping[tuple[str, int], int | tuple[int, ...]] detection_options: Mapping[str, Any] output_port: str outcome: str - execution: str = "same_loop" semantic_slots: Mapping[str, int] | None = None semantic_loop: asyncio.AbstractEventLoop | None = None semantic_transform_slot: int | None = None semantic_entry_slot: int | None = None handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] | None = None - async def submit( - self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests - ) -> LocalCompleted | LocalFailure: - return await asyncio.to_thread(self.run, inputs, requests) - def run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: context = copy_context() return ( @@ -93,12 +78,10 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests ): raise AnonymizerWorkflowError("DD graph detection requires one source-text input per association") frame = requests.bind_frame(self.source, targets=self.targets, source_limit=len(self.targets)) - bridge = None if self.physical is None else NddRequestBridge(requests, self.slots, self.physical) arguments: dict[str, Any] = { "model_configs": self.model_configs, "selected_models": self.selected_models, "source_frame": frame, - "request_bridge": bridge, **self.detection_options, } from anonymizer.engine.detection._semantic_detection import ( @@ -111,10 +94,7 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests if self.semantic_slots is not None: if self.semantic_loop is None: raise AnonymizerWorkflowError("Detection binding has no owning loop") - if self.physical is None: - asyncio.run_coroutine_threadsafe( - complete_detection_entry(requests, inputs), self.semantic_loop - ).result() + asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), self.semantic_loop).result() detected = lower_detection( self.detection, self.source, @@ -164,37 +144,6 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests outcome=self.outcome, consumed_context_ports=frozenset(), outputs=( - *( - ( - PortArtifact( - port="validation_chunks", - artifact_type=ArtifactType(name="anonymizer.validation-chunks", revision=1), - artifact=None, - value=TextCollectionValue( - items=tuple( - TextCollectionItem( - key=index, - version=1, - value=TextArtifactValue( - text="\n".join(candidate.id for candidate, _ in chunk) - ), - ) - for index, chunk in enumerate( - validation_chunks( - detected.dataframe.iloc[rows[item.association]].to_dict(), - self.detection_options.get( - "validation_max_entities_per_call", - _DEFAULT_VALIDATION_MAX_ENTITIES_PER_CALL, - ), - )[2] - ) - ) - ), - ), - ) - if self.physical is not None - else () - ), PortArtifact( port="spans", artifact_type=ENTITY_SPANS_TYPE, artifact=None, value=spans[item.association] ), diff --git a/src/anonymizer/engine/detection/_graph_profile.py b/src/anonymizer/engine/detection/_graph_profile.py index 99bdd58c..200fcc90 100644 --- a/src/anonymizer/engine/detection/_graph_profile.py +++ b/src/anonymizer/engine/detection/_graph_profile.py @@ -3,109 +3,27 @@ """DD detection/Redact consumers of the single graph executor. The public facade selects execution-only publication with unknown physical -request visibility. The separate qualified development profile retains explicit -child-request admission and its narrower backend conformance requirements. +request visibility: DataDesigner keeps its own transport, retries and scheduling. """ from __future__ import annotations -import asyncio from collections.abc import Callable, Mapping -from dataclasses import replace from functools import partial -from typing import Any, cast +from typing import Any import pandas as pd from data_designer.config import ModelConfig from anonymizer.config.models import DetectionModelSelection, ReplaceModelSelection from anonymizer.config.replace_strategies import Annotate, Hash, Redact, ReplaceMethod -from anonymizer.engine.constants import COL_AUGMENTED_ENTITIES, COL_RAW_DETECTED, COL_TEXT, COL_VALIDATION_DECISIONS from anonymizer.engine.detection._graph_invocation import DetectionRedactInvocation -from anonymizer.engine.detection.artifacts import ENTITY_SPANS_TYPE -from anonymizer.engine.detection.detection_workflow import ( - _DEFAULT_VALIDATION_MAX_ENTITIES_PER_CALL, - EntityDetectionWorkflow, -) -from anonymizer.engine.graph_sdk._activation_values import ActivationLimits -from anonymizer.engine.graph_sdk._artifact_shape import content_bytes -from anonymizer.engine.graph_sdk._authoring_defaults import ( - _compile_graph, - _execution_limits, - _ingress_limits, - _operation_spec, - _outcome_spec, - _validate_targets, -) +from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool -from anonymizer.engine.graph_sdk._workflow_values import ( - ArtifactType, - EvidencePromise, - InputBinding, - InputPort, - NodeId, - NodeInputRef, - NodeOutcomeRef, - NodeOutputRef, - OperationNode, - OperationSpec, - OutcomeBinding, - OutputBinding, - OutputDependency, - OutputPort, - ProtectionRequirement, - ResourceCeiling, - SequenceEdge, - WorkflowId, - WorkflowInputRef, - WorkflowLimits, - WorkflowOutcomeRef, - WorkflowOutputRef, -) -from anonymizer.engine.graph_sdk.capabilities import ( - FrozenConfig, - ImplementationCapability, - ImplementationRef, - ImplementationSelection, -) -from anonymizer.engine.graph_sdk.context import admit_context_plan -from anonymizer.engine.graph_sdk.data import DataGraph, DataLimits -from anonymizer.engine.graph_sdk.executor import ( - AssessmentLimits, - DelegatedGenerationSelection, - DelegatedReadiness, - DelegatedRequestContract, - EvidenceProductionDecl, - ExecutionImplementation, - ExecutionLimits, - ExecutionResult, - ImplementationHandle, - LocalCompleted, - LocalFailure, - OperationExecutionPolicy, - admit_execution_plan, -) -from anonymizer.engine.graph_sdk.preparation import ( - BoundInput, - PreparationConfiguration, - PreparationLimits, - prepare, -) -from anonymizer.engine.graph_sdk.requests import ChildRoute, PhysicalRequestPolicy -from anonymizer.engine.graph_sdk.resources import ResourceLease +from anonymizer.engine.graph_sdk.executor import LocalCompleted, LocalFailure from anonymizer.engine.harness import kernel -from anonymizer.engine.ndd._route import builtin_detection -from anonymizer.engine.replace._graph_redact import ( - REDACT_EVIDENCE_TYPE, - REDACT_MATCH, - REDACT_MEANING, - REDACT_MISMATCH, - RedactAssessment, - local_configuration, - redact_configuration, -) +from anonymizer.engine.replace._graph_redact import local_configuration from anonymizer.engine.replace.replace_runner import ReplacementResult, ReplacementWorkflow -from anonymizer.engine.source_text import source_text def run_detection_redact( @@ -115,32 +33,21 @@ def run_detection_redact( model_configs: list[ModelConfig], selected_models: DetectionModelSelection, detection_options: Mapping[str, Any], - limits: ExecutionLimits | None = None, replace_method: ReplaceMethod | None = None, replacement: ReplacementWorkflow | None = None, replacement_models: ReplaceModelSelection | None = None, - opaque: bool = False, ) -> ReplacementResult: - """Run the admitted profile synchronously with one owned callback worker. + """Run detection and replacement as one opaque graph invocation, synchronously on the caller's thread. - Counts follow the selected source and declared work. Callers may explicitly - supply tighter count or byte limits. A notebook's active loop - stays untouched; the host thread runs the same authoritative async executor, - while actual DD clients remain on DD's existing foreign loop. + Without ``replacement`` the graph applies Redact, Annotate or Hash itself; with it, the caller's + (or builtin) replacement runner runs inside the same invocation. """ - if (replacement is not None or opaque) and limits is not None: - raise ValueError("Opaque injected execution cannot guarantee caller-supplied execution limits") selected_method = replace_method.model_copy(deep=True) if replace_method is not None else Redact() - if replacement is None and not isinstance(selected_method, (Redact, Hash, Annotate)): - raise ValueError("Substitute requires a replacement workflow") - if replacement is None and not opaque and not isinstance(selected_method, Redact): - raise ValueError("The qualified detection profile requires Redact") - local_config = ( - local_configuration(selected_method) - if replacement is None and isinstance(selected_method, (Redact, Hash, Annotate)) - else None - ) - selected_limits = replace(limits) if limits is not None else None + local_config = None + if replacement is None: + if not isinstance(selected_method, (Redact, Hash, Annotate)): + raise ValueError("Substitute requires a replacement workflow") + local_config = local_configuration(selected_method) async def operation( pool: SubmitterPool, @@ -176,554 +83,24 @@ async def operation( pool, ) return kernel.project_detection_redact(result) - if opaque: - from anonymizer.engine.detection._opaque_redact import execute_injected_redact - - assert local_config is not None - result = await execute_injected_redact( - DetectionRedactInvocation( - source, - (), - detection, - model_configs, - selected_models, - local_config, - None, - {}, - detection_options, - "redacted", - "ok", - handoff=handoff, - ), - pool, - ) - return kernel.project_detection_redact(result) - result = await execute_detection_redact( - source, - detection=detection, - model_configs=model_configs, - selected_models=selected_models, - detection_options=detection_options, - replace_method=cast(Redact, selected_method), - pool=pool, - limits=selected_limits, + from anonymizer.engine.detection._opaque_redact import execute_injected_redact + + assert local_config is not None + result = await execute_injected_redact( + DetectionRedactInvocation( + source, + (), + detection, + model_configs, + selected_models, + local_config, + detection_options, + "redacted", + "ok", + handoff=handoff, + ), + pool, ) return kernel.project_detection_redact(result) return kernel._run_sync_profile(operation) - - -async def execute_detection_redact( - source: pd.DataFrame, - *, - detection: EntityDetectionWorkflow, - model_configs: list[ModelConfig], - selected_models: DetectionModelSelection, - detection_options: Mapping[str, Any], - validation_chunks: int | None = None, - replace_method: Redact | None = None, - pool: SubmitterPool, - limits: ExecutionLimits | None = None, -) -> ExecutionResult: - """Admit and execute the named real-DD profile without a second workflow runner.""" - redaction = redact_configuration(replace_method if replace_method is not None else Redact()) - if limits is not None: - limits = replace(limits) - preview = detection_options.get("preview_num_records") - if preview is not None and (type(preview) is not int or preview < 0): - raise ValueError("preview_num_records must be a nonnegative integer") - selected_source = source if preview is None else source.iloc[:preview] - texts = tuple(source_text(value) for value in selected_source[COL_TEXT]) - if validation_chunks is None: - chunk_size = detection_options.get( - "validation_max_entities_per_call", _DEFAULT_VALIDATION_MAX_ENTITIES_PER_CALL - ) - if type(chunk_size) is not int or chunk_size < 1: - raise ValueError("validation_max_entities_per_call must be a positive integer") - # parse_raw_entities admits only nonempty, in-range, nonoverlapping spans. - # Thus detector-only validation has at most one candidate per character. - # This reserves capacity from existing input, without limiting public input. - characters = max((len(text) for text in texts), default=0) - validation_chunks = max(1, (characters + chunk_size - 1) // chunk_size) - if type(validation_chunks) is not int or validation_chunks < 1: - raise ValueError("validator capacity must be a positive integer") - generations = ( - (COL_RAW_DETECTED, 0), - *((COL_VALIDATION_DECISIONS, i) for i in range(validation_chunks)), - (COL_AUGMENTED_ENTITIES, 0), - ) - run_config = detection._adapter._data_designer.run_config - parser_capacity = (run_config.max_conversation_correction_steps + 1) * (run_config.max_conversation_restarts + 1) - group_sizes = (parser_capacity,) + (1,) * validation_chunks + (parser_capacity,) - if len(selected_source) == 0: - generations, group_sizes = (), () - graph = DataGraph.new() - targets = [] - for text in texts: - graph, target = graph.add_text(text) - targets.append(target) - count = sum(group_sizes) - size = len(targets) - if limits is None: - limits = _execution_limits( - max_local_in_flight=max(1, size + 1), - max_remote_outstanding=max(1, size * count + 2), - # Each source retains one root, three producer outputs and one assessment. - max_runtime_artifacts=8 * size, - max_collection_items=max(validation_chunks, max((len(text) for text in texts), default=0)), - ) - byte_limit = limits.max_runtime_artifact_bytes - span_capacity = sum(len(text) for text in texts) - chunk_capacity = size * validation_chunks - data = _validate_targets( - graph, - targets=tuple(targets), - limits=DataLimits( - max_datums=size, - max_targets=size, - max_text_bytes=(sum(content_bytes(text) for text in texts) if byte_limit is None else byte_limit), - max_declarations=0, - max_group_members=0, - ), - ) - workflow_id = WorkflowId.new() - owner, readiness, readiness_request, probe, *leaves = [NodeId.new(workflow=workflow_id) for _ in range(count + 4)] - complete = NodeId.new(workflow=workflow_id) - locals_ = {probe, complete} - text_type = ArtifactType(name="anonymizer.text", revision=1) - ceiling = ResourceCeiling( - max_activations=1, max_model_requests=1, max_input_bytes=byte_limit, max_output_bytes=byte_limit - ) - - def operation(name: str, outcomes: tuple[str, ...], requests: int, io: bool = False) -> OperationSpec: - return _operation_spec( - name=name, - inputs=(InputPort(name="source", artifact_type=text_type),) if io else (), - outputs=( - OutputPort(name="redacted", artifact_type=text_type), - OutputPort(name="spans", artifact_type=ENTITY_SPANS_TYPE), - OutputPort( - name="validation_chunks", - artifact_type=ArtifactType(name="anonymizer.validation-chunks", revision=1), - ), - ) - if io - else (), - output_dependencies=tuple( - OutputDependency(output=port, inputs=frozenset({"source"}), identity_input=None) - for port in ("redacted", "spans", "validation_chunks") - ) - if io - else (), - outcomes=tuple( - _outcome_spec( - name=value, - category="success", - produced_ports=frozenset({"redacted", "spans", "validation_chunks"}) if io else frozenset(), - ceiling=replace(ceiling, max_model_requests=requests), - ) - for value in outcomes - ), - ) - - specs = [ - operation("dd-detection-redact", ("ok",), count, True), - operation("dd-readiness", ("ready",), 1), - operation("dd-readiness-request", ("ready",), 1), - operation("dd-async-probe", ("async_required",), 0), - *(operation(f"dd-generation-{index}", ("ok",), 1) for index in range(count)), - operation("dd-frame-complete", ("ok",), 0), - ] - specs[-1] = replace( - specs[-1], - name="redact-assessment", - inputs=( - InputPort(name="source", artifact_type=text_type), - InputPort(name="candidate", artifact_type=text_type), - InputPort(name="spans", artifact_type=ENTITY_SPANS_TYPE), - ), - outputs=(OutputPort(name="assessment", artifact_type=REDACT_EVIDENCE_TYPE),), - output_dependencies=( - OutputDependency( - output="assessment", inputs=frozenset({"source", "candidate", "spans"}), identity_input=None - ), - ), - outcomes=tuple( - replace( - outcome, - produced_ports=frozenset({"assessment"}), - evidence=frozenset( - { - EvidencePromise( - name="redaction", - meaning=REDACT_MEANING, - subject_port="candidate", - consumed_ports=frozenset({"source", "candidate", "spans"}), - coverage=frozenset(), - ) - } - ), - ) - for outcome in specs[-1].outcomes - ), - ) - ids = [owner, readiness, readiness_request, probe, *leaves, complete] - if not targets: - specs = [operation("dd-empty-detection", ("ok",), 1), *specs[1:4], operation("dd-empty-complete", ("ok",), 0)] - ids = [owner, readiness, readiness_request, probe, complete] - nodes = tuple(OperationNode(id=node, operation=spec) for node, spec in zip(ids, specs, strict=True)) - if not targets: - interface = replace( - specs[0], - name="dd-empty-detection-profile", - outcomes=( - replace( - specs[0].outcomes[0], - ceiling=replace( - ceiling, - max_activations=len(nodes), - max_model_requests=3, - max_input_bytes=None if byte_limit is None else byte_limit * len(nodes), - max_output_bytes=None if byte_limit is None else byte_limit * len(nodes), - ), - ), - ), - ) - else: - interface = replace( - specs[0], - name="dd-detection-redact-profile", - outputs=(specs[0].outputs[0],), - output_dependencies=(specs[0].output_dependencies[0],), - outcomes=( - replace( - specs[0].outcomes[0], - produced_ports=frozenset({"redacted"}), - evidence=frozenset( - { - replace( - next(iter(specs[-1].outcomes[0].evidence)), - subject_port="redacted", - consumed_ports=frozenset({"source"}), - ) - } - ), - ceiling=replace( - ceiling, - max_activations=len(nodes), - max_model_requests=2 * count + 2, - max_input_bytes=None if byte_limit is None else byte_limit * len(nodes), - max_output_bytes=None if byte_limit is None else byte_limit * len(nodes), - ), - ), - ), - ) - raw = _compile_graph( - workflow=workflow_id, - interface=interface, - nodes=nodes, - input_bindings=() - if not targets - else ( - InputBinding(source=WorkflowInputRef(port="source"), destination=NodeInputRef(node=owner, port="source")), - InputBinding( - source=WorkflowInputRef(port="source"), destination=NodeInputRef(node=complete, port="source") - ), - InputBinding( - source=NodeOutputRef(node=owner, port="redacted"), - destination=NodeInputRef(node=complete, port="candidate"), - ), - InputBinding( - source=NodeOutputRef(node=owner, port="spans"), destination=NodeInputRef(node=complete, port="spans") - ), - ), - output_bindings=() - if not targets - else ( - OutputBinding( - source=NodeOutputRef(node=owner, port="redacted"), destination=WorkflowOutputRef(port="redacted") - ), - ), - outcome_bindings=( - OutcomeBinding( - source=NodeOutcomeRef(node=complete, outcome="ok"), - destination=WorkflowOutcomeRef(outcome="ok"), - ), - ), - sequence=( - SequenceEdge(before=readiness, after=probe), - *(SequenceEdge(before=probe, after=leaf) for leaf in leaves), - *(SequenceEdge(before=node, after=complete) for node in ids if node != complete), - ), - protection=() - if not targets - else ( - ProtectionRequirement( - outcome="ok", - meaning=REDACT_MEANING, - subject_port="redacted", - consumed_ports=frozenset({"source"}), - coverage=frozenset(), - ), - ), - limits=WorkflowLimits( - max_nodes=len(nodes), - max_bindings=6, - max_sequence_edges=count + len(nodes), - max_choices=0, - max_branch_members=0, - max_subgraph_depth=1, - max_choice_states=1, - ), - ) - workflow = raw - capabilities = tuple( - ImplementationCapability( - implementation=ImplementationRef(name=spec.name, revision=1), - operation=spec, - configuration=redaction if node in {owner, complete} else FrozenConfig(fields=()), - effect="local" if node in locals_ else "external", - attribution="per_task", - request_visibility="none" if node in locals_ else "dispatch_and_settlement", - pre_dispatch_control="none" if node in locals_ else "executor", - retry_owner="none" if node in locals_ else "implementation" if node == owner else "executor", - error_reporting="typed_terminal", - cancellation="before_dispatch_only", - settlement="synchronous" if node in locals_ else "explicit_ack", - usage="exact", - resource_lifetime="caller_owned" - if node == owner - else "stateless" - if node in {*locals_, readiness} - else "executor_owned", - max_physical_requests_per_activation=max(1, count) if node == owner else 0 if node in locals_ else 1, - ) - for node, spec in zip(ids, specs, strict=True) - ) - detection_ids = {} - detection_capabilities, detection_policies = (), () - from anonymizer.engine.detection._semantic_detection import extend_detection_graph - - # Classified once: builtin detection is lowered by this graph's children; a caller's detector runs opaque. - builtin = builtin_detection(detection) - if targets and builtin: - workflow, detection_capabilities, detection_policies, detection_ids = extend_detection_graph( - workflow, - owner, - FrozenConfig(fields=()), - latent=False, - physical_sink=complete, - ) - else: - detection_ids = {"core": owner, "final": complete} - all_capabilities = (*capabilities, *detection_capabilities) - all_ids = (*ids, *(node for node in detection_ids.values() if node not in ids)) - prepared = prepare( - data=data, - workflow=workflow, - activation_limits=ActivationLimits( - max_events=3 * (len(nodes) + len(detection_ids)), - max_entries=len(nodes) + len(detection_ids), - max_parent_depth=1, - ), - bound_inputs=tuple( - BoundInput(target=target, source=target, port="source", artifact_type=text_type) for target in targets - ), - configuration=PreparationConfiguration( - purpose="protection" if targets else "execution_only", - required_protection_outcomes=frozenset({"ok"}) if targets else frozenset(), - hard_request_limit=None, - invocation_owned=not targets, - ), - selections=tuple( - ImplementationSelection(node=node, implementation=cap.implementation, configuration=cap.configuration) - for node, cap in zip(all_ids, all_capabilities, strict=True) - ), - capabilities=all_capabilities, - limits=PreparationLimits( - max_bound_inputs=size, - max_selections=len(nodes) + len(detection_ids), - max_config_depth=3, - # Producer and assessor each add five atoms, counted in selection and capability. - max_config_atoms=2 * len(nodes) + 20 + 20 * len(detection_ids), - max_total_activation_slots=max(size, 1) * (len(nodes) + len(detection_ids)), - max_capabilities=len(nodes) + len(detection_ids), - ), - ) - slots = {slot.template: slot.index for slot in prepared.reservation_recipe} - groups = [] - position = 0 - for length in group_sizes: - groups.append(tuple(slots[leaf] for leaf in leaves[position : position + length])) - position += length - policies = [] - for node, cap in zip(ids, capabilities, strict=True): - request = ( - None - if node in locals_ - else PhysicalRequestPolicy( - visibility="dispatch_and_settlement", - pre_dispatch_control="executor", - retry_owner="implementation" if node == owner else "executor", - replay="idempotent", - max_attempts=max(1, count) if node == owner else 1, - ) - ) - delegated = ( - DelegatedRequestContract( - mode="qualified", - child_slots=tuple(slots[leaf] for leaf in leaves), - generation_groups=tuple(groups), - selection=DelegatedGenerationSelection( - port="validation_chunks", slots=tuple(group[0] for group in groups[1:-1]) - ) - if targets - else None, - routes=(ChildRoute(name="primary", parse_attempts=parser_capacity if targets else 1),), - followups=frozenset({"correction", "conversation_restart"}), - readiness=DelegatedReadiness( - owner_slot=slots[readiness], - request_slot=slots[readiness_request], - probe_slot=slots[probe], - route=ChildRoute(name="readiness", parse_attempts=1), - ready_outcome="ready", - probe_outcomes=frozenset({"async_required"}), - ), - ) - if node == owner - else None - ) - names = tuple(outcome.name for outcome in cap.operation.outcomes) - policies.append( - OperationExecutionPolicy( - node=node, - kind="local" if node in locals_ else "delegated" if node == owner else "external", - request=request, - safe_detachment="forbidden", - implementations=( - ExecutionImplementation( - implementation=cap.implementation, - configuration=cap.configuration, - capability=cap, - request=request, - ), - ), - result_outcomes=frozenset(names), - runtime_outcomes=kernel._runtime_rows(names, node not in locals_), - delegated=delegated, - submission_owner=owner if not targets and node == complete else None, - ) - ) - policies.extend(detection_policies) - admitted = admit_execution_plan( - context=admit_context_plan(prepared=prepared, bound_context=None), - capabilities=all_capabilities, - policies=tuple(policies), - assessment_productions=() - if not targets - else ( - EvidenceProductionDecl( - node=complete, - outcome="ok", - promise="redaction", - evidence_port="assessment", - supported_findings=frozenset({REDACT_MATCH, REDACT_MISMATCH}), - ), - ), - ingress_limits=_ingress_limits( - max_associations=max(1, size), - max_outputs_per_association=3, - # Final nonoverlapping spans are bounded by source characters; - # validation membership has one entry per admitted chunk per row. - max_collection_items=max(1, span_capacity + chunk_capacity), - max_assessments=max(1, size), - # Per row: association/outcome (2), three port/type names (6), - # span-source reference (1); three scalar fields per span and - # key/version per validation member. The assessment response is smaller. - max_metadata_items=max(9, 9 * size + 3 * span_capacity + 2 * chunk_capacity), - max_depth=10, - ), - assessment_limits=AssessmentLimits( - max_productions=1, - max_findings_per_production=2, - max_finding_code_bytes=64, - max_assessment_facts=size, - max_port_facts=size * 24, - max_provenance_edges=size * 32, - ), - ) - loop = ensure_async_engine_loop() - - transport = None - handed_to_runtime = False - try: - physical, transport = await kernel.acquire_native_transport() - consumer = DetectionRedactInvocation( - source, - tuple(targets), - detection, - model_configs, - selected_models, - redaction, - physical, - dict(zip(generations, groups, strict=True)), - detection_options, - "redacted", - "ok", - semantic_slots={name: slots[node] for name, node in detection_ids.items()} if builtin else None, - semantic_loop=asyncio.get_running_loop(), - ) - worker = kernel.delegated_submitter(consumer._run, pool, loop) - lease = ResourceLease.create(owner="caller", safe_detachment="forbidden", handle=worker) - handles = tuple( - ImplementationHandle( - implementation=cap.implementation, - operation=cap.operation, - configuration=cap.configuration, - local=RedactAssessment(configuration=cap.configuration) - if node == complete and targets - else kernel._ProfileLocal("async_required") - if node == probe - else None, - transport=transport if node not in {owner, *locals_, readiness} else None, - delegated=worker if node == owner else None, - resource=lease - if node == owner - else transport.lease - if node not in {owner, *locals_, readiness} - else None, - ) - for node, cap in zip(ids, capabilities, strict=True) - if node != readiness - ) - handles = ( - *handles, - *( - ImplementationHandle( - implementation=cap.implementation, - operation=cap.operation, - configuration=cap.configuration, - local=None, - transport=None, - delegated=None, - resource=None, - ) - for cap in detection_capabilities - ), - ) - - def started() -> None: - nonlocal handed_to_runtime - handed_to_runtime = True - - return await kernel.run_admitted(admitted, all_capabilities, handles, limits, started=started) - finally: - if not handed_to_runtime and transport is not None: - await transport.close() - - -def ensure_async_engine_loop() -> asyncio.AbstractEventLoop: - """DataDesigner's shared async engine loop, imported on first use so importing this module needs no engine.""" - from data_designer.engine.dataset_builders.utils.async_concurrency import ensure_async_engine_loop as engine_loop - - return engine_loop() diff --git a/src/anonymizer/engine/detection/_opaque_redact.py b/src/anonymizer/engine/detection/_opaque_redact.py index ef4c2b01..125e85f2 100644 --- a/src/anonymizer/engine/detection/_opaque_redact.py +++ b/src/anonymizer/engine/detection/_opaque_redact.py @@ -522,14 +522,11 @@ async def execute_injected_redact( parent_capabilities, parent_policies, detection_ids["entry"] ) map_ids = {} - native_ids = {} if semantic_map: from anonymizer.engine.constants import COL_ENTITIES_BY_VALUE - from anonymizer.engine.replace._native_map_requests import extend_native_map_requests, native_map_capacity from anonymizer.engine.replace._semantic_map import extend_map_graph, map_configuration assert isinstance(callback, _DetectionInvocation) and callback.replacement_arguments is not None - helper = cast(Any, callback.replacement_receiver)._llm_workflow options = callback.replacement_arguments args = dict( model_configs=options["model_configs"], @@ -567,11 +564,6 @@ async def execute_injected_redact( else policy for policy in child_policies ) - capacity = native_map_capacity(helper, options) - if size and capacity is not None: - raw, physical_caps, physical_policies, native_ids = extend_native_map_requests(raw, capacity) - child_capabilities = (*child_capabilities, *physical_caps) - child_policies = (*child_policies, *physical_policies) prepared = kernel._prepare_frame_plan( data, raw, @@ -631,5 +623,4 @@ async def execute_injected_redact( max_port_facts=(48 if map_ids else 20) * size, max_provenance_edges=(100 if map_ids else 20) * size, ), - native_request_nodes=native_ids, ) diff --git a/src/anonymizer/engine/detection/_semantic_detection.py b/src/anonymizer/engine/detection/_semantic_detection.py index 2fa002a0..c33410ab 100644 --- a/src/anonymizer/engine/detection/_semantic_detection.py +++ b/src/anonymizer/engine/detection/_semantic_detection.py @@ -100,7 +100,7 @@ def detection_configuration(arguments: dict[str, Any]) -> FrozenConfig: for key, value in normalized.arguments.items() # privacy_goal is a Rewrite argument admitted by the facade; a caller detector may mutate its own copy # before delegating to builtin detection, so it is not part of the detection configuration. - if key not in {"self", "dataframe", "source_frame", "request_bridge", "privacy_goal"} + if key not in {"self", "dataframe", "source_frame", "privacy_goal"} } return FrozenConfig( fields=( @@ -425,11 +425,6 @@ def lower(self) -> EntityDetectionResult: ) }, **({"source_frame": stage_source} if stage_source is not None else {}), - **( - {"request_bridge": arguments["request_bridge"]} - if arguments["request_bridge"] is not None - else {} - ), ) else: result = receiver.identify_latent_entities( @@ -522,12 +517,6 @@ async def publish(self, item: AssociationInput, port: str, value: str) -> None: if result.condition != "result" or result.category != "success": raise AnonymizerWorkflowError(self.rejected) - def check_outcomes(self, outcomes: tuple[Any, ...]) -> None: - if self.arguments["request_bridge"] is not None and any( - outcome.condition == "artifact_limit_exhausted" for outcome in outcomes - ): - raise AnonymizerWorkflowError("Detection/Redact execution did not qualify all returned sources") - def publication(self, item: AssociationInput, port: str, value: str) -> LocalCompleted: source = next((port for port in item.inputs if port.port == "in_source"), None) return completed( @@ -535,7 +524,7 @@ def publication(self, item: AssociationInput, port: str, value: str) -> LocalCom ( *( (PortArtifact(port="source", artifact_type=_TEXT, artifact=None, value=source.value),) - if self.arguments["request_bridge"] is None and source is not None + if source is not None else () ), PortArtifact(port=port, artifact_type=_ENTITIES, artifact=None, value=TextArtifactValue(text=value)), @@ -558,8 +547,6 @@ def detection_arguments( values = {key: value for key, value in normalized.arguments.items() if key not in {"self", "dataframe"}} if values["tag_latent_entities"] and values["privacy_goal"] is None: raise ValueError("privacy_goal is required when tag_latent_entities=True (rewrite mode)") - if values["request_bridge"] is not None and (values["source_frame"] is None or values["tag_latent_entities"]): - raise ValueError("Graph request binding requires a source frame and detection-only workflow") if values["source_frame"] is not None and not values["source_frame"].matches(frame): raise AnonymizerWorkflowError("Workflow source frame does not match submitted data") return values diff --git a/src/anonymizer/engine/detection/detection_workflow.py b/src/anonymizer/engine/detection/detection_workflow.py index 8a55cf09..e49b11fa 100644 --- a/src/anonymizer/engine/detection/detection_workflow.py +++ b/src/anonymizer/engine/detection/detection_workflow.py @@ -67,7 +67,6 @@ from anonymizer.measurement import stage_timer if TYPE_CHECKING: - from anonymizer.engine.ndd._graph_requests import NddRequestBridge from anonymizer.engine.ndd._source_frame import WorkflowSourceFrame @@ -121,7 +120,6 @@ def detect_and_validate_entities( data_summary: str | None = None, preview_num_records: int | None = None, source_frame: WorkflowSourceFrame | None = None, - request_bridge: NddRequestBridge | None = None, _resolved_label_config: ResolvedEntityLabelConfig | None = None, ) -> EntityDetectionResult: """Run the core detection pipeline: GLiNER NER, LLM validation, LLM augmentation, and finalization. @@ -152,7 +150,6 @@ def detect_and_validate_entities( workflow_name="entity-detection", preview_num_records=preview_num_records, **({"source_frame": source_frame} if source_frame is not None else {}), - **({"request_bridge": request_bridge} if request_bridge is not None else {}), ) if source_frame is not None and detection_result.source_positions is None: raise AnonymizerWorkflowError("Bound detection result is missing source correspondence") @@ -451,7 +448,6 @@ def run( compute_grouped_entities: bool | None = None, preview_num_records: int | None = None, source_frame: WorkflowSourceFrame | None = None, - request_bridge: NddRequestBridge | None = None, ) -> EntityDetectionResult: """Run the full detection pipeline, optionally including latent entity detection. @@ -477,7 +473,6 @@ def run( "compute_grouped_entities": compute_grouped_entities, "preview_num_records": preview_num_records, "source_frame": source_frame, - "request_bridge": request_bridge, } with stage_timer( "EntityDetectionWorkflow.run", diff --git a/src/anonymizer/engine/detection/run_arguments.py b/src/anonymizer/engine/detection/run_arguments.py index a6906305..d256e401 100644 --- a/src/anonymizer/engine/detection/run_arguments.py +++ b/src/anonymizer/engine/detection/run_arguments.py @@ -11,7 +11,7 @@ # Keyword arguments EntityDetectionWorkflow.run() gained after v0.4.0. A caller-supplied # detection_workflow written against v0.4.0's exact run() signature (none of these, no # **kwargs) raises TypeError if they are passed unconditionally. -POST_040_RUN_KEYWORDS = ("entity_label_examples", "source_frame", "request_bridge") +POST_040_RUN_KEYWORDS = ("entity_label_examples", "source_frame") def accepts_keyword(method: Callable[..., object], name: str) -> bool: diff --git a/src/anonymizer/engine/harness/kernel.py b/src/anonymizer/engine/harness/kernel.py index a973c87f..49e3aeb3 100644 --- a/src/anonymizer/engine/harness/kernel.py +++ b/src/anonymizer/engine/harness/kernel.py @@ -13,13 +13,11 @@ from collections.abc import Awaitable, Callable from concurrent.futures import Future, InvalidStateError from contextvars import copy_context -from dataclasses import dataclass from queue import Empty, Queue from threading import Event, Thread, get_ident from time import monotonic_ns -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast -from anonymizer.engine.constants import COL_REPLACEMENT_MAP from anonymizer.engine.graph_sdk._activation_values import ActivationLimits from anonymizer.engine.graph_sdk._execution_delegated import ( CallerChannel, @@ -39,7 +37,6 @@ ) from anonymizer.engine.graph_sdk.context import admit_context_plan from anonymizer.engine.graph_sdk.data import ValidatedDataGraph -from anonymizer.engine.graph_sdk.evidence import QualificationLimits, admit_qualification, evidence_revision_view from anonymizer.engine.graph_sdk.executor import ( AdmittedExecutionPlan, AssessmentLimits, @@ -66,43 +63,18 @@ PreparedPlan, prepare, ) -from anonymizer.engine.graph_sdk.qualification import QualificationResult, qualify_retained -from anonymizer.engine.graph_sdk.requests import AssociationInput, AssociationResult +from anonymizer.engine.graph_sdk.requests import AssociationInput from anonymizer.engine.graph_sdk.resources import ResourceLease from anonymizer.engine.harness.frames import InvocationFrame from anonymizer.engine.replace.replace_runner import ReplacementResult from anonymizer.interface.errors import AnonymizerWorkflowError -if TYPE_CHECKING: - from anonymizer.engine.graph_sdk._foreign_transport import ForeignLoopTransport - from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport - class _Clock: def now_ns(self) -> int: return monotonic_ns() -@dataclass(frozen=True) -class _ProfileLocal: - outcome: str - - async def run(self, request: tuple[AssociationInput, ...]) -> LocalCompleted: - if self.outcome == "async_required" and not ensure_async_engine_loop().is_running(): - raise RuntimeError("DD async engine is not running") - return LocalCompleted( - results=tuple( - AssociationResult( - association=item.association, - outcome=self.outcome, - outputs=(), - consumed_context_ports=frozenset(), - ) - for item in request - ) - ) - - def _runtime_rows(names: tuple[str, ...], external: bool) -> tuple[RuntimeOutcome, ...]: rows = [ RuntimeOutcome( @@ -371,58 +343,6 @@ def _prepare_frame_plan( ) -# Native transport posture (recorded for DF-6/S8): every invocation that issues DataDesigner native requests -# acquires exactly one exclusive client, created on DataDesigner's engine loop and leased to every DD stage of -# that invocation; the per-request gate waits at most 1 s. ``ForeignLoopTransport.create`` refuses non-exclusive -# clients, so concurrency between stages comes from the runtime's scheduling, not from extra clients. -NATIVE_TRANSPORT_GATE_TIMEOUT_NS = 1_000_000_000 - - -async def acquire_native_transport() -> tuple[NddPhysicalTransport, ForeignLoopTransport]: - """Create the invocation's exclusive native client on DataDesigner's engine loop. - - Cancelling this observer never cancels or abandons the foreign factory: the first interruption is kept - until the factory settles. A lease that was created anyway is closed before that interruption - propagates; a factory failure after an interruption is chained under it; a factory that was itself - cancelled propagates at once (no retry loop on a cancelled future). - """ - from anonymizer.engine.graph_sdk._foreign_transport import ForeignLoopTransport - from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport - - async def create_resource() -> tuple[NddPhysicalTransport, ForeignLoopTransport]: - physical = NddPhysicalTransport() - try: - return physical, ForeignLoopTransport.create( - factory=lambda: physical, - owner="sdk", - gate_timeout_ns=NATIVE_TRANSPORT_GATE_TIMEOUT_NS, - exclusive=True, - ) - except BaseException: - await physical.close() - raise - - acquisition = asyncio.wrap_future(asyncio.run_coroutine_threadsafe(create_resource(), ensure_async_engine_loop())) - interrupted: asyncio.CancelledError | None = None - while True: - try: - physical, transport = await asyncio.shield(acquisition) - break - except asyncio.CancelledError as error: - if acquisition.cancelled(): - raise - if interrupted is None: - interrupted = error - except BaseException as error: - if interrupted is not None: - raise interrupted from error - raise - if interrupted is not None: - await transport.close() - raise interrupted - return physical, transport - - async def _execute_frame_plan( *, prepared: PreparedPlan, @@ -435,54 +355,8 @@ async def _execute_frame_plan( ingress_limits: IngressLimits, assessment_limits: AssessmentLimits, assessment_productions: tuple[EvidenceProductionDecl, ...] = (), - native_request_nodes: dict[str, NodeId] | None = None, ) -> ExecutionResult: """Run a single grouped submission with its admitted observed operations.""" - - physical = None - transport = None - if native_request_nodes: - from anonymizer.engine.replace._native_map_requests import bind_native_map_policy - - policies = bind_native_map_policy(prepared, policies, native_request_nodes) - physical, transport = await acquire_native_transport() - try: - return await _execute_frame_plan_owned( - prepared=prepared, - capabilities=capabilities, - policies=policies, - callback=callback, - pool=pool, - borrowed_loop=borrowed_loop, - limits=limits, - ingress_limits=ingress_limits, - assessment_limits=assessment_limits, - assessment_productions=assessment_productions, - native_request_nodes=native_request_nodes, - physical=physical, - transport=transport, - ) - finally: - if transport is not None and not transport._claimed: - await transport.close() - - -async def _execute_frame_plan_owned( - *, - prepared: PreparedPlan, - capabilities: tuple[ImplementationCapability, ...], - policies: tuple[OperationExecutionPolicy, ...], - callback: Callable[..., Any], - pool: SubmitterPool, - borrowed_loop: asyncio.AbstractEventLoop, - limits: ExecutionLimits, - ingress_limits: IngressLimits, - assessment_limits: AssessmentLimits, - assessment_productions: tuple[EvidenceProductionDecl, ...], - native_request_nodes: dict[str, NodeId] | None, - physical: Any, - transport: Any, -) -> ExecutionResult: execution = admit_execution_plan( context=admit_context_plan(prepared=prepared, bound_context=None), capabilities=capabilities, @@ -491,49 +365,20 @@ async def _execute_frame_plan_owned( ingress_limits=ingress_limits, assessment_limits=assessment_limits, ) - - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests): - if not native_request_nodes: - return callback(inputs, requests) - from anonymizer.engine.ndd._graph_requests import NddRequestBridge - from anonymizer.engine.replace._native_map_requests import _BRIDGE - - leaves = tuple( - slot.index - for slot in prepared.reservation_recipe - if slot.template in {node for name, node in native_request_nodes.items() if name.startswith("leaf-")} - ) - token = _BRIDGE.set(NddRequestBridge(requests, {(COL_REPLACEMENT_MAP, 0): leaves}, physical, retries=True)) - try: - return callback(inputs, requests) - finally: - _BRIDGE.reset(token) - - worker = delegated_submitter(dispatch, pool, borrowed_loop) + worker = delegated_submitter(callback, pool, borrowed_loop) handles = tuple( ImplementationHandle( implementation=cap.implementation, operation=cap.operation, configuration=cap.configuration, - local=_ProfileLocal("async_required") - if native_request_nodes and policy.node == native_request_nodes["probe"] - else None, - transport=transport - if native_request_nodes - and policy.node - in {node for name, node in native_request_nodes.items() if name not in {"readiness", "probe"}} - else None, + local=None, + transport=None, delegated=worker if policy.delegated is not None else None, resource=ResourceLease.create(owner="caller", safe_detachment="forbidden", handle=worker) if policy.delegated is not None - else transport.lease - if native_request_nodes - and policy.node - in {node for name, node in native_request_nodes.items() if name not in {"readiness", "probe"}} else None, ) for policy, cap in zip(policies, capabilities, strict=True) - if not native_request_nodes or policy.node != native_request_nodes["readiness"] ) return await run_admitted(execution, capabilities, handles, limits) @@ -607,39 +452,6 @@ def _frame_implementation( return capability, policy -def qualify_detection_redact(result: ExecutionResult) -> QualificationResult: - """Reconcile this profile's source checks and physical receipts before release.""" - execution = result._execution - prepared = execution.context.prepared - limits = execution.assessment_limits - admitted = admit_qualification( - execution=execution, - productions=execution.assessment_productions, - limits=QualificationLimits( - max_productions=limits.max_productions, - max_submissions=limits.max_assessment_facts, - max_port_facts=limits.max_port_facts, - max_consumed_per_assessment=3, - max_coverage_atoms=0, - max_verified_evidence=limits.max_assessment_facts, - max_fixed_point_steps=len(prepared.target_occurrences) + 1, - max_revision_entries=limits.max_port_facts + prepared.limits.max_capabilities, - max_provenance_edges=limits.max_provenance_edges, - ), - ) - current = evidence_revision_view( - admitted=admitted, - result=result, - artifacts=tuple(reference for reference, _ in result.artifacts), - configurations=tuple( - (policy.node, policy.implementations[0].configuration) - for policy in execution.policies - if any(production.node == policy.node for production in execution.assessment_productions) - ), - ) - return qualify_retained(admitted=admitted, result=result, current=current) - - def project_detection_redact(result: ExecutionResult) -> ReplacementResult: """Project canonical content under its selected purpose, preserving original errors.""" for failure in result.submission_failures: @@ -665,23 +477,10 @@ def project_detection_redact(result: ExecutionResult) -> ReplacementResult: return ReplacementResult( dataframe=frame.project(result), failed_records=list(frame.failed_records), _execution=result ) - if result._execution.context.prepared.configuration.purpose == "execution_only" and len(result.frames) == 1: - frame = result.frames[0] - return ReplacementResult( - dataframe=frame.project(result), failed_records=list(frame.failed_records), _execution=result - ) - qualified = qualify_detection_redact(result) - if len(result.frames) != 1 or len(qualified.qualified) != len(result.final_outputs): + if result._execution.context.prepared.configuration.purpose != "execution_only" or len(result.frames) != 1: _retained_execution = result raise AnonymizerWorkflowError("Detection/Redact execution did not qualify all returned sources") frame = result.frames[0] return ReplacementResult( dataframe=frame.project(result), failed_records=list(frame.failed_records), _execution=result ) - - -def ensure_async_engine_loop() -> asyncio.AbstractEventLoop: - """DataDesigner's shared async engine loop, imported on first use so importing this module needs no engine.""" - from data_designer.engine.dataset_builders.utils.async_concurrency import ensure_async_engine_loop as engine_loop - - return engine_loop() diff --git a/src/anonymizer/engine/ndd/_graph_requests.py b/src/anonymizer/engine/ndd/_graph_requests.py index 248076c4..93744bbf 100644 --- a/src/anonymizer/engine/ndd/_graph_requests.py +++ b/src/anonymizer/engine/ndd/_graph_requests.py @@ -1,62 +1,28 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Capability-checked DD HTTP entry borrowed from the invocation request owner. +"""Capability checks for the private DataDesigner hooks the source-frame binding uses. -This development binding observes each actual parser attempt in its generation chain. -The caller registers NddPhysicalTransport with ForeignLoopTransport before -execution. Its cleanup closes actual DD clients after the worker drains. DD owns -conversation restarts, correction turns and transient-failure retries; a bridge that -declares ``retries`` accounts each retry as its own retry child. +Every check runs before any DataDesigner work starts and raises one standard error naming the missing hook, +the installed DataDesigner version and the supported range. """ from __future__ import annotations -import asyncio import importlib -import inspect -import logging -from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass from importlib.metadata import PackageNotFoundError, version -from threading import Lock from typing import Any -import httpx - -from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests -from anonymizer.engine.graph_sdk.requests import ( - AssociationResult, - DispatchEnvelope, - ExactUsage, - ExternalSettlement, - FailureClass, - PhysicalRequestId, - StopUnknown, - TransportFailure, - TransportSuccess, - UnknownUsage, -) -from anonymizer.engine.ndd._source_frame import current_source_work from anonymizer.interface.errors import AnonymizerWorkflowError SUPPORTED_DATA_DESIGNER_RANGE = ">=0.9.4,<0.10" -"""DataDesigner releases this graph request binding supports. +"""DataDesigner releases the source-frame binding supports. -The binding relies on private DataDesigner hooks, so it checks for them instead of an exact version (see -``require_binding_capabilities``). Must stay in sync with the ``data-designer`` specifier in -``pyproject.toml``; ``tests/engine/test_graph_requests_capabilities.py`` checks this. +The binding relies on private DataDesigner hooks, so it checks for them instead of an exact version. Must stay in +sync with the ``data-designer`` specifier in ``pyproject.toml``; ``tests/engine/test_graph_requests_capabilities.py`` +checks this. """ -_REGISTRY_MODULE = "data_designer.engine.models.registry" -_FACADE_MODULE = "data_designer.engine.models.facade" -_RETRY_MODULE = "data_designer.engine.models.clients.retry" -_PARSER_ERRORS_MODULE = "data_designer.engine.models.parsers.errors" -_READINESS_MODULE = "data_designer.engine.readiness" -_SCHEDULER_MODULE = "data_designer.engine.dataset_builders.async_scheduler" _ARTIFACT_STORAGE_MODULE = "data_designer.engine.storage.artifact_storage" -_HTTP_CLIENT_FIELDS = ("_retry_config", "_aclient", "_client", "_transport", "_limits") def _installed_data_designer() -> str: @@ -82,34 +48,6 @@ def _module_attribute(module: str, name: str) -> Any: raise _missing_capability(f"{module}.{name}") from error -def require_binding_capabilities(designer: Any) -> None: - """Check, before any DD work runs, every private hook resolvable without a live DD resource. - - Instance attributes that DataDesigner assigns in constructors (the registry's facade factory, the - facade's client and the HTTP client's fields) are checked when each object is first created, still - before any request is sent. - """ - if not callable(getattr(type(designer), "_create_resource_provider", None)): - raise _missing_capability("DataDesigner._create_resource_provider") - try: - registry = importlib.import_module(_REGISTRY_MODULE).ModelRegistry - facade = importlib.import_module(_FACADE_MODULE).ModelFacade - except (ImportError, AttributeError) as error: - raise _missing_capability(f"{_REGISTRY_MODULE}.ModelRegistry / {_FACADE_MODULE}.ModelFacade") from error - try: - parameters = inspect.signature(registry.__init__).parameters - except (TypeError, ValueError): - parameters = {} - if "model_facade_factory" not in parameters: - raise _missing_capability("ModelRegistry(model_facade_factory=...)") - if not inspect.iscoroutinefunction(getattr(facade, "agenerate", None)): - raise _missing_capability("ModelFacade.agenerate") - if not callable(_module_attribute(_RETRY_MODULE, "create_retry_transport")): - raise _missing_capability(f"{_RETRY_MODULE}.create_retry_transport") - if not isinstance(_module_attribute(_PARSER_ERRORS_MODULE, "ParserException"), type): - raise _missing_capability(f"{_PARSER_ERRORS_MODULE}.ParserException") - - def require_source_frame_capabilities(designer: Any) -> None: """Check, before any DD work runs, the hooks the source-frame binding resolves on classes and modules.""" if not callable(getattr(designer, "_create_dataset_builder", None)): @@ -137,440 +75,8 @@ def require_dataset_builder_capabilities(builder: Any) -> Any: return storage -def skip_health_checks_env() -> str: - """Name of DataDesigner's environment switch that skips model health checks (Substitute's native map).""" - name = _module_attribute(_READINESS_MODULE, "_SKIP_MODEL_HEALTH_CHECKS_ENV") - if not isinstance(name, str): - raise _missing_capability(f"{_READINESS_MODULE}._SKIP_MODEL_HEALTH_CHECKS_ENV") - return name - - -def scheduler_salvage_rounds() -> int: - """DataDesigner's default ``AsyncTaskScheduler`` salvage rounds (Substitute's native map retry allowance).""" - scheduler = _module_attribute(_SCHEDULER_MODULE, "AsyncTaskScheduler") - try: - rounds = inspect.signature(scheduler).parameters["salvage_max_rounds"].default - except (KeyError, TypeError, ValueError): - rounds = None - if type(rounds) is not int: - raise _missing_capability("AsyncTaskScheduler(salvage_max_rounds=...)") - return rounds - - def _require_attribute(owner: Any, name: str, label: str) -> Any: try: return getattr(owner, name) except AttributeError: raise _missing_capability(label) from None - - -@dataclass -class _Exchange: - owner: NddPhysicalTransport - transport: httpx.AsyncBaseTransport - request: httpx.Request - response: httpx.Response | None = None - error: Exception | None = None - failure: str | None = None - retries: bool = False - - -class SingleAttemptTransportError(Exception): - """A transport failure DataDesigner must not retry within a bridge that admits one physical attempt. - - Through 0.9.4, DataDesigner retried a transport error only when its class name mentions a connection - (``ConnectError``); from 0.9.5 it also retries every ``httpx.NetworkError`` and ``RemoteProtocolError``, - such as a ``ReadError`` after the request may have reached the provider. A bridge without ``retries`` - admits one attempt per chain, so DD receives those errors as this type instead. It is not an httpx - type and its name names no retryable kind, so every supported release classifies it as a non-retryable - API error and the record keeps its unknown outcome, as on 0.9.4. - """ - - -def _single_attempt_error(error: httpx.TransportError) -> Exception: - """The error DD sees for a single-attempt exchange: unchanged when every supported release treats it alike.""" - if ( - isinstance(error, (httpx.NetworkError, httpx.RemoteProtocolError)) - and "connect" not in type(error).__name__.lower() - ): - wrapped = SingleAttemptTransportError(str(error) or type(error).__name__) - wrapped.__cause__ = error - return wrapped - return error - - -_RETRY_FAILURES = frozenset({"retryable", "transport_unknown", "rejected_before_acceptance"}) - - -@dataclass -class _Chain: - """One generation's physical attempts for one record, in order; DD decides whether and when to retry.""" - - used: int = 0 - previous: PhysicalRequestId | None = None - retry: bool = False - beyond_admitted: int = 0 - - -def _observe_generation(generation: _Generation, payload: Any) -> FailureClass | None: - """Record DD's parse of a completed response for the facade; return its failure class, if any.""" - try: - content = (payload["choices"][0]["message"]["content"] or "").strip() - except (KeyError, IndexError, TypeError, AttributeError): - return "malformed_response" - generation.content, generation.error = content, None - try: - generation.value = generation.parser(content) - except Exception as error: - generation.error = error - parser_exception = _module_attribute(_PARSER_ERRORS_MODULE, "ParserException") - return "malformed_response" if isinstance(error, parser_exception) else "permanent" - return None - - -logger = logging.getLogger(__name__) - -_EXCHANGE: ContextVar[_Exchange] = ContextVar("anonymizer_dd_physical_exchange") - - -@dataclass -class _Generation: - parser: Callable[[str], Any] - correction_steps: int = 0 - content: str | None = None - value: Any = None - error: Exception | None = None - turns: int = 0 - - def parse(self, content: str) -> Any: - if content != self.content: - raise AnonymizerWorkflowError("DD parser content differs from the physical receipt") - if self.error is not None: - raise self.error - return self.value - - -_GENERATION: ContextVar[_Generation | None] = ContextVar("anonymizer_dd_generation", default=None) - - -class NddPhysicalTransport: - """Dispatch the actual HTTP exchange after the graph's foreign-loop gate.""" - - def __init__(self) -> None: - self.loop = asyncio.get_running_loop() - self.clients: list[Any] = [] - self.closed = False - self._lock = Lock() - self._exchanges: dict[PhysicalRequestId, asyncio.Task[Any]] = {} - - def register(self, client: Any) -> None: - with self._lock: - if self.closed: - raise AnonymizerWorkflowError("DD physical resource is closed") - if not any(client is existing for existing in self.clients): - self.clients.append(client) - - async def close(self) -> None: - if asyncio.get_running_loop() is not self.loop: - raise AnonymizerWorkflowError("DD physical resource closed on a foreign loop") - with self._lock: - clients = tuple(self.clients) - self.closed = True - results = await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True) - for result in results: - if isinstance(result, BaseException): - raise result - - async def dispatch(self, request: DispatchEnvelope) -> TransportSuccess | TransportFailure: - if self.closed or asyncio.get_running_loop() is not self.loop: - raise AnonymizerWorkflowError("DD physical resource is not live on its owning loop") - exchange = _EXCHANGE.get() - work = current_source_work() - if ( - exchange.owner is not self - or exchange.response is not None - or len(request.associations) != 1 - or (work is not None and request.associations[0].association != work.association) - ): - raise AnonymizerWorkflowError("DD physical exchange has invalid source ownership") - task = asyncio.current_task() - if task is not None: - self._exchanges[request.request] = task - try: - response = await exchange.transport.handle_async_request(exchange.request) - await response.aread() - except httpx.TransportError as error: - if not exchange.retries: - exchange.error = _single_attempt_error(error) - raise - # DD sees the same error and may retry it. A refused connection never sent the request; - # any other transport error leaves the provider's side unknown. - exchange.error = error - refused = isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout)) - exchange.failure = "rejected_before_acceptance" if refused else "transport_unknown" - return TransportFailure( - failure=exchange.failure, - settlement=ExternalSettlement( - request=request.request, - disposition="rejected" if refused else "unknown", - remote_stopped=True if refused else None, - usage=ExactUsage(input_units=0, output_units=0) if refused else UnknownUsage(), - ), - ) - except Exception as error: - exchange.error = error - raise - finally: - self._exchanges.pop(request.request, None) - exchange.response = response - try: - payload = response.json() - except ValueError: - payload = None - usage = payload.get("usage", {}) if isinstance(payload, dict) else {} - incoming = usage.get("prompt_tokens") if isinstance(usage, dict) else None - outgoing = usage.get("completion_tokens") if isinstance(usage, dict) else None - observed_usage = ( - ExactUsage(input_units=incoming, output_units=outgoing) - if type(incoming) is int and type(outgoing) is int and min(incoming, outgoing) >= 0 - else UnknownUsage() - ) - settlement = ExternalSettlement( - request=request.request, - disposition="completed", - remote_stopped=True, - usage=observed_usage, - ) - if response.is_error: - # DD retries 5xx and rate limits (429); other client errors are final. - retryable = response.status_code >= 500 or (exchange.retries and response.status_code == 429) - exchange.failure = "retryable" if retryable else "permanent" - return TransportFailure(failure=exchange.failure, settlement=settlement) - if not isinstance(payload, dict): - exchange.failure = "malformed_response" - return TransportFailure(failure="malformed_response", settlement=settlement) - generation = _GENERATION.get() - if work is not None and generation is not None: - failure = _observe_generation(generation, payload) - if failure is not None: - return TransportFailure(failure=failure, settlement=settlement) - return TransportSuccess( - results=( - AssociationResult( - association=request.associations[0].association, - outcome="ready" if current_source_work() is None else "ok", - outputs=(), - consumed_context_ports=frozenset(), - ), - ), - settlement=settlement, - ) - - async def cancel(self, request: object) -> StopUnknown: - """Abort the local HTTP exchange; the provider gives no stop evidence, so the request stays unknown.""" - exchange = self._exchanges.get(request) if isinstance(request, PhysicalRequestId) else None - if exchange is not None and not exchange.done(): - exchange.cancel() - return StopUnknown() - - -class NddRequestBridge: - """Bind actual DD generation identity to pre-admitted independent chains.""" - - def __init__( - self, - requests: DelegatedRequests, - slots: Mapping[tuple[str, int], int | tuple[int, ...]], - physical: NddPhysicalTransport, - *, - retries: bool = False, - ) -> None: - """``retries``: the admitted chains declare retry follow-ups, so DD's transient-failure retries are - accounted as retry children instead of rejecting the binding.""" - self.physical = physical - self.retries = retries - self.requests = requests - self.slots = dict(slots) - self.chains: dict[Any, _Chain] = {} - self.rejected = False - self.capability_error: AnonymizerWorkflowError | None = None - - def _capability(self, error: AnonymizerWorkflowError) -> AnonymizerWorkflowError: - """Record a capability failure so it aborts the run even if DD absorbs the raise per row.""" - self.rejected = True - if self.capability_error is None: - self.capability_error = error - return error - - @contextmanager - def bind(self, designer: Any) -> Iterator[None]: - self.requests.require_transport(self.physical) - require_binding_capabilities(designer) - create_retry_transport = _module_attribute(_RETRY_MODULE, "create_retry_transport") - original = designer._create_resource_provider - bridge = self - - class Gate(httpx.AsyncBaseTransport): - def __init__(self, model: Any) -> None: - self.inner = model._transport - self.model = model - - async def aclose(self) -> None: - if self.inner is not None: - await self.inner.aclose() - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - if self.inner is None: - self.inner = create_retry_transport( - self.model._retry_config, - strip_rate_limit_codes=True, - transport=httpx.AsyncHTTPTransport(limits=self.model._limits), - ) - exchange = _Exchange(bridge.physical, self.inner, request, retries=bridge.retries) - token = _EXCHANGE.set(exchange) - try: - work = current_source_work() - if work is None: - await bridge.requests.readiness() - await bridge.requests.probe() - else: - slot = bridge.slots.get((work.column, work.generation)) - if slot is None: - raise AnonymizerWorkflowError("DD generation has no admitted physical slot") - slots = (slot,) if isinstance(slot, int) else slot - generation = _GENERATION.get() - chain = bridge.chains.setdefault((work.association, work.column, work.generation), _Chain()) - if chain.used >= len(slots) or chain.beyond_admitted: - if not (bridge.retries and chain.retry): - raise AnonymizerWorkflowError("DD generation exceeded its admitted request capacity") - # DD keeps retrying past the admitted leaves (429 deferrals are unbounded). The - # invocation already declares an unknown request bound and no hard budget, so these - # attempts run as DD-owned, unaccounted requests instead of failing every row. - chain.beyond_admitted += 1 - response = await self.inner.handle_async_request(request) - await response.aread() - if generation is not None and not response.is_error: - try: - payload = response.json() - except ValueError: - payload = None - _observe_generation(generation, payload) - return response - previous = chain.previous - # A retry follows a transient failure; a parser turn follows a rejected response. - retry = previous is not None and chain.retry - evidence = ( - await bridge.requests.aparser_rejected(previous) - if previous is not None and not retry - else None - ) - physical = await bridge.requests.request( - slots[chain.used], - association=work.association, - source_kind=( - "initial" - if previous is None - else "retry" - if retry - else "conversation_restart" - if generation is not None and generation.turns % (generation.correction_steps + 1) == 0 - else "correction" - ), - predecessor=previous, - evidence=evidence, - ) - chain.used += 1 - chain.previous = physical - chain.retry = bridge.retries and exchange.failure in _RETRY_FAILURES - if generation is not None and not chain.retry: - generation.turns += 1 - if exchange.error is not None: - raise exchange.error - if exchange.response is None: - raise AnonymizerWorkflowError("DD physical request did not return a response") - return exchange.response - except BaseException as error: - if error is not exchange.error: - bridge.rejected = True - raise - finally: - _EXCHANGE.reset(token) - - def resources(*args: Any, **kwargs: Any) -> Any: - provider = original(*args, **kwargs) - try: - registry = _require_attribute(provider, "model_registry", "ResourceProvider.model_registry") - factory = _require_attribute(registry, "_model_facade_factory", "ModelRegistry._model_facade_factory") - if not callable(factory): - raise _missing_capability("ModelRegistry._model_facade_factory") - except AnonymizerWorkflowError as error: - raise bridge._capability(error) from None - - def facade(*args: Any, **kwargs: Any) -> Any: - result = factory(*args, **kwargs) - try: - client = _require_attribute(result, "_client", "ModelFacade._client") - inner = _require_attribute(client, "_inner", "ModelRequestExecutor._inner") - for field in _HTTP_CLIENT_FIELDS: - _require_attribute(inner, field, f"HttpModelClient.{field}") - _require_attribute(inner._retry_config, "max_retries", "RetryConfig.max_retries") - except AnonymizerWorkflowError as error: - raise bridge._capability(error) from None - bridge.physical.register(client) - if inner._retry_config.max_retries != 0 or inner._aclient is not None or inner._client is not None: - bridge.rejected = True - raise AnonymizerWorkflowError("DD client is not eligible for graph request binding") - if inner._transport is not None and not isinstance(inner._transport, httpx.AsyncBaseTransport): - bridge.rejected = True - raise AnonymizerWorkflowError("DD physical transport must be explicitly registered") - inner._transport = Gate(inner) - generate = result.agenerate - - async def agenerate(*args: Any, **kwargs: Any) -> Any: - work = current_source_work() - if work is None: - return await generate(*args, **kwargs) - slots = bridge.slots.get((work.column, work.generation)) - capacity = len(slots) if isinstance(slots, tuple) else 1 - corrections = kwargs.get("max_correction_steps", 0) - restarts = kwargs.get("max_conversation_restarts", 0) - if ( - type(corrections) is not int - or corrections < 0 - or type(restarts) is not int - or restarts < 0 - or (corrections + 1) * (restarts + 1) > capacity - or kwargs.get("tool_alias") is not None - ): - bridge.rejected = True - raise AnonymizerWorkflowError("DD parser policy exceeds admitted generation capacity") - generation = _Generation(kwargs.get("parser", lambda value: value), corrections) - kwargs["parser"] = generation.parse - token = _GENERATION.set(generation) - try: - return await generate(*args, **kwargs) - finally: - _GENERATION.reset(token) - - result.agenerate = agenerate - return result - - provider.model_registry._model_facade_factory = facade - return provider - - designer._create_resource_provider = resources - try: - yield - if self.rejected: - raise AnonymizerWorkflowError("DD request binding rejected physical work") - finally: - designer._create_resource_provider = original - beyond = sum(chain.beyond_admitted for chain in self.chains.values()) - if beyond: - logger.warning( - "%d DataDesigner retry request(s) beyond the admitted per-record allowance ran outside graph " - "request accounting; this invocation declares no request upper bound.", - beyond, - ) - if self.capability_error is not None: - raise self.capability_error diff --git a/src/anonymizer/engine/ndd/_route.py b/src/anonymizer/engine/ndd/_route.py index d76dcb3f..e4cc7898 100644 --- a/src/anonymizer/engine/ndd/_route.py +++ b/src/anonymizer/engine/ndd/_route.py @@ -50,18 +50,6 @@ def native_map(receiver: Any) -> bool: ) -def native_map_columns(receiver: Any) -> bool: - """Whether a map helper's column generation can run as native graph requests.""" - from anonymizer.engine.replace.llm_replace_workflow import LlmReplaceWorkflow - - adapter = getattr(receiver, "_adapter", None) - return ( - getattr(adapter, "run_workflow", None) is not None - and native_adapter(adapter) - and _bound_to(receiver._generate_columns, LlmReplaceWorkflow._generate_columns) - ) - - def builtin_replacement(receiver: object) -> bool: """Whether a replacement runner is the builtin one.""" from anonymizer.engine.replace.replace_runner import _REPLACEMENT_RUN diff --git a/src/anonymizer/engine/ndd/adapter.py b/src/anonymizer/engine/ndd/adapter.py index e7cdb18a..9a7cb44b 100644 --- a/src/anonymizer/engine/ndd/adapter.py +++ b/src/anonymizer/engine/ndd/adapter.py @@ -11,7 +11,7 @@ import time import uuid from collections.abc import Iterator, Mapping -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, replace from functools import wraps @@ -39,8 +39,6 @@ import pandas as pd from data_designer.interface.data_designer import DataDesigner - from anonymizer.engine.ndd._graph_requests import NddRequestBridge - logger = logging.getLogger("anonymizer.ndd") RECORD_ID_COLUMN = "_anonymizer_record_id" @@ -320,7 +318,6 @@ def run_workflow( workflow_name: str, preview_num_records: int | None = None, source_frame: WorkflowSourceFrame | None = None, - request_bridge: NddRequestBridge | None = None, rewrite_binding: StageBinding | None = None, ) -> WorkflowRunResult: """Run one workflow and return output with missing-record tracking. @@ -344,7 +341,7 @@ def run_workflow( Returns: `WorkflowRunResult` with the output DataFrame and any `FailedRecord` entries. """ - if (request_bridge is not None or rewrite_binding is not None) and ( + if rewrite_binding is not None and ( source_frame is None or not source_frame.associations and not (source_frame.invocation_bound and (len(df) == 0 or preview_num_records == 0)) @@ -404,7 +401,6 @@ def run_workflow( with ( self._run_lock, usage_probe, - request_bridge.bind(self._data_designer) if request_bridge is not None else nullcontext(), _temporary_dd_task_trace(self._data_designer, collector=collector), source_frame.bind_generators( self._data_designer, frozenset(col_names), rewrite_binding diff --git a/src/anonymizer/engine/replace/_graph_redact.py b/src/anonymizer/engine/replace/_graph_redact.py index 918a8aa9..b2195fe5 100644 --- a/src/anonymizer/engine/replace/_graph_redact.py +++ b/src/anonymizer/engine/replace/_graph_redact.py @@ -1,17 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Check Redact against the exact source and its accepted candidate spans.""" +"""Freeze and restore the local replace strategies (Redact, Annotate, Hash) as admitted graph configuration.""" from __future__ import annotations import json -from dataclasses import dataclass from pydantic import TypeAdapter from anonymizer.config.replace_strategies import LocalReplaceMethod, Redact -from anonymizer.engine.detection.artifacts import EntitySpansValue -from anonymizer.engine.graph_sdk._workflow_values import ArtifactType from anonymizer.engine.graph_sdk.capabilities import ( ConfigBoolean, ConfigField, @@ -19,20 +16,6 @@ ConfigText, FrozenConfig, ) -from anonymizer.engine.graph_sdk.executor import AssessmentFinding, LocalAssessmentResult, LocalCompleted -from anonymizer.engine.graph_sdk.requests import ( - AssociationInput, - AssociationResult, - PortArtifact, - SemanticAssociation, - TextArtifactValue, -) -from anonymizer.engine.replace.strategies import ReplacementEntry, apply_entity_spans - -REDACT_MEANING = "All admitted entity spans are redacted in the exact source text" -REDACT_EVIDENCE_TYPE = ArtifactType(name="anonymizer.redact-check", revision=1) -REDACT_MATCH = AssessmentFinding(status="satisfied", code="span_redaction_matches") -REDACT_MISMATCH = AssessmentFinding(status="unsatisfied", code="span_redaction_mismatch") def redact_configuration(strategy: Redact) -> FrozenConfig: @@ -86,62 +69,3 @@ def local_strategy(configuration: FrozenConfig) -> LocalReplaceMethod: {**json.loads(field.value.value), "kind": field.name} ) return redact_strategy(configuration) - - -@dataclass(frozen=True, slots=True) -class RedactAssessment: - configuration: FrozenConfig - - async def run(self, request: tuple[AssociationInput, ...]) -> LocalCompleted: - (item,) = request - ports = {port.port: port for port in item.inputs} - source, spans, candidate = ports["source"], ports["spans"].value, ports["candidate"].value - if ( - not isinstance(item.association, SemanticAssociation) - or source.artifact is None - or type(source.value) is not TextArtifactValue - or type(spans) is not EntitySpansValue - or type(candidate) is not TextArtifactValue - ): - raise ValueError("Redact assessment requires source, spans and candidate artifacts") - strategy = redact_strategy(self.configuration) - expected, application = apply_entity_spans( - source.value.text, - spans.entities, - [ - ReplacementEntry(entity.value, entity.label, strategy.replace(text=entity.value, label=entity.label)) - for entity in spans.entities - ], - allow_value_fallback=False, - ) - matched = ( - spans.matches_source(source.artifact, source.value.text) - and application.skipped_span_count == 0 - and expected == candidate.text - ) - finding = REDACT_MATCH if matched else REDACT_MISMATCH - return LocalCompleted( - results=( - AssociationResult( - association=item.association, - outcome="ok", - consumed_context_ports=frozenset(), - outputs=( - PortArtifact( - port="assessment", - artifact_type=REDACT_EVIDENCE_TYPE, - artifact=None, - value=TextArtifactValue(text=finding.code), - ), - ), - ), - ), - assessments=( - LocalAssessmentResult( - association=item.association, - promise="redaction", - evidence_port="assessment", - finding=finding, - ), - ), - ) diff --git a/src/anonymizer/engine/replace/_native_map_requests.py b/src/anonymizer/engine/replace/_native_map_requests.py deleted file mode 100644 index 5f1f7eac..00000000 --- a/src/anonymizer/engine/replace/_native_map_requests.py +++ /dev/null @@ -1,213 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""Pre-admitted native map requests inside an otherwise opaque invocation.""" - -from __future__ import annotations - -import os -from collections.abc import Mapping -from contextvars import ContextVar -from dataclasses import replace -from typing import Any - -from anonymizer.engine.graph_sdk._authoring_defaults import _compile_graph, _operation_spec, _outcome_spec -from anonymizer.engine.graph_sdk._workflow_values import ( - NodeId, - OperationNode, - ResourceCeiling, - SequenceEdge, -) -from anonymizer.engine.graph_sdk.capabilities import FrozenConfig, ImplementationCapability, ImplementationRef -from anonymizer.engine.graph_sdk.executor import DelegatedReadiness, ExecutionImplementation, OperationExecutionPolicy -from anonymizer.engine.graph_sdk.requests import ChildRoute, PhysicalRequestPolicy -from anonymizer.engine.ndd._graph_requests import NddRequestBridge, scheduler_salvage_rounds, skip_health_checks_env -from anonymizer.engine.ndd._route import native_map_columns -from anonymizer.engine.ndd.model_loader import resolve_model_alias - -_BRIDGE: ContextVar[NddRequestBridge | None] = ContextVar("native_map_request_bridge", default=None) - - -def native_map_capacity(receiver: Any, arguments: Mapping[str, Any]) -> int | None: - """Return the map's physical request capacity per record when DD can run as native graph requests, else None. - - The capacity is DD's parser attempts plus its bounded transport retries (see ``dd_retry_allowance``); each - physical attempt, including a retry, is its own leaf with its own dispatch and settlement. - - None keeps the map call opaque (the 0.4.0 shape). The native contract has a readiness slot that only DD's - model health check satisfies (``NddRequestBridge`` routes the non-source request to ``readiness()``), so a - replacement model that DD will not health-check cannot use it. - """ - if not native_map_columns(receiver) or not _health_checked(arguments): - return None - config = receiver._adapter._data_designer.run_config - return (config.max_conversation_correction_steps + 1) * ( - config.max_conversation_restarts + 1 - ) + dd_retry_allowance() - - -def dd_retry_allowance() -> int: - """Retries admitted as accounted graph leaves for one record, beyond its parser attempts. - - DD 0.9.4 retries 502/503/504 and connection errors up to ``RetryConfig.max_retries`` times within one model - call (``ModelRequestExecutor``), and its async scheduler re-runs a task that failed with a retryable model error - (5xx, 429, timeout, connection) in up to ``salvage_max_rounds`` salvage rounds before dropping that record. This - allowance covers that bounded policy exactly, so 5xx/timeout/connection exhaustion drops the record as in 0.4.0. - 429 (and request-admission timeouts) are "preserved retryable": DD never drops them and keeps re-salvaging. A - record whose retries outrun the admitted leaves keeps retrying as DD-owned requests outside graph request - accounting (``NddRequestBridge`` logs the count); the invocation declares no request upper bound and admits no - hard request budget, so no bound is claimed for them. - """ - from data_designer.engine.models.clients.retry import RetryConfig - - rounds = scheduler_salvage_rounds() - return (RetryConfig().max_retries + 1) * (rounds + 1) - 1 - - -def _health_checked(arguments: Mapping[str, Any]) -> bool: - """Mirror DD's readiness skip rules (``data_designer.engine.readiness`` and ``ModelRegistry``) for the map model.""" - if os.environ.get(skip_health_checks_env()) == "1": - return False - alias = resolve_model_alias("replacement_generator", arguments["selected_models"]) - return not any(model.alias == alias and model.skip_health_check for model in arguments["model_configs"]) - - -def extend_native_map_requests( - raw: Any, capacity: int -) -> tuple[Any, tuple[Any, ...], tuple[Any, ...], dict[str, NodeId]]: - from anonymizer.engine.harness.kernel import _runtime_rows - - ids = { - name: NodeId.new(workflow=raw.workflow) - for name in ("readiness", "request", "probe", *(f"leaf-{i}" for i in range(capacity))) - } - nodes, capabilities, policies = [], [], [] - for name, node in ids.items(): - local = name == "probe" - outcome = "async_required" if local else "ready" if name in {"readiness", "request"} else "ok" - operation = _operation_spec( - name="native-map-" + name, - outcomes=( - _outcome_spec( - name=outcome, - category="success", - ceiling=ResourceCeiling( - max_activations=1, - max_model_requests=0 if local else 1, - max_input_bytes=None, - max_output_bytes=None, - ), - ), - ), - ) - request = ( - None - if local - else PhysicalRequestPolicy( - visibility="dispatch_and_settlement", - pre_dispatch_control="executor", - retry_owner="executor", - replay="idempotent", - max_attempts=1, - ) - ) - cap = ImplementationCapability( - implementation=ImplementationRef(name=operation.name, revision=1), - operation=operation, - configuration=FrozenConfig(fields=()), - effect="local" if local else "external", - attribution="per_task", - request_visibility="none" if local else "dispatch_and_settlement", - pre_dispatch_control="none" if local else "executor", - retry_owner="none" if local else "executor", - error_reporting="typed_terminal", - cancellation="cooperative_ack", - settlement="synchronous" if local else "uncertain_possible", - usage="exact", - resource_lifetime="stateless" if name in {"readiness", "probe"} else "executor_owned", - max_physical_requests_per_activation=0 if local else 1, - ) - policy = OperationExecutionPolicy( - node=node, - kind="local" if local else "external", - request=request, - safe_detachment="forbidden", - implementations=( - ExecutionImplementation( - implementation=cap.implementation, configuration=cap.configuration, capability=cap, request=request - ), - ), - result_outcomes=frozenset({outcome}), - runtime_outcomes=_runtime_rows((outcome,), not local), - ) - nodes.append(OperationNode(id=node, operation=operation)) - capabilities.append(cap) - policies.append(policy) - count = len(nodes) - terminals = {binding.source.node for binding in raw.outcome_bindings} - edges = tuple(SequenceEdge(before=node, after=terminal) for node in ids.values() for terminal in terminals) + ( - SequenceEdge(before=ids["readiness"], after=ids["probe"]), - ) - graph = _compile_graph( - workflow=raw.workflow, - interface=replace( - raw.interface, - outcomes=tuple( - replace(o, ceiling=replace(o.ceiling, max_activations=o.ceiling.max_activations + count)) - for o in raw.interface.outcomes - ), - ), - nodes=(*raw.nodes, *nodes), - input_bindings=tuple(raw.input_bindings), - output_bindings=tuple(raw.output_bindings), - outcome_bindings=tuple(raw.outcome_bindings), - sequence=(*raw.sequence, *edges), - protection=tuple(raw.protection_requirements), - limits=replace( - raw.limits, - max_nodes=raw.limits.max_nodes + count, - max_sequence_edges=raw.limits.max_sequence_edges + len(edges), - ), - dynamic_limits=replace( - raw.dynamic_limits, max_activation_occurrences=raw.dynamic_limits.max_activation_occurrences + count - ), - ) - return graph, tuple(capabilities), tuple(policies), ids - - -def bind_native_map_policy(prepared: Any, policies: tuple[Any, ...], ids: dict[str, NodeId]) -> tuple[Any, ...]: - slots = { - name: next(slot.index for slot in prepared.reservation_recipe if slot.template == node) - for name, node in ids.items() - } - leaves = tuple(slot for name, slot in slots.items() if name.startswith("leaf-")) - request = PhysicalRequestPolicy( - visibility="dispatch_and_settlement", - pre_dispatch_control="executor", - retry_owner="implementation", - replay="idempotent", - max_attempts=len(leaves), - ) - return tuple( - replace( - policy, - delegated=replace( - policy.delegated, - observed_request=request, - child_slots=leaves, - generation_groups=(leaves,), - routes=(ChildRoute(name="primary", parse_attempts=len(leaves)),), - followups=frozenset({"retry", "correction", "conversation_restart"}), - readiness=DelegatedReadiness( - owner_slot=slots["readiness"], - request_slot=slots["request"], - probe_slot=slots["probe"], - route=ChildRoute(name="readiness", parse_attempts=1), - ready_outcome="ready", - probe_outcomes=frozenset({"async_required"}), - ), - ), - ) - if policy.delegated is not None - else policy - for policy in policies - ) diff --git a/src/anonymizer/engine/replace/_semantic_map.py b/src/anonymizer/engine/replace/_semantic_map.py index f4c96bba..714b6a40 100644 --- a/src/anonymizer/engine/replace/_semantic_map.py +++ b/src/anonymizer/engine/replace/_semantic_map.py @@ -394,8 +394,6 @@ def lower(self) -> LlmReplaceResult: else [(cast(SemanticAssociation, bound[0].association), self.publish(bound[0], outcome="empty"))] ) elif name == "generate": - from anonymizer.engine.replace._native_map_requests import _BRIDGE - native = native_adapter(self.receiver._adapter) stage_source = ( WorkflowSourceFrame.capture(selected, associations=selected_sources, invocation_bound=True) @@ -410,7 +408,6 @@ def lower(self) -> LlmReplaceResult: instructions=arguments["instructions"], preview_num_records=min(preview, len(selected)) if preview is not None else None, **({"source_frame": stage_source} if stage_source is not None else {}), - **({"request_bridge": _BRIDGE.get()} if native and _BRIDGE.get() is not None else {}), ) generated_sources.update(selected_sources) self.authorize() @@ -490,10 +487,6 @@ def lower(self) -> LlmReplaceResult: successful.append(one) terminal.append((position, item.association, name)) self.call(self.complete_results(results)) - from anonymizer.engine.replace._native_map_requests import _BRIDGE - - if _BRIDGE.get() is not None: - self.call(self.requests.close_observed_requests(self.source.associations, frozenset(generated_sources))) combined = ( merge_and_reorder(*successful, *((empty_partition,) if empty_partition is not None else ())) if successful diff --git a/src/anonymizer/engine/replace/_semantic_map_profile.py b/src/anonymizer/engine/replace/_semantic_map_profile.py index 08fbac96..d40a3345 100644 --- a/src/anonymizer/engine/replace/_semantic_map_profile.py +++ b/src/anonymizer/engine/replace/_semantic_map_profile.py @@ -173,17 +173,6 @@ async def execute(pool: SubmitterPool, handoff: Any) -> ReplacementResult: ) capabilities = (cap, projection_cap, *child_caps) policies = (policy, projection_policy, *child_policies) - from anonymizer.engine.replace._native_map_requests import extend_native_map_requests, native_map_capacity - - native_ids = {} - capacity = native_map_capacity(receiver, arguments) - preview = arguments.get("preview_num_records") - # A preview narrower than the frame (a caller detector's extra rows) leaves sources DD never generates; - # those run through the opaque DD call, as in the release. - if size and capacity is not None and (preview is None or preview >= size): - raw, physical_caps, physical_policies, native_ids = extend_native_map_requests(raw, capacity) - capabilities = (*capabilities, *physical_caps) - policies = (*policies, *physical_policies) prepared = _prepare_frame_plan( data, raw, @@ -302,7 +291,6 @@ def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) max_port_facts=40 * size, max_provenance_edges=80 * size, ), - native_request_nodes=native_ids, ) return project_detection_redact(result) diff --git a/src/anonymizer/engine/rewrite/_semantic_evaluation.py b/src/anonymizer/engine/rewrite/_semantic_evaluation.py index 6741312b..e347564f 100644 --- a/src/anonymizer/engine/rewrite/_semantic_evaluation.py +++ b/src/anonymizer/engine/rewrite/_semantic_evaluation.py @@ -7,7 +7,7 @@ import asyncio from collections.abc import Coroutine from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast import pandas as pd import pyarrow as pa @@ -779,7 +779,6 @@ async def publish( class _StageArguments(TypedDict): source_frame: WorkflowSourceFrame rewrite_binding: RewriteEvaluationBinding - request_bridge: NotRequired[None] def _replacement_ready(baseline: RewriteCandidateValue) -> bool: diff --git a/src/anonymizer/interface/anonymizer.py b/src/anonymizer/interface/anonymizer.py index bc02f5b9..8974dda4 100644 --- a/src/anonymizer/interface/anonymizer.py +++ b/src/anonymizer/interface/anonymizer.py @@ -835,7 +835,6 @@ def _run_internal_impl( selected_models=self._selected_models.detection, detection_options=detection_options, replace_method=config.replace, - opaque=not opaque_replacement, **( {"replacement": self._replace_runner, "replacement_models": self._selected_models.replace} if opaque_replacement diff --git a/tests/engine/test_all_replace_graph.py b/tests/engine/test_all_replace_graph.py index c8657bc9..5ddd87db 100644 --- a/tests/engine/test_all_replace_graph.py +++ b/tests/engine/test_all_replace_graph.py @@ -18,7 +18,7 @@ from anonymizer.engine.replace._graph_redact import local_strategy from anonymizer.interface.errors import AnonymizerWorkflowError from tests.engine import test_detection_source_frame as detection_tests -from tests.engine.test_detection_graph_publication import _modify_dd_responses +from tests.engine.test_detection_source_frame import _modify_dd_responses from tests.retained import retained scripted_detection = detection_tests.scripted_detection @@ -134,15 +134,8 @@ def response(request, answer): assert isinstance(result._execution.frames[0], PublishedFrame) assert not result._execution.final_outputs receipt = result._execution.requests - assert receipt.dispatched_count == (0 if mode == "no_entities" else 3) - assert sum(request.child.role == "readiness" for request in receipt.dispatches) == ( - 0 if mode == "no_entities" else 1 - ) - generations = [request for request in receipt.dispatches if request.child.role == "generation"] - assert len(generations) == (0 if mode == "no_entities" else 2) - assert all(len(request.associations) == 1 for request in generations) - assert len({next(iter(request.associations)).task.activation for request in generations}) == len(generations) - assert len(receipt.settlements) == receipt.dispatched_count + # DataDesigner's own transport makes the map requests: the graph accounts the map as one opaque call. + assert not receipt.dispatches assert not receipt.remote_outstanding and not receipt.local_in_flight assert result._execution._execution.context.prepared.configuration.purpose == "execution_only" assert result._execution._execution.context.prepared.declared_request_upper_bound is None diff --git a/tests/engine/test_detection_count_policy.py b/tests/engine/test_detection_count_policy.py deleted file mode 100644 index 96f30b8e..00000000 --- a/tests/engine/test_detection_count_policy.py +++ /dev/null @@ -1,206 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""The actual DD consumer derives count capacity without implicit output-byte limits.""" - -from __future__ import annotations - -import json -from typing import Any - -import httpx -import pandas as pd -import pytest - -from anonymizer.engine.constants import COL_REPLACED_TEXT, COL_TEXT -from anonymizer.engine.detection import _graph_profile -from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow -from anonymizer.engine.graph_sdk.executor import ExecutionLimits -from anonymizer.engine.graph_sdk.requests import TextArtifactValue -from anonymizer.engine.ndd.adapter import NddAdapter -from anonymizer.graph import ContractViolation -from anonymizer.interface.errors import AnonymizerWorkflowError -from tests.engine import test_detection_source_frame as detection_tests -from tests.engine.test_detection_graph_publication import _modify_dd_responses - -scripted_detection = detection_tests.scripted_detection - - -def test_default_profile_retains_large_valid_labels_and_actual_byte_observations(scripted_detection, monkeypatch): - designer, models, selected, calls = scripted_detection - label = "L" * 7000 - - def modify(request, answer): - if json.loads(request.content)["model"] == "detector": - payload = answer.json() - content = json.loads(payload["choices"][0]["message"]["content"]) - for entity in content["entities"]: - entity["label"] = label - payload["choices"][0]["message"]["content"] = json.dumps(content) - return httpx.Response(200, json=payload) - return answer - - _modify_dd_responses(scripted_detection, monkeypatch, modify) - arguments: dict[str, Any] = dict( - detection=EntityDetectionWorkflow(NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options={"gliner_detection_threshold": 0.3, "tag_latent_entities": False, "preview_num_records": 1}, - ) - source = pd.DataFrame({COL_TEXT: ["Alice travels."], "extra": [17]}) - projected = _graph_profile.run_detection_redact(source, **arguments) - assert projected.dataframe[COL_REPLACED_TEXT].tolist() == [f"[REDACTED_{label}] travels."] - assert projected.dataframe["extra"].tolist() == [17] - result = projected._execution - assert result is not None and len(result.artifacts) == 7 - observations = [ - port - for port in result.ports - if port.artifact_type.name == "anonymizer.entity-observation-json" and port.port in {"detected", "entities"} - ] - assert sorted(port.port for port in observations) == ["detected", "entities"] - assert len({port.artifact for port in observations}) == 2 - artifacts = dict(result.artifacts) - assert all(isinstance(value := artifacts[port.artifact], TextArtifactValue) and value.text for port in observations) - core = next(port for port in observations if port.port == "detected") - final = next(port for port in observations if port.port == "entities") - consumed = [port for port in result.ports if port.port == "in_detected"] - assert len(consumed) == 1 and consumed[0].artifact == core.artifact and consumed[0].node == final.node - operations = { - node.id: node.operation.name - for body in result._execution.context.prepared.workflow.bodies() - for node in body.operations - } - assert operations[core.node] == "detection-core" and operations[final.node] == "detection-final" - assert sum(value.shape.content_bytes for _, value in result.artifacts) > 21000 - assert result._execution.ingress_limits.max_text_bytes is None - assert result._execution.ingress_limits.max_metadata_bytes is None - assert calls == {"detector": 1, "validator": 1, "augmenter": 1, "readiness": 1} - with pytest.raises(AnonymizerWorkflowError, match="did not qualify"): - _graph_profile.run_detection_redact( - source, - **arguments, - limits=ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=8, - max_runtime_artifacts=5, - max_runtime_artifact_bytes=10000, - max_collection_items=16, - ), - ) - - -def test_profile_association_capacity_comes_from_selected_sources(scripted_detection, monkeypatch): - designer, models, selected, calls = scripted_detection - - class Captured(Exception): - pass - - def admission(**kwargs): - limits = kwargs["ingress_limits"] - assert limits.max_associations == 1025 - assert limits.max_collection_items == 1025 * (len("Alice travels.") + 1) - assert limits.max_text_bytes is limits.max_metadata_bytes is None - raise Captured - - monkeypatch.setattr(_graph_profile, "admit_execution_plan", admission) - with pytest.raises(Captured): - _graph_profile.run_detection_redact( - pd.DataFrame({COL_TEXT: ["Alice travels."] * 1025}), - detection=EntityDetectionWorkflow(NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options={"gliner_detection_threshold": 0.3, "tag_latent_entities": False}, - ) - assert not calls - - -@pytest.mark.parametrize("rows", [0, 2]) -def test_default_count_policy_preserves_empty_failure_and_complete_inventory(scripted_detection, rows): - designer, models, selected, _ = scripted_detection - source = pd.DataFrame({COL_TEXT: ["Alice travels."] * rows}) - arguments: dict[str, Any] = dict( - detection=EntityDetectionWorkflow(NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options={ - "gliner_detection_threshold": 0.3, - "tag_latent_entities": False, - "preview_num_records": rows, - }, - ) - if not rows: - with pytest.raises(AnonymizerWorkflowError, match="Dataset is empty"): - _graph_profile.run_detection_redact(source, **arguments) - else: - projected = _graph_profile.run_detection_redact(source, **arguments) - assert len(projected.dataframe) == rows - assert projected._execution is not None - assert len(projected._execution.artifacts) == 7 * rows - observations = [ - port - for port in projected._execution.ports - if port.artifact_type.name == "anonymizer.entity-observation-json" and port.port in {"detected", "entities"} - ] - assert sorted(port.port for port in observations) == ["detected"] * rows + ["entities"] * rows - assert len({port.artifact for port in observations}) == 2 * rows - consumed = [port for port in projected._execution.ports if port.port == "in_detected"] - assert len(consumed) == rows - assert {port.artifact for port in consumed} == { - port.artifact for port in observations if port.port == "detected" - } - - -def test_default_policy_preserves_canonical_unicode_rejection_before_effects(scripted_detection): - designer, models, selected, calls = scripted_detection - with pytest.raises(ContractViolation): - _graph_profile.run_detection_redact( - # Object dtype: pandas 3 would otherwise encode the cell to Arrow at construction. - pd.DataFrame({COL_TEXT: pd.Series(["\ud800"], dtype=object)}), - detection=EntityDetectionWorkflow(NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options={"gliner_detection_threshold": 0.3, "tag_latent_entities": False}, - ) - assert not calls - - -@pytest.mark.parametrize("shape", ["builtin", "subclass", "delegating", "wrapper"]) -def test_direct_profile_lowers_only_builtin_detection(scripted_detection: Any, shape: str) -> None: - """The direct Redact profile classifies its runner once; only the builtin one is lowered by its graph.""" - from anonymizer.engine.detection.detection_workflow import EntityDetectionResult - - designer, models, selected, calls = scripted_detection - receiver = EntityDetectionWorkflow(NddAdapter(designer)) - if shape == "subclass": - - class Delegating(EntityDetectionWorkflow): - def run(self, dataframe: pd.DataFrame, **kwargs: Any) -> EntityDetectionResult: - return super().run(dataframe, **kwargs) - - receiver = Delegating(NddAdapter(designer)) - elif shape == "delegating": - builtin = receiver.run - setattr(receiver, "run", lambda dataframe, **kwargs: builtin(dataframe, **kwargs)) - elif shape == "wrapper": - own = EntityDetectionWorkflow(NddAdapter(designer)) - - def run(dataframe: pd.DataFrame, **kwargs: Any) -> EntityDetectionResult: - kwargs = {key: value for key, value in kwargs.items() if key not in {"source_frame", "request_bridge"}} - return own.run(dataframe, **kwargs) - - setattr(receiver, "run", run) - arguments: dict[str, Any] = dict( - detection=receiver, - model_configs=models, - selected_models=selected, - detection_options={"gliner_detection_threshold": 0.3, "tag_latent_entities": False}, - ) - source = pd.DataFrame({COL_TEXT: ["Alice travels."]}) - if shape == "builtin": - projected = _graph_profile.run_detection_redact(source, **arguments) - assert projected.dataframe[COL_REPLACED_TEXT].tolist() == ["[REDACTED_FIRST_NAME] travels."] - return - # Only the facade's injected route runs a caller's detector; this profile does not admit its results. - with pytest.raises(AnonymizerWorkflowError): - _graph_profile.run_detection_redact(source, **arguments) - assert bool(calls) == (shape == "wrapper") diff --git a/tests/engine/test_detection_graph_publication.py b/tests/engine/test_detection_graph_publication.py index f994f260..7a7ccc03 100644 --- a/tests/engine/test_detection_graph_publication.py +++ b/tests/engine/test_detection_graph_publication.py @@ -1,24 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Distinct owner, setup and canonical publication faults for actual DD execution.""" +"""Source-partition and private-storage faults of the opaque detection/Redact invocation.""" from __future__ import annotations import asyncio from dataclasses import replace -from typing import Any -import httpx import pandas as pd import pytest -from anonymizer.engine.constants import COL_REPLACEMENT_MAP, COL_TEXT -from anonymizer.engine.harness.kernel import retained_execution -from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport, NddRequestBridge -from anonymizer.graph import EffectRejected +from anonymizer.engine.constants import COL_TEXT from tests.engine import test_detection_source_frame as detection_tests from tests.engine.test_detection_source_frame import run_graph_profile -from tests.retained import retained scripted_detection = detection_tests.scripted_detection @@ -37,316 +31,6 @@ def _options(): return {"gliner_detection_threshold": 0.3, "tag_latent_entities": False, "preview_num_records": 2} -@pytest.mark.parametrize("fault", ["foreign_physical", "ineligible_client"]) -def test_resource_rejection_precedes_http_and_closes_created_clients(scripted_detection, monkeypatch, fault): - physicals = [] - initialize = NddPhysicalTransport.__init__ - bind = NddRequestBridge.__init__ - register = NddPhysicalTransport.register - - def initialized(self): - initialize(self) - physicals.append(self) - - def bridged(self, requests, slots, physical): - if fault == "foreign_physical": - - async def create(): - return NddPhysicalTransport() - - physical = asyncio.run_coroutine_threadsafe(create(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - bind(self, requests, slots, physical) - - def registered(self, client): - register(self, client) - if fault == "ineligible_client": - client._inner._retry_config = replace(client._inner._retry_config, max_retries=1) - - monkeypatch.setattr(NddPhysicalTransport, "__init__", initialized) - monkeypatch.setattr(NddPhysicalTransport, "register", registered) - monkeypatch.setattr(NddRequestBridge, "__init__", bridged) - try: - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert result.requests.dispatched_count == 0 - assert scripted_detection[3] == {} - assert not result.final_outputs and not result.frames - assert physicals[0].closed - assert all(client._inner._closed for client in physicals[0].clients) - assert len(physicals[0].clients) == (0 if fault == "foreign_physical" else 1) - if fault == "foreign_physical": - assert len(physicals) == 2 and not physicals[1].clients - finally: - for physical in physicals: - if not physical.closed: - asyncio.run_coroutine_threadsafe(physical.close(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - - -def test_generated_frame_projection_is_isolated_and_partial_publication_is_withheld(scripted_detection): - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - projected = result.frames[0].project(result) - assert projected[COL_REPLACEMENT_MAP].iloc[0] - projected[COL_REPLACEMENT_MAP].iloc[0].clear() - assert result.frames[0].project(result)[COL_REPLACEMENT_MAP].iloc[0] - limited = asyncio.run(run_graph_profile(scripted_detection, _source(), _options(), artifact_limit=3)) - assert limited.requests.dispatched_count == 7 - assert not limited.final_outputs - assert not limited.frames - with pytest.raises(ValueError, match="different execution"): - result.frames[0].project(limited) - - -@pytest.mark.parametrize("zero,sends", [(True, 5), (False, 8)]) -def test_zero_or_variable_chunk_profile_qualifies_exact_selected_work(scripted_detection, zero, sends): - source = pd.DataFrame( - {COL_TEXT: ["No entities here.", "No entities there."] if zero else ["Alice travels.", "Alice Bob stays."]} - ) - result = asyncio.run( - run_graph_profile( - scripted_detection, - source, - {**_options(), "validation_max_entities_per_call": 1}, - chunks=1 if zero else 2, - ) - ) - assert result.requests.dispatched_count == sends - from anonymizer.engine.constants import COL_REPLACED_TEXT - from anonymizer.engine.harness.kernel import qualify_detection_redact - - assert len(result.final_outputs) == len(qualify_detection_redact(result).qualified) == 2 - assert result.frames[0].project(result)[COL_REPLACED_TEXT].tolist() == ( - source[COL_TEXT].tolist() - if zero - else ["[REDACTED_FIRST_NAME] travels.", "[REDACTED_FIRST_NAME] [REDACTED_FIRST_NAME] stays."] - ) - assert sum(row.reasons == {"omitted_optional"} for row in result.record.terminals) == (2 if zero else 1) - assert all(state.complete for state in result.states) - assert result.invocation_state.complete - - -def test_profile_cancellation_drains_actual_dd_borrow_before_close(scripted_detection, monkeypatch): - from threading import Event - - entered, release = Event(), Event() - resources = [] - dispatch = NddPhysicalTransport.dispatch - - async def held(self, request): - if not resources: - resources.append(self) - entered.set() - await asyncio.to_thread(release.wait) - return await dispatch(self, request) - - monkeypatch.setattr(NddPhysicalTransport, "dispatch", held) - - async def run(): - task = asyncio.create_task(run_graph_profile(scripted_detection, _source(), _options())) - try: - assert await asyncio.to_thread(entered.wait, _HANG_GUARD_TIMEOUT) - task.cancel() - await asyncio.sleep(0) - assert not task.done() and not resources[0].closed - release.set() - with pytest.raises(asyncio.CancelledError): - await task - assert resources[0].closed - assert all(client._inner._closed for client in resources[0].clients) - finally: - release.set() - - asyncio.run(run()) - - -@pytest.mark.parametrize("unknown", [False, True]) -def test_unknown_remote_retains_registered_resource(scripted_detection, monkeypatch, unknown): - designer, _, _, _ = scripted_detection - original_resources = designer._create_resource_provider - original_initialize = NddPhysicalTransport.__init__ - physicals = [] - actual_entries = [] - - def initialize(self): - original_initialize(self) - physicals.append(self) - - monkeypatch.setattr(NddPhysicalTransport, "__init__", initialize) - - def resources(*args, **kwargs): - provider = original_resources(*args, **kwargs) - factory = provider.model_registry._model_facade_factory - - def facade(*args, **kwargs): - result = factory(*args, **kwargs) - if unknown: - - async def fail(request): - actual_entries.append(request.method) - raise httpx.ReadError("scripted uncertain response", request=request) - - result._client._inner._transport = httpx.MockTransport(fail) - return result - - provider.model_registry._model_facade_factory = facade - return provider - - monkeypatch.setattr(designer, "_create_resource_provider", resources) - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - if unknown: - assert len(physicals) == 1 and not physicals[0].closed - assert all(not c._inner._closed for c in physicals[0].clients) - assert len(result._retained_resources) == 1 - assert result._retained_resources[0].handle.owns(physicals[0]) - assert result.requests.dispatched_count == 1 and len(actual_entries) == 1 - assert len(result.requests.remote_outstanding) == 1 - assert not result.frames and not result.final_outputs - assert ("sdk", "left_open") in {(f.owner, f.disposition) for f in result.cleanup} - else: - assert len(physicals) == 1 and physicals[0].closed - assert all(c._inner._closed for c in physicals[0].clients) - assert not result._retained_resources - assert result.requests.dispatched_count == 7 and len(result.frames) == 1 - assert not result.requests.remote_outstanding - - # Test owner explicitly releases the scripted lost request after asserting retention. - for physical in physicals: - if not physical.closed: - asyncio.run_coroutine_threadsafe(physical.close(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - - -def test_redact_check_records_candidate_mismatch_with_exact_span_dependencies(scripted_detection, monkeypatch): - import anonymizer.engine.detection._graph_invocation as invocation - from anonymizer.engine.constants import COL_REPLACED_TEXT - - original = invocation.apply_local_replace_strategy - - def changed(*args, **kwargs): - returned = original(*args, **kwargs) - returned.loc[returned.index[0], COL_REPLACED_TEXT] = "Alice travels." - return returned - - monkeypatch.setattr(invocation, "apply_local_replace_strategy", changed) - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - values = dict(result.artifacts) - checks_by_target = {port.target: port for port in result.ports if port.port == "assessment"} - checks = [checks_by_target[item.target] for item in result._execution.context.prepared.target_occurrences] - assert sorted(values[port.artifact].text for port in checks) == [ - "span_redaction_matches", - "span_redaction_mismatch", - ] - for check in checks: - provenance = next(item for item in result.provenance if item.artifact == check.artifact) - assert {getattr(parent, "port", None) for parent in provenance.parents} == {"source", "redacted", "spans"} - assert result._execution.context.prepared.configuration.purpose == "protection" - facts = {fact.activation: fact for fact in result.assessments} - assert sorted(facts[port.activation].finding.status for port in checks) == ["satisfied", "unsatisfied"] - - -def test_real_redact_evidence_retains_source_candidate_and_span_versions(scripted_detection): - from anonymizer.engine.graph_sdk.evidence import ( - AssessmentSubmission, - admit_qualification, - evidence_revision_view, - evidence_validity, - verify_evidence, - ) - from tests.graph_sdk.evidence_fixtures import _qualification_limits - - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - admitted = admit_qualification( - execution=result._execution, - productions=result._execution.assessment_productions, - limits=_qualification_limits(max_port_facts=64), - ) - verified = verify_evidence( - admitted=admitted, - result=result, - submissions=tuple(AssessmentSubmission(fact=fact) for fact in result.assessments), - ) - assert len(verified) == 2 - for item in verified: - assert {port for port, _ in item.consumed_by_port} == {"source", "candidate", "spans"} - assert item.finding.status == "satisfied" - assert len(item.reference.consumed) == 3 - assert len(admitted._projections) == 1 - assert admitted._projections[0].promise.consumed_ports == frozenset({"source"}) - - def view(artifacts): - fact = result.assessments[0] - return evidence_revision_view( - admitted=admitted, - result=result, - artifacts=artifacts, - configurations=((fact.node, fact.environment.configuration),), - ) - - artifacts = tuple(reference for reference, _ in result.artifacts) - current = view(artifacts) - assert all(evidence_validity(evidence=item, current=current) == "current" for item in verified) - from anonymizer.engine.harness.kernel import qualify_detection_redact - - qualified = qualify_detection_redact(result) - assert len(qualified.qualified) == 2 - - for port, consumed in verified[0].consumed_by_port: - reference = consumed.artifact if hasattr(consumed, "artifact") else consumed - missing = view(tuple(item for item in artifacts if item != reference)) - assert evidence_validity(evidence=verified[0], current=missing) == "unknown", port - - -@pytest.mark.parametrize("fault,code", [("missing_span", "missing"), ("wrong_summary", "contradictory")]) -def test_redact_evidence_projection_rejects_incomplete_dependencies(scripted_detection, monkeypatch, fault, code): - from anonymizer.engine.detection import _graph_profile - from anonymizer.graph import ContractViolation - - original = _graph_profile._compile_graph - - def malformed(**kwargs): - if fault == "missing_span": - kwargs["input_bindings"] = tuple( - item for item in kwargs["input_bindings"] if item.destination.port != "spans" - ) - else: - interface = kwargs["interface"] - kwargs["interface"] = replace( - interface, - outcomes=tuple( - replace( - outcome, - evidence=frozenset( - replace(promise, consumed_ports=frozenset()) for promise in outcome.evidence - ), - ) - for outcome in interface.outcomes - ), - ) - return original(**kwargs) - - monkeypatch.setattr(_graph_profile, "_compile_graph", malformed) - with pytest.raises(ContractViolation) as error: - asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert error.value.code.value == code - assert scripted_detection[3] == {} - - -def test_dropped_source_preserves_peer_candidate_and_legacy_failure(scripted_detection): - from anonymizer.engine.constants import COL_REPLACED_TEXT - from anonymizer.engine.ndd.adapter import RECORD_ID_COLUMN - - source = pd.DataFrame( - {COL_TEXT: ["Alice travels.", "Alice fails."], RECORD_ID_COLUMN: ["keep", "drop"], "extra": [17, 29]} - ) - result = asyncio.run(run_graph_profile(scripted_detection, source, _options())) - assert len(result.final_outputs) == 1 - projected = result.frames[0].project(result) - assert projected[COL_REPLACED_TEXT].tolist() == ["[REDACTED_FIRST_NAME] travels."] - assert projected["extra"].tolist() == [17] - assert [failure.record_id for failure in result.frames[0].failed_records] == ["drop"] - from anonymizer.engine.harness.kernel import qualify_detection_redact - - assert len(qualify_detection_redact(result).qualified) == 1 - assert all(state.complete for state in result.states) - - @pytest.mark.parametrize("fault", ["overlap", "foreign", "unattributed", "duplicate"]) def test_delegated_source_partition_rejects_uncorrelated_completion(scripted_detection, monkeypatch, fault): from anonymizer.engine.detection._graph_invocation import DetectionRedactInvocation @@ -375,311 +59,6 @@ def corrupted(self, inputs, requests): assert all(state.complete for state in result.states) -@pytest.mark.parametrize("fault", ["fewer", "more", "wrong_keys"]) -def test_generation_selection_must_match_actual_physical_work(scripted_detection, monkeypatch, fault): - from anonymizer.engine.detection._graph_invocation import DetectionRedactInvocation - from anonymizer.engine.graph_sdk.requests import TextCollectionValue - - original = DetectionRedactInvocation._run - - def corrupted(self, inputs, requests): - result = original(self, inputs, requests) - row = result.results[0] - plan = next(output for output in row.outputs if output.port == "validation_chunks") - assert isinstance(plan.value, TextCollectionValue) - items = plan.value.items - if fault == "fewer": - changed = () - elif fault == "more": - changed = (*items, replace(items[0], key=1)) - else: - changed = (replace(items[0], key=1),) - output = replace(plan, value=TextCollectionValue(items=changed)) - row = replace(row, outputs=tuple(output if value is plan else value for value in row.outputs)) - return replace(result, results=(row, *result.results[1:])) - - monkeypatch.setattr(DetectionRedactInvocation, "_run", corrupted) - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert result.requests.dispatched_count == 7 - assert not result.final_outputs and not result.frames - - -def _modify_dd_responses(scripted_detection, monkeypatch, modify): - designer = scripted_detection[0] - original = designer._create_resource_provider - - def resources(*args, **kwargs): - provider = original(*args, **kwargs) - factory = provider.model_registry._model_facade_factory - - def facade(*args, **kwargs): - result = factory(*args, **kwargs) - prior = result._client._inner._transport - - async def response(request): - return modify(request, await prior.handle_async_request(request)) - - result._client._inner._transport = httpx.MockTransport(response) - return result - - provider.model_registry._model_facade_factory = facade - return provider - - monkeypatch.setattr(designer, "_create_resource_provider", resources) - - -@pytest.mark.parametrize("failed", [False, True]) -def test_completed_response_without_usage_is_settled_but_never_fabricated(scripted_detection, monkeypatch, failed): - import json - - from anonymizer.engine.graph_sdk.requests import UnknownUsage - from anonymizer.engine.harness.kernel import qualify_detection_redact - - def response(request, answer): - body = json.loads(request.content) - if body["model"] == "detector" and "Alice stays." in json.dumps(body["messages"]): - payload = answer.json() - payload.pop("usage", None) - if failed: - payload = {"error": {"message": "missing-usage failure", "type": "invalid_request_error"}} - return httpx.Response(400 if failed else answer.status_code, json=payload) - return answer - - _modify_dd_responses(scripted_detection, monkeypatch, response) - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert result.requests.dispatched_count == (5 if failed else 7) - assert not result.requests.remote_outstanding and not result.requests.local_in_flight - assert sum(isinstance(row.usage, UnknownUsage) for row in result.requests.settlements) == 1 - assert all(row.remote_stopped for row in result.requests.settlements) - assert len(result.final_outputs) == (1 if failed else 2) - assert len(qualify_detection_redact(result).qualified) == 1 - assert not result._retained_resources - - -def test_chunked_validation_qualifies_every_source(scripted_detection): - from anonymizer.engine.harness.kernel import qualify_detection_redact - - source = pd.DataFrame({COL_TEXT: ["Alice travels.", "Alice Bob stays."]}) - result = asyncio.run( - run_graph_profile(scripted_detection, source, {**_options(), "validation_max_entities_per_call": 1}, chunks=2) - ) - assert len(qualify_detection_redact(result).qualified) == 2 - - -def test_default_augmenter_restart_preserves_legacy_and_qualifies(scripted_detection, monkeypatch): - import json - - from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow - from anonymizer.engine.ndd.adapter import NddAdapter - - designer, models, selected, calls = scripted_detection - attempted = [] - - def response(request, answer): - body = json.loads(request.content) - if "Find untagged sensitive entities" in json.dumps(body["messages"]): - attempted.append(request.content) - if len(attempted) == 1: - payload = answer.json() - payload["choices"][0]["message"]["content"] = "not structured output" - return httpx.Response(200, json=payload) - return answer - - _modify_dd_responses(scripted_detection, monkeypatch, response) - legacy = EntityDetectionWorkflow(NddAdapter(designer)).run( - _source(), model_configs=models, selected_models=selected, **_options() - ) - assert len(legacy.dataframe) == 2 and not legacy.failed_records - assert len(attempted) == 3 - attempted.clear() - calls.clear() - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - from anonymizer.engine.constants import COL_FINAL_ENTITIES - from anonymizer.engine.harness.kernel import qualify_detection_redact - - assert len(result.final_outputs) == len(qualify_detection_redact(result).qualified) == 2 - assert ( - result.frames[0].project(result)[COL_FINAL_ENTITIES].tolist() == legacy.dataframe[COL_FINAL_ENTITIES].tolist() - ) - assert len(attempted) == 3 - assert result.requests.dispatched_count == sum(calls.values()) == 8 - restarts = [row for row in result.requests.dispatches if row.child.source_kind == "conversation_restart"] - assert len(restarts) == 1 - predecessor = restarts[0].child.predecessor - terminal = next(row for row in result.requests.terminals if row.request == predecessor) - assert terminal.failure == "malformed_response" - assert next(row for row in result.requests.settlements if row.request == predecessor).disposition == "completed" - assert all(state.complete for state in result.states) - - from anonymizer.graph import ContractViolation - from tests.graph_sdk.child_qualification_fixtures import result_with - - unused = next(row for row in result.record.terminals if row.unused_retry is not None) - foreign = next( - row.attempt - for row in result.record.terminals - if row.attempt is not None and row.attempt not in (unused.unused_retry.owner, unused.unused_retry.after) - ) - forged = replace(unused, unused_retry=replace(unused.unused_retry, after=foreign)) - mutations = ( - result_with(result, requests=replace(result.requests, child_evidence=())), - result_with( - result, - record=replace( - result.record, terminals=tuple(forged if row is unused else row for row in result.record.terminals) - ), - ), - ) - for changed in mutations: - with pytest.raises((EffectRejected, ContractViolation)): - qualify_detection_redact(changed) - - -@pytest.mark.parametrize( - "rejected,qualified,sends,corrections,restarts", [(5, 2, 12, 0, 5), (6, 1, 12, 0, 5), (2, 2, 9, 1, 1)] -) -def test_default_restart_capacity_preserves_exhaustion_and_peer( - scripted_detection, monkeypatch, rejected, qualified, sends, corrections, restarts -): - import json - - from anonymizer.engine.constants import COL_FINAL_ENTITIES - from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow - from anonymizer.engine.harness.kernel import qualify_detection_redact - from anonymizer.engine.ndd.adapter import NddAdapter - - designer, models, selected, calls = scripted_detection - designer.set_run_config( - designer.run_config.model_copy( - update={ - "max_conversation_correction_steps": corrections, - "max_conversation_restarts": restarts, - } - ) - ) - count = min(rejected + 1, (corrections + 1) * (restarts + 1)) - attempts = [] - - def response(request, answer): - body = json.loads(request.content) - messages = json.dumps(body["messages"]) - if "Find untagged sensitive entities" in messages and "travels." in messages: - attempts.append(request.content) - if len(attempts) <= rejected: - payload = answer.json() - payload["choices"][0]["message"]["content"] = "not structured output" - return httpx.Response(200, json=payload) - return answer - - _modify_dd_responses(scripted_detection, monkeypatch, response) - legacy = EntityDetectionWorkflow(NddAdapter(designer)).run( - _source(), model_configs=models, selected_models=selected, **_options() - ) - assert len(legacy.dataframe) == qualified - assert len(attempts) == count - attempts.clear() - calls.clear() - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert len(attempts) == count - assert result.requests.dispatched_count == sum(calls.values()) == sends - assert len(result.final_outputs) == len(qualify_detection_redact(result).qualified) == qualified - projected = result.frames[0].project(result) - assert projected[COL_FINAL_ENTITIES].tolist() == legacy.dataframe[COL_FINAL_ENTITIES].tolist() - assert result.frames[0].failed_records == tuple(legacy.failed_records) - assert sum(row.failure == "malformed_response" for row in result.requests.terminals) == rejected - assert all(state.complete for state in result.states) and result.invocation_state.complete - assert not result.requests.remote_outstanding - if corrections: - kinds = [row.child.source_kind for row in result.requests.dispatches if row.child.source_kind != "initial"] - assert kinds == ["correction", "conversation_restart"] - - -@pytest.mark.parametrize("preview", [True, False]) -def test_all_failed_dd_sources_raise_legacy_and_withhold_graph(scripted_detection, preview): - from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow - from anonymizer.engine.harness.kernel import project_detection_redact, qualify_detection_redact - from anonymizer.engine.ndd.adapter import NddAdapter - from anonymizer.interface.errors import AnonymizerWorkflowError - - designer, models, selected, calls = scripted_detection - source = pd.DataFrame({COL_TEXT: ["Alice fails.", "Alice fails."], "extra": [17, 29]}) - options: dict[str, Any] = {**_options(), "preview_num_records": 2 if preview else None} - with pytest.raises(AnonymizerWorkflowError, match="scripted row failure") as legacy: - EntityDetectionWorkflow(NddAdapter(designer)).run( - source, model_configs=models, selected_models=selected, **options - ) - calls.clear() - result = asyncio.run(run_graph_profile(scripted_detection, source, options)) - assert not result.final_outputs and not result.frames and not qualify_detection_redact(result).qualified - assert result.requests.dispatched_count == sum(calls.values()) == 3 - assert all(state.complete for state in result.states) and result.invocation_state.complete - with pytest.raises(AnonymizerWorkflowError) as projected: - project_detection_redact(result) - assert str(projected.value) == str(legacy.value) - assert type(projected.value.__cause__) is type(legacy.value.__cause__) - assert projected.value is result.submission_failures[0].cause - assert retained(projected.value) is result - - -@pytest.mark.parametrize("chunk_size,expected", [(1, 8), (2, 7)]) -def test_admission_derives_validator_capacity_without_input_cap(scripted_detection, chunk_size, expected): - from anonymizer.engine.harness.kernel import qualify_detection_redact - - source = pd.DataFrame({COL_TEXT: ["Alice travels.", "Alice Bob stays."]}) - result = asyncio.run( - run_graph_profile( - scripted_detection, - source, - {**_options(), "validation_max_entities_per_call": chunk_size}, - chunks=None, - ) - ) - contract = next(policy.delegated for policy in result._execution.policies if policy.delegated) - assert len(contract.selection.slots) == (len("Alice Bob stays.") + chunk_size - 1) // chunk_size - assert result.requests.dispatched_count == expected - assert len(result.final_outputs) == len(qualify_detection_redact(result).qualified) == 2 - - -def test_unknown_remote_source_retains_resource_and_qualifies_independent_peer(scripted_detection, monkeypatch): - import json - - from anonymizer.engine.harness.kernel import qualify_detection_redact - - physicals = [] - initialize = NddPhysicalTransport.__init__ - - def initialized(self): - initialize(self) - physicals.append(self) - - def response(request, answer): - messages = json.dumps(json.loads(request.content)["messages"]) - if "Find untagged sensitive entities" in messages and "travels." in messages: - raise httpx.ReadError("scripted uncertain source", request=request) - return answer - - monkeypatch.setattr(NddPhysicalTransport, "__init__", initialized) - _modify_dd_responses(scripted_detection, monkeypatch, response) - try: - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options(), remote_limit=2)) - assert result.requests.dispatched_count == 7 - assert len(result.requests.remote_outstanding) == 1 - assert len(result.final_outputs) == 1 - # Lost remains charged and uncertain; an authenticated retained lease - # does not invalidate the independently verified successful source. - assert len(qualify_detection_redact(result).qualified) == 1 - assert result.frames[0].project(result)[COL_TEXT].tolist() == ["Alice stays."] - assert len(result._retained_resources) == 1 and not physicals[0].closed - from anonymizer.engine.harness.kernel import project_detection_redact - - projected = project_detection_redact(result) - assert projected._execution is result - assert projected.dataframe[COL_TEXT].tolist() == ["Alice stays."] - finally: - for physical in physicals: - asyncio.run_coroutine_threadsafe(physical.close(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - - @pytest.mark.parametrize("failed", [False, True]) def test_create_projection_keeps_source_identity_out_of_persisted_parquet(scripted_detection, tmp_path, failed): from anonymizer.engine.constants import COL_REPLACED_TEXT @@ -736,397 +115,3 @@ def test_private_storage_batch_identity_is_ordered_and_closed(tmp_path, fault): for path in tmp_path.rglob("*.parquet"): assert source.key_column not in pd.read_parquet(path).columns assert storage.write_parquet_file == original_write - - -@pytest.mark.parametrize("preview", [True, False]) -def test_empty_source_invocation_preserves_actual_dd_failure(scripted_detection, preview): - from anonymizer.engine.harness.kernel import project_detection_redact, qualify_detection_redact - from anonymizer.interface.errors import AnonymizerWorkflowError - - source = pd.DataFrame({COL_TEXT: pd.Series([], dtype=str), "extra": pd.Series([], dtype=object)}) - result = asyncio.run( - run_graph_profile( - scripted_detection, source, {**_options(), "preview_num_records": 0 if preview else None}, chunks=None - ) - ) - assert result.requests.dispatched_count == 1 - assert scripted_detection[3] == {"readiness": 1} - assert len(result.requests.settlements) == 1 and not result.requests.remote_outstanding - assert result.states == () and result.invocation_state.complete - assert not result.artifacts and not result.final_outputs and not result.frames - assert len(result.record.terminals) == 5 - assert result.record.memberships[0].closed - assert len(result.record.memberships[0].members) == 5 - prepared = result._execution.context.prepared - assert prepared.configuration.invocation_owned and prepared.total_activation_slots == 5 - assert prepared.declared_request_upper_bound == 3 - assert not qualify_detection_redact(result).qualified - with pytest.raises( - AnonymizerWorkflowError, match="Dataset is empty" if preview else "Failed to load generated dataset" - ) as error: - project_detection_redact(result) - assert error.value is result.submission_failures[0].cause - assert type(error.value.__cause__).__name__ == "DataDesignerGenerationError" - - -@pytest.mark.parametrize("requested", [0, 1]) -def test_preview_admits_only_requested_sources_but_calls_detection_with_whole_frame( - scripted_detection, monkeypatch, requested -): - import anonymizer.engine.detection._semantic_detection as semantic_detection - from anonymizer.engine.harness.kernel import project_detection_redact - from anonymizer.interface.errors import AnonymizerWorkflowError - - seen = [] - original = semantic_detection.lower_detection - - def lower(receiver, frame, arguments, *rest): - seen.append((len(frame), arguments["preview_num_records"])) - return original(receiver, frame, arguments, *rest) - - monkeypatch.setattr(semantic_detection, "lower_detection", lower) - result = asyncio.run( - run_graph_profile(scripted_detection, _source(), {**_options(), "preview_num_records": requested}, chunks=None) - ) - assert seen == [(2, requested)] - assert len(result.states) == requested - assert len(result._execution.context.prepared.data.targets) == requested - assert result.requests.dispatched_count == (4 if requested else 1) - if requested: - projected = project_detection_redact(result) - assert projected.dataframe[COL_TEXT].tolist() == ["Alice travels."] - assert not projected.failed_records - assert all(state.complete for state in result.states) - else: - assert result.invocation_state.complete - with pytest.raises(AnonymizerWorkflowError, match="Dataset is empty"): - project_detection_redact(result) - - -def test_empty_invocation_preentry_cancel_closes_without_dd_calls(scripted_detection, monkeypatch): - - from anonymizer.engine.harness import kernel - - original = kernel.start_execution - - async def start(**kwargs): - running = await original(**kwargs) - running.request_cancel() - return running - - monkeypatch.setattr(kernel, "start_execution", start) - source = pd.DataFrame({COL_TEXT: pd.Series([], dtype=str)}) - result = asyncio.run( - run_graph_profile(scripted_detection, source, {**_options(), "preview_num_records": 0}, chunks=None) - ) - assert not scripted_detection[3] and result.requests.dispatched_count == 0 - assert result.invocation_state.complete and result.states == () - assert len(result.record.terminals) == 5 - assert all(terminal.category == "blocked" for terminal in result.record.terminals) - assert result.record.memberships[0].closed - assert not result.final_outputs and not result.frames - - -@pytest.mark.parametrize("fault", ["activation_capacity", "foreign_target_scope"]) -def test_invocation_owned_preparation_rejects_before_dd_effects(scripted_detection, monkeypatch, fault): - import anonymizer.engine.detection._graph_profile as profile - from anonymizer.engine.graph_sdk.capabilities import PreparationRejected - - if fault == "activation_capacity": - original_limits = profile.PreparationLimits - - def limited(**kwargs: Any): - kwargs["max_total_activation_slots"] = 4 - return original_limits(**kwargs) - - monkeypatch.setattr(profile, "PreparationLimits", limited) - source = pd.DataFrame({COL_TEXT: pd.Series([], dtype=str)}) - else: - original_configuration = profile.PreparationConfiguration - - def wrong_scope(**kwargs: Any): - kwargs["invocation_owned"] = True - return original_configuration(**kwargs) - - monkeypatch.setattr(profile, "PreparationConfiguration", wrong_scope) - source = _source() - with pytest.raises(PreparationRejected): - asyncio.run(run_graph_profile(scripted_detection, source, _options(), chunks=None)) - assert not scripted_detection[3] - - -@pytest.mark.parametrize("active_loop", [False, True]) -def test_synchronous_graph_profile_hosts_one_loop_thread_and_reuses_dd_loop( - scripted_detection, monkeypatch, active_loop -): - import threading - - from anonymizer.engine.detection._graph_profile import run_detection_redact - from anonymizer.engine.graph_sdk.executor import ExecutionLimits - from anonymizer.engine.replace.replace_runner import ReplacementWorkflow - - started = [] - original = threading.Thread.start - - def capture(self): - started.append(self.name) - original(self) - - def retired(*args, **kwargs): - raise AssertionError("graph Redact must not construct the generic replacement dispatcher") - - monkeypatch.setattr(threading.Thread, "start", capture) - monkeypatch.setattr(ReplacementWorkflow, "run", retired) - designer, models, selected, calls = scripted_detection - - def run(): - return run_detection_redact( - _source(), - detection=detection_tests.EntityDetectionWorkflow(detection_tests.NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options=_options(), - limits=ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=1, - max_runtime_artifacts=16, - max_runtime_artifact_bytes=10000, - max_collection_items=32, - ), - ) - - async def nested(): - loop = asyncio.get_running_loop() - result = run() - assert asyncio.get_running_loop() is loop and loop.is_running() - return result - - projected = asyncio.run(nested()) if active_loop else run() - assert projected.dataframe[detection_tests.COL_REPLACED_TEXT].tolist() == [ - "[REDACTED_FIRST_NAME] travels.", - "[REDACTED_FIRST_NAME] stays.", - ] - assert projected._execution is not None - assert projected._execution.requests.dispatched_count == 7 - assert calls == {"readiness": 1, "detector": 2, "validator": 2, "augmenter": 2} - # The validator rows borrow the one outer profile; its body runs on the caller, so no worker thread starts. - assert started.count("anonymizer-graph-host") == 1 - assert [name for name in started if name.startswith("anonymizer-graph_")] == [] - assert not projected._execution._retained_resources - - -@pytest.mark.parametrize("lost", [False, True]) -def test_synchronous_profile_interrupt_drains_actual_callback(scripted_detection, monkeypatch, lost): - """A Ctrl-C delivered to the caller while a model request is in flight drains and keeps the owner.""" - import os - import signal - from threading import Event - - import anonymizer.engine.detection._graph_profile as profile - from anonymizer.engine.graph_sdk.executor import ExecutionLimits, RunningExecution - - entered, release = Event(), Event() - physicals = [] - dispatch = NddPhysicalTransport.dispatch - cancel = RunningExecution.request_cancel - - async def held(self, request): - if not physicals: - physicals.append(self) - entered.set() - # A real Ctrl-C: the signal lands on the main thread, wherever the caller is waiting. - os.kill(os.getpid(), signal.SIGINT) - await asyncio.to_thread(release.wait) - return await dispatch(self, request) - - def stop(self): - assert not physicals[0].closed - cancel(self) - release.set() - - if lost: - - def lose(request, answer): - raise httpx.ReadError("scripted readiness uncertainty during cancellation", request=request) - - _modify_dd_responses(scripted_detection, monkeypatch, lose) - monkeypatch.setattr(NddPhysicalTransport, "dispatch", held) - monkeypatch.setattr(RunningExecution, "request_cancel", stop) - designer, models, selected, _ = scripted_detection - try: - with pytest.raises(KeyboardInterrupt) as interrupted: - profile.run_detection_redact( - _source(), - detection=detection_tests.EntityDetectionWorkflow(detection_tests.NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options=_options(), - limits=ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=1, - max_runtime_artifacts=16, - max_runtime_artifact_bytes=10000, - max_collection_items=32, - ), - ) - result = retained(interrupted.value) - assert result.requests.dispatched_count == 1 - assert len(physicals) == 1 and physicals[0].closed is not lost - if lost: - assert len(result.requests.remote_outstanding) == 1 - assert len(result._retained_resources) == 1 - assert result._retained_resources[0].handle.owns(physicals[0]) - assert all(not client._inner._closed for client in physicals[0].clients) - else: - assert not result.requests.remote_outstanding and not result._retained_resources - assert all(client._inner._closed for client in physicals[0].clients) - finally: - release.set() - for physical in physicals: - if not physical.closed: - asyncio.run_coroutine_threadsafe(physical.close(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - - -@pytest.mark.parametrize("preview", [False, True]) -def test_actual_lost_lease_survives_public_result_and_preview_projection( - scripted_detection, monkeypatch, tmp_path, preview -): - import json - import pickle - - import anonymizer.interface.anonymizer as facade_module - from anonymizer import AnonymizerConfig, AnonymizerInput - from anonymizer.config.replace_strategies import Redact - from anonymizer.engine.detection._graph_profile import run_detection_redact - from anonymizer.engine.graph_sdk._foreign_transport import ForeignLoopTransport - - # Lost-lease ownership belongs to the explicitly selected qualified profile. - # Ordinary DD publication now declares hidden physical work as unknown. - def qualified(*args: Any, **kwargs: Any): - kwargs["opaque"] = False - return run_detection_redact(*args, **kwargs) - - monkeypatch.setattr(facade_module, "run_detection_redact", qualified) - - def response(request, answer): - messages = json.dumps(json.loads(request.content)["messages"]) - if "Find untagged sensitive entities" in messages and "travels." in messages: - raise httpx.ReadError("scripted uncertain source", request=request) - return answer - - _modify_dd_responses(scripted_detection, monkeypatch, response) - path = tmp_path / "public-input.csv" - _source().rename(columns={COL_TEXT: "text"}).to_csv(path, index=False) - facade = detection_tests.public_anonymizer(scripted_detection) - data = AnonymizerInput(source=str(path)) - config = AnonymizerConfig(replace=Redact(), emit_telemetry=False) - public = ( - facade.preview(config=config, data=data, num_records=2) if preview else facade.run(config=config, data=data) - ) - execution = public._execution - assert execution is not None and execution.requests.dispatched_count == 7 - assert len(execution.requests.remote_outstanding) == 1 - (lease,) = execution._retained_resources - assert isinstance(lease.handle, ForeignLoopTransport) - physical = lease.handle._created_inner - assert isinstance(physical, NddPhysicalTransport) - try: - assert not physical.closed and all(not client._inner._closed for client in physical.clients) - assert public.dataframe["text_replaced"].tolist() == ["[REDACTED_FIRST_NAME] stays."] - assert len(public.failed_records) == 1 - restored = pickle.loads(pickle.dumps(public)) - assert restored._execution is None - pd.testing.assert_frame_equal(restored.dataframe, public.dataframe) - pd.testing.assert_frame_equal(restored.trace_dataframe, public.trace_dataframe) - assert restored.failed_records == public.failed_records - assert not physical.closed - finally: - asyncio.run_coroutine_threadsafe(physical.close(), physical.loop).result(timeout=_HANG_GUARD_TIMEOUT) - - -def test_async_acquisition_and_close_drain_repeated_cancellation(scripted_detection, monkeypatch): - from threading import Event - - from anonymizer.engine.harness import kernel - - acquired, release_acquire, closing, release_close = Event(), Event(), Event(), Event() - physicals, acquisitions = [], [] - initialize, close = NddPhysicalTransport.__init__, NddPhysicalTransport.close - submit = asyncio.run_coroutine_threadsafe - - def held_init(self): - initialize(self) - physicals.append(self) - acquired.set() - assert release_acquire.wait(_HANG_GUARD_TIMEOUT) - - async def held_close(self): - closing.set() - assert await asyncio.to_thread(release_close.wait, _HANG_GUARD_TIMEOUT) - await close(self) - - def track(coroutine, loop): - future = submit(coroutine, loop) - if coroutine.__qualname__.endswith("create_resource"): - acquisitions.append(future) - return future - - async def unexpected_handoff(**kwargs): - raise AssertionError("cancelled acquisition must not start the graph invocation") - - monkeypatch.setattr(NddPhysicalTransport, "__init__", held_init) - monkeypatch.setattr(NddPhysicalTransport, "close", held_close) - monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", track) - monkeypatch.setattr(kernel, "start_execution", unexpected_handoff) - - async def run(): - task = asyncio.create_task(run_graph_profile(scripted_detection, _source(), _options())) - try: - assert await asyncio.to_thread(acquired.wait, _HANG_GUARD_TIMEOUT) - task.cancel("acquisition interrupted") - await asyncio.sleep(0) - task.cancel("observer interrupted again") - await asyncio.sleep(0) - assert not task.done() and not physicals[0].closed - assert len(acquisitions) == 1 and not acquisitions[0].cancelled() - release_acquire.set() - assert await asyncio.to_thread(closing.wait, _HANG_GUARD_TIMEOUT) - task.cancel("interrupted while closing") - await asyncio.sleep(0) - assert not task.done() and not physicals[0].closed - release_close.set() - with pytest.raises(asyncio.CancelledError, match="acquisition interrupted") as interrupted: - await task - assert retained_execution(interrupted.value) is None - assert physicals[0].closed and not physicals[0].clients - assert acquisitions[0].done() and not acquisitions[0].cancelled() - assert physicals[0].loop.is_running() - assert not scripted_detection[3] - finally: - release_acquire.set() - release_close.set() - - asyncio.run(run()) - - -def test_failed_transport_acquisition_closes_its_actual_physical_resource(scripted_detection, monkeypatch): - from anonymizer.engine.graph_sdk._foreign_transport import ForeignLoopTransport - - physicals = [] - initialize = NddPhysicalTransport.__init__ - failure = RuntimeError("scripted transport declaration rejection") - - def capture(self): - initialize(self) - physicals.append(self) - - def reject(**kwargs): - raise failure - - monkeypatch.setattr(NddPhysicalTransport, "__init__", capture) - monkeypatch.setattr(ForeignLoopTransport, "create", reject) - with pytest.raises(RuntimeError, match="scripted transport declaration rejection") as caught: - asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) - assert caught.value is failure - assert len(physicals) == 1 and physicals[0].closed and not physicals[0].clients - assert physicals[0].loop.is_running() and not scripted_detection[3] diff --git a/tests/engine/test_detection_redact_config.py b/tests/engine/test_detection_redact_config.py index 9bfb8b18..2c6e0b13 100644 --- a/tests/engine/test_detection_redact_config.py +++ b/tests/engine/test_detection_redact_config.py @@ -15,15 +15,11 @@ from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput from anonymizer.config.replace_strategies import Redact from anonymizer.engine.constants import COL_REPLACED_TEXT, COL_TEXT -from anonymizer.engine.detection import _graph_invocation, _graph_profile -from anonymizer.engine.graph_sdk.executor import ExecutionLimits from anonymizer.engine.harness import kernel -from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport from anonymizer.engine.replace._graph_redact import redact_strategy from anonymizer.engine.replace.strategies import apply_local_replace_strategy from tests.engine import test_detection_source_frame as detection_tests -from tests.engine.test_detection_graph_publication import _modify_dd_responses -from tests.engine.test_detection_source_frame import run_graph_profile +from tests.engine.test_detection_source_frame import _modify_dd_responses, run_graph_profile from tests.retained import retained scripted_detection = detection_tests.scripted_detection @@ -64,10 +60,13 @@ def test_existing_redact_configuration_matches_legacy_and_literal_output( projected = kernel.project_detection_redact(result) pd.testing.assert_frame_equal(projected.dataframe, expected) assert projected.dataframe[COL_REPLACED_TEXT].tolist() == literal - assert len(kernel.qualify_detection_redact(result).qualified) == 2 assert calls == {"detector": 2, "validator": 2, "augmenter": 2, "readiness": 1} - configured = [cap.configuration for cap in result._execution.capabilities if cap.configuration.fields] - assert len(configured) == 2 + configured = [ + cap.configuration + for cap in result._execution.capabilities + if {field.name for field in cap.configuration.fields} == {"format_template", "normalize_label"} + ] + assert len(configured) == 1 assert all(redact_strategy(configuration) == strategy for configuration in configured) @@ -79,75 +78,6 @@ def test_empty_render_configuration_rejects_before_detection(scripted_detection, assert scripted_detection[3] == {} -@pytest.mark.parametrize( - "wrong", [Redact(format_template="WRONG", normalize_label=False), Redact(format_template="<{label}>")] -) -def test_wrong_producer_configuration_cannot_satisfy_selected_redact(scripted_detection, monkeypatch, wrong): - def changed(frame, *, strategy): - return apply_local_replace_strategy(frame, strategy=wrong) - - monkeypatch.setattr(_graph_invocation, "apply_local_replace_strategy", changed) - result = asyncio.run( - run_graph_profile( - scripted_detection, - _source(), - _options(), - replace_method=Redact(format_template="<{label}>", normalize_label=False), - ) - ) - assert [fact.finding.status for fact in result.assessments] == ["unsatisfied", "unsatisfied"] - assert not kernel.qualify_detection_redact(result).qualified - - -def test_async_configuration_snapshot_precedes_resource_acquisition(scripted_detection, monkeypatch): - strategy = Redact(format_template="<{label}>", normalize_label=False) - initialize = NddPhysicalTransport.__init__ - - def initialized(self): - strategy.format_template = "WRONG" - strategy.normalize_label = True - initialize(self) - - monkeypatch.setattr(NddPhysicalTransport, "__init__", initialized) - result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options(), replace_method=strategy)) - assert strategy.format_template == "WRONG" - assert kernel.project_detection_redact(result).dataframe[COL_REPLACED_TEXT].tolist() == [ - " travels.", - " stays.", - ] - - -def test_sync_configuration_snapshot_precedes_host_thread(scripted_detection, monkeypatch): - strategy = Redact(format_template="<{label}>", normalize_label=False) - start = kernel.Thread.start - - def started(self): - strategy.format_template = "WRONG" - strategy.normalize_label = True - start(self) - - monkeypatch.setattr(kernel.Thread, "start", started) - designer, models, selected, calls = scripted_detection - projected = _graph_profile.run_detection_redact( - _source(), - detection=detection_tests.EntityDetectionWorkflow(detection_tests.NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options=_options(), - replace_method=strategy, - limits=ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=1, - max_runtime_artifacts=16, - max_runtime_artifact_bytes=10000, - max_collection_items=16, - ), - ) - assert strategy.format_template == "WRONG" - assert projected.dataframe[COL_REPLACED_TEXT].tolist() == [" travels.", " stays."] - assert calls == {"detector": 2, "validator": 2, "augmenter": 2, "readiness": 1} - - @pytest.mark.parametrize("route", ["run", "preview", "graph"]) def test_label_dependent_empty_render_propagates_without_successful_output( scripted_detection, monkeypatch, tmp_path, route @@ -196,34 +126,3 @@ def numeric_label(request, answer): else: anonymizer.run(**options) assert calls == {"detector": 2, "validator": 2, "augmenter": 2, "readiness": 1} - - -@pytest.mark.parametrize("byte_limit", [0, 10]) -def test_sync_explicit_limits_snapshot_precedes_host_thread(scripted_detection, monkeypatch, byte_limit): - from anonymizer.graph import ContractViolation - - limits = ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=8, - max_runtime_artifacts=10, - max_runtime_artifact_bytes=byte_limit, - max_collection_items=32, - ) - start = kernel.Thread.start - - def started(self): - object.__setattr__(limits, "max_runtime_artifact_bytes", None) - start(self) - - monkeypatch.setattr(kernel.Thread, "start", started) - designer, models, selected, calls = scripted_detection - with pytest.raises(ContractViolation): - _graph_profile.run_detection_redact( - _source(), - detection=detection_tests.EntityDetectionWorkflow(detection_tests.NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options=_options(), - limits=limits, - ) - assert not calls diff --git a/tests/engine/test_detection_semantic_control.py b/tests/engine/test_detection_semantic_control.py index c5d79e81..72519633 100644 --- a/tests/engine/test_detection_semantic_control.py +++ b/tests/engine/test_detection_semantic_control.py @@ -187,7 +187,6 @@ async def capture(**kwargs: Any) -> Any: model_configs=stub_detector_model_configs, selected_models=stub_detection_model_selection, detection_options={"gliner_detection_threshold": 0.3, "tag_latent_entities": False}, - opaque=True, ) from anonymizer.engine.constants import COL_REPLACED_TEXT diff --git a/tests/engine/test_detection_source_frame.py b/tests/engine/test_detection_source_frame.py index bc57da4f..9b0d00d6 100644 --- a/tests/engine/test_detection_source_frame.py +++ b/tests/engine/test_detection_source_frame.py @@ -22,7 +22,7 @@ from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput from anonymizer.config.models import DetectionModelSelection, ReplaceModelSelection from anonymizer.config.replace_strategies import Redact -from anonymizer.engine.constants import COL_FINAL_ENTITIES, COL_REPLACED_TEXT, COL_TEXT +from anonymizer.engine.constants import COL_REPLACED_TEXT, COL_TEXT from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow from anonymizer.engine.ndd.adapter import RECORD_ID_COLUMN, NddAdapter from anonymizer.engine.replace.replace_runner import ReplacementWorkflow @@ -240,7 +240,7 @@ def displaced(*args, **kwargs): assert len(detections) == 1 assert len(detections[0][0]) == len(expected) assert detections[0][1]["source_frame"] is not None - assert detections[0][1]["request_bridge"] is None + assert "request_bridge" not in detections[0][1] assert result._execution._execution.context.prepared.declared_request_upper_bound is None assert not result._execution.requests.dispatches assert not any(str(name).startswith("_anonymizer_source_") for name in result.trace_dataframe.columns) @@ -294,103 +294,55 @@ def test_real_detection_dropped_row_preserves_peer_and_failure( assert calls == {"detector": 2, "validator": 1, "augmenter": 1, "readiness": 1} -@pytest.mark.parametrize( - "preview,unattributed_loss,chunks", [(True, False, 1), (False, False, 1), (True, True, 1), (True, False, 2)] -) -def test_real_dd_whole_frame_uses_invocation_readiness_and_source_requests( - scripted_detection, monkeypatch, preview, unattributed_loss, chunks -): - designer, models, selected, calls = scripted_detection - prefix = "Alice" if chunks == 1 else "Alice Bob" - source = pd.DataFrame( - { - COL_TEXT: [f"{prefix} travels.", f"{prefix} stays."], - RECORD_ID_COLUMN: ["duplicate", "duplicate"], - "extra": [{"numbers": [17]}, {"numbers": [29]}], - }, - index=pd.Index([4, 4]), - ) - options: dict[str, Any] = { - "gliner_detection_threshold": 0.3, - "tag_latent_entities": False, - "validation_max_entities_per_call": 1, - "preview_num_records": 2 if preview else None, - } - legacy = EntityDetectionWorkflow(NddAdapter(designer)).run( - source, - model_configs=models, - selected_models=selected, - **options, - ) - expected = ( - ReplacementWorkflow() - .run( - legacy.dataframe, - replace_method=Redact(), - model_configs=models, - selected_models=ReplaceModelSelection(replacement_generator="singleton"), - ) - .dataframe - ) - calls.clear() - if unattributed_loss: - original = designer.preview +def _modify_dd_responses(scripted_detection, monkeypatch, modify): + designer = scripted_detection[0] + original = designer._create_resource_provider + + def resources(*args, **kwargs): + provider = original(*args, **kwargs) + factory = provider.model_registry._model_facade_factory + + def facade(*args, **kwargs): + result = factory(*args, **kwargs) + prior = result._client._inner._transport - def lose_output(*args, **kwargs): - result = original(*args, **kwargs) - result.dataset = result.dataset.iloc[:1] + async def response(request): + return modify(request, await prior.handle_async_request(request)) + + result._client._inner._transport = httpx.MockTransport(response) return result - monkeypatch.setattr(designer, "preview", lose_output) - - result = asyncio.run(run_graph_profile(scripted_detection, source, options, chunks)) - assert result._execution.context.prepared.configuration.purpose == "protection" - assert result.requests.dispatched_count == 5 + 2 * chunks - assert calls == {"detector": 2, "validator": 2 * chunks, "augmenter": 2, "readiness": 1} - assert len(result.frames) == (0 if unattributed_loss else 1) - assert len(result.final_outputs) == (0 if unattributed_loss else 2) - if not unattributed_loss: - projected = result.frames[0].project(result) - pd.testing.assert_frame_equal(projected, expected) - assert result.frames[0].failed_records == () - assert COL_REPLACED_TEXT not in result.frames[0]._content - assert COL_FINAL_ENTITIES not in result.frames[0]._content - checks = [port for port in result.ports if port.port == "assessment"] - assert len(checks) == 2 - assert [dict(result.artifacts)[port.artifact].text for port in checks] == ["span_redaction_matches"] * 2 - artifacts = dict(result.artifacts) - assert sorted(artifacts[output.candidate.artifact].text for output in result.final_outputs) == sorted( - expected[COL_REPLACED_TEXT] - ) - assert any(fact.disposition == "closed" for fact in result.cleanup) + provider.model_registry._model_facade_factory = facade + return provider + monkeypatch.setattr(designer, "_create_resource_provider", resources) -async def run_graph_profile( - scripted_detection, source, options, chunks=1, artifact_limit=16, remote_limit=1, replace_method=None -): - from anonymizer.engine.detection._graph_profile import execute_detection_redact + +async def run_graph_profile(scripted_detection, source, options, replace_method=None): + """The facade's opaque detection/Redact invocation on a one-worker pool; returns its execution.""" + from anonymizer.config.replace_strategies import Redact + from anonymizer.engine.detection._graph_invocation import DetectionRedactInvocation + from anonymizer.engine.detection._opaque_redact import execute_injected_redact from anonymizer.engine.graph_sdk.blocking import BlockingPool - from anonymizer.engine.graph_sdk.executor import ExecutionLimits + from anonymizer.engine.replace._graph_redact import local_configuration designer, models, selected, _ = scripted_detection + configuration = local_configuration(replace_method if replace_method is not None else Redact()) pool = BlockingPool(max_workers=1) try: - return await execute_detection_redact( - source, - detection=EntityDetectionWorkflow(NddAdapter(designer)), - model_configs=models, - selected_models=selected, - detection_options=options, - replace_method=replace_method, - validation_chunks=chunks, - pool=pool, - limits=ExecutionLimits( - max_local_in_flight=1, - max_remote_outstanding=remote_limit, - max_runtime_artifacts=artifact_limit, - max_runtime_artifact_bytes=10000, - max_collection_items=16, + return await execute_injected_redact( + DetectionRedactInvocation( + source, + (), + EntityDetectionWorkflow(NddAdapter(designer)), + models, + selected, + configuration, + options, + "redacted", + "ok", ), + pool, ) finally: await pool.close() @@ -517,8 +469,6 @@ def test_public_opaque_dd_preserves_backend_retry_and_usage_behavior(scripted_de from anonymizer import Detect from anonymizer.engine.harness.frames import PublishedFrame - from anonymizer.engine.harness.kernel import qualify_detection_redact - from tests.engine.test_detection_graph_publication import _modify_dd_responses monkeypatch.setattr(ModelRequestExecutor, "_retry_delay_seconds", lambda self, attempt: 0) remaining = [1 if case == "rate_limit" else 4 if case == "second_chunk" else 0] @@ -567,4 +517,3 @@ def response(request, answer): assert execution._execution.context.prepared.declared_request_upper_bound is None assert execution._execution.capabilities[0].attribution == "per_task" assert not execution.requests.dispatches - assert not qualify_detection_redact(execution).qualified diff --git a/tests/engine/test_graph_requests_capabilities.py b/tests/engine/test_graph_requests_capabilities.py index 4d4e0b5a..f7f3eeb8 100644 --- a/tests/engine/test_graph_requests_capabilities.py +++ b/tests/engine/test_graph_requests_capabilities.py @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""The graph request bridge checks DataDesigner's private hooks, not its exact version. +"""The source-frame binding checks DataDesigner's private hooks, not its exact version. -Every case runs against fake DataDesigner modules and classes, so it pins the bridge's behavior on a +Every case runs against fake DataDesigner modules and classes, so it pins the binding's behavior on a release shaped differently from the installed one. """ @@ -10,131 +10,34 @@ import sys import types -from collections.abc import Callable from types import SimpleNamespace -from typing import Any, cast +from typing import Any -import httpx import pytest from anonymizer.engine.ndd import _graph_requests -from anonymizer.engine.ndd._graph_requests import SUPPORTED_DATA_DESIGNER_RANGE, NddRequestBridge +from anonymizer.engine.ndd._graph_requests import SUPPORTED_DATA_DESIGNER_RANGE from anonymizer.interface.errors import AnonymizerWorkflowError -_SECRET = "Alice Example lives at 1 Main St" - - -class _Registry: - def __init__(self, *, model_facade_factory: Callable[..., Any] | None = None) -> None: - self._model_facade_factory = model_facade_factory - - -class _Facade: - async def agenerate(self, *args: Any, **kwargs: Any) -> Any: - return _SECRET - - -class _Designer: - def __init__(self, provider: Any) -> None: - self.provider = provider - self.created = 0 - - def _create_resource_provider(self, *args: Any, **kwargs: Any) -> Any: - self.created += 1 - return self.provider - - -class _Physical: - def __init__(self) -> None: - self.clients: list[Any] = [] - - def register(self, client: Any) -> None: - self.clients.append(client) - - -def _http_client(**overrides: Any) -> SimpleNamespace: - fields = { - "_retry_config": SimpleNamespace(max_retries=0), - "_aclient": None, - "_client": None, - "_transport": None, - "_limits": None, - } - fields.update(overrides) - return SimpleNamespace(**{key: value for key, value in fields.items() if value is not _MISSING}) - - _MISSING = object() -def _facade(client: Any) -> Any: - facade = _Facade() - setattr(facade, "_client", client) - return facade - - @pytest.fixture def fake_dd(monkeypatch: pytest.MonkeyPatch) -> types.SimpleNamespace: - """Install fake DataDesigner registry/facade modules and report installed version 0.9.9.""" - registry = types.ModuleType("fake_registry") - setattr(registry, "ModelRegistry", _Registry) - facade = types.ModuleType("fake_facade") - setattr(facade, "ModelFacade", _Facade) - retry = types.ModuleType("fake_retry") - setattr(retry, "create_retry_transport", lambda *args, **kwargs: None) - parsers = types.ModuleType("fake_parser_errors") - setattr(parsers, "ParserException", type("ParserException", (Exception,), {})) - readiness = types.ModuleType("fake_readiness") - setattr(readiness, "_SKIP_MODEL_HEALTH_CHECKS_ENV", "FAKE_SKIP_HEALTH_CHECKS") - scheduler = types.ModuleType("fake_scheduler") - - class AsyncTaskScheduler: - def __init__(self, *, salvage_max_rounds: int = 7) -> None: - pass - - setattr(scheduler, "AsyncTaskScheduler", AsyncTaskScheduler) + """Install a fake DataDesigner artifact-storage module and report installed version 0.9.9.""" storage = types.ModuleType("fake_artifact_storage") setattr(storage, "BatchStage", SimpleNamespace(PARTIAL_RESULT="partial", FINAL_RESULT="final")) setattr(storage, "SDG_CONFIG_FILENAME", "sdg.json") - for name, module in ( - (_graph_requests._REGISTRY_MODULE, registry), - (_graph_requests._FACADE_MODULE, facade), - (_graph_requests._RETRY_MODULE, retry), - (_graph_requests._PARSER_ERRORS_MODULE, parsers), - (_graph_requests._READINESS_MODULE, readiness), - (_graph_requests._SCHEDULER_MODULE, scheduler), - (_graph_requests._ARTIFACT_STORAGE_MODULE, storage), - ): - monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setitem(sys.modules, _graph_requests._ARTIFACT_STORAGE_MODULE, storage) monkeypatch.setattr(_graph_requests, "version", lambda _name: "0.9.9") - return types.SimpleNamespace( - registry=registry, - facade=facade, - retry=retry, - parsers=parsers, - readiness=readiness, - scheduler=scheduler, - storage=storage, - ) - - -def _bridge() -> tuple[NddRequestBridge, _Physical]: - physical = _Physical() - requests: Any = SimpleNamespace(require_transport=lambda _physical: None) - return NddRequestBridge(requests, {}, cast(Any, physical)), physical - - -def _provider(factory: Callable[..., Any]) -> SimpleNamespace: - return SimpleNamespace(model_registry=_Registry(model_facade_factory=factory)) + return types.SimpleNamespace(storage=storage) def _assert_capability_message(error: BaseException, attribute: str) -> None: - message = str(error) - assert message == ( + assert str(error) == ( f"Anonymizer's DataDesigner binding requires DataDesigner's private `{attribute}`, which the installed " f"data-designer 0.9.9 does not provide (supported: data-designer {SUPPORTED_DATA_DESIGNER_RANGE})" ) - assert _SECRET not in message def test_pyproject_range_matches_supported_range() -> None: @@ -146,161 +49,13 @@ def test_pyproject_range_matches_supported_range() -> None: assert declared == [f"data-designer{SUPPORTED_DATA_DESIGNER_RANGE}"] -def test_any_release_in_range_with_the_hooks_binds(fake_dd: types.SimpleNamespace) -> None: - client = SimpleNamespace(_inner=_http_client()) - designer = _Designer(_provider(lambda: _facade(client))) - bridge, physical = _bridge() - - with bridge.bind(designer): - provider = designer._create_resource_provider() - facade = provider.model_registry._model_facade_factory() - - assert designer.created == 1 - assert physical.clients == [client] - assert isinstance(client._inner._transport, _graph_requests.httpx.AsyncBaseTransport) - assert facade.agenerate is not _Facade.agenerate - assert designer._create_resource_provider.__func__ is _Designer._create_resource_provider - - -def test_missing_resource_provider_hook_fails_at_bind(fake_dd: types.SimpleNamespace) -> None: - class Designer: - pass - - bridge, _ = _bridge() - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(Designer()): - pytest.fail("bind must not yield") - _assert_capability_message(raised.value, "DataDesigner._create_resource_provider") - - -def test_registry_without_facade_factory_parameter_fails_at_bind( - fake_dd: types.SimpleNamespace, -) -> None: - class Registry: - def __init__(self, *, model_configs: Any = None) -> None: - pass - - fake_dd.registry.ModelRegistry = Registry - designer = _Designer(_provider(lambda: None)) - bridge, _ = _bridge() - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - pytest.fail("bind must not yield") - _assert_capability_message(raised.value, "ModelRegistry(model_facade_factory=...)") - assert designer.created == 0 - - -def test_facade_without_async_generate_fails_at_bind(fake_dd: types.SimpleNamespace) -> None: - class Facade: - def generate(self) -> None: - pass - - fake_dd.facade.ModelFacade = Facade - designer = _Designer(_provider(lambda: None)) - bridge, _ = _bridge() - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - pytest.fail("bind must not yield") - _assert_capability_message(raised.value, "ModelFacade.agenerate") - assert designer.created == 0 - - -def test_provider_without_facade_factory_fails_at_creation_even_if_swallowed( - fake_dd: types.SimpleNamespace, -) -> None: - registry = _Registry() - del registry._model_facade_factory - designer = _Designer(SimpleNamespace(model_registry=registry)) - bridge, _ = _bridge() - - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - with pytest.raises(AnonymizerWorkflowError) as inner: - designer._create_resource_provider() - _assert_capability_message(inner.value, "ModelRegistry._model_facade_factory") - # DataDesigner may absorb the raise as a dropped row; leaving the binding still aborts the run. - assert raised.value is inner.value - - -@pytest.mark.parametrize( - ("client", "attribute"), - [ - (SimpleNamespace(), "ModelRequestExecutor._inner"), - (SimpleNamespace(_inner=_http_client(_limits=_MISSING)), "HttpModelClient._limits"), - (SimpleNamespace(_inner=_http_client(_transport=_MISSING)), "HttpModelClient._transport"), - (SimpleNamespace(_inner=_http_client(_retry_config=SimpleNamespace())), "RetryConfig.max_retries"), - ], - ids=["inner", "limits", "transport", "max_retries"], -) -def test_facade_without_client_fields_fails_before_any_request_even_if_swallowed( - fake_dd: types.SimpleNamespace, client: Any, attribute: str -) -> None: - designer = _Designer(_provider(lambda: _facade(client))) - bridge, physical = _bridge() - - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - factory = designer._create_resource_provider().model_registry._model_facade_factory - with pytest.raises(AnonymizerWorkflowError) as inner: - factory() - _assert_capability_message(inner.value, attribute) - assert raised.value is inner.value - assert physical.clients == [] - - -def test_facade_without_client_fails_at_creation(fake_dd: types.SimpleNamespace) -> None: - designer = _Designer(_provider(lambda: _Facade())) - bridge, _ = _bridge() - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - designer._create_resource_provider().model_registry._model_facade_factory() - _assert_capability_message(raised.value, "ModelFacade._client") - - -@pytest.mark.parametrize( - ("module", "name"), - [("retry", "create_retry_transport"), ("parsers", "ParserException")], -) -def test_missing_module_hook_fails_at_bind(fake_dd: types.SimpleNamespace, module: str, name: str) -> None: - delattr(getattr(fake_dd, module), name) - designer = _Designer(_provider(lambda: None)) - bridge, _ = _bridge() - with pytest.raises(AnonymizerWorkflowError) as raised, bridge.bind(designer): - pytest.fail("bind must not yield") - path = {"retry": _graph_requests._RETRY_MODULE, "parsers": _graph_requests._PARSER_ERRORS_MODULE}[module] - _assert_capability_message(raised.value, f"{path}.{name}") - assert designer.created == 0 - - -def test_native_map_lookups_resolve_from_the_installed_release(fake_dd: types.SimpleNamespace) -> None: - assert _graph_requests.skip_health_checks_env() == "FAKE_SKIP_HEALTH_CHECKS" - assert _graph_requests.scheduler_salvage_rounds() == 7 - - -def test_missing_health_check_switch_fails_with_capability_error(fake_dd: types.SimpleNamespace) -> None: - delattr(fake_dd.readiness, "_SKIP_MODEL_HEALTH_CHECKS_ENV") - with pytest.raises(AnonymizerWorkflowError) as raised: - _graph_requests.skip_health_checks_env() - _assert_capability_message(raised.value, f"{_graph_requests._READINESS_MODULE}._SKIP_MODEL_HEALTH_CHECKS_ENV") - - -def test_scheduler_without_salvage_rounds_fails_with_capability_error(fake_dd: types.SimpleNamespace) -> None: - class AsyncTaskScheduler: - def __init__(self) -> None: - pass - - setattr(fake_dd.scheduler, "AsyncTaskScheduler", AsyncTaskScheduler) - with pytest.raises(AnonymizerWorkflowError) as raised: - _graph_requests.scheduler_salvage_rounds() - _assert_capability_message(raised.value, "AsyncTaskScheduler(salvage_max_rounds=...)") - - def test_bridge_modules_import_no_data_designer_internals_at_module_scope() -> None: - """Importing ``anonymizer`` must not fail on a release that moves a hook the bridge checks at bind time.""" + """Importing ``anonymizer`` must not fail on a release that moves a hook the binding checks before it runs.""" import ast from pathlib import Path src = Path(__file__).resolve().parents[2] / "src" / "anonymizer" - lazy = { - "engine/ndd/_graph_requests.py", - "engine/replace/_native_map_requests.py", - "engine/harness/kernel.py", - "engine/detection/_graph_profile.py", - } + lazy = {"engine/ndd/_graph_requests.py", "engine/harness/kernel.py", "engine/detection/_graph_profile.py"} offenders = [] for path in sorted(src.rglob("*.py")): relative = path.relative_to(src).as_posix() @@ -319,32 +74,6 @@ def test_bridge_modules_import_no_data_designer_internals_at_module_scope() -> N assert offenders == [] -@pytest.mark.parametrize( - ("error", "wrapped"), - [ - (httpx.ReadError("read failed"), True), - (httpx.WriteError("write failed"), True), - (httpx.RemoteProtocolError("peer closed"), True), - (httpx.ConnectError("refused"), False), - (httpx.ReadTimeout("slow"), False), - (httpx.LocalProtocolError("bad request"), False), - ], - ids=lambda value: type(value).__name__ if isinstance(value, Exception) else str(value), -) -def test_single_attempt_errors_keep_the_released_retry_classification(error: Exception, wrapped: bool) -> None: - """DD sees the same retry decision on every supported release: only errors 0.9.4 retried stay retryable.""" - seen = _graph_requests._single_attempt_error(cast(Any, error)) - if not wrapped: - assert seen is error - return - assert isinstance(seen, _graph_requests.SingleAttemptTransportError) - assert seen.__cause__ is error - assert str(seen) == str(error) - from data_designer.engine.models.clients.errors import ProviderErrorKind, infer_error_kind_from_exception - - assert infer_error_kind_from_exception(seen) is ProviderErrorKind.API_ERROR - - class _BuilderDesigner: def _create_dataset_builder(self) -> None: pass diff --git a/tests/engine/test_opaque_redact_frame_acquisition.py b/tests/engine/test_opaque_redact_frame_acquisition.py deleted file mode 100644 index 8f4a2e6a..00000000 --- a/tests/engine/test_opaque_redact_frame_acquisition.py +++ /dev/null @@ -1,209 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""Acquisition-wait harness around ``_execute_frame_plan``'s native transport lease. - -``_execute_frame_plan`` (in ``harness/kernel.py``) schedules a ``create_resource`` -coroutine on DataDesigner's engine loop and waits for it under ``asyncio.shield``. -These tests exercise that wait loop directly: a genuinely cancelled acquisition must -not busy-spin, and an outer-cancelled caller must preserve the *first* interruption -across either a later successful acquisition or a later non-cancel acquisition -failure, matching the guarded twin in ``_graph_profile.py``. -""" - -from __future__ import annotations - -import asyncio -import threading -from typing import Any, cast - -import pytest -from data_designer.engine.dataset_builders.utils.async_concurrency import ensure_async_engine_loop - -from anonymizer.engine.graph_sdk._foreign_transport import ForeignLoopTransport -from anonymizer.engine.harness import kernel - -# Hang-guard bound for waits on normally-fast in-memory thread/event signals in -# this module. Not a measurement of expected runtime -- only a generous ceiling -# so a hung test fails instead of blocking CI, chosen to stay robust under a -# loaded CI host. -_HANG_GUARD_TIMEOUT = 30.0 - - -class _FakeTransport: - def __init__(self) -> None: - self.closed = False - self._claimed = False - - async def close(self) -> None: - self.closed = True - - -class _SlowClosingPhysical: - """Stands in for ``NddPhysicalTransport``; its ``close`` suspends indefinitely. - - Used only to give the engine-loop ``create_resource`` task a real await point - to be cancelled at (the factory failure path awaits ``value.close()``). - """ - - def __init__(self) -> None: - self.closed = False - - async def close(self) -> None: - await asyncio.sleep(3600) - - -def _patch_owned_and_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "anonymizer.engine.replace._native_map_requests.bind_native_map_policy", - lambda prepared, policies, nodes: policies, - ) - - async def owned(**kwargs: Any) -> tuple[str, Any]: - return "owned-ran", kwargs["transport"] - - monkeypatch.setattr(kernel, "_execute_frame_plan_owned", owned) - - -def _call_frame_plan() -> Any: - return kernel._execute_frame_plan( - prepared=cast(Any, None), - capabilities=(), - policies=(), - callback=cast(Any, None), - pool=cast(Any, None), - borrowed_loop=cast(Any, None), - limits=cast(Any, None), - ingress_limits=cast(Any, None), - assessment_limits=cast(Any, None), - native_request_nodes=cast(Any, {"probe": object()}), - ) - - -def test_cancelled_acquisition_raises_promptly_without_busy_spin(monkeypatch: pytest.MonkeyPatch) -> None: - """A genuinely cancelled acquisition must be observed and re-raised, not retried forever.""" - _patch_owned_and_policy(monkeypatch) - monkeypatch.setattr("anonymizer.engine.ndd._graph_requests.NddPhysicalTransport", _SlowClosingPhysical) - - def failing_create(cls: type[ForeignLoopTransport], **kwargs: Any) -> ForeignLoopTransport: - raise RuntimeError("forced create failure") - - monkeypatch.setattr(ForeignLoopTransport, "create", classmethod(failing_create)) - - engine_loop = ensure_async_engine_loop() - outcome: dict[str, Any] = {} - - def runner() -> None: - async def main() -> None: - task = asyncio.ensure_future(_call_frame_plan()) - # Let create_resource start on the engine loop, fail in ForeignLoopTransport.create, - # and suspend inside `await value.close()`. - await asyncio.sleep(0.2) - - found = threading.Event() - - def cancel_when_present() -> None: - for engine_task in asyncio.all_tasks(engine_loop): - coro = engine_task.get_coro() - code = getattr(coro, "cr_code", None) - if getattr(code, "co_name", "") == "create_resource": - engine_task.cancel() - found.set() - return - engine_loop.call_later(0.02, cancel_when_present) - - engine_loop.call_soon_threadsafe(cancel_when_present) - if not found.wait(timeout=_HANG_GUARD_TIMEOUT): - outcome["result"] = "engine-task-not-found" - return - try: - await task - outcome["result"] = "no-error" - except asyncio.CancelledError: - outcome["result"] = "cancelled" - - asyncio.run(main()) - - thread = threading.Thread(target=runner, daemon=True) - thread.start() - thread.join(timeout=_HANG_GUARD_TIMEOUT) - assert not thread.is_alive(), ( - "caller did not return promptly after the acquisition was cancelled (busy-spin regression: engine loop starved)" - ) - assert outcome.get("result") == "cancelled" - - -def test_outer_cancel_then_success_closes_transport_and_reraises_first(monkeypatch: pytest.MonkeyPatch) -> None: - """Caller cancelled twice while pending; acquisition then succeeds -> close + first error. - - The acquisition's success is gated on a ``threading.Event`` set only after both outer - cancels have been delivered and observed (each is followed by a yielding sleep so the - cancellation has reached the awaiting coroutine). This removes the fixed-delay race - between the two cancels and ``ForeignLoopTransport.create`` completing: without the - gate, a slow CI host could let the acquisition resolve before either cancel lands. - """ - _patch_owned_and_policy(monkeypatch) - created: list[_FakeTransport] = [] - cancels_delivered = threading.Event() - - def slow_create(cls: type[ForeignLoopTransport], **kwargs: Any) -> _FakeTransport: - assert cancels_delivered.wait(timeout=_HANG_GUARD_TIMEOUT), ( - "outer cancels were not delivered before the gate timeout" - ) - transport = _FakeTransport() - created.append(transport) - return transport - - monkeypatch.setattr(ForeignLoopTransport, "create", classmethod(slow_create)) - ensure_async_engine_loop() - - async def main() -> asyncio.CancelledError: - task = asyncio.ensure_future(_call_frame_plan()) - await asyncio.sleep(0.05) - task.cancel("first-cancel") - await asyncio.sleep(0.05) # let the first cancellation reach the awaiting coroutine - task.cancel("second-cancel") - await asyncio.sleep(0.05) # let the second cancellation reach the awaiting coroutine - cancels_delivered.set() - with pytest.raises(asyncio.CancelledError) as excinfo: - await task - return excinfo.value - - error = asyncio.run(main()) - assert created, "ForeignLoopTransport.create was never invoked" - assert created[0].closed is True - assert error.args and error.args[0] == "first-cancel" - - -def test_outer_cancel_then_factory_failure_chains_first_interrupt(monkeypatch: pytest.MonkeyPatch) -> None: - """Caller cancelled once while pending; acquisition then fails with a non-cancel error. - - As above, the factory failure is gated on a ``threading.Event`` set only after the - outer cancel has been delivered and observed, so the ordering is deterministic rather - than relying on a fixed delay racing the cancel. - """ - _patch_owned_and_policy(monkeypatch) - cancel_delivered = threading.Event() - - def failing_slow_create(cls: type[ForeignLoopTransport], **kwargs: Any) -> ForeignLoopTransport: - assert cancel_delivered.wait(timeout=_HANG_GUARD_TIMEOUT), ( - "outer cancel was not delivered before the gate timeout" - ) - raise RuntimeError("factory boom") - - monkeypatch.setattr(ForeignLoopTransport, "create", classmethod(failing_slow_create)) - ensure_async_engine_loop() - - async def main() -> asyncio.CancelledError: - task = asyncio.ensure_future(_call_frame_plan()) - await asyncio.sleep(0.05) - task.cancel("first-cancel") - await asyncio.sleep(0.05) # let the cancellation reach the awaiting coroutine - cancel_delivered.set() - with pytest.raises(asyncio.CancelledError) as excinfo: - await task - return excinfo.value - - error = asyncio.run(main()) - assert error.args and error.args[0] == "first-cancel" - assert isinstance(error.__cause__, RuntimeError) - assert "factory boom" in str(error.__cause__) diff --git a/tests/engine/test_publication_source_admission.py b/tests/engine/test_publication_source_admission.py index 4a7531b8..aaa1b9b2 100644 --- a/tests/engine/test_publication_source_admission.py +++ b/tests/engine/test_publication_source_admission.py @@ -17,7 +17,7 @@ from anonymizer.engine.graph_sdk.executor import admit_execution_plan from anonymizer.graph import EffectCode, EffectRejected from tests.engine import test_detection_source_frame as detection_tests -from tests.engine.test_detection_graph_publication import _modify_dd_responses +from tests.engine.test_detection_source_frame import _modify_dd_responses scripted_detection = detection_tests.scripted_detection diff --git a/tests/engine/test_route_resolution.py b/tests/engine/test_route_resolution.py index bbc8086e..bc287224 100644 --- a/tests/engine/test_route_resolution.py +++ b/tests/engine/test_route_resolution.py @@ -254,31 +254,12 @@ def test_detection_redact_entry_opaque( selected_models=_any(None), detection_options={}, replace_method=method, - opaque=True, ) assert builtin_detection(detector()) is builtin assert seen["hosted"] assert seen["args"][0].handoff is _HANDOFF -@pytest.mark.parametrize(("detector", "builtin"), _DETECTORS, ids=_DETECTOR_IDS) -def test_detection_redact_entry_native(monkeypatch: pytest.MonkeyPatch, detector: Any, builtin: bool) -> None: - from anonymizer.engine.detection import _graph_profile - - seen = _observe(monkeypatch, _graph_profile, "execute_detection_redact") - with pytest.raises(_Stop): - _graph_profile.run_detection_redact( - _SOURCE, - detection=detector(), - model_configs=[], - selected_models=_any(None), - detection_options={}, - ) - assert builtin_detection(detector()) is builtin - assert seen["hosted"] - assert "handoff" not in seen["kwargs"] - - @pytest.mark.parametrize(("detector", "builtin"), _DETECTORS, ids=_DETECTOR_IDS) @pytest.mark.parametrize( "replacement", [ReplacementWorkflow, _SubclassReplacement], ids=["builtin-replacement", "subclass-replacement"] @@ -401,30 +382,6 @@ def run_sync_profile(operation: Any) -> Any: assert seen["hosted"] -def _designer_stub() -> Any: - run_config = SimpleNamespace(max_conversation_correction_steps=0, max_conversation_restarts=0) - return _any(SimpleNamespace(run_config=run_config)) - - -@pytest.mark.parametrize( - ("receiver", "native"), - [ - (lambda: LlmReplaceWorkflow(NddAdapter(_designer_stub())), True), - (lambda: _SubclassMap(NddAdapter(_designer_stub())), True), - (lambda: LlmReplaceWorkflow(_OverriddenAdapter(_designer_stub())), False), - (lambda: _SubclassColumns(NddAdapter(_designer_stub())), False), - (lambda: SimpleNamespace(_generate_columns=None), False), - ], - ids=["builtin", "subclass-map-only", "overridden-adapter", "subclass-columns", "adapterless-duck"], -) -def test_native_map_columns_gate(receiver: Any, native: bool) -> None: - from anonymizer.config.models import ReplaceModelSelection - from anonymizer.engine.replace._native_map_requests import native_map_capacity - - arguments = {"selected_models": ReplaceModelSelection(replacement_generator="m"), "model_configs": []} - assert (native_map_capacity(receiver(), arguments) is not None) is native - - class _GateSeen(Exception): pass diff --git a/tests/engine/test_substitute_lineage_compat.py b/tests/engine/test_substitute_lineage_compat.py index 4c38afba..d596de81 100644 --- a/tests/engine/test_substitute_lineage_compat.py +++ b/tests/engine/test_substitute_lineage_compat.py @@ -38,7 +38,6 @@ from anonymizer.engine.detection._graph_profile import run_detection_redact from anonymizer.engine.detection.detection_workflow import EntityDetectionResult, EntityDetectionWorkflow from anonymizer.engine.graph_sdk._execution_delegated import source_worker_borrow -from anonymizer.engine.ndd._graph_requests import NddPhysicalTransport from anonymizer.engine.ndd.adapter import RECORD_ID_COLUMN, FailedRecord, NddAdapter, WorkflowRunResult from anonymizer.engine.replace.llm_replace_workflow import LlmReplaceResult, LlmReplaceWorkflow from anonymizer.engine.replace.replace_runner import ReplacementWorkflow @@ -49,7 +48,7 @@ from anonymizer.graph import InvocationId from anonymizer.interface.errors import AnonymizerWorkflowError from tests.engine import test_detection_source_frame as detection_tests -from tests.engine.test_detection_graph_publication import _modify_dd_responses +from tests.engine.test_detection_source_frame import _modify_dd_responses pytestmark = pytest.mark.compat scripted_detection = detection_tests.scripted_detection @@ -260,7 +259,6 @@ def respond(frame: pd.DataFrame, **kwargs: Any) -> WorkflowRunResult: replace_method=Substitute(), replacement=ReplacementWorkflow(llm_workflow=LlmReplaceWorkflow(adapter)), replacement_models=ReplaceModelSelection(replacement_generator="test"), - opaque=True, ) assert result.dataframe[COL_REPLACED_TEXT].tolist() == ["Maya"] # The caller's detector runs opaque; the builtin Substitute it hands off to runs standalone, as in the release. @@ -268,19 +266,9 @@ def respond(frame: pd.DataFrame, **kwargs: Any) -> WorkflowRunResult: assert result.dataframe[COL_TEXT].tolist() == ["Alice"] and not result.failed_records -def test_native_map_accepts_normalized_nested_numpy_source( - scripted_detection: Any, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_map_accepts_normalized_nested_numpy_source(scripted_detection: Any, monkeypatch: pytest.MonkeyPatch) -> None: designer, models, _, calls = scripted_detection map_prompts: list[str] = [] - resources: list[NddPhysicalTransport] = [] - initialize = NddPhysicalTransport.__init__ - - def capture_resource(self: NddPhysicalTransport) -> None: - initialize(self) - resources.append(self) - - monkeypatch.setattr(NddPhysicalTransport, "__init__", capture_resource) def respond(request: httpx.Request, answer: httpx.Response) -> httpx.Response: messages = json.dumps(json.loads(request.content)["messages"]) @@ -319,15 +307,9 @@ def respond(request: httpx.Request, answer: httpx.Response) -> httpx.Response: assert nested["entities_by_value"][0]["value"] == "Alice" assert nested["entities_by_value"][0]["labels"].tolist() == ["first_name"] assert result._execution is not None and len(result._execution.frames) == 1 - assert result._execution.requests.dispatched_count == len(result._execution.requests.settlements) == 2 + # DataDesigner's own transport makes the map request: the graph accounts it as one opaque invocation. + assert not result._execution.requests.dispatches assert not result._execution.requests.remote_outstanding and not result._execution.requests.local_in_flight - roles = [] - for request in result._execution.requests.dispatches: - assert request.child is not None - roles.append(request.child.role) - assert roles == ["readiness", "generation"] - assert len(resources) == 1 and resources[0].closed - assert resources[0].clients and all(client._inner._closed for client in resources[0].clients) @pytest.mark.parametrize("combined", [False, True]) diff --git a/tests/interface/test_dd_capability_abort.py b/tests/interface/test_dd_capability_abort.py index a14fa6e2..c7d2836f 100644 --- a/tests/interface/test_dd_capability_abort.py +++ b/tests/interface/test_dd_capability_abort.py @@ -1,18 +1,15 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""A DataDesigner release without the private hooks the request bridge needs aborts the whole run. +"""A DataDesigner release without the private hooks the source-frame binding needs aborts the whole run. -DataDesigner creates model facades inside column generators, where a raise could otherwise become a -dropped row. These cases remove a hook from a real DataDesigner and check that ``run()`` and ``preview()`` -raise ``AnonymizerWorkflowError`` instead of returning a partial result. +These cases remove a hook from a real DataDesigner and check that ``run()`` and ``preview()`` raise +``AnonymizerWorkflowError`` before any model request, instead of returning a partial result. """ from __future__ import annotations import asyncio import json -from collections.abc import Iterator -from contextlib import contextmanager from pathlib import Path from typing import Any @@ -26,7 +23,7 @@ from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput, Rewrite, Substitute from anonymizer.config.models import EvaluateModelSelection, RewriteModelSelection -from anonymizer.engine.ndd._graph_requests import SUPPORTED_DATA_DESIGNER_RANGE, NddRequestBridge +from anonymizer.engine.ndd._graph_requests import SUPPORTED_DATA_DESIGNER_RANGE from anonymizer.interface.errors import AnonymizerWorkflowError from tests.interface.test_rewrite_withholding import _content @@ -35,16 +32,7 @@ _RECORDS = ["Alice travels.", "Alice stays."] -class _NoInner: - """Stands in for a facade client whose release dropped the ``_inner`` HTTP client.""" - - def __init__(self, client: Any) -> None: - self.client = client - - -def _designer( - tmp_path: Path, fault: str, sent: list[httpx.Request], bridged: list[bool] -) -> tuple[DataDesigner, list[Any]]: +def _designer(tmp_path: Path, sent: list[httpx.Request]) -> tuple[DataDesigner, list[Any]]: clients: list[Any] = [] designer = DataDesigner( artifact_path=tmp_path / "artifacts", @@ -84,23 +72,17 @@ def respond(request: httpx.Request) -> httpx.Response: def record(request: httpx.Request) -> httpx.Response: sent.append(request) - return httpx.Response(500) + return respond(request) def resources(*args: Any, **kwargs: Any) -> Any: provider: Any = original(*args, **kwargs) registry = provider.model_registry - if bridged and fault == "provider": - del registry._model_facade_factory - return provider factory = registry._model_facade_factory - faulted = bool(bridged) def facade(*args: Any, **kwargs: Any) -> Any: result: Any = factory(*args, **kwargs) - result._client._inner._transport = httpx.MockTransport(record if faulted else respond) + result._client._inner._transport = httpx.MockTransport(record) clients.append(result._client) - if faulted: - result._client = _NoInner(result._client) return result registry._model_facade_factory = facade @@ -111,7 +93,6 @@ def facade(*args: Any, **kwargs: Any) -> Any: def _model_configs() -> str: - # The replacement model is health-checked: only then does the map run as bridged graph requests. models = [ ModelConfig(alias=name, model=name, provider="scripted", skip_health_check=name != "mapper") for name in ("detector", "singleton", "mapper") @@ -135,91 +116,6 @@ def _model_configs() -> str: ) -def _run( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mode: str, preview: bool, fault: str -) -> tuple[Any, BaseException | None, list[bool], list[httpx.Request]]: - sent: list[httpx.Request] = [] - bridged: list[bool] = [] - entered: list[bool] = [] - bind = NddRequestBridge.bind - - @contextmanager - def bind_marked(self: NddRequestBridge, designer: Any) -> Iterator[None]: - # The hook is removed only while a graph request bridge is bound; unbridged stages run as released. - with bind(self, designer): - entered.append(True) - bridged.append(True) - try: - yield - finally: - bridged.clear() - - monkeypatch.setattr(NddRequestBridge, "bind", bind_marked) - designer, clients = _designer(tmp_path, fault, sent, bridged) - source = tmp_path / "input.csv" - pd.DataFrame({"text": _RECORDS}).to_csv(source, index=False) - anonymizer = Anonymizer(data_designer=designer, model_configs=_model_configs()) - config = ( - AnonymizerConfig(replace=Substitute(), emit_telemetry=False) - if mode == "substitute" - else AnonymizerConfig(rewrite=Rewrite(), emit_telemetry=False) - ) - data = AnonymizerInput(source=str(source), text_column="text") - result: Any = None - error: BaseException | None = None - try: - if preview: - result = anonymizer.preview(config=config, data=data, num_records=len(_RECORDS)) - else: - result = anonymizer.run(config=config, data=data) - except AnonymizerWorkflowError as raised: - error = raised - finally: - - async def close() -> None: - for client in clients: - await client.aclose() - - asyncio.run_coroutine_threadsafe(close(), ensure_async_engine_loop()).result(timeout=30) - return result, error, entered, sent - - -@pytest.mark.parametrize("fault", ["provider", "facade"]) -@pytest.mark.parametrize("preview", [False, True], ids=["run", "preview"]) -def test_missing_dd_hook_aborts_substitute_without_partial_result( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preview: bool, fault: str -) -> None: - result, error, entered, sent = _run(tmp_path, monkeypatch, "substitute", preview, fault) - - assert entered, "the Substitute map did not bind a graph request bridge" - assert result is None - assert isinstance(error, AnonymizerWorkflowError) - assert not sent - message = str(error) - attribute = "ModelRegistry._model_facade_factory" if fault == "provider" else "ModelRequestExecutor._inner" - assert f"private `{attribute}`" in message - assert SUPPORTED_DATA_DESIGNER_RANGE in message - assert "Alice" not in message - - -@pytest.mark.parametrize("fault", ["provider", "facade"]) -@pytest.mark.parametrize("preview", [False, True], ids=["run", "preview"]) -def test_rewrite_does_not_depend_on_the_bridge_hooks( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preview: bool, fault: str -) -> None: - """Rewrite binds no graph request bridge, so it never touches the hooks and completes in full. - - If Rewrite starts binding the bridge, this test fails on ``entered`` and must assert the abort instead. - """ - result, error, entered, sent = _run(tmp_path, monkeypatch, "rewrite", preview, fault) - - assert not entered - assert error is None - assert not sent - assert len(result.dataframe) == len(_RECORDS) - assert not result.failed_records - - @pytest.mark.parametrize("fault", ["designer", "builder"]) @pytest.mark.parametrize("preview", [False, True], ids=["run", "preview"]) @pytest.mark.parametrize("mode", ["substitute", "rewrite"]) @@ -228,7 +124,7 @@ def test_missing_source_frame_hook_aborts_every_route_before_any_request( ) -> None: """Every route runs DD through ``NddAdapter.run_workflow``, which binds the source frame to DD's builder.""" sent: list[httpx.Request] = [] - designer, clients = _designer(tmp_path, "facade", sent, []) + designer, clients = _designer(tmp_path, sent) if fault == "designer": monkeypatch.setattr(designer, "_create_dataset_builder", None) else: diff --git a/tests/interface/test_dd_interrupt_and_retry_compat.py b/tests/interface/test_dd_interrupt_and_retry_compat.py index 78207132..b91cbb2a 100644 --- a/tests/interface/test_dd_interrupt_and_retry_compat.py +++ b/tests/interface/test_dd_interrupt_and_retry_compat.py @@ -389,17 +389,6 @@ def test_substitute_map_retries_transient_errors_like_release( } execution = getattr(result, "_execution", None) if execution is not None: - # Each attempt is its own dispatch and settlement; a retry follows its failed predecessor on the chain. - # Attempts beyond the record's admitted leaves (17) are DataDesigner-owned and unaccounted, which is - # honest only because the invocation declares no request upper bound. - accounted = min(failures, 17) - maps = [ - item for item in execution.requests.dispatches if item.child is not None and item.child.role == "generation" - ] - # The two other records' requests, each accounted failure, and the record's successful final attempt - # unless that attempt fell past the allowance (DataDesigner-owned) or the record was dropped. - assert len(maps) == 2 + accounted + (kept == 3 and failures < 17) - assert sorted(item.child.source_kind for item in maps) == sorted(["initial"] * 3 + ["retry"] * (len(maps) - 3)) - terminals = Counter(item.category for item in execution.requests.terminals) - assert terminals["failure"] == accounted and not execution.requests.defects + # DataDesigner's own transport retries the map requests; the graph accounts the map as one opaque call. + assert not execution.requests.dispatches assert execution._execution.context.prepared.declared_request_upper_bound is None diff --git a/tests/interface/test_detector_signature_compat.py b/tests/interface/test_detector_signature_compat.py index 9de33b1e..13ec6de3 100644 --- a/tests/interface/test_detector_signature_compat.py +++ b/tests/interface/test_detector_signature_compat.py @@ -229,7 +229,6 @@ def test_release_040_detector_through_graph_invocation_withholds_source_frame( "compute_grouped_entities": True, }, replace_method=strategy(), - opaque=True, ) assert detector.calls == 1 diff --git a/tests/interface/test_rewrite_graph.py b/tests/interface/test_rewrite_graph.py index 9ba3dd99..213035af 100644 --- a/tests/interface/test_rewrite_graph.py +++ b/tests/interface/test_rewrite_graph.py @@ -303,7 +303,7 @@ def run(self, dataframe, **kwargs): # type: ignore[override] "generate_map_only", lambda self, frame, **kwargs: WorkflowRunResult(frame.copy(), [], tuple(range(len(frame)))), ) - from tests.engine.test_detection_graph_publication import _modify_dd_responses + from tests.engine.test_detection_source_frame import _modify_dd_responses def latent(request, answer): if "LatentEntitiesSchema" not in request.content.decode(): diff --git a/tests/interface/test_substitute_map_requests.py b/tests/interface/test_substitute_map_requests.py index ed8c6936..d976d00f 100644 --- a/tests/interface/test_substitute_map_requests.py +++ b/tests/interface/test_substitute_map_requests.py @@ -1,20 +1,15 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Substitute replacement-map requests through the public API: concurrency and cancellation match 0.4.0. +"""Substitute replacement-map requests through the public API: concurrency matches 0.4.0. -Only the HTTP transport is scripted (``httpx.MockTransport`` with a fixed latency); DataDesigner, the graph -request bridge and the replacement-map generation run for real. Runtime: about 3 s for the latency gate and -about 6 s for the interrupt test (a subprocess that imports the package). +Only the HTTP transport is scripted (``httpx.MockTransport`` with a fixed latency); DataDesigner and the +replacement-map generation run for real. Runtime: about 3 s. """ from __future__ import annotations import asyncio import json -import os -import signal -import subprocess -import sys import time from collections.abc import Iterator from pathlib import Path @@ -166,162 +161,5 @@ def test_substitute_map_requests_run_concurrently_up_to_max_parallel_requests( # 0.4.0 runs the map in one wave (about 1 x latency); one request at a time takes about 16 x latency. assert map_stage < 3 * _LATENCY_S, f"map stage {map_stage:.2f}s for {_RECORDS} requests of {_LATENCY_S}s" assert mock.peak >= 8 - # Per-request accounting stays exact under concurrency: every map request is dispatched once and settles - # once with its exact usage, after its readiness request. - requests = getattr(result, "_execution").requests - assert requests.dispatched_count == _RECORDS + 1 - assert [terminal.category for terminal in requests.terminals] == ["success"] * (_RECORDS + 1) - assert len(requests.settlements) == _RECORDS + 1 - assert not requests.defects and not requests.local_in_flight and not requests.remote_outstanding - - -_INTERRUPTED_RUN = r""" -import asyncio, json, signal, sys, tempfile, time -from pathlib import Path - -import httpx, pandas as pd, yaml -from data_designer.config.models import ModelConfig, ModelProvider -from data_designer.interface.data_designer import DataDesigner - -from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput, Substitute - -signal.signal(signal.SIGINT, signal.default_int_handler) -log = open(sys.argv[1], "a", buffering=1) - - -def emit(**fields): - log.write(json.dumps({"t": time.monotonic(), **fields}) + "\n") - - -def completion(model, content): - choice = {"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"} - usage = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - body = {"id": "s", "object": "chat.completion", "created": 0, "model": model, "choices": [choice], "usage": usage} - return httpx.Response(200, json=body) - - -def fenced(value): - return "```json\n" + json.dumps(value) + "\n```" - - -names = ["Alice", "Bob", "Carol"] - - -async def handler(request): - body = json.loads(request.content) - model, messages = body["model"], json.dumps(body["messages"]) - if model == "detector": - name = next(name for name in names if name + " waved." in messages) - entity = {"text": name, "label": "first_name", "start": 0, "end": len(name), "score": 0.9} - return completion(model, json.dumps({"entities": [entity]})) - if "Validate entity tags" in messages: - ids = dict.fromkeys(f"first_name_0_{len(name)}" for name in names if name in messages) - return completion(model, fenced({"decisions": [{"id": item, "decision": "keep"} for item in ids]})) - if "Find untagged sensitive entities" in messages: - return completion(model, fenced({"entities": []})) - if "Generate synthetic replacements" not in messages: - return completion(model, "Hello!") - emit(event="map_request") - try: - await asyncio.sleep(60) - except asyncio.CancelledError: - emit(event="map_cancelled") - raise - emit(event="map_answered") - return completion(model, fenced({"replacements": []})) - - -root = Path(tempfile.mkdtemp()) -designer = DataDesigner( - artifact_path=root / "artifacts", - model_providers=[ModelProvider(name="s", endpoint="http://scripted.invalid/v1", provider_type="openai", api_key="x")], - auto_configure_logging=False, -) -original = designer._create_resource_provider - - -def resources(*args, **kwargs): - provider = original(*args, **kwargs) - factory = provider.model_registry._model_facade_factory - - def facade(*args, **kwargs): - result = factory(*args, **kwargs) - result._client._inner._transport = httpx.MockTransport(handler) - return result - - provider.model_registry._model_facade_factory = facade - return provider - - -designer._create_resource_provider = resources -models = [ModelConfig(alias=n, model=n, provider="s", skip_health_check=n == "detector") for n in ("detector", "llm")] -roles = {"entity_detector": "detector", "entity_validator": ["llm"], "entity_augmenter": "llm", "latent_detector": "llm"} -configs = { - "model_configs": [model.model_dump(mode="json", exclude_none=True) for model in models], - "selected_models": {"detection": roles, "replace": {"replacement_generator": "llm"}}, -} -source = root / "input.csv" -pd.DataFrame({"text": [name + " waved." for name in names]}).to_csv(source, index=False) -anonymizer = Anonymizer(data_designer=designer, model_configs=yaml.safe_dump(configs)) -try: - anonymizer.run(config=AnonymizerConfig(replace=Substitute()), data=AnonymizerInput(source=str(source), text_column="text")) - emit(event="finished") -except KeyboardInterrupt as error: - from anonymizer.engine.harness.kernel import retained_execution - - execution = retained_execution(error) - requests = getattr(execution, "requests", None) - emit( - event="interrupted", - terminals=None if requests is None else sorted(terminal.category for terminal in requests.terminals), - dispatched=None if requests is None else requests.dispatched_count, - ) - raise -""" - - -def test_interrupt_during_substitute_map_cancels_in_flight_requests_promptly(tmp_path: Path) -> None: - script, log = tmp_path / "interrupted_run.py", tmp_path / "events.jsonl" - script.write_text(_INTERRUPTED_RUN) - environment = {**os.environ, "NEMO_TELEMETRY_ENABLED": "false"} - child = subprocess.Popen( - [sys.executable, str(script), str(log)], env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE - ) - - def events() -> list[dict[str, Any]]: - return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] - - try: - deadline = time.monotonic() + 60 - while "map_request" not in [item["event"] for item in events()] and child.poll() is None: - assert time.monotonic() < deadline, "no map request started" - time.sleep(0.02) - time.sleep(0.3) # Let the run reach its steady in-flight set (three requests in 0.4.0). - assert child.poll() is None, child.stderr.read().decode() if child.stderr else "" - interrupted = time.monotonic() - child.send_signal(signal.SIGINT) - second = None - while child.poll() is None and time.monotonic() - interrupted < 10: - if second is None and time.monotonic() - interrupted >= 1: - # 0.4.0 has stopped by now; a second Ctrl-C must not be needed, and is not swallowed by a wait. - second = time.monotonic() - child.send_signal(signal.SIGINT) - time.sleep(0.01) - elapsed = time.monotonic() - interrupted - finally: - if child.poll() is None: - child.kill() - child.wait() - - recorded = events() - names = [item["event"] for item in recorded] - # 0.4.0 stops within about half a second; the bridged route used to wait for the in-flight request. - assert elapsed < 2, f"run ended {elapsed:.2f}s after the first SIGINT" - assert second is None - assert child.returncode == -signal.SIGINT - # Every in-flight request is cancelled (three: the map runs one wave), none is answered or started late. - assert names.count("map_request") == names.count("map_cancelled") == 3 - assert "map_answered" not in names and "finished" not in names - (outcome,) = [item for item in recorded if item["event"] == "interrupted"] - # Truthful accounting: a cancelled exchange has no stop evidence from the provider, so each request is lost. - assert outcome["terminals"] is not None and outcome["terminals"].count("lost") == 3 + # DataDesigner's own transport makes the map requests: the graph accounts the map as one opaque call. + assert not getattr(result, "_execution").requests.dispatches From 46b9fbdef31eb9196ab181fb5e13d37a767280e6 Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Wed, 21 Oct 2026 19:30:47 +0000 Subject: [PATCH 2/5] refactor: drive delegated bookkeeping synchronously instead of hopping to the owner loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The facade's bodies and DataDesigner's per-row hooks no longer call back into the graph's owner loop and wait for it. Their bookkeeping (enter, complete, ready, result, authorize) never awaits anything that can suspend, so it now runs to completion on the calling thread under one runtime lock; `drive()` raises if an owner path ever suspends, which turns the "no owner path suspends" claim into a runtime check. This applies to every owner without physical children, which after the opaque Substitute map is every facade invocation. An owner with physical children (the SDK's worker/foreign-transport path) keeps its owner-loop gate, so in-flight children still serialize bookkeeping as before. One dependency was not visible in the owner methods: Rewrite's repair loop needs the scheduler to start the next iteration's subgraph between two bookkeeping calls of the same body. After a synchronous call that changed the ledger, the runtime applies one scheduler frontier without launching work (subgraph starts and closures only); launches stay on the owner loop. Scheduler state transitions and synchronous bookkeeping hold the same runtime lock, and the scheduler is woken thread-safely. The bindings' loop fields and the hop sites' loop arguments go with the hops. The owner-plural yields (asyncio.sleep(0) every 64 items) go too. One validation-plugin test case injected a suspending _authorize_source; that interleaving no longer exists and the case is removed. Measured (src): 9 files, +140 / -105, net +35 (the design estimated about -50; the SDK child path keeps its owner-loop gate and the frontier step is new). Tests: 1 file, +2 / -17. Gate: full suite pandas 2.3.3 and 3.0.6 5554 passed each; make format-check and make typecheck clean; parity corpus vs main f4f2b73d 0 BLOCKING (both pandas), R0 0 BLOCKING. Runtime probe (tests/interface): 10,038 synchronous drives, 0 owner-loop hops, 0 suspensions. 🤖 Generated with Claude Code Co-Authored-By: Claude Signed-off-by: Aaron Gonzales --- .../engine/detection/_graph_invocation.py | 12 +- .../engine/detection/_opaque_redact.py | 18 +-- .../engine/detection/_semantic_detection.py | 26 ++--- .../engine/graph_sdk/_execution_delegated.py | 104 +++++++++++++----- .../engine/graph_sdk/_execution_runtime.py | 30 ++++- .../engine/replace/_semantic_map.py | 5 +- .../engine/replace/_semantic_map_profile.py | 14 +-- .../engine/rewrite/_semantic_evaluation.py | 11 +- .../engine/rewrite/_semantic_profile.py | 25 ++--- tests/engine/test_validation_plugin_graph.py | 19 +--- 10 files changed, 142 insertions(+), 122 deletions(-) diff --git a/src/anonymizer/engine/detection/_graph_invocation.py b/src/anonymizer/engine/detection/_graph_invocation.py index 34eb496d..57526834 100644 --- a/src/anonymizer/engine/detection/_graph_invocation.py +++ b/src/anonymizer/engine/detection/_graph_invocation.py @@ -8,7 +8,6 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Mapping from contextvars import copy_context from dataclasses import dataclass, replace @@ -25,6 +24,7 @@ ) from anonymizer.engine.detection.postprocess import EntitySpan from anonymizer.engine.detection.run_arguments import detection_run_arguments +from anonymizer.engine.graph_sdk._execution_delegated import drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests, LocalCompleted, LocalFailure from anonymizer.engine.graph_sdk._graph_values import ArtifactRef, DatumId from anonymizer.engine.graph_sdk.capabilities import FrozenConfig @@ -56,7 +56,6 @@ class DetectionRedactInvocation: output_port: str outcome: str semantic_slots: Mapping[str, int] | None = None - semantic_loop: asyncio.AbstractEventLoop | None = None semantic_transform_slot: int | None = None semantic_entry_slot: int | None = None handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] | None = None @@ -92,9 +91,7 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests # The submitting profile classified the runner: only a builtin one has detection children. if self.semantic_slots is not None: - if self.semantic_loop is None: - raise AnonymizerWorkflowError("Detection binding has no owning loop") - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), self.semantic_loop).result() + drive(complete_detection_entry(requests, inputs)) detected = lower_detection( self.detection, self.source, @@ -102,7 +99,6 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests requests, frame, dict(self.semantic_slots), - self.semantic_loop, self.semantic_entry_slot, ) else: @@ -159,8 +155,6 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests if item.association in rows ) if self.semantic_transform_slot is not None: - if self.semantic_loop is None: - raise AnonymizerWorkflowError("Detection binding has no owning loop") transform_slot = self.semantic_transform_slot completed_sources = {} @@ -197,7 +191,7 @@ async def finish() -> None: await complete_empty_stage(requests, [item]) results = accepted - asyncio.run_coroutine_threadsafe(finish(), self.semantic_loop).result() + drive(finish()) successful = tuple(completed_sources[association] for association in successful) results_by_source = {item.association: item for item in results} results = tuple(results_by_source[association] for association in successful) diff --git a/src/anonymizer/engine/detection/_opaque_redact.py b/src/anonymizer/engine/detection/_opaque_redact.py index 125e85f2..13cc4915 100644 --- a/src/anonymizer/engine/detection/_opaque_redact.py +++ b/src/anonymizer/engine/detection/_opaque_redact.py @@ -30,7 +30,7 @@ _validate_targets, ) from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject -from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool +from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool, drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests from anonymizer.engine.graph_sdk._workflow_values import ( ArtifactType, @@ -86,7 +86,6 @@ class _DetectionInvocation: detection_options: Mapping[str, Any] name: str = "opaque-detection-replace" semantic_slots: Mapping[str, int] | None = None - semantic_loop: asyncio.AbstractEventLoop | None = None semantic_targets: tuple[Any, ...] = () semantic_transform_slot: int | None = None semantic_entry_slot: int | None = None @@ -114,11 +113,9 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests } # Slots exist only for builtin runners, which the graph lowers directly; caller runners run opaque. if self.semantic_slots is not None: - if self.semantic_loop is None: - raise AnonymizerWorkflowError("Detection binding has no owning loop") from anonymizer.engine.detection._semantic_detection import complete_detection_entry, lower_detection - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), self.semantic_loop).result() + drive(complete_detection_entry(requests, inputs)) source = requests.bind_frame( self.source, targets=self.semantic_targets, source_limit=len(self.semantic_targets) ) @@ -129,7 +126,6 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests requests, source, dict(self.semantic_slots), - self.semantic_loop, self.semantic_entry_slot, ) else: @@ -145,7 +141,7 @@ def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests from anonymizer.engine.replace._semantic_map import MapBinding from anonymizer.engine.replace.replace_runner import replacement_stage - if self.replacement_receiver is None or self.replacement_arguments is None or self.semantic_loop is None: + if self.replacement_receiver is None or self.replacement_arguments is None: reject(EffectCode.MISSING) options = self.replacement_arguments map_slots = self.map_slots @@ -165,7 +161,6 @@ async def enter_map(): requests, WorkflowSourceFrame.capture(detected.dataframe, associations=active, invocation_bound=True), {name: slot for name, slot in map_slots.items() if name != "entry"}, - self.semantic_loop, self.replacement_receiver._llm_workflow, detected.dataframe, dict( @@ -176,7 +171,7 @@ async def enter_map(): preview_num_records=options["preview_num_records"], ), apply=True, - entry_inputs=asyncio.run_coroutine_threadsafe(enter_map(), self.semantic_loop).result(), + entry_inputs=drive(enter_map()), ) mapped = map_binding.lower() replaced = ReplacementResult(mapped.dataframe, list(mapped.failed_records)) @@ -191,8 +186,6 @@ async def enter_map(): else detected.source_positions ) if self.semantic_transform_slot is not None: - if self.semantic_loop is None: - raise AnonymizerWorkflowError("Detection binding has no owning loop") transform_slot = self.semantic_transform_slot async def finish() -> None: @@ -212,7 +205,7 @@ async def finish() -> None: completed(item.association), ) - asyncio.run_coroutine_threadsafe(finish(), self.semantic_loop).result() + drive(finish()) succeeded = ( {source.associations[position] for position in selected_positions} if self.semantic_slots is not None @@ -577,7 +570,6 @@ async def execute_injected_redact( callback = replace( callback, semantic_slots=detection_slots(prepared, detection_ids), - semantic_loop=asyncio.get_running_loop(), semantic_entry_slot=next( slot.index for slot in prepared.reservation_recipe if slot.template == detection_ids["entry"] ) diff --git a/src/anonymizer/engine/detection/_semantic_detection.py b/src/anonymizer/engine/detection/_semantic_detection.py index c33410ab..f01c6d31 100644 --- a/src/anonymizer/engine/detection/_semantic_detection.py +++ b/src/anonymizer/engine/detection/_semantic_detection.py @@ -33,7 +33,7 @@ _validate_targets, ) from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject -from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool +from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool, drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests from anonymizer.engine.graph_sdk._workflow_values import ( ArtifactType, @@ -320,7 +320,6 @@ class DetectionBinding(StageBinding): requests: DelegatedRequests source: WorkflowSourceFrame slots: dict[str, int] - loop: asyncio.AbstractEventLoop receiver: EntityDetectionWorkflow arguments: dict[str, Any] entry: pd.DataFrame @@ -456,9 +455,8 @@ def lower(self) -> EntityDetectionResult: return EntityDetectionResult(selected, failures, positions) def call(self, operation: Any) -> Any: - from anonymizer.engine.graph_sdk._execution_delegated import source_worker_borrow - return asyncio.run_coroutine_threadsafe(operation, source_worker_borrow().loop).result() + return drive(operation) async def complete_empty_entry(self) -> None: if self.entry_slot is None: @@ -559,7 +557,6 @@ def lower_detection( requests: DelegatedRequests, source: WorkflowSourceFrame, slots: dict[str, int], - loop: asyncio.AbstractEventLoop, entry_slot: int | None = None, ) -> EntityDetectionResult: """Lower builtin detection directly into the admitted graph's detection children.""" @@ -567,7 +564,7 @@ def lower_detection( with stage_timer( "EntityDetectionWorkflow.run", input_row_count=len(frame), tag_latent_entities=values["tag_latent_entities"] ) as measurement: - result = DetectionBinding(requests, source, slots, loop, receiver, values, frame, entry_slot).lower() + result = DetectionBinding(requests, source, slots, receiver, values, frame, entry_slot).lower() measurement.update(output_row_count=len(result.dataframe), failed_record_count=len(result.failed_records)) return result @@ -746,8 +743,8 @@ async def execute_detection( def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: source = requests.bind_frame(frame, targets=tuple(targets), source_limit=size) - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), loop).result() - result = DetectionBinding(requests, source, slots, loop, receiver, arguments, frame).lower() + drive(complete_detection_entry(requests, inputs)) + result = DetectionBinding(requests, source, slots, receiver, arguments, frame).lower() captured.append(result) successful = tuple(source.associations[index] for index in result.source_positions) terminal = [] @@ -801,7 +798,7 @@ async def finish() -> None: ), ) - asyncio.run_coroutine_threadsafe(finish(), loop).result() + drive(finish()) return LocalCompleted( results=tuple( AssociationResult( @@ -915,18 +912,16 @@ def run_empty_detection( limits=DataLimits(max_datums=0, max_targets=0, max_text_bytes=0, max_declarations=0, max_group_members=0), ) prepared = _prepare_frame_plan(data, raw, capabilities, (), invocation_owned=True) - loop = None def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: del inputs - if loop is None or requests.cancelled: + if requests.cancelled: reject(EffectCode.FOREIGN_OWNER) source = requests.bind_frame(frame, targets=(), source_limit=0) binding = DetectionBinding( requests, source, detection_slots(prepared, ids), - loop, receiver, arguments, frame, @@ -934,10 +929,10 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) ) selected = binding.lower() owner_slot = next(slot.index for slot in prepared.reservation_recipe if slot.template == node) - item = asyncio.run_coroutine_threadsafe(requests.enter_operation(owner_slot, None), loop).result() + item = drive(requests.enter_operation(owner_slot, None)) if item is None: reject(EffectCode.REQUEST_CAUSALITY) - asyncio.run_coroutine_threadsafe(complete_empty_stage(requests, [item]), loop).result() + drive(complete_empty_stage(requests, [item])) return LocalCompleted( results=(), frame=InvocationFrame( @@ -946,9 +941,6 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) ) async def execute(pool: SubmitterPool, handoff: Callable[..., Any]) -> ReplacementResult: - nonlocal loop - loop = asyncio.get_running_loop() - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: context = copy_context() return handoff(lambda: context.run(callback, inputs, requests)) diff --git a/src/anonymizer/engine/graph_sdk/_execution_delegated.py b/src/anonymizer/engine/graph_sdk/_execution_delegated.py index df4be03a..bc598096 100644 --- a/src/anonymizer/engine/graph_sdk/_execution_delegated.py +++ b/src/anonymizer/engine/graph_sdk/_execution_delegated.py @@ -8,8 +8,10 @@ import inspect from collections.abc import Callable, Coroutine from concurrent.futures import Future +from contextlib import suppress from contextvars import ContextVar, copy_context from dataclasses import replace +from threading import get_ident from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast from anonymizer.engine.graph_sdk._activation_reducer import advance_activation @@ -115,6 +117,20 @@ class _Failed(Exception): } +def drive(operation: Coroutine[object, object, _T]) -> _T: + """Run delegated bookkeeping to completion on the calling thread. + + Owner bookkeeping never awaits anything that can suspend; this enforces it at runtime instead of + hopping to the owner loop and waiting for it. + """ + try: + operation.send(None) + except StopIteration as done: + return cast(_T, done.value) + operation.close() + raise RuntimeError("delegated graph bookkeeping suspended") + + def source_worker_borrow() -> _WorkerBorrow: """Recover the issued worker; source membership is checked on its owner loop.""" borrow = _WORKER_BORROW.get() @@ -263,7 +279,18 @@ def _check_origin(self) -> None: if loop is not self.loop and loop is not self.transport_loop: reject(EffectCode.UNSUPPORTED) + def _drive(self, operation: Callable[[], Coroutine[object, object, _T]]) -> _T: + """Bookkeeping from synchronous code: driven here, or on the owner loop while physical children may run.""" + self._require_borrow() + if self.owner.direct: + return self.owner._drive_call(self, operation) + if asyncio._get_running_loop() is self.owner.loop: + reject(EffectCode.UNSUPPORTED) # Waiting here would block the owner loop it waits for. + return asyncio.run_coroutine_threadsafe(self.owner._worker_call(self, operation), self.owner.loop).result() + async def _call(self, operation: Callable[[], Coroutine[object, object, _T]], *, concurrent: bool = False) -> _T: + if not concurrent and self.owner.direct: + return self._drive(operation) self._check_origin() future = asyncio.run_coroutine_threadsafe( self.owner._worker_call(self, operation, concurrent=concurrent), self.owner.loop @@ -280,6 +307,8 @@ async def _call(self, operation: Callable[[], Coroutine[object, object, _T]], *, raise async def authorize_source(self, association: SemanticAssociation) -> None: + if self.owner.direct: + return self.authorize_source_sync(association) self._require_borrow() future = asyncio.run_coroutine_threadsafe( self.owner._worker_call(self, lambda: self.owner._authorize_source(association)), self.owner.loop @@ -300,21 +329,8 @@ async def authorize_source(self, association: SemanticAssociation) -> None: raise cancellation completion.result() - def _check_sync_origin(self) -> None: - """Sync callbacks block their thread, so they must not run on the owner loop (no loop is fine).""" - self._require_borrow() - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - if loop is self.owner.loop: - reject(EffectCode.UNSUPPORTED) - def authorize_source_sync(self, association: SemanticAssociation) -> None: - self._check_sync_origin() - asyncio.run_coroutine_threadsafe( - self.owner._worker_call(self, lambda: self.owner._authorize_source(association)), self.owner.loop - ).result() + self._drive(lambda: self.owner._authorize_source(association)) def require_transport(self, transport: object) -> None: self._require_borrow() @@ -381,13 +397,11 @@ async def close_observed_requests( return await self._call(lambda: self.owner.close_observed_requests(associations, generated)) def authorize_sources_sync(self, associations: tuple[SemanticAssociation, ...]) -> None: - self._check_sync_origin() - async def authorize() -> None: for association in associations: await self.owner._authorize_source(association) - asyncio.run_coroutine_threadsafe(self.owner._worker_call(self, authorize), self.owner.loop).result() + self._drive(authorize) async def readiness(self) -> PhysicalRequestId: return await self._call(self.owner.readiness) @@ -431,6 +445,10 @@ def __init__( self.inputs = tuple(value for job in jobs for value in job.inputs) self.loop = asyncio.get_running_loop() self.closed = False + # The thread currently driving bookkeeping synchronously under the runtime lock, if any, and whether + # that call changed the ledger in a way the scheduler must see. + self.driving: int | None = None + self.wake_pending = False # Borrowed children in flight, each keyed by its request chain (None: readiness or probe). self.children: dict[asyncio.Task[PhysicalRequestId | None], RequestChainId | None] = {} self.children_idle = asyncio.Event() @@ -470,6 +488,9 @@ def __init__( for slot in group: self.chains[job.association, slot] = chain self.own_chains = frozenset(chain.chain for chain in self.chains.values()) + # Without physical children nothing can be in flight beside the body, so its bookkeeping needs no + # owner-loop gate: it is driven synchronously on the calling thread (every facade invocation). + self.direct = not contract.child_slots and contract.readiness is None self.owns_children = any(item.submission_owner == self.policy.node for item in runtime.policies.values()) def require_transport(self, transport: object) -> None: @@ -518,9 +539,19 @@ def _source_job(self, association: SemanticAssociation | None = None) -> _Operat return self.jobs[association] def _check(self) -> None: - if self.closed or asyncio.get_running_loop() is not self.loop: + if self.closed or (self.driving != get_ident() and asyncio._get_running_loop() is not self.loop): reject(EffectCode.UNSUPPORTED) + def _wake(self) -> None: + """Wake the scheduler: after a synchronous drive returns, or now on the owner loop, or thread-safely.""" + if self.driving == get_ident(): + self.wake_pending = True + elif asyncio._get_running_loop() is self.loop: + self.runtime.control.scheduler_changed.set() + else: + with suppress(RuntimeError): # A closed owner loop has no scheduler left to wake. + self.loop.call_soon_threadsafe(self.runtime.control.scheduler_changed.set) + async def aparser_rejected(self, request: PhysicalRequestId) -> ChildEvidence: return self.parser_rejected(request) @@ -696,7 +727,7 @@ async def complete_operation(self, association: SemanticAssociation, result: Loc else: mapping = _mapping(job.policy, "failure", None, "malformed_response") final = self.runtime._complete_job(job, mapping, results, assessments) - self.runtime.control.scheduler_changed.set() + self._wake() return final async def ready_operations_many(self, associations: tuple[SemanticAssociation, ...]) -> tuple[tuple[int, ...], ...]: @@ -714,8 +745,6 @@ async def enter_operations( reject(EffectCode.INVALID_TYPE) entered = [] for index, association in enumerate(associations): - if index and index % 64 == 0: - await asyncio.sleep(0) entered.append(await self.enter_operation(slot, association)) return tuple(entered) @@ -735,8 +764,6 @@ async def complete_operations( reject(EffectCode.INVALID_TYPE) outcomes = [] for index, item in enumerate(results): - if index and index % 64 == 0: - await asyncio.sleep(0) if type(item) is not tuple or len(item) != 2: reject(EffectCode.INVALID_TYPE) association, result = item @@ -788,8 +815,9 @@ async def request( def _check_submission(self, *, concurrent: bool = False) -> None: self._check() - task = asyncio.current_task() - worker = task in self.entered_calls + driven = self.driving == get_ident() + task = None if driven else asyncio.current_task() + worker = driven or task in self.entered_calls # Only worker physical requests overlap borrowed children; same-loop submission keeps one at a time. if (task is not self.submission and not worker) or (self.active and not (concurrent and worker)): reject(EffectCode.UNSUPPORTED) @@ -849,6 +877,28 @@ def _cancel_worker(self, borrow: _WorkerBorrow) -> None: if self.worker_borrow is borrow: self.cancel_requested = True + def _drive_call(self, borrow: _WorkerBorrow, operation: Callable[[], Coroutine[object, object, _T]]) -> _T: + """Run one worker bookkeeping call synchronously on the calling thread, under the runtime lock.""" + with self.runtime.lock: + if self.closed: + reject(EffectCode.UNSUPPORTED) + if self.worker_borrow is not borrow or _WORKER_BORROW.get() is not borrow or borrow.expired: + reject(EffectCode.FOREIGN_OWNER) + if self.driving is not None or self.active or self.cancelled: + reject(EffectCode.REQUEST_CAUSALITY) + if len(self.jobs) == 1 and not self.contract.generation_groups and self.entered_calls: + reject(EffectCode.REQUEST_CAUSALITY) + self.driving = get_ident() + try: + return drive(operation()) + finally: + self.driving = None + if self.wake_pending: + self.wake_pending = False + # The frontier the owner loop would apply before the body's next call; launches stay there. + self.runtime.advance() + self._wake() + async def _worker_call( self, borrow: _WorkerBorrow, @@ -971,7 +1021,7 @@ async def close_observed_requests( source.state = advance_activation( state=source.state, event=CloseUnstarted(activation=key, category="blocked") ) - self.runtime.control.scheduler_changed.set() + self._wake() async def _readiness(self) -> PhysicalRequestId: descriptor = self.contract.readiness @@ -1201,7 +1251,7 @@ def _finish( assessments: tuple[LocalAssessmentResult, ...] = (), ) -> None: self.runtime._complete_job(job, mapping, results, assessments) - self.runtime.control.scheduler_changed.set() + self._wake() async def _drain(self, task: asyncio.Task[object]) -> None: while not task.done(): diff --git a/src/anonymizer/engine/graph_sdk/_execution_runtime.py b/src/anonymizer/engine/graph_sdk/_execution_runtime.py index e0b5252a..48e876d6 100644 --- a/src/anonymizer/engine/graph_sdk/_execution_runtime.py +++ b/src/anonymizer/engine/graph_sdk/_execution_runtime.py @@ -7,6 +7,7 @@ import asyncio from collections.abc import Callable from contextlib import suppress +from threading import RLock from typing import TypeVar, cast from anonymizer.engine.graph_sdk._activation_reducer import advance_activation @@ -194,12 +195,16 @@ def __init__( self.publication_failures: list[PublicationFailureFact] = [] self.submission_failures: list[SubmissionFailure] = [] self.submissions: dict[_ExecutionTask, tuple[_OperationJob[DatumId | InvocationId], ...]] = {} + # Delegated bookkeeping may arrive synchronously from another thread (DataDesigner's engine loop); + # it and the scheduler's state transitions hold this lock. + self.lock = RLock() async def run(self) -> ExecutionResult: try: while True: - scheduled = self._schedule_ready() - self._close_observed_activations() + with self.lock: + scheduled = self._schedule_ready() + self._close_observed_activations() if all((state.complete for state in self.frame.all_states)) and (not self.submissions): break if not self.submissions: @@ -235,7 +240,17 @@ def _close_observed_activations(self, changed: ActivationState | None = None) -> span.outcome = entry.status span.finish() - def _schedule_ready(self) -> bool: + def advance(self) -> None: + """Apply one scheduler frontier without launching work: subgraph starts and closures only. + + Synchronous delegated bookkeeping calls this after it changed the ledger, so a body that is still + running sees the same frontier the owner loop's scheduler would have applied between its calls. + """ + with self.lock: + self._schedule_ready(launch=False) + self._close_observed_activations() + + def _schedule_ready(self, *, launch: bool = True) -> bool: if self.control.cancelled: changed = False for source in (*self.frame.sources.values(), self.frame.invocation_source): @@ -361,6 +376,8 @@ def _schedule_ready(self) -> bool: source.state = advance_activation(state=source.state, event=Start(activation=entry.activation)) scheduled = True continue + if not launch: + continue batchable = isinstance(scope, DatumId) and policy.kind in {"delegated", "external"} shared_batch = policy.kind == "delegated" or all( @@ -534,9 +551,10 @@ async def _process_completed(self) -> None: await scheduler_wakeup if scheduler_wakeup in completed: self.control.scheduler_changed.clear() - for task in tuple(self.submissions): - if task in completed: - self._complete_submission(task) + with self.lock: + for task in tuple(self.submissions): + if task in completed: + self._complete_submission(task) def _complete_submission(self, task: _ExecutionTask) -> None: jobs = self.submissions.pop(task) diff --git a/src/anonymizer/engine/replace/_semantic_map.py b/src/anonymizer/engine/replace/_semantic_map.py index 714b6a40..8983c1a9 100644 --- a/src/anonymizer/engine/replace/_semantic_map.py +++ b/src/anonymizer/engine/replace/_semantic_map.py @@ -4,7 +4,6 @@ from __future__ import annotations -import asyncio import json from dataclasses import dataclass, field, replace from typing import Any, cast @@ -21,6 +20,7 @@ ) from anonymizer.engine.graph_sdk._authoring_defaults import _compile_graph, _operation_spec, _outcome_spec from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject +from anonymizer.engine.graph_sdk._execution_delegated import drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests, LocalCompleted from anonymizer.engine.graph_sdk._workflow_values import ( ArtifactType, @@ -252,7 +252,6 @@ class MapBinding(StageBinding): requests: DelegatedRequests source: WorkflowSourceFrame slots: dict[str, int] - loop: asyncio.AbstractEventLoop receiver: LlmReplaceWorkflow entry: pd.DataFrame arguments: dict[str, Any] @@ -300,7 +299,7 @@ def authorize(self) -> None: source_worker_borrow().authorize_sources_sync(self.source.associations) def call(self, operation: Any) -> Any: - return asyncio.run_coroutine_threadsafe(operation, self.loop).result() + return drive(operation) async def ready( self, associations: tuple[Any, ...] diff --git a/src/anonymizer/engine/replace/_semantic_map_profile.py b/src/anonymizer/engine/replace/_semantic_map_profile.py index d40a3345..003ba0d8 100644 --- a/src/anonymizer/engine/replace/_semantic_map_profile.py +++ b/src/anonymizer/engine/replace/_semantic_map_profile.py @@ -22,7 +22,7 @@ _validate_targets, ) from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject -from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool +from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool, drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests from anonymizer.engine.graph_sdk._workflow_values import ( InputBinding, @@ -191,14 +191,13 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) bound_frame = dataframe if COL_TEXT in dataframe else dataframe.assign(**{COL_TEXT: [""] * size}) issued = requests.bind_frame(bound_frame, targets=tuple(targets), source_limit=size) source = WorkflowSourceFrame.capture(dataframe, associations=issued.associations, invocation_bound=True) - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), loop).result() + drive(complete_detection_entry(requests, inputs)) map_entry_slot = slots["entry"] - entry_inputs = asyncio.run_coroutine_threadsafe( + entry_inputs = drive( requests.enter_operations(map_entry_slot, source.associations) if size - else _enter_empty(requests, map_entry_slot), - loop, - ).result() + else _enter_empty(requests, map_entry_slot) + ) if any(item is None for item in entry_inputs): reject(EffectCode.REQUEST_CAUSALITY) entry_inputs = cast(tuple[AssociationInput, ...], entry_inputs) @@ -206,7 +205,6 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) requests, source, {k: v for k, v in slots.items() if k != "entry"}, - loop, receiver, dataframe, arguments, @@ -238,7 +236,7 @@ async def finish() -> None: ) ) - asyncio.run_coroutine_threadsafe(finish(), loop).result() + drive(finish()) return LocalCompleted( results=tuple( AssociationResult( diff --git a/src/anonymizer/engine/rewrite/_semantic_evaluation.py b/src/anonymizer/engine/rewrite/_semantic_evaluation.py index e347564f..d2e694f2 100644 --- a/src/anonymizer/engine/rewrite/_semantic_evaluation.py +++ b/src/anonymizer/engine/rewrite/_semantic_evaluation.py @@ -39,6 +39,7 @@ COL_WEIGHTED_LEAKAGE_RATE, ) from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject +from anonymizer.engine.graph_sdk._execution_delegated import drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests from anonymizer.engine.graph_sdk._workflow_values import ArtifactType from anonymizer.engine.graph_sdk.executor import AssessmentFinding, LocalAssessmentResult @@ -95,7 +96,6 @@ class RewriteEvaluationBinding(StageBinding): requests: DelegatedRequests source: WorkflowSourceFrame slots: dict[str, int] - loop: asyncio.AbstractEventLoop state: EvaluationState criteria: EvaluationCriteria iteration_slots: dict[int, dict[str, int]] = field(default_factory=dict) @@ -129,7 +129,6 @@ def authorize_entry(self) -> None: if ( self is not self._entry_owner or self.requests is not borrow - or self.loop is not borrow.owner.loop or borrow.owner.closed or type(self.source) is not WorkflowSourceFrame or not self.source.invocation_bound @@ -402,7 +401,7 @@ async def observe(rows: pd.DataFrame, name: str, iteration: int) -> None: prefix=name == "prefix", ) - asyncio.run_coroutine_threadsafe(observe(frame, "prefix", 0), self.loop).result() + drive(observe(frame, "prefix", 0)) async def ready() -> tuple[int, list[int]] | None: selected = {} @@ -412,7 +411,7 @@ async def ready() -> tuple[int, list[int]] | None: selected.setdefault(slot, []).append(index) return min(selected.items()) if selected else None - while (batch := asyncio.run_coroutine_threadsafe(ready(), self.loop).result()) is not None: + while (batch := drive(ready())) is not None: slot, indices = batch name, iteration = stages[slot] rows = frame.iloc[indices].copy() @@ -432,7 +431,7 @@ async def ready() -> tuple[int, list[int]] | None: rows, result.dataframe, source_positions=result.source_positions, overwrite=True, seed_cols=seed_cols ) if self.opaque: - asyncio.run_coroutine_threadsafe(observe(rows, name, iteration), self.loop).result() + drive(observe(rows, name, iteration)) if name == "repair": if COL_REWRITTEN_TEXT_NEXT in rows: rows[COL_REWRITTEN_TEXT] = rows[COL_REWRITTEN_TEXT_NEXT] @@ -478,7 +477,7 @@ def columns(self, columns: list[ColumnConfigT]) -> list[ColumnConfigT]: ] async def _call(self, operation: Coroutine[Any, Any, _T]) -> _T: - return await asyncio.shield(asyncio.wrap_future(asyncio.run_coroutine_threadsafe(operation, self.loop))) + return drive(operation) def stage(self, frame: pd.DataFrame, positions: tuple[int, ...], *, iteration: int = 0) -> _StageArguments: return { diff --git a/src/anonymizer/engine/rewrite/_semantic_profile.py b/src/anonymizer/engine/rewrite/_semantic_profile.py index 00b9e303..5a1ad803 100644 --- a/src/anonymizer/engine/rewrite/_semantic_profile.py +++ b/src/anonymizer/engine/rewrite/_semantic_profile.py @@ -30,7 +30,7 @@ _validate_targets, ) from anonymizer.engine.graph_sdk._effect_values import EffectCode, reject -from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool +from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool, drive from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests from anonymizer.engine.graph_sdk._workflow_values import ( ArtifactType, @@ -433,11 +433,11 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) if map_entry is not None: from anonymizer.engine.detection._semantic_detection import complete_detection_entry - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), loop).result() + drive(complete_detection_entry(requests, inputs)) if detection_ids: from anonymizer.engine.detection._semantic_detection import complete_detection_entry - asyncio.run_coroutine_threadsafe(complete_detection_entry(requests, inputs), loop).result() + drive(complete_detection_entry(requests, inputs)) def detect() -> EntityDetectionResult | None: if detection is None: @@ -456,7 +456,6 @@ def detect() -> EntityDetectionResult | None: requests, original_frame, detection_slots(prepared, detection_ids), - loop, ) return detection.run(source, **arguments_for_detection) @@ -497,9 +496,7 @@ def prepare_maps(entity_rows: pd.DataFrame, passthrough_rows: pd.DataFrame): if rows.empty: continue associations = tuple(active[position] for position in rows[ROW_ORDER_COL]) - entry_inputs = asyncio.run_coroutine_threadsafe( - requests.enter_operations(map_slots["entry"], associations), loop - ).result() + entry_inputs = drive(requests.enter_operations(map_slots["entry"], associations)) helper = LlmReplaceWorkflow(adapter) if passthrough else map_factory(adapter=adapter) # The builtin map helper is lowered by this graph; a caller's helper runs opaque, as in the release. native = builtin_map(helper) @@ -509,7 +506,6 @@ def prepare_maps(entity_rows: pd.DataFrame, passthrough_rows: pd.DataFrame): requests, WorkflowSourceFrame.capture(rows, associations=associations, invocation_bound=True), {k: v for k, v in map_slots.items() if k != "entry"}, - loop, helper if native else LlmReplaceWorkflow(adapter), rows, map_arguments, @@ -539,15 +535,14 @@ async def begin_map_prefix() -> None: for association in surviving: await requests.enter_operation(slots["prefix"], association) - asyncio.run_coroutine_threadsafe(begin_map_prefix(), loop).result() - asyncio.run_coroutine_threadsafe(binding.passthrough(detected_frame), loop).result() + drive(begin_map_prefix()) + drive(binding.passthrough(detected_frame)) return map_result binding = RewriteEvaluationBinding( requests, WorkflowSourceFrame.capture(detected_frame, associations=active, invocation_bound=True), slots, - loop, _evaluation_state(0 if combined else None, COL_REWRITTEN_TEXT_INITIAL if combined else COL_REWRITTEN_TEXT), iteration_slots=iteration_slots, combined=combined, @@ -564,9 +559,9 @@ async def begin_prefix() -> None: for association in active: await requests.enter_operation(slots["prefix"], association) - asyncio.run_coroutine_threadsafe(begin_prefix(), loop).result() + drive(begin_prefix()) if not map_ids: - asyncio.run_coroutine_threadsafe(binding.passthrough(detected_frame), loop).result() + drive(binding.passthrough(detected_frame)) binding.authorize_entry() result = selected_runner._run_lowering(detected_frame, binding=binding, **arguments) if on_result is not None: @@ -575,9 +570,7 @@ async def begin_prefix() -> None: reject(EffectCode.MISSING) positions = resolve_source_positions(detected_frame, result.dataframe, result.source_positions) successful = tuple(binding.source.associations[index] for index in positions) - selected, projected = asyncio.run_coroutine_threadsafe( - binding.finish(result.dataframe, successful), loop - ).result() + selected, projected = drive(binding.finish(result.dataframe, successful)) publication = FramePublication.capture( projected, associations=tuple(selected[association] for association in successful), diff --git a/tests/engine/test_validation_plugin_graph.py b/tests/engine/test_validation_plugin_graph.py index 2b7229c1..5a788c33 100644 --- a/tests/engine/test_validation_plugin_graph.py +++ b/tests/engine/test_validation_plugin_graph.py @@ -352,8 +352,7 @@ def generate(): asyncio.run(run()) -@pytest.mark.parametrize("phase", ["authorization", "chunks"]) -def test_owned_async_plugin_repeated_cancel_keeps_submission_until_chunks_end(monkeypatch, phase): +def test_owned_async_plugin_repeated_cancel_keeps_submission_until_chunks_end(): from threading import Event from anonymizer.engine.graph_sdk.executor import LocalFailure @@ -380,18 +379,6 @@ async def agenerate(self, **kwargs): prompt=kwargs["prompt"], parser=kwargs["parser"], system_prompt=None, purpose=None, kwargs={} ) - if phase == "authorization": - from anonymizer.engine.graph_sdk._execution_delegated import _DelegatedOwner - - authorize = _DelegatedOwner._authorize_source - - async def held(owner, association): - entered.set() - while not release.is_set(): - await asyncio.sleep(0.01) - return await authorize(owner, association) - - monkeypatch.setattr(_DelegatedOwner, "_authorize_source", held) generator = _generator(Model("v0", response={"decisions": []})) execution, services, *_ = _fixture() @@ -426,9 +413,7 @@ def callback(inputs, borrowed): finally: release.set() result = await asyncio.wait_for(running, _HANG_GUARD_TIMEOUT) - assert len(finished) == (2 if phase == "chunks" else 0) - if phase == "authorization": - assert captured["cancellation"] == ("first",) + assert len(finished) == 2 assert captured["borrow"].expired and pool.occupied == 0 assert not result.requests.dispatches and not loop.is_closed() From 0b2a072024be3101f5104236c9b3a67117d30aa1 Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Wed, 21 Oct 2026 20:10:39 +0000 Subject: [PATCH 3/5] refactor: run each graph invocation on a private loop on the caller's thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no bookkeeping hop left, nothing needs the graph's event loop to keep running while a synchronous body runs. Each public run, preview and evaluate now runs its graph on a private event loop on the caller's own thread (new_event_loop + run_until_complete, no Runner and so no SIGINT handler), and every body runs inline on that thread. The per-invocation host thread, the caller-thread queue and its serve loop, the handoff plumbing, and the settle/drain/cancel_host interrupt machinery are gone. A caller that already runs a loop (Jupyter, asyncio.run) is blocked by the synchronous call anyway: its running-loop marker is set aside for the private loop and restored around each body and on return. Python 3.14 tracks the executing task per thread, so on 3.14 the caller's current task is swapped with the marker. A caller that is itself a BlockingPool worker may start an invocation: it uses no pool, so it cannot wait on its own. Ctrl-C now lands where the body waits, inside DataDesigner, as in 0.4.0. A KeyboardInterrupt or SystemExit raised in a body is kept, the invocation is cancelled and settled, and the same exception is re-raised with the execution retained by the traceback. Bookkeeping, nested invocations and DataDesigner calls all run on the caller's thread; only DataDesigner's own engine threads remain. Tests: the queue-topology interrupt test (test_interrupt_handoff.py) and the nested-handoff test go; route rows stop pinning the handoff; the threads-started test now rejects any anonymizer-graph thread, including the former host. Measured (src): 9 files, +135 / -240, net -105 (design estimate about -130). Tests: 6 files, +19 / -136. Docs: 2 files, +5 / -6. Gate: full suite pandas 2.3.3 and 3.0.6 5547 passed each; make format-check and make typecheck clean; parity corpus vs main f4f2b73d 0 BLOCKING (both pandas), R0 0 BLOCKING; test_thread_placement.py green; threads started per run (5 methods x builtin/delegating detector): no anonymizer thread. 🤖 Generated with Claude Code Co-Authored-By: Claude Signed-off-by: Aaron Gonzales --- docs/concepts/graph-sdk.md | 9 +- docs/development/graph-sdk-cutover.md | 2 +- .../engine/detection/_graph_invocation.py | 16 +- .../engine/detection/_graph_profile.py | 6 +- .../engine/detection/_opaque_redact.py | 21 +-- .../engine/detection/_semantic_detection.py | 21 +-- .../engine/graph_sdk/_execution_delegated.py | 108 +++++++---- src/anonymizer/engine/harness/kernel.py | 175 +++++------------- .../engine/replace/_semantic_map_profile.py | 9 +- .../engine/rewrite/_semantic_profile.py | 18 +- .../engine/workflow_columns/detection/impl.py | 1 - tests/engine/test_all_replace_graph.py | 9 +- .../test_detection_graph_publication.py | 4 +- tests/engine/test_route_resolution.py | 15 +- tests/interface/test_interrupt_handoff.py | 110 ----------- tests/interface/test_rewrite_caller_thread.py | 13 +- tests/interface/test_thread_placement.py | 4 +- 17 files changed, 159 insertions(+), 382 deletions(-) delete mode 100644 tests/interface/test_interrupt_handoff.py diff --git a/docs/concepts/graph-sdk.md b/docs/concepts/graph-sdk.md index 61f44512..bff88497 100644 --- a/docs/concepts/graph-sdk.md +++ b/docs/concepts/graph-sdk.md @@ -223,11 +223,10 @@ thread that called them, at every depth, including built-in code reached through bodies, and every DataDesigner `create()`/`preview()` call and wait, run there too. Your code therefore sees that thread's thread-locals and can install signal handlers exactly when that thread is the main thread, and Ctrl-C reaches the run as in 0.4.0. -Some Anonymizer internals still run on other threads: the per-call -`anonymizer-graph-host` event-loop thread does bookkeeping such as stage completion, -entity grouping and schema parsing, and DataDesigner's engine threads run -Anonymizer's column generators and send model requests, as in 0.4.0. None of -them runs your code. Each call runs in a copy of the caller's +The graph's own bookkeeping runs on that thread as well, on a private event loop that +leaves a notebook's or `asyncio.run` caller's running loop untouched; only +DataDesigner's engine threads run Anonymizer's column generators and send model +requests, as in 0.4.0, and none of them runs your code. Each call runs in a copy of the caller's context variables: values set before the call are visible inside, and values set inside are not visible to the caller afterwards. diff --git a/docs/development/graph-sdk-cutover.md b/docs/development/graph-sdk-cutover.md index 4fa43210..b49a68c7 100644 --- a/docs/development/graph-sdk-cutover.md +++ b/docs/development/graph-sdk-cutover.md @@ -27,7 +27,7 @@ M2 and M3 can proceed independently after their M1 prerequisites. The campaign c ## Rewrite semantic controller checkpoint -Built-in Rewrite now uses one admitted controller for iterative batches and combined DataDesigner columns. Default facade calls, direct calls and supplied instances that inherit the library entry bind the actual runner, adapter, arguments and source before a single lowering entry. Injected detection callbacks share that Rewrite owner while preserving their caller thread and original argument objects. Every synchronous body runs on the thread that called the public entry point, with a copy of its context variables; values set inside are not visible to the caller afterwards. This covers the Rewrite body, built-in detection, Redact, Annotate, Hash and Substitute, built-in code reached from a caller override through `super()`, `build_graph` overrides, the built-in detection lowered inside Rewrite (including its stage overrides and its detection adapter's `run_workflow`), and the DataDesigner calls they make. No caller code and no DataDesigner wait runs off the caller thread. The per-invocation `anonymizer-graph-host` loop thread still runs Anonymizer internals (stage completion, entity grouping, schema parsing), and DataDesigner's engine threads run Anonymizer column generators, as on main. Arbitrary runner overrides remain opaque. The previous iterative repair loop, duplicate combined `run()` controller and last-populated-row finalizer have been removed. Physical batching and conditional column unrolling remain lowerings. +Built-in Rewrite now uses one admitted controller for iterative batches and combined DataDesigner columns. Default facade calls, direct calls and supplied instances that inherit the library entry bind the actual runner, adapter, arguments and source before a single lowering entry. Injected detection callbacks share that Rewrite owner while preserving their caller thread and original argument objects. Every synchronous body runs on the thread that called the public entry point, with a copy of its context variables; values set inside are not visible to the caller afterwards. This covers the Rewrite body, built-in detection, Redact, Annotate, Hash and Substitute, built-in code reached from a caller override through `super()`, `build_graph` overrides, the built-in detection lowered inside Rewrite (including its stage overrides and its detection adapter's `run_workflow`), and the DataDesigner calls they make. No caller code and no DataDesigner wait runs off the caller thread. Graph bookkeeping runs on the caller thread too, on a private event loop; only DataDesigner's engine threads run Anonymizer column generators, as on main. Arbitrary runner overrides remain opaque. The previous iterative repair loop, duplicate combined `run()` controller and last-populated-row finalizer have been removed. Physical batching and conditional column unrolling remain lowerings. Native generators publish their observed typed stages. Caller callbacks with opaque internals publish source-bound observations without invented QA assessments, repair history or physical request counts. Retained candidate values and presentation dtypes cannot change the accepted observation. Source snapshots detach structured semantic payloads, including supported model and NumPy cells, while retaining opaque caller metadata references. Rows of an admitted Rewrite are finalized by the lowering's binding instead of the combined graph's own finalization column. That column finalizes rows in two other cases: combined columns run outside an admitted Rewrite, and the built-in combined Rewrite under an adapter whose `run_workflow` is overridden. It projects the final state directly from the observed evaluation states without running the admitted transitions: the final state is the first evaluation state whose repair decision is false, or the last state when every decision calls for repair. A missing evaluation-state column, or a missing, null or non-boolean repair decision, in an active state fails explicitly rather than falling back to the first state. The projection reads no original text. Arbitrary injected runners retain their explicitly opaque compatibility contract. diff --git a/src/anonymizer/engine/detection/_graph_invocation.py b/src/anonymizer/engine/detection/_graph_invocation.py index 57526834..e7b8ed0c 100644 --- a/src/anonymizer/engine/detection/_graph_invocation.py +++ b/src/anonymizer/engine/detection/_graph_invocation.py @@ -8,8 +8,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from contextvars import copy_context +from collections.abc import Mapping from dataclasses import dataclass, replace from typing import Any, cast @@ -25,7 +24,7 @@ from anonymizer.engine.detection.postprocess import EntitySpan from anonymizer.engine.detection.run_arguments import detection_run_arguments from anonymizer.engine.graph_sdk._execution_delegated import drive -from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests, LocalCompleted, LocalFailure +from anonymizer.engine.graph_sdk._execution_values import DelegatedRequests, LocalCompleted from anonymizer.engine.graph_sdk._graph_values import ArtifactRef, DatumId from anonymizer.engine.graph_sdk.capabilities import FrozenConfig from anonymizer.engine.graph_sdk.requests import ( @@ -58,17 +57,8 @@ class DetectionRedactInvocation: semantic_slots: Mapping[str, int] | None = None semantic_transform_slot: int | None = None semantic_entry_slot: int | None = None - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] | None = None - def run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: - context = copy_context() - return ( - self._run(inputs, requests) - if self.handoff is None - else self.handoff(lambda: context.run(self._run, inputs, requests)) - ) - - def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: + def run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: if any( type(item.association) is not SemanticAssociation or len(item.inputs) != 1 diff --git a/src/anonymizer/engine/detection/_graph_profile.py b/src/anonymizer/engine/detection/_graph_profile.py index 200fcc90..a71a9fa8 100644 --- a/src/anonymizer/engine/detection/_graph_profile.py +++ b/src/anonymizer/engine/detection/_graph_profile.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from functools import partial from typing import Any @@ -20,7 +20,6 @@ from anonymizer.engine.detection._graph_invocation import DetectionRedactInvocation from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow from anonymizer.engine.graph_sdk._execution_delegated import SubmitterPool -from anonymizer.engine.graph_sdk.executor import LocalCompleted, LocalFailure from anonymizer.engine.harness import kernel from anonymizer.engine.replace._graph_redact import local_configuration from anonymizer.engine.replace.replace_runner import ReplacementResult, ReplacementWorkflow @@ -51,7 +50,6 @@ def run_detection_redact( async def operation( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: if replacement is not None: from anonymizer.engine.detection._opaque_redact import _DetectionInvocation, execute_injected_redact @@ -71,7 +69,6 @@ async def operation( model_configs, selected_models, detection_options, - handoff=handoff, replacement_receiver=replacement, replacement_arguments=dict( replace_method=replace_method, @@ -97,7 +94,6 @@ async def operation( detection_options, "redacted", "ok", - handoff=handoff, ), pool, ) diff --git a/src/anonymizer/engine/detection/_opaque_redact.py b/src/anonymizer/engine/detection/_opaque_redact.py index 13cc4915..8417737f 100644 --- a/src/anonymizer/engine/detection/_opaque_redact.py +++ b/src/anonymizer/engine/detection/_opaque_redact.py @@ -6,7 +6,6 @@ import asyncio from collections.abc import Callable, Mapping -from contextvars import copy_context from dataclasses import dataclass, replace from time import perf_counter from typing import Any, cast @@ -89,20 +88,11 @@ class _DetectionInvocation: semantic_targets: tuple[Any, ...] = () semantic_transform_slot: int | None = None semantic_entry_slot: int | None = None - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] | None = None replacement_receiver: Any | None = None replacement_arguments: dict[str, Any] | None = None map_slots: Mapping[str, int] | None = None def run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: - context = copy_context() - return ( - self._run(inputs, requests) - if self.handoff is None - else self.handoff(lambda: context.run(self._run, inputs, requests)) - ) - - def _run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: if requests.cancelled: return LocalFailure(failure="implementation_exception") started = perf_counter() @@ -257,8 +247,6 @@ class _EvaluationInvocation: source: pd.DataFrame evaluate: Callable[[DelegatedRequests], ReplacementResult | LocalFailure] retained_owner: object | None - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] - name: str = "opaque-evaluation" def run(self, inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: @@ -282,7 +270,7 @@ def evaluate() -> LocalCompleted | LocalFailure: ), ) - return self.handoff(evaluate) + return evaluate() def run_detection_rewrite( @@ -324,7 +312,6 @@ def run_detection_rewrite( async def operation( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: result = await execute_injected_redact( _DetectionInvocation( @@ -335,7 +322,6 @@ async def operation( selected_models, detection_options, name="opaque-detection-rewrite", - handoff=handoff, ), pool, ) @@ -356,11 +342,8 @@ def run_evaluation( async def operation( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: - result = await execute_injected_redact( - _EvaluationInvocation(source, evaluate, retained_owner, handoff, name), pool - ) + result = await execute_injected_redact(_EvaluationInvocation(source, evaluate, retained_owner, name), pool) return project_detection_redact(result) return _run_sync_profile(operation) diff --git a/src/anonymizer/engine/detection/_semantic_detection.py b/src/anonymizer/engine/detection/_semantic_detection.py index f01c6d31..f9ccacdf 100644 --- a/src/anonymizer/engine/detection/_semantic_detection.py +++ b/src/anonymizer/engine/detection/_semantic_detection.py @@ -6,8 +6,6 @@ import asyncio import json -from collections.abc import Callable -from contextvars import copy_context from dataclasses import dataclass, replace from inspect import signature from typing import Any, cast @@ -592,8 +590,8 @@ def run_direct_detection( return run_empty_detection(receiver, frame, arguments) captured = [] - async def operation(pool: SubmitterPool, handoff: Callable[..., Any]) -> ReplacementResult: - result = await execute_detection(receiver, frame, arguments, pool, captured, handoff) + async def operation(pool: SubmitterPool) -> ReplacementResult: + result = await execute_detection(receiver, frame, arguments, pool, captured) return project_detection_redact(result) projected = _run_sync_profile(operation) @@ -608,7 +606,6 @@ async def execute_detection( arguments: dict[str, Any], pool: SubmitterPool, captured: list[EntityDetectionResult], - handoff: Callable[..., Any], ) -> ExecutionResult: from anonymizer.engine.harness.kernel import _execute_frame_plan, _frame_implementation, _prepare_frame_plan @@ -820,15 +817,11 @@ async def finish() -> None: ), ) - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: - context = copy_context() - return handoff(lambda: context.run(callback, inputs, requests)) - return await _execute_frame_plan( prepared=prepared, capabilities=capabilities, policies=(*parent_policies, *child_policies), - callback=dispatch, + callback=callback, pool=pool, borrowed_loop=loop, limits=_execution_limits( @@ -940,16 +933,12 @@ def callback(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) ), ) - async def execute(pool: SubmitterPool, handoff: Callable[..., Any]) -> ReplacementResult: - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: - context = copy_context() - return handoff(lambda: context.run(callback, inputs, requests)) - + async def execute(pool: SubmitterPool) -> ReplacementResult: result = await _execute_frame_plan( prepared=prepared, capabilities=capabilities, policies=policies, - callback=dispatch, + callback=callback, pool=pool, borrowed_loop=asyncio.get_running_loop(), limits=_execution_limits( diff --git a/src/anonymizer/engine/graph_sdk/_execution_delegated.py b/src/anonymizer/engine/graph_sdk/_execution_delegated.py index bc598096..d1cdbc2e 100644 --- a/src/anonymizer/engine/graph_sdk/_execution_delegated.py +++ b/src/anonymizer/engine/graph_sdk/_execution_delegated.py @@ -6,8 +6,8 @@ import asyncio import inspect +import sys from collections.abc import Callable, Coroutine -from concurrent.futures import Future from contextlib import suppress from contextvars import ContextVar, copy_context from dataclasses import replace @@ -47,6 +47,7 @@ from anonymizer.engine.graph_sdk._workflow_values import SubgraphNode from anonymizer.engine.graph_sdk.blocking import ( _NOT_STARTED, + _WORKER, BlockingAffinity, BlockingPool, _BlockingAdapter, @@ -176,27 +177,73 @@ async def submit(self, inputs: tuple[AssociationInput, ...], requests: Delegated reject(EffectCode.UNSUPPORTED) -class CallerChannel: - """The synchronous caller's queue, seen from the host loop: ``channel(call)`` runs ``call`` on that thread.""" +# Python 3.14 tracks the executing task per thread, not per loop: a task of a second loop cannot start while a +# task of the first is executing on the same thread. Swapping the thread's current task alongside the running-loop +# marker restores the 3.11-3.13 behaviour; ``_swap_current_task`` only accepts the loop currently marked running. +_SWAP_TASK = sys.version_info >= (3, 14) - __slots__ = ("_put",) - def __init__(self, put: Callable[[Callable[[], Any]], Future[Any]]) -> None: - self._put = put +def _swap_task(loop: asyncio.AbstractEventLoop | None, task: asyncio.Task[Any] | None) -> asyncio.Task[Any] | None: + from asyncio import tasks - def __call__(self, call: Callable[[], Any]) -> Future[Any]: - return self._put(call) + return tasks._swap_current_task(loop, task) if _SWAP_TASK and loop is not None else None # type: ignore[attr-defined] -SubmitterPool = BlockingPool | CallerChannel +class InlineChannel: + """The synchronous caller seen from its private loop: ``run(call)`` runs a body inline on the caller's thread. + The body sees the caller's own running-loop marker (and current task), never the private loop. A + KeyboardInterrupt or SystemExit raised in a body is kept, the invocation is cancelled, and the kernel + re-raises that same exception once the private loop has settled. + """ + + __slots__ = ("interrupted", "outer", "outer_task", "root", "worker") + + def __init__(self) -> None: + self.outer: asyncio.AbstractEventLoop | None = None + self.outer_task: asyncio.Task[Any] | None = None + self.root: asyncio.Task[Any] | None = None + self.interrupted: BaseException | None = None + self.worker = False + + def enter(self) -> None: + """Set the caller's loop marker (and, on 3.14, task) aside; the caller's loop is blocked by this call. + + A caller that is itself a ``BlockingPool`` worker may start this invocation: it needs no pool, so it + cannot wait on the worker's own pool. + """ + self.outer = asyncio._get_running_loop() + self.outer_task = _swap_task(self.outer, None) + asyncio._set_running_loop(None) + self.worker, _WORKER.active = getattr(_WORKER, "active", False), False + + def exit(self) -> None: + _WORKER.active = self.worker + asyncio._set_running_loop(self.outer) + _swap_task(self.outer, self.outer_task) + + def run(self, call: Callable[[], Any]) -> Any: + own = asyncio._get_running_loop() + task = _swap_task(own, self.outer_task) + asyncio._set_running_loop(self.outer) + try: + return call() + except (KeyboardInterrupt, SystemExit) as error: + if self.interrupted is None: + self.interrupted = error + if self.root is not None: + self.root.cancel() + return LocalFailure(failure="implementation_exception") + finally: + asyncio._set_running_loop(own) + _swap_task(own, task) -class CallerThreadSubmitter(PrivateValue): - """Run the synchronous body on the thread that serves ``channel``, with the borrowed loop as host loop. - ``channel(call)`` queues ``call`` for that thread and returns a future for its result. The submission - keeps ownership until the queued call has actually returned, also when the observer is cancelled. - """ +SubmitterPool = BlockingPool | InlineChannel + + +class InlineSubmitter(PrivateValue): + """Run the synchronous body inline, on the private loop's (the caller's) thread, through ``channel``.""" execution: ClassVar[Literal["worker"]] = "worker" @@ -204,10 +251,10 @@ def __init__( self, *, callback: Callable[[tuple[AssociationInput, ...], DelegatedRequests], LocalResult], - channel: CallerChannel, + channel: InlineChannel, borrowed_loop: asyncio.AbstractEventLoop, ) -> None: - if not callable(callback) or inspect.iscoroutinefunction(callback) or not isinstance(channel, CallerChannel): + if not callable(callback) or inspect.iscoroutinefunction(callback) or not isinstance(channel, InlineChannel): reject(EffectCode.INVALID_TYPE) if not isinstance(borrowed_loop, asyncio.AbstractEventLoop): reject(EffectCode.INVALID_TYPE) @@ -229,32 +276,19 @@ async def submit(self, inputs: tuple[AssociationInput, ...], requests: Delegated reject(EffectCode.UNSUPPORTED) async def _run(self, callback: Callable[[], object], before: Callable[[], bool]) -> object: - completion = asyncio.wrap_future(self._channel(lambda: callback() if before() else _NOT_STARTED)) - try: - return await asyncio.shield(completion) - except asyncio.CancelledError: - while not completion.done(): - try: - await asyncio.shield(completion) - except asyncio.CancelledError: - continue - except Exception: - break - return completion.result() + return self._channel.run(callback) if before() else _NOT_STARTED -DELEGATED_SUBMITTERS = (WorkerDelegatedSubmitter, CallerThreadSubmitter) +DELEGATED_SUBMITTERS = (WorkerDelegatedSubmitter, InlineSubmitter) -def delegated_submitter_of(value: object) -> WorkerDelegatedSubmitter | CallerThreadSubmitter | None: +def delegated_submitter_of(value: object) -> WorkerDelegatedSubmitter | InlineSubmitter | None: """``value`` when it is exactly one of the owner-borrowing submitters, else None.""" - return ( - cast(WorkerDelegatedSubmitter | CallerThreadSubmitter, value) if type(value) in DELEGATED_SUBMITTERS else None - ) + return cast(WorkerDelegatedSubmitter | InlineSubmitter, value) if type(value) in DELEGATED_SUBMITTERS else None class _WorkerBorrow(PrivateValue): - def __init__(self, owner: _DelegatedOwner, adapter: WorkerDelegatedSubmitter | CallerThreadSubmitter) -> None: + def __init__(self, owner: _DelegatedOwner, adapter: WorkerDelegatedSubmitter | InlineSubmitter) -> None: self.owner = owner self.adapter = adapter self.loop = adapter.borrowed_loop @@ -731,7 +765,7 @@ async def complete_operation(self, association: SemanticAssociation, result: Loc return final async def ready_operations_many(self, associations: tuple[SemanticAssociation, ...]) -> tuple[tuple[int, ...], ...]: - """Coalesce loop handoffs while checking each source against the live owner.""" + """Coalesce bookkeeping calls while checking each source against the live owner.""" self._check_submission() if type(associations) is not tuple: reject(EffectCode.INVALID_TYPE) @@ -936,7 +970,7 @@ async def _worker_call( finally: self.worker_calls.discard(task) - async def _submit_worker(self, adapter: WorkerDelegatedSubmitter | CallerThreadSubmitter) -> LocalResult: + async def _submit_worker(self, adapter: WorkerDelegatedSubmitter | InlineSubmitter) -> LocalResult: borrow = _WorkerBorrow(self, adapter) self.worker_borrow = borrow context = copy_context() @@ -946,7 +980,7 @@ def call() -> object: return context.run(adapter._callback, self.inputs, borrow) try: - if isinstance(adapter, CallerThreadSubmitter): + if isinstance(adapter, InlineSubmitter): value = await adapter._run(call, lambda: not self.cancelled) else: value = await _BlockingInvocation(adapter, call).run(lambda: not self.cancelled, self) diff --git a/src/anonymizer/engine/harness/kernel.py b/src/anonymizer/engine/harness/kernel.py index 49e3aeb3..59cebb24 100644 --- a/src/anonymizer/engine/harness/kernel.py +++ b/src/anonymizer/engine/harness/kernel.py @@ -3,25 +3,22 @@ """Route-neutral execution kernel: plan preparation, admit/start, synchronous hosting and projection. Every adapter route (detection, replace, rewrite, evaluation, detection transforms) prepares a -fixed-operation plan, admits and starts it on the single Graph SDK executor, hosts it on a private -thread while serving caller-thread callbacks, and projects the canonical frame back to a DataFrame. +fixed-operation plan, admits and starts it on the single Graph SDK executor, runs it on a private +event loop on the caller's thread with every synchronous body inline, and projects the canonical +frame back to a DataFrame. """ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable -from concurrent.futures import Future, InvalidStateError -from contextvars import copy_context -from queue import Empty, Queue -from threading import Event, Thread, get_ident +from collections.abc import Callable, Coroutine from time import monotonic_ns from typing import Any, cast from anonymizer.engine.graph_sdk._activation_values import ActivationLimits from anonymizer.engine.graph_sdk._execution_delegated import ( - CallerChannel, - CallerThreadSubmitter, + InlineChannel, + InlineSubmitter, SubmitterPool, WorkerDelegatedSubmitter, ) @@ -126,10 +123,10 @@ def _runtime_rows(names: tuple[str, ...], external: bool) -> tuple[RuntimeOutcom def delegated_submitter( callback: Callable[..., Any], pool: SubmitterPool, borrowed_loop: asyncio.AbstractEventLoop -) -> WorkerDelegatedSubmitter | CallerThreadSubmitter: - """Run the delegated body on the synchronous caller (kernel-hosted) or on ``pool`` (standalone use).""" - if isinstance(pool, CallerChannel): - return CallerThreadSubmitter(callback=callback, channel=pool, borrowed_loop=borrowed_loop) +) -> WorkerDelegatedSubmitter | InlineSubmitter: + """Run the delegated body inline on the synchronous caller (kernel-hosted) or on ``pool`` (standalone use).""" + if isinstance(pool, InlineChannel): + return InlineSubmitter(callback=callback, channel=pool, borrowed_loop=borrowed_loop) return WorkerDelegatedSubmitter( callback=callback, pool=pool, @@ -139,127 +136,53 @@ def delegated_submitter( def _run_sync_profile( - operation: Callable[ - [CallerChannel, Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure]], - Awaitable[ReplacementResult], - ], + operation: Callable[[InlineChannel], Coroutine[Any, Any, ReplacementResult]], ) -> ReplacementResult: - """Host one graph invocation on a private loop thread; every synchronous body runs on the caller's thread.""" - started = Event() - cancellation: list[tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]]] = [] - # (call, future) pairs for the caller's thread; (None, None) marks the host's completion. - calls: Queue[tuple[Callable[[], Any], Future[Any]] | tuple[None, None]] = Queue() - serving = get_ident() + """Run one graph invocation on a private event loop on the caller's thread; bodies run inline. - # Every queued body's future until it settles, so a Ctrl-C can settle what it interrupted. - outstanding: set[Future[Any]] = set() - - def put(call: Callable[[], Any]) -> Future[Any]: - returned: Future[Any] = Future() - outstanding.add(returned) - returned.add_done_callback(outstanding.discard) - calls.put((call, returned)) - return returned - - def handoff(callback: Callable[[], LocalCompleted | LocalFailure]) -> LocalCompleted | LocalFailure: - if get_ident() == serving: - # Already on the caller's thread (inside a body it is running): run inline. - return callback() - return put(callback).result() - - def settle_failed(pending: Future[Any]) -> None: - if not pending.done(): - try: - pending.set_result(LocalFailure(failure="implementation_exception")) - except InvalidStateError: - pass - - def settle_outstanding() -> None: - # Only this thread runs bodies, so after an interrupt nothing is running: settle whatever - # is still waiting, including a body taken from the queue but not yet settled. - for pending in list(outstanding): - settle_failed(pending) - - def cancel_host() -> None: - # The host loop may already be closed; a stop request then has nothing left to stop. - if cancellation: - loop, task = cancellation[0] - try: - loop.call_soon_threadsafe(task.cancel) - except RuntimeError: - if not loop.is_closed(): - raise - - def serve_callback() -> None: - try: - # The timeout only keeps Ctrl-C responsive where a blocking get is not interruptible. - callback, returned = calls.get(timeout=0.1) - if callback is None or returned is None: - return - if interrupted is not None: - # The interrupt may already have settled it. - settle_failed(returned) - return - try: - returned.set_result(callback()) - except Exception as error: - returned.set_exception(error) - except Empty: - return - except BaseException: - settle_outstanding() - cancel_host() - raise - - async def run() -> ReplacementResult: - task = asyncio.current_task() - assert task is not None - cancellation.append((asyncio.get_running_loop(), task)) - started.set() - return await operation(CallerChannel(put), handoff) - - completion: Future[ReplacementResult] = Future() - completion.add_done_callback(lambda _: calls.put((None, None))) - - def host() -> None: + No asyncio Runner, so no SIGINT handler: Ctrl-C stays a KeyboardInterrupt and lands where the body is + waiting (inside DataDesigner, as in 0.4.0). A caller that already runs a loop (Jupyter, ``asyncio.run``) + is blocked by this synchronous call anyway, so its running-loop marker is set aside for the private + loop and restored around each body and on return. + """ + channel = InlineChannel() + loop = asyncio.new_event_loop() + failure: BaseException | None = None + channel.enter() + try: + root = loop.create_task(operation(channel)) + channel.root = root try: - completion.set_result(asyncio.run(run())) + result = loop.run_until_complete(root) except BaseException as error: - completion.set_exception(error) + if channel.interrupted is None: + raise + failure = error + finally: + try: + _close_private_loop(loop) finally: - started.set() + channel.exit() + if channel.interrupted is not None: + owner = retained_execution(failure) if failure is not None else result._execution + if owner is not None: + _retained_execution = owner + raise channel.interrupted + return result + - context = copy_context() - thread = Thread(target=lambda: context.run(host), name="anonymizer-graph-host") - thread.start() - interrupted: BaseException | None = None +def _close_private_loop(loop: asyncio.AbstractEventLoop) -> None: + """Cancel and settle whatever the invocation left on its loop, then close it (best effort, like a Runner).""" try: - while not completion.done(): - serve_callback() - return completion.result() - except BaseException as error: - interrupted = error - raise + pending = [task for task in asyncio.all_tasks(loop) if not task.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.run_until_complete(loop.shutdown_default_executor()) finally: - # Cancellation only requests a stop. Keep serving until the host has drained - # every queued body and the client work it started. - while thread.is_alive(): - try: - if interrupted is not None: - # Repeated every pass, so a further Ctrl-C inside the handler cannot leave a body waiting. - settle_outstanding() - started.wait() - cancel_host() - serve_callback() - thread.join(timeout=0.01) - except KeyboardInterrupt as error: - interrupted = error - if interrupted is not None: - failure = completion.exception() - owner = retained_execution(failure) if failure is not None else completion.result()._execution - if owner is not None: - _retained_execution = owner - raise interrupted + loop.close() def retained_execution(error: BaseException) -> ExecutionResult | None: diff --git a/src/anonymizer/engine/replace/_semantic_map_profile.py b/src/anonymizer/engine/replace/_semantic_map_profile.py index 003ba0d8..f8cad33d 100644 --- a/src/anonymizer/engine/replace/_semantic_map_profile.py +++ b/src/anonymizer/engine/replace/_semantic_map_profile.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio -from contextvars import copy_context from dataclasses import replace from typing import Any, cast @@ -81,7 +80,7 @@ def run_direct_map( captured = [] configuration = map_configuration(arguments) - async def execute(pool: SubmitterPool, handoff: Any) -> ReplacementResult: + async def execute(pool: SubmitterPool) -> ReplacementResult: graph = DataGraph.new() targets = [] texts = dataframe[COL_TEXT] if COL_TEXT in dataframe else [""] * len(dataframe) @@ -260,15 +259,11 @@ async def finish() -> None: ), ) - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted: - context = copy_context() - return handoff(lambda: context.run(callback, inputs, requests)) - result = await _execute_frame_plan( prepared=prepared, capabilities=capabilities, policies=policies, - callback=dispatch, + callback=callback, pool=pool, borrowed_loop=loop, limits=_execution_limits( diff --git a/src/anonymizer/engine/rewrite/_semantic_profile.py b/src/anonymizer/engine/rewrite/_semantic_profile.py index 5a1ad803..be3d2dc8 100644 --- a/src/anonymizer/engine/rewrite/_semantic_profile.py +++ b/src/anonymizer/engine/rewrite/_semantic_profile.py @@ -6,7 +6,6 @@ import asyncio from collections.abc import Callable -from contextvars import copy_context from copy import deepcopy from dataclasses import replace from time import perf_counter @@ -148,7 +147,6 @@ def run_builtin_rewrite(runner: RewriteWorkflow, source: pd.DataFrame, arguments async def operation( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: result = await execute_initial_evaluation( source, @@ -162,7 +160,6 @@ async def operation( ), runner=runner, opaque=opaque, - handoff=handoff, on_result=captured.append, ) projected = project_detection_redact(result) @@ -208,7 +205,6 @@ def run_semantic_rewrite( async def operation( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: result = await execute_initial_evaluation( source, @@ -221,7 +217,6 @@ async def operation( detection_arguments=detection_arguments, runner=runner, opaque=opaque, - handoff=handoff, ) return project_detection_redact(result) @@ -234,7 +229,6 @@ async def operation( async def rewrite_detected( pool: SubmitterPool, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure], ) -> ReplacementResult: result = await execute_initial_evaluation( detected.dataframe, @@ -248,7 +242,6 @@ async def rewrite_detected( ), runner=runner, opaque=opaque, - handoff=handoff, predetected=detected, ) return project_detection_redact(result) @@ -269,7 +262,6 @@ async def execute_initial_evaluation( runner: RewriteWorkflow | None = None, opaque: bool = False, on_result: Callable[[RewriteResult], None] | None = None, - handoff: Callable[[Callable[[], LocalCompleted | LocalFailure]], LocalCompleted | LocalFailure] | None = None, predetected: EntityDetectionResult | None = None, ) -> ExecutionResult: """Execute Rewrite lowerings under one admitted source-bound owner. @@ -462,7 +454,7 @@ def detect() -> EntityDetectionResult | None: if predetected is not None: detected = predetected else: - # Runs inline in the body, on the caller's thread; a nested handoff from here runs inline too. + # Runs inline in the body, on the caller's thread, as does any invocation nested in it. detected = detect() if requests.cancelled: return LocalFailure(failure="implementation_exception") @@ -601,17 +593,11 @@ async def begin_prefix() -> None: frame=publication, ) - def dispatch(inputs: tuple[AssociationInput, ...], requests: DelegatedRequests) -> LocalCompleted | LocalFailure: - if handoff is None: - return callback(inputs, requests) - context = copy_context() - return handoff(lambda: context.run(callback, inputs, requests)) - result = await _execute_frame_plan( prepared=prepared, capabilities=tuple(capabilities), policies=tuple(policies), - callback=dispatch, + callback=callback, pool=pool, borrowed_loop=loop, limits=replace(limits, max_runtime_artifacts=limits.max_runtime_artifacts + (20 * size if map_ids else 0)), diff --git a/src/anonymizer/engine/workflow_columns/detection/impl.py b/src/anonymizer/engine/workflow_columns/detection/impl.py index 28a08541..80598306 100644 --- a/src/anonymizer/engine/workflow_columns/detection/impl.py +++ b/src/anonymizer/engine/workflow_columns/detection/impl.py @@ -225,7 +225,6 @@ def validate(requests: DelegatedRequests) -> ReplacementResult: pd.DataFrame({COL_VALIDATION_DECISIONS: [data]}), validate, None, - lambda callback: callback(), "opaque-chunked-validation", ), pool, diff --git a/tests/engine/test_all_replace_graph.py b/tests/engine/test_all_replace_graph.py index 5ddd87db..77279df9 100644 --- a/tests/engine/test_all_replace_graph.py +++ b/tests/engine/test_all_replace_graph.py @@ -37,13 +37,14 @@ def test_public_local_replacement_keeps_authenticated_frame( scripted_detection, monkeypatch, tmp_path, strategy, expected ): original = strategy.model_copy(deep=True) - start = kernel.Thread.start + enter = kernel.InlineChannel.enter - def started(thread): + def entered(channel): + # The caller mutates its strategy once hosting starts; the graph must already hold its snapshot. strategy.format_template = "CHANGED" - start(thread) + enter(channel) - monkeypatch.setattr(kernel.Thread, "start", started) + monkeypatch.setattr(kernel.InlineChannel, "enter", entered) path = tmp_path / "local.csv" pd.DataFrame({"text": ["Alice travels."], "extra": [17]}).to_csv(path, index=False) facade = detection_tests.public_anonymizer(scripted_detection) diff --git a/tests/engine/test_detection_graph_publication.py b/tests/engine/test_detection_graph_publication.py index 7a7ccc03..7c1c5059 100644 --- a/tests/engine/test_detection_graph_publication.py +++ b/tests/engine/test_detection_graph_publication.py @@ -37,7 +37,7 @@ def test_delegated_source_partition_rejects_uncorrelated_completion(scripted_det from anonymizer.engine.graph_sdk.requests import SemanticAssociation from anonymizer.graph import TaskAttemptId - original = DetectionRedactInvocation._run + original = DetectionRedactInvocation.run def corrupted(self, inputs, requests): result = original(self, inputs, requests) @@ -53,7 +53,7 @@ def corrupted(self, inputs, requests): association = SemanticAssociation(task=TaskAttemptId.new(activation=association.task.activation)) return replace(result, failed_sources=frozenset({association})) - monkeypatch.setattr(DetectionRedactInvocation, "_run", corrupted) + monkeypatch.setattr(DetectionRedactInvocation, "run", corrupted) result = asyncio.run(run_graph_profile(scripted_detection, _source(), _options())) assert not result.final_outputs and not result.frames assert all(state.complete for state in result.states) diff --git a/tests/engine/test_route_resolution.py b/tests/engine/test_route_resolution.py index bc287224..1f33fda0 100644 --- a/tests/engine/test_route_resolution.py +++ b/tests/engine/test_route_resolution.py @@ -3,7 +3,7 @@ """Native vs opaque routing per runner shape, observed at each entry's executor boundary. Each row pins how the entry classifies its runner (``opaque`` or native) and that every entry is hosted -by the kernel with its handoff, whatever the route: a caller's code, or a caller's +by the kernel, whatever the route: a caller's code, or a caller's adapter, runs opaque; builtin code on a builtin adapter is lowered natively. Where each body actually runs is pinned end to end in ``tests/interface/test_thread_placement.py``. """ @@ -27,8 +27,6 @@ from anonymizer.engine.rewrite.combined_rewrite_workflow import CombinedRewriteWorkflow from anonymizer.engine.rewrite.rewrite_workflow import RewriteWorkflow -_HANDOFF = object() - def _any(value: object) -> Any: return cast(Any, value) @@ -112,7 +110,7 @@ def _patched_instance() -> NddAdapter: def _observe(monkeypatch: pytest.MonkeyPatch, module: Any, name: str) -> dict[str, Any]: - """Run an entry's kernel operation with a marked handoff; stop at the executor and keep what it received.""" + """Run an entry's kernel operation; stop at the executor and keep what it received.""" from anonymizer.engine.harness import kernel seen: dict[str, Any] = {} @@ -120,7 +118,7 @@ def _observe(monkeypatch: pytest.MonkeyPatch, module: Any, name: str) -> dict[st def run_sync_profile(operation: Any) -> Any: seen["hosted"] = True try: - asyncio.run(operation(None, _HANDOFF)) + asyncio.run(operation(None)) except _Stop: pass raise _Stop @@ -150,7 +148,6 @@ def test_builtin_detection_entry(monkeypatch: pytest.MonkeyPatch, adapter: Any, _semantic_detection.run_direct_detection(EntityDetectionWorkflow(adapter()), _SOURCE, {}) assert native_adapter(adapter()) is not opaque assert seen["hosted"] - assert seen["args"][5] is _HANDOFF @pytest.mark.parametrize( @@ -200,7 +197,6 @@ def test_builtin_rewrite_entry(monkeypatch: pytest.MonkeyPatch, runner: Any, opa _semantic_profile.run_builtin_rewrite(runner(), _SOURCE, _ARGUMENTS) assert seen["hosted"] assert seen["kwargs"]["opaque"] is opaque - assert seen["kwargs"]["handoff"] is _HANDOFF @pytest.mark.parametrize( @@ -224,7 +220,6 @@ def test_semantic_rewrite_entry(monkeypatch: pytest.MonkeyPatch, adapter: Any, o ) assert seen["hosted"] assert seen["kwargs"]["opaque"] is opaque - assert seen["kwargs"]["handoff"] is _HANDOFF _DETECTORS = [ @@ -257,7 +252,6 @@ def test_detection_redact_entry_opaque( ) assert builtin_detection(detector()) is builtin assert seen["hosted"] - assert seen["args"][0].handoff is _HANDOFF @pytest.mark.parametrize(("detector", "builtin"), _DETECTORS, ids=_DETECTOR_IDS) @@ -282,7 +276,6 @@ def test_detection_substitute_entry( replacement_models=_any(SimpleNamespace()), ) assert seen["hosted"] - assert seen["args"][0].handoff is _HANDOFF class _Signature040Rewrite: @@ -413,7 +406,7 @@ def new() -> Any: raise _GateSeen def run_sync_profile(operation: Any) -> Any: - asyncio.run(operation(None, _HANDOFF)) + asyncio.run(operation(None)) if patch_class: monkeypatch.setattr(LlmReplaceWorkflow, "generate_map_only", _delegating_generate_map_only) diff --git a/tests/interface/test_interrupt_handoff.py b/tests/interface/test_interrupt_handoff.py deleted file mode 100644 index 539d5af4..00000000 --- a/tests/interface/test_interrupt_handoff.py +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""A Ctrl-C that lands while the caller is between taking a body and settling it still ends the run. - -The caller's thread takes each synchronous body from a queue, runs it and settles the waiting -submission. A KeyboardInterrupt delivered right after the take, or right before the settle, must not -leave that submission waiting forever: the process has to exit on the interrupt, as in 0.4.0. -""" - -from __future__ import annotations - -import os -import signal -import subprocess -import sys -from pathlib import Path - -import pytest - -pytestmark = pytest.mark.compat - -_ROOT = Path(__file__).resolve().parents[2] - -_SCRIPT = r""" -import sys, tempfile, threading -from pathlib import Path - -from anonymizer.engine.harness import kernel -from tests.interface.test_thread_placement import _run - -point, method = sys.argv[1], sys.argv[2] -fired = [] - - -class InterruptedQueue(kernel.Queue): - def get(self, *args, **kwargs): - item = super().get(*args, **kwargs) - if point == "dequeue" and item[0] is not None and not fired: - fired.append(True) - raise KeyboardInterrupt - return item - - -class InterruptedFuture(kernel.Future): - def set_result(self, result): - on_caller = threading.current_thread() is threading.main_thread() - if point == "settle" and on_caller and not fired: - fired.append(True) - raise KeyboardInterrupt - return super().set_result(result) - - -kernel.Queue = InterruptedQueue -kernel.Future = InterruptedFuture -_run(Path(tempfile.mkdtemp()), method=method) -print("finished") -""" - - -@pytest.mark.parametrize("method", ["redact", "substitute", "rewrite"]) -@pytest.mark.parametrize("point", ["dequeue", "settle"]) -def test_interrupt_between_take_and_settle_exits(tmp_path: Path, point: str, method: str) -> None: - script = tmp_path / "interrupted.py" - script.write_text(_SCRIPT) - environment = { - **os.environ, - "NEMO_TELEMETRY_ENABLED": "false", - "PYTHONPATH": os.pathsep.join([str(_ROOT), os.environ.get("PYTHONPATH", "")]), - } - try: - child = subprocess.run( - [sys.executable, str(script), point, method], - cwd=_ROOT, - env=environment, - capture_output=True, - timeout=60, - ) - except subprocess.TimeoutExpired: - pytest.fail(f"run hung after a Ctrl-C at {point}") - assert child.returncode == -signal.SIGINT, child.stderr.decode()[-2000:] - assert b"finished" not in child.stdout - - -def test_interrupt_settles_queued_bodies_once() -> None: - """A body still queued when an earlier body is interrupted is settled once, as failed.""" - import asyncio - from types import SimpleNamespace - from typing import Any - - from anonymizer.engine.graph_sdk._execution_values import LocalFailure - from anonymizer.engine.harness.kernel import _run_sync_profile - - queued: list[Any] = [] - - def interrupted() -> Any: - raise KeyboardInterrupt - - async def operation(channel: Any, handoff: Any) -> Any: - first = channel(interrupted) - second = channel(lambda: "ran") - queued.append(second) - await asyncio.gather(asyncio.wrap_future(first), asyncio.wrap_future(second)) - # Settled bodies resolve as failures, so the operation can finish before the stop request - # arrives; like every production operation it then returns a result carrying its execution. - return SimpleNamespace(_execution=None) - - with pytest.raises(KeyboardInterrupt): - _run_sync_profile(operation) - (second,) = queued - assert second.result() == LocalFailure(failure="implementation_exception") diff --git a/tests/interface/test_rewrite_caller_thread.py b/tests/interface/test_rewrite_caller_thread.py index 03001d5c..8ec0ba76 100644 --- a/tests/interface/test_rewrite_caller_thread.py +++ b/tests/interface/test_rewrite_caller_thread.py @@ -230,22 +230,21 @@ async def inside() -> tuple[int, list[tuple[str, int, str]]]: assert all(ident == caller for _, ident, _ in seen) -def test_nested_handoff_from_the_serving_thread_runs_inline_on_the_caller_thread() -> None: +def test_invocation_nested_in_a_body_runs_inline_on_the_caller_thread() -> None: from anonymizer.engine.harness.kernel import _run_sync_profile caller = threading.get_ident() seen: list[int] = [] - def inner() -> Any: - seen.append(threading.get_ident()) - return "inner" + async def inner(channel: Any) -> Any: + return channel.run(lambda: seen.append(threading.get_ident()) or "inner") - async def operation(pool: Any, handoff: Any) -> Any: + async def operation(channel: Any) -> Any: def body() -> Any: seen.append(threading.get_ident()) - return handoff(inner) + return _run_sync_profile(inner) - return await asyncio.to_thread(handoff, body) + return channel.run(body) assert _run_sync_profile(operation) == "inner" assert seen == [caller, caller] diff --git a/tests/interface/test_thread_placement.py b/tests/interface/test_thread_placement.py index 022df51f..1b5a93ea 100644 --- a/tests/interface/test_thread_placement.py +++ b/tests/interface/test_thread_placement.py @@ -193,5 +193,5 @@ def start(self: threading.Thread) -> None: patch.setattr(threading.Thread, "start", start) _run(tmp_path, method="redact") - # DataDesigner may start its own threads; Anonymizer starts no worker threads for the body. - assert [name for name in started if name.startswith("anonymizer-graph_")] == [] + # DataDesigner may start its own threads; Anonymizer starts none: no body worker and no loop host. + assert [name for name in started if name.startswith("anonymizer-graph")] == [] From f898c0954a106a05ee059d603c8cf9435b540465 Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Wed, 21 Oct 2026 20:20:59 +0000 Subject: [PATCH 4/5] test: one subprocess Ctrl-C test, checked against 0.4.0, replaces seven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user relies on one thing: Ctrl-C during run() or preview() stops promptly, cancels the in-flight model requests, raises KeyboardInterrupt and does not hang. test_interrupt.py checks exactly that in a subprocess with a real Anonymizer and DataDesigner whose HTTP transport holds one kind of request open, and real SIGINTs: - plain: Redact run(), detection request held; one Ctrl-C; - asyncio_run: Substitute preview() called from inside asyncio.run; the Runner takes the first Ctrl-C, a second one stops the run; - second: Rewrite run() with a request that ignores cancellation, so DataDesigner's post-cancel wait holds until a second Ctrl-C. Each case asserts the exit status, the time after the last SIGINT and the exact event sequence. The script uses only the public API and DataDesigner's transport hook; the sequences were recorded on main f4f2b73d, and the unchanged file passes there. Removed: the remaining in-process and subprocess interrupt tests that pinned the old host topology or graph internals (DataDesigner compat interrupt cases, the injected-redact and evaluate interrupt cases). The queue, native-map and qualified-profile interrupt tests went with the code they tested in the previous commits. Main's telemetry CANCELED test stays. Measured: tests 4 files, +228 / -237; src unchanged. Gate: full suite pandas 2.3.3 and 3.0.6 5538 passed each; make format-check and make typecheck clean; test_interrupt.py 3/3 against main f4f2b73d, the graph-SDK line head e0d8b317 and this branch (three runs each); src identical to the previous commit, whose corpus run was 0 BLOCKING. 🤖 Generated with Claude Code Co-Authored-By: Claude Signed-off-by: Aaron Gonzales --- .../test_dd_interrupt_and_retry_compat.py | 203 +--------------- tests/interface/test_evaluate_graph.py | 21 -- tests/interface/test_injected_redact_graph.py | 15 -- tests/interface/test_interrupt.py | 228 ++++++++++++++++++ 4 files changed, 230 insertions(+), 237 deletions(-) create mode 100644 tests/interface/test_interrupt.py diff --git a/tests/interface/test_dd_interrupt_and_retry_compat.py b/tests/interface/test_dd_interrupt_and_retry_compat.py index b91cbb2a..c1ae5cfc 100644 --- a/tests/interface/test_dd_interrupt_and_retry_compat.py +++ b/tests/interface/test_dd_interrupt_and_retry_compat.py @@ -1,22 +1,17 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Ctrl-C during Rewrite, Redact and nested Substitute, and transient provider errors during Substitute, compared with 0.4.0. +"""Transient provider errors during Substitute, compared with 0.4.0. Only the HTTP transport is scripted; DataDesigner and the graph run for real. The expected rows, failed records and HTTP call counts were recorded by running the same stubs against main (0.4.0 behavior): DataDesigner retries a 5xx, 429 or timeout on the map request and keeps the record; after three failed tries of one task it -drops only that record. Runtime: about 2 s per interrupt case (a subprocess) and under 4 s per retry case. +drops only that record. Runtime: under 4 s per case. Ctrl-C is covered in ``test_interrupt.py``. """ from __future__ import annotations import asyncio import json -import os -import signal -import subprocess -import sys -import time from collections import Counter from collections.abc import Iterator from pathlib import Path @@ -34,200 +29,6 @@ pytestmark = pytest.mark.compat -_INTERRUPTED_REWRITE = r""" -import asyncio, json, signal, sys, tempfile, time -from pathlib import Path - -import httpx, pandas as pd, yaml -from data_designer.config.models import ModelConfig, ModelProvider -from data_designer.interface.data_designer import DataDesigner - -from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput, Redact, Rewrite, Substitute -from anonymizer.engine.detection.detection_workflow import EntityDetectionWorkflow -from anonymizer.engine.ndd.adapter import NddAdapter - -signal.signal(signal.SIGINT, signal.default_int_handler) -log = open(sys.argv[1], "a", buffering=1) -stage, schedule = sys.argv[2], sys.argv[3] - - -def emit(**fields): - log.write(json.dumps({"t": time.monotonic(), **fields}) + "\n") - - -def completion(model, content): - choice = {"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"} - usage = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - body = {"id": "s", "object": "chat.completion", "created": 0, "model": model, "choices": [choice], "usage": usage} - return httpx.Response(200, json=body) - - -def fenced(value): - return "```json\n" + json.dumps(value) + "\n```" - - -names = ["Alice", "Bob", "Carol"] - - -async def slow(model): - emit(event="request") - try: - await asyncio.sleep(60) - except asyncio.CancelledError: - emit(event="cancelled") - raise - emit(event="answered") - return completion(model, "{}") - - -async def handler(request): - body = json.loads(request.content) - model, messages = body["model"], json.dumps(body["messages"]) - if model == "detector" and stage == "detect": - return await slow(model) - if model == "detector": - name = next(name for name in names if name + " waved." in messages) - entity = {"text": name, "label": "first_name", "start": 0, "end": len(name), "score": 0.9} - return completion(model, json.dumps({"entities": [entity]})) - if model == "latent": - return completion(model, fenced({"latent_entities": []})) - if model == "rewriter": - return await slow(model) if stage == "rewrite" else completion(model, "{}") - if "Validate entity tags" in messages: - ids = dict.fromkeys(f"first_name_0_{len(name)}" for name in names if name in messages) - return completion(model, fenced({"decisions": [{"id": item, "decision": "keep"} for item in ids]})) - if "Find untagged sensitive entities" in messages: - return completion(model, fenced({"entities": []})) - if "Generate synthetic replacements" in messages: - return await slow(model) - return completion(model, "Hello!") - - -root = Path(tempfile.mkdtemp()) -designer = DataDesigner( - artifact_path=root / "artifacts", - model_providers=[ModelProvider(name="s", endpoint="http://scripted.invalid/v1", provider_type="openai", api_key="x")], - auto_configure_logging=False, -) -original = designer._create_resource_provider - - -def resources(*args, **kwargs): - provider = original(*args, **kwargs) - factory = provider.model_registry._model_facade_factory - - def facade(*args, **kwargs): - result = factory(*args, **kwargs) - result._client._inner._transport = httpx.MockTransport(handler) - return result - - provider.model_registry._model_facade_factory = facade - return provider - - -designer._create_resource_provider = resources -aliases = ("detector", "llm", "rewriter", "latent") -models = [ModelConfig(alias=n, model=n, provider="s", skip_health_check=n != "llm") for n in aliases] -roles = {"entity_detector": "detector", "entity_validator": ["llm"], "entity_augmenter": "llm", "latent_detector": "latent"} -steps = ("domain_classifier", "disposition_analyzer", "meaning_extractor", "qa_generator", "rewriter", "evaluator", "repairer") -configs = { - "model_configs": [model.model_dump(mode="json", exclude_none=True) for model in models], - "selected_models": { - "detection": roles, - "replace": {"replacement_generator": "llm"}, - "rewrite": {step: "rewriter" for step in steps}, - }, -} -source = root / "input.csv" -pd.DataFrame({"text": [name + " waved." for name in names]}).to_csv(source, index=False) - - -class Delegating(EntityDetectionWorkflow): - def run(self, *args, **kwargs): - return super().run(*args, **kwargs) - - -extra = {"detection_workflow": Delegating(NddAdapter(designer))} if schedule.endswith("delegating") else {} -anonymizer = Anonymizer(data_designer=designer, model_configs=yaml.safe_dump(configs), **extra) -if schedule.startswith("redact"): - config = AnonymizerConfig(replace=Redact()) -elif schedule.startswith("substitute"): - config = AnonymizerConfig(replace=Substitute()) -else: - config = AnonymizerConfig(rewrite=Rewrite(use_combined_graph=schedule == "combined")) -try: - anonymizer.run(config=config, data=AnonymizerInput(source=str(source), text_column="text")) - emit(event="finished") -except KeyboardInterrupt as error: - from anonymizer.engine.harness.kernel import retained_execution - - execution = retained_execution(error) - emit( - event="interrupted", - requests=None if execution is None else sorted(t.category for t in execution.requests.terminals), - tasks=None if execution is None else sorted(t.category for t in execution.record.terminals), - ) - raise -""" - - -@pytest.mark.parametrize( - "stage,schedule", - [ - ("map", "staged"), - ("rewrite", "staged"), - ("rewrite", "combined"), - ("detect", "redact"), - ("detect", "redact-delegating"), - ("map", "substitute-delegating"), - ], -) -def test_interrupt_cancels_in_flight_requests_promptly(tmp_path: Path, stage: str, schedule: str) -> None: - script, log = tmp_path / "interrupted_rewrite.py", tmp_path / "events.jsonl" - script.write_text(_INTERRUPTED_REWRITE) - environment = {**os.environ, "NEMO_TELEMETRY_ENABLED": "false"} - child = subprocess.Popen( - [sys.executable, str(script), str(log), stage, schedule], - env=environment, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - - def events() -> list[dict[str, Any]]: - return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] - - try: - deadline = time.monotonic() + 60 - while "request" not in [item["event"] for item in events()] and child.poll() is None: - assert time.monotonic() < deadline, "no slow request started" - time.sleep(0.02) - time.sleep(0.3) - assert child.poll() is None, child.stderr.read().decode() if child.stderr else "" - interrupted = time.monotonic() - child.send_signal(signal.SIGINT) - while child.poll() is None and time.monotonic() - interrupted < 10: - time.sleep(0.01) - elapsed = time.monotonic() - interrupted - finally: - if child.poll() is None: - child.kill() - child.wait() - - recorded = events() - names = [item["event"] for item in recorded] - # 0.4.0 stops within about half a second; the graph route used to wait for, or ignore, the interrupt. - assert elapsed < 2, f"run ended {elapsed:.2f}s after SIGINT" - assert child.returncode == -signal.SIGINT - assert names.count("request") >= 1 and names.count("request") == names.count("cancelled") - assert "answered" not in names and "finished" not in names - (outcome,) = [item for item in recorded if item["event"] == "interrupted"] - if outcome["tasks"] is not None and schedule in ("staged", "combined"): # 0.4.0 attaches no execution record - # Rewrite's model requests are DataDesigner-owned, not graph physical requests: none is claimed as - # settled, and each of the three interrupted records' tasks closes cancelled. - assert "success" not in outcome["requests"] - assert outcome["tasks"].count("cancelled") == 3 - - _NAMES = ("Zorba", "Zorbb", "Zorbc") diff --git a/tests/interface/test_evaluate_graph.py b/tests/interface/test_evaluate_graph.py index 52ce3d1c..66b6c3bf 100644 --- a/tests/interface/test_evaluate_graph.py +++ b/tests/interface/test_evaluate_graph.py @@ -111,27 +111,6 @@ async def active_loop(): connection.close() -@pytest.mark.parametrize("error_type", [KeyboardInterrupt, SystemExit]) -def test_rewrite_evaluation_interrupt_does_not_start_coverage(monkeypatch, error_type): - from anonymizer.config.anonymizer_config import Rewrite - from anonymizer.interface import anonymizer as facade - - trace = pd.DataFrame({"text": ["Alice"], "text_rewritten": ["hidden"]}) - output = AnonymizerResult(trace, trace, "text", [], rewrite_config=Rewrite().privacy_goal) - anonymizer, _, _, rewrite = _make_anonymizer() - error = error_type("literal caller interruption") - rewrite.evaluate.side_effect = error - coverage = [] - monkeypatch.setattr(facade, "EntityCoverageWorkflow", lambda **kwargs: coverage.append(kwargs)) - with pytest.raises(error_type) as caught: - anonymizer.evaluate(output) - assert caught.value is error - assert coverage == [] - assert isinstance(retained(error), ExecutionResult) - assert [entry.status for entry in retained(error).invocation_state.entries] == ["cancelled"] - assert retained(error).record.terminals[0].reasons == frozenset({"cancel_requested"}) - - @pytest.mark.parametrize( "strategy,validity,drop", [ diff --git a/tests/interface/test_injected_redact_graph.py b/tests/interface/test_injected_redact_graph.py index 61af2e69..c0191b81 100644 --- a/tests/interface/test_injected_redact_graph.py +++ b/tests/interface/test_injected_redact_graph.py @@ -364,18 +364,3 @@ async def run(): assert len(calls) == 2 and result._execution is not None finally: connection.close() - - -@pytest.mark.parametrize("stage", ["detection", "replacement"]) -@pytest.mark.parametrize("error_type", [KeyboardInterrupt, SystemExit]) -def test_caller_interrupt_drains_graph_and_preserves_identity(tmp_path, stage, error_type): - anonymizer, detector, replacement, _ = _make_anonymizer() - interrupted = error_type() - (detector if stage == "detection" else replacement).run.side_effect = interrupted - with pytest.raises(error_type) as caught: - anonymizer.run(config=AnonymizerConfig(replace=Redact()), data=_input(tmp_path, 1)) - assert caught.value is interrupted - execution = retained(interrupted) - assert not execution.frames - assert [terminal.category for terminal in execution.record.terminals] == ["cancelled"] - assert replacement.run.call_count == (stage == "replacement") diff --git a/tests/interface/test_interrupt.py b/tests/interface/test_interrupt.py new file mode 100644 index 00000000..b136e474 --- /dev/null +++ b/tests/interface/test_interrupt.py @@ -0,0 +1,228 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Ctrl-C during ``run()`` and ``preview()`` stops promptly and cancels in-flight model requests, as in 0.4.0. + +Each case runs a real Anonymizer and DataDesigner in a subprocess whose HTTP transport is scripted to hold one +kind of model request open, sends it real SIGINTs, and checks the event sequence. The script uses only the public +API and DataDesigner's transport hook, so it runs unchanged against 0.4.0; the expected sequences were recorded +there. Runtime: about 3 s per case. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.compat + +_SCRIPT = r""" +import asyncio, json, signal, sys, tempfile, time +from pathlib import Path + +import httpx, pandas as pd, yaml +from data_designer.config.models import ModelConfig, ModelProvider +from data_designer.interface.data_designer import DataDesigner + +from anonymizer import Anonymizer, AnonymizerConfig, AnonymizerInput, Redact, Rewrite, Substitute + +signal.signal(signal.SIGINT, signal.default_int_handler) +log = open(sys.argv[1], "a", buffering=1) +case = sys.argv[2] +held = {"plain": "detector", "asyncio_run": "map", "second": "rewriter"}[case] + + +def emit(event): + log.write(json.dumps({"t": time.monotonic(), "event": event}) + "\n") + + +def completion(model, content): + choice = {"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"} + usage = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + body = {"id": "s", "object": "chat.completion", "created": 0, "model": model, "choices": [choice], "usage": usage} + return httpx.Response(200, json=body) + + +def fenced(value): + return "```json\n" + json.dumps(value) + "\n```" + + +names = ["Alice", "Bob", "Carol"] + + +async def hold(model): + emit("request") + while True: + try: + await asyncio.sleep(60) + break + except asyncio.CancelledError: + if case != "second": + emit("cancelled") + raise + emit("cancel_ignored") # a provider call that does not stop: only a second Ctrl-C ends the wait + emit("answered") + return completion(model, "{}") + + +async def handler(request): + body = json.loads(request.content) + model, messages = body["model"], json.dumps(body["messages"]) + if model == "detector": + if held == "detector": + return await hold(model) + name = next(name for name in names if name + " waved." in messages) + entity = {"text": name, "label": "first_name", "start": 0, "end": len(name), "score": 0.9} + return completion(model, json.dumps({"entities": [entity]})) + if model == "latent": + return completion(model, fenced({"latent_entities": []})) + if model == "rewriter": + return await hold(model) if held == "rewriter" else completion(model, "{}") + if "Validate entity tags" in messages: + ids = dict.fromkeys(f"first_name_0_{len(name)}" for name in names if name in messages) + return completion(model, fenced({"decisions": [{"id": item, "decision": "keep"} for item in ids]})) + if "Find untagged sensitive entities" in messages: + return completion(model, fenced({"entities": []})) + if "Generate synthetic replacements" in messages: + return await hold(model) + return completion(model, "Hello!") + + +root = Path(tempfile.mkdtemp()) +designer = DataDesigner( + artifact_path=root / "artifacts", + model_providers=[ModelProvider(name="s", endpoint="http://scripted.invalid/v1", provider_type="openai", api_key="x")], + auto_configure_logging=False, +) +original = designer._create_resource_provider + + +def resources(*args, **kwargs): + provider = original(*args, **kwargs) + factory = provider.model_registry._model_facade_factory + + def facade(*args, **kwargs): + result = factory(*args, **kwargs) + result._client._inner._transport = httpx.MockTransport(handler) + return result + + provider.model_registry._model_facade_factory = facade + return provider + + +designer._create_resource_provider = resources +aliases = ("detector", "llm", "rewriter", "latent") +models = [ModelConfig(alias=n, model=n, provider="s", skip_health_check=True) for n in aliases] +roles = {"entity_detector": "detector", "entity_validator": ["llm"], "entity_augmenter": "llm", "latent_detector": "latent"} +steps = ("domain_classifier", "disposition_analyzer", "meaning_extractor", "qa_generator", "rewriter", "evaluator", "repairer") +configs = { + "model_configs": [model.model_dump(mode="json", exclude_none=True) for model in models], + "selected_models": { + "detection": roles, + "replace": {"replacement_generator": "llm"}, + "rewrite": {step: "rewriter" for step in steps}, + }, +} +source = root / "input.csv" +pd.DataFrame({"text": [name + " waved." for name in names]}).to_csv(source, index=False) +anonymizer = Anonymizer(data_designer=designer, model_configs=yaml.safe_dump(configs)) +data = AnonymizerInput(source=str(source), text_column="text") + + +def call(): + if case == "plain": + return anonymizer.run(config=AnonymizerConfig(replace=Redact(), emit_telemetry=False), data=data) + if case == "asyncio_run": + config = AnonymizerConfig(replace=Substitute(), emit_telemetry=False) + return anonymizer.preview(config=config, data=data, num_records=len(names)) + return anonymizer.run(config=AnonymizerConfig(rewrite=Rewrite(), emit_telemetry=False), data=data) + + +async def handler_task(): + # A service handler that calls the synchronous API from inside its running event loop. + return call() + + +try: + asyncio.run(handler_task()) if case == "asyncio_run" else call() + emit("finished") +except KeyboardInterrupt: + emit("interrupted") + raise +""" + +# What 0.4.0 does (recorded against main f4f2b73d). The first Ctrl-C reaches DataDesigner's wait on the caller's +# thread, which cancels its scheduler, waits for the in-flight requests to unwind and re-raises. Inside a caller's +# asyncio.run, the Runner's own SIGINT handler takes the first Ctrl-C and only cancels the caller's task, which is +# blocked in the synchronous call, so a second one is needed. A request that ignores cancellation keeps +# DataDesigner's post-cancel wait open until a second Ctrl-C ends it. +_EXPECTED = { + "plain": ["request", "sigint", "cancelled", "interrupted"], + "asyncio_run": ["request", "sigint", "sigint", "cancelled", "interrupted"], + "second": ["request", "sigint", "cancel_ignored", "sigint", "interrupted"], +} + + +def _collapse(events: list[str]) -> list[str]: + """Concurrent requests for several rows log the same event once per row; each SIGINT stays its own step.""" + return [ + event for index, event in enumerate(events) if index == 0 or event == "sigint" or events[index - 1] != event + ] + + +@pytest.mark.parametrize("case", ["plain", "asyncio_run", "second"]) +def test_ctrl_c_stops_the_run_and_cancels_in_flight_requests(tmp_path: Path, case: str) -> None: + script, log = tmp_path / "interrupted.py", tmp_path / "events.jsonl" + script.write_text(_SCRIPT) + child = subprocess.Popen( + [sys.executable, str(script), str(log), case], + env={**os.environ, "NEMO_TELEMETRY_ENABLED": "false"}, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + def events() -> list[str]: + return [json.loads(line)["event"] for line in log.read_text().splitlines()] if log.exists() else [] + + def interrupt() -> float: + with log.open("a") as handle: + handle.write(json.dumps({"t": time.monotonic(), "event": "sigint"}) + "\n") + child.send_signal(signal.SIGINT) + return time.monotonic() + + def wait(seconds: float) -> None: + deadline = time.monotonic() + seconds + while child.poll() is None and time.monotonic() < deadline: + time.sleep(0.01) + + try: + deadline = time.monotonic() + 60 + while "request" not in events() and child.poll() is None: + assert time.monotonic() < deadline, "no held request started" + time.sleep(0.02) + time.sleep(0.3) + assert child.poll() is None, child.stderr.read().decode() if child.stderr else "" + sent = interrupt() + if case != "plain": + wait(1.0) + assert child.poll() is None, "the first Ctrl-C ended the run" + sent = interrupt() + wait(10) + elapsed = time.monotonic() - sent + finally: + if child.poll() is None: + child.kill() + child.wait() + + assert elapsed < 2, f"run ended {elapsed:.2f}s after the last SIGINT" + assert child.returncode == -signal.SIGINT + recorded = events() + assert _collapse(recorded) == _EXPECTED[case] + if case != "second": + assert recorded.count("request") == recorded.count("cancelled") From d203db07c1c848dd930ef60ceabe99edcfdc5b90 Mon Sep 17 00:00:00 2001 From: Aaron Gonzales Date: Wed, 21 Oct 2026 21:08:05 +0000 Subject: [PATCH 5/5] docs: describe where graph bookkeeping runs and drop the deleted profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread paragraph in graph-sdk.md and the cutover note said all graph bookkeeping runs on the caller's thread. Bodies and the bookkeeping they perform do, but the bookkeeping that DataDesigner's per-row hooks trigger is driven synchronously on DataDesigner's own threads, under the runtime lock (_InvocationRuntime.lock, _execution_runtime.py:200; held by _DelegatedOwner._drive_call, _execution_delegated.py:914-936). Both texts now say so. Also removes the paragraph on the qualified development detection/Redact profile, deleted earlier on this branch, and the stale request_bridge mentions in the detector-signature test docstrings (text only). Measured: docs 2 files, +7 / -12; tests 1 file, +2 / -2; src unchanged. Gate: mkdocs build --strict passes; make format-check clean; test_detector_signature_compat.py and test_thread_placement.py 52 passed on pandas 2.3.3 and 3.0.6. 🤖 Generated with Claude Code Co-Authored-By: Claude Signed-off-by: Aaron Gonzales --- docs/concepts/graph-sdk.md | 17 ++++++----------- docs/development/graph-sdk-cutover.md | 2 +- .../interface/test_detector_signature_compat.py | 4 ++-- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/concepts/graph-sdk.md b/docs/concepts/graph-sdk.md index bff88497..e711dedb 100644 --- a/docs/concepts/graph-sdk.md +++ b/docs/concepts/graph-sdk.md @@ -99,13 +99,6 @@ ceiling does not claim bounded memory usage or a hard transport-response size. Context retrieval, observations and other explicitly bounded contracts retain their separate limits. -The qualified development detection/Redact profile derives count capacity from the selected -source and declared work when no execution limits are supplied. Five retained -artifacts per source is an upper bound: one root, three producer outputs and one -assessment output. Final span count is bounded by source character count; -validation membership is bounded by the admitted chunk capacity. Actual counts -can be lower. Caller-supplied execution limits remain authoritative. The separate qualified development profile retains this five-artifact inventory. - This counts-first policy records the 2026-10-13 operator clarification that ordinary runs should not impose incidental byte or per-string ceilings. It qualifies the RFC's earlier mandatory-retained-byte-bound wording; it does not @@ -223,10 +216,12 @@ thread that called them, at every depth, including built-in code reached through bodies, and every DataDesigner `create()`/`preview()` call and wait, run there too. Your code therefore sees that thread's thread-locals and can install signal handlers exactly when that thread is the main thread, and Ctrl-C reaches the run as in 0.4.0. -The graph's own bookkeeping runs on that thread as well, on a private event loop that -leaves a notebook's or `asyncio.run` caller's running loop untouched; only -DataDesigner's engine threads run Anonymizer's column generators and send model -requests, as in 0.4.0, and none of them runs your code. Each call runs in a copy of the caller's +The graph's event loop runs on that thread as well, as a private loop that leaves a +notebook's or `asyncio.run` caller's running loop untouched, and so does the graph +bookkeeping those bodies perform. DataDesigner's engine threads run Anonymizer's +column generators and send model requests, as in 0.4.0; the graph bookkeeping that +DataDesigner's per-row hooks trigger runs there too, under the invocation's lock. +None of those threads runs your code. Each call runs in a copy of the caller's context variables: values set before the call are visible inside, and values set inside are not visible to the caller afterwards. diff --git a/docs/development/graph-sdk-cutover.md b/docs/development/graph-sdk-cutover.md index b49a68c7..7f919123 100644 --- a/docs/development/graph-sdk-cutover.md +++ b/docs/development/graph-sdk-cutover.md @@ -27,7 +27,7 @@ M2 and M3 can proceed independently after their M1 prerequisites. The campaign c ## Rewrite semantic controller checkpoint -Built-in Rewrite now uses one admitted controller for iterative batches and combined DataDesigner columns. Default facade calls, direct calls and supplied instances that inherit the library entry bind the actual runner, adapter, arguments and source before a single lowering entry. Injected detection callbacks share that Rewrite owner while preserving their caller thread and original argument objects. Every synchronous body runs on the thread that called the public entry point, with a copy of its context variables; values set inside are not visible to the caller afterwards. This covers the Rewrite body, built-in detection, Redact, Annotate, Hash and Substitute, built-in code reached from a caller override through `super()`, `build_graph` overrides, the built-in detection lowered inside Rewrite (including its stage overrides and its detection adapter's `run_workflow`), and the DataDesigner calls they make. No caller code and no DataDesigner wait runs off the caller thread. Graph bookkeeping runs on the caller thread too, on a private event loop; only DataDesigner's engine threads run Anonymizer column generators, as on main. Arbitrary runner overrides remain opaque. The previous iterative repair loop, duplicate combined `run()` controller and last-populated-row finalizer have been removed. Physical batching and conditional column unrolling remain lowerings. +Built-in Rewrite now uses one admitted controller for iterative batches and combined DataDesigner columns. Default facade calls, direct calls and supplied instances that inherit the library entry bind the actual runner, adapter, arguments and source before a single lowering entry. Injected detection callbacks share that Rewrite owner while preserving their caller thread and original argument objects. Every synchronous body runs on the thread that called the public entry point, with a copy of its context variables; values set inside are not visible to the caller afterwards. This covers the Rewrite body, built-in detection, Redact, Annotate, Hash and Substitute, built-in code reached from a caller override through `super()`, `build_graph` overrides, the built-in detection lowered inside Rewrite (including its stage overrides and its detection adapter's `run_workflow`), and the DataDesigner calls they make. No caller code and no DataDesigner wait runs off the caller thread. The graph's private event loop and the bookkeeping the bodies perform run on the caller thread too. DataDesigner's engine threads run Anonymizer column generators, as on main, and the graph bookkeeping that DataDesigner's per-row hooks trigger runs on those threads under the invocation's lock. Arbitrary runner overrides remain opaque. The previous iterative repair loop, duplicate combined `run()` controller and last-populated-row finalizer have been removed. Physical batching and conditional column unrolling remain lowerings. Native generators publish their observed typed stages. Caller callbacks with opaque internals publish source-bound observations without invented QA assessments, repair history or physical request counts. Retained candidate values and presentation dtypes cannot change the accepted observation. Source snapshots detach structured semantic payloads, including supported model and NumPy cells, while retaining opaque caller metadata references. Rows of an admitted Rewrite are finalized by the lowering's binding instead of the combined graph's own finalization column. That column finalizes rows in two other cases: combined columns run outside an admitted Rewrite, and the built-in combined Rewrite under an adapter whose `run_workflow` is overridden. It projects the final state directly from the observed evaluation states without running the admitted transitions: the final state is the first evaluation state whose repair decision is false, or the last state when every decision calls for repair. A missing evaluation-state column, or a missing, null or non-boolean repair decision, in an active state fails explicitly rather than falling back to the first state. The projection reads no original text. Arbitrary injected runners retain their explicitly opaque compatibility contract. diff --git a/tests/interface/test_detector_signature_compat.py b/tests/interface/test_detector_signature_compat.py index 13ec6de3..eeefcc8a 100644 --- a/tests/interface/test_detector_signature_compat.py +++ b/tests/interface/test_detector_signature_compat.py @@ -3,7 +3,7 @@ """Caller-supplied detectors with v0.4.0's exact ``run`` signature, through every public route. ``EntityDetectionWorkflow.run()`` gained ``entity_label_examples`` (#275) and the graph-only -``source_frame``/``request_bridge`` keywords after v0.4.0. A detector written against v0.4.0's +``source_frame`` keyword after v0.4.0. A detector written against v0.4.0's signature (no such parameters, no ``**kwargs``) must keep running, with those keywords withheld and one counts-only warning when configured examples are dropped. Expectations are main with #311 applied. """ @@ -210,7 +210,7 @@ def test_release_040_detector_without_examples_does_not_warn( def test_release_040_detector_through_graph_invocation_withholds_source_frame( strategy: type[Redact | Annotate | Hash], ) -> None: - """The graph-invocation consumer adds source_frame/request_bridge; a 0.4.0 run() must not receive them.""" + """The graph-invocation consumer adds source_frame; a 0.4.0 run() must not receive it.""" detector = _Release040Subclass(Mock(), positioned=True) selected = DetectionModelSelection( entity_detector="d", entity_validator=["v"], entity_augmenter="a", latent_detector="l"