Skip to content

feat(graph): run Anonymizer's pipelines on a private graph kernel - #319

Open
binaryaaron wants to merge 187 commits into
mainfrom
aagonzales/graph-sdk/feat/main
Open

binaryaaron wants to merge 187 commits into
mainfrom
aagonzales/graph-sdk/feat/main

Conversation

@binaryaaron

@binaryaaron binaryaaron commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

This PR is the long-lived base of the Graph SDK feature line. It is not a merge candidate for main. Later steps land on this branch as merge commits, without rewriting history, and main is merged in as it moves. The branch contains main at 7605c13c.

Against main, Anonymizer's pipelines run on a small private graph kernel, behind the Anonymizer API and config. anonymizer.graph is provisional: its names, signatures and defaults may change before it is published. The compatibility contract is the Anonymizer API and config of 0.4.0; this branch only adds to them: Substitute(consistency=...), declared detector facts on Detect (detector_labels, detector_labels_fixed, detector_offset_unit), result.outcome(row) and result.detection_scope.

What changes against main

118 files, +10,269 / -1,442: src/ 35 files, +4,186 / -874; tests/ 55 files, +4,437 / -491; docs/ +88 / -10; skills/ +62 / -54. Non-blank, non-comment, non-docstring lines under src/: +2,438. The branch has 180 commits, 36 of them merges.

  • The earlier graph SDK is gone. At its previous head (9a8369cb, +100,340 / -1,237) this PR carried engine/graph_sdk, engine/harness, their adapters and their test suite. All of it is removed, so none of it appears in the diff against main.
  • A private kernel, src/anonymizer/engine/graph_core/ (1,593 lines in six files), with five modules:
    • kernel.py (419): kind registry, static plan, the executor over its state, cancel token;
    • authoring.py (379): operations (per target or phase-scoped), workflows, graph values;
    • execution.py (697): prepare and protect, on the caller's thread, and the request forecast;
    • observe.py (44): the observed-request channel (below);
    • stages.py (45): runs a fixed sequence of stages as one invocation. Detection and the rewrite judges use it.
  • anonymizer.graph (66 lines) re-exports exactly 24 names from the kernel: Assessment, CancelToken, ContextScope, ContextSource, ContextView, Datum, Dependency, GraphRejection, GraphScope, InspectionSummary, Limits, Mention, Operation, OutputGroup, PreparedProtection, ProtectionGraph, ProtectionResult, Reason, Requests, TargetOutcome, Workflow, prepare, protect, register_kind. tests/graph/test_public_api.py pins the list and checks that importing the module loads only the kernel.
  • Replace routes on the kernel (engine/replace/route.py). Redact, Annotate and Hash run one target per row. Substitute's replacement map is a phase-scoped step of the same per-row workflow, so a row DataDesigner drops is a failed target in the same record as every other row.
  • Detection and Replace as one invocation. In Replace mode, one protect() call runs detect, then substitute-map (Substitute only), replace and verify-patches. A row the detector drops fails at detect and blocks its later steps. A caller-supplied runner that overrides run keeps the previous path.
  • Execution backend seam (engine/ndd/backend.py). DataDesignerBackend runs a DataDesigner workflow and returns the surviving frame, each surviving row's input position and the observed request count. At this head only Substitute's map step uses it; detection, Rewrite and the judges call NddAdapter.run_workflow() directly. NddAdapter.run_workflow() is still the only caller of DataDesigner.create() and preview().
  • Observed-request channel (engine/graph_core/observe.py). Code inside a step calls report_requests(n). ProtectionResult.summary() shows the total as exact only when every step that can make requests reported. report_requests is not exported.
  • Also added: docs/concepts/graph-sdk.md, a provider-free example in examples/graph_sdk/, the spec programs in tests/spec_programs/, and a dogfood kit in tools/dogfood/ that uses only the public anonymizer API.

Since #345-#347 folded in, Rewrite runs as one invocation with detection as its first step, and evaluate() runs its judges as one invocation. Inside those invocations, detection's own stages (detect, tag-latent, finalize) and the rewrite judges still run through run_stages.

Reading order

  1. docs/concepts/graph-sdk.md (61 lines): what anonymizer.graph declares, prepares, protects and reports.
  2. The kernel, src/anonymizer/engine/graph_core/: kernel.py, authoring.py, execution.py, then observe.py and stages.py.
  3. src/anonymizer/engine/replace/route.py (375 lines): the Replace pipeline. Then engine/ndd/backend.py and the facade in interface/anonymizer.py.
  4. tests/spec_programs/: numbered programs, each pinning one kernel behaviour. run_all runs them all.

Folded step history

Each step below was a draft PR stacked on this one. This branch was fast-forwarded to 44924e2e and then, step by step, to 13fb7470, which contains all of them unchanged. Each is folded into this PR; closed, description kept as the record.

Since then, PRs into this branch merge with merge commits, and main was merged in five times, most recently at 7605c13c:

How to test

uv sync --frozen --group dev
make check
uv run --group dev pytest
uv run --group dev python -m tests.spec_programs.run_all

Please test at this PR's head and name the SHA you ran.

CI's Test job runs only on PRs to main, so this PR is where the full suite runs on GitHub.

Known gaps

  • docs/development/graph-sdk-cutover.md (the M0 stage plan, requirement map and route inventory) is removed on this branch: every path it named is gone and every route it listed has moved to the kernel. It stays in history at 44924e2e.
  • skills/anonymizer/ differs from main in 6 files (+62 / -54). require-nvskills-ci passes at this head; if the skill changes again it needs a /nvskills-ci comment, and a signature run may add a bot commit to this branch.
  • Earlier review comments were made on a much larger head. The two inline review comments from 2026-10-28 sit on engine/graph_sdk/_execution_publication.py and _workflow_admission.py, which no longer exist.
  • Behaviour differences from main are described in the steps' PRs: trace-column dtypes in refactor(replace): run Substitute as one invocation #336, and measurement-stage and custom-adapter notes under "Known differences" in refactor(replace): run detection and replacement as one invocation #342 and feat(graph): count observed requests and name the DataDesigner backend #343.
  • Nothing was rewritten, so the removed package, including about 9 MB of reference data, stays in this branch's history. How this branch reaches main is decided when it lands. This repo merges by squash only.
  • One item from the 2026-10-29 conformance review is unchanged and now documented in docs/concepts/graph-sdk.md: prepare(request_budget=...) checks the planned request count and does not enforce a ceiling on actual requests while the workflow runs. The other four (checks on mentions produced by a step, content() after a failed text producer, a missing leakage number in the final checks, protect() rechecking the prepared combination) are fixed on this branch.
  • The effective label names were removed from the detection-scope log lines in this branch as part of its content-safe logging change; the resolved scope, labels included, is now reported as result.detection_scope (feat(detect): validate declared detector facts and report the resolved scope #385); whether the labels should also be logged is an open question.

🤖 Generated with Claude Code

binaryaaron and others added 30 commits October 20, 2026 21:28
Add the engine-side graph values and workflow modules (identity and reference values, workflow declarations, static and dynamic admission and substitution, composition and the metered compiler budget) together with the closed effect and artifact-shape values used by later layers. Introduce the lazy public anonymizer.graph facade over them. Tests cover static workflow analysis and the compiler reference, with the workflow_static corpus.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add the engine-side activation modules (activation values, topology and reducer), data-graph declarations and validation, canonical records, capability declarations and bounded plan preparation. Tests and references cover activation, data graphs and the compiler.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add request policies, values and receipts with a pure reducer, typed context and its admission, resource leases and cleanup facts, ingress envelopes and bounded content-free observations. Add the detection and Rewrite artifact types that requests carry.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…nsport

Add execution declarations and sealed results, bounded blocking-worker adapters, a single physical request driver, callback-ingress inspection, foreign transport entry with a client lease, and asynchronous initial context binding.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add execution admission, the invocation runtime and scheduling, dispatch, delegated slot borrowing, frames, map and context materialization, publication and provenance, cleanup, entry points and the executor, plus the NDD source frame. Tests cover effects, map, delegated, foreign and optional-tail conformance with their references.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add evidence admission and authentication, provenance reconciliation and release qualification, with the qualification and evidence suites, witnesses and the evidence model.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
… SDK

Add request inspection and extensions (context providers and retained-result views) and lazy root-package exports including graph. Document the public graph SDK in docs/concepts/graph-sdk.md with mkdocs navigation, reference pages and a provider-free example, and add the public API and export-surface tests.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
NddAdapter.run_workflow gains optional source_frame, request_bridge and rewrite_binding parameters; the change is additive apart from removing the private _detect_missing_records. Add the version-qualified DataDesigner request entry, row partitioning and source positions, stage binding, the Rewrite evaluation binding, the data-designer==0.9.4 pin with a drift test, and the compat pytest marker.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add test_production_contracts.py, a provider-free compatibility oracle for the public facade covering Redact projection and failure, saved-result evaluation and DataDesigner retry. It lands before any route moves so each cutover is measured against it.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Admitted core, optional-latent and final stages replace the builtin detection controller. Add the whole-frame detection and Redact consumers, the shared harness (_opaque_redact, _frame_publication, harness retention), the NDD route module _route.py and the graph SDK _authoring_defaults.py. Redact evidence checking rejects empty templates, with matching config, replace.md and skill updates and the skill signature files. Validation-plugin and local-transform mediation and the frozen detection corpora are included. This commit also carries the logging hunks of replace/replace_runner.py.

Part of the B10–B12 route cutover; the full suite is green again at B12.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…acade

Graph control owns Rewrite transitions for both DataDesigner schedules. The legacy repair loop and CombinedRewriteWorkflow.run are retired, and one source-bound invocation runs detection plus the semantic tail. The public Anonymizer facade is routed here, including the logging hunks of interface/anonymizer.py. Evaluation source-position fixes are included; evaluate() keeps its existing path.

Part of the B10–B12 route cutover; the full suite is green again at B12.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Move Substitute map selection, generation and acceptance under graph control, with native map requests, compatibility entries and the Substitute lineage and compat suites. This closes the route cutover; the full suite is green from here.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Keep sensitive payloads out of library log output. This covers the logging change except interface/anonymizer.py and replace/replace_runner.py, whose logging hunks ride B11 and B10.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add docs/development/graph-sdk-cutover.md and the AGENTS.md section describing the graph SDK preview branch rules.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Add tools/dogfood/ with a README, compare.py, dogfood_run.py and model and provider configs for comparing this branch against the released package.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the lazy attribute table with static imports and a sorted
__all__ that binds the same names to the same objects. This drops the
import_module/TYPE_CHECKING mirror of the export list.

The pure-import test no longer asserts that importing anonymizer.graph
avoids pandas; it keeps its two engine-component cases. The export pin is
re-recorded for the new sorted __all__ order.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Importing anonymizer.graph now pulls in the engine eagerly. Defer the
four edges that dragged in pandas and numpy: the harness frame types
become TYPE_CHECKING imports with local imports at their few runtime
uses, and rewrite artifacts import numpy and pandas inside the one
method that needs them.

import anonymizer.graph drops from roughly 580 ms and 735 modules to
roughly 290 ms and 280 modules, with pandas no longer loaded.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…equest bridge

Restore the upper-bounded DataDesigner range used on main instead of the
exact 0.9.4 pin. The graph request bridge no longer compares the installed
version; it checks the private DataDesigner hooks it relies on:

- at bind time, before any work runs: DataDesigner._create_resource_provider,
  ModelRegistry's model_facade_factory parameter and ModelFacade.agenerate;
- when DataDesigner first creates a provider or facade, before any request:
  the registry's facade factory and the facade client's HTTP fields, which
  are instance attributes and cannot be checked earlier.

A missing hook raises AnonymizerWorkflowError naming the attribute, the
supported range and the installed version. The bridge records the failure
and re-raises it when the binding exits, so DataDesigner's per-row error
handling cannot turn it into dropped records.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…oks lazily

data-designer 0.9.5 classifies httpx.NetworkError and RemoteProtocolError
(for example a ReadError) as connection errors, so its request executor
now retries them. A bridge that admits one physical attempt per chain
(detection) then saw a second attempt and rejected the whole invocation,
where 0.9.4 kept the record's outcome unknown. For single-attempt chains
the bridge now hands DataDesigner those errors as a
SingleAttemptTransportError, which every supported release classifies as
a non-retryable API error; errors 0.9.4 already retried (ConnectError,
timeouts) are passed through unchanged.

Private DataDesigner names are no longer imported at module scope, so
importing anonymizer does not depend on them:

- create_retry_transport and ParserException are resolved lazily and
  checked when the bridge binds;
- the health-check skip switch and AsyncTaskScheduler's
  salvage_max_rounds, used only by the Substitute native map, are checked
  when the map plans its requests, before any work runs;
- ensure_async_engine_loop is imported on first use.

Each missing hook raises the standard capability error.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Every route runs DataDesigner through NddAdapter.run_workflow, which binds
the source frame to DataDesigner's private dataset builder. A release
without those hooks failed with a bare AttributeError message.

The binding now checks, before any work runs,
DataDesigner._create_dataset_builder and the artifact-storage module's
BatchStage members and SDG_CONFIG_FILENAME. When DataDesigner creates the
builder, before it runs, it checks the builder's
_initialize_generators_and_graph, _write_builder_config and
_data_designer_config, and the storage methods and paths it wraps. Each
failure raises the standard capability error naming the attribute, the
supported range and the installed version.

A real DataDesigner always requires the hooks; a caller's stand-in
designer without a dataset builder is still left unbound.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Builtin Redact, Annotate and Hash, and builtin detection or Substitute
map generation reached from a caller override through super(), ran
their DataDesigner calls on a library worker thread. In 0.4.0 they ran
on the thread that called run() or preview(). Hand those bodies to the
caller's thread like every other body, and run a handoff issued on the
serving thread inline instead of raising.

New placement tests pin 0.4.0 behaviour for builtin Redact, Annotate
and Hash, for detectors that delegate to the builtin run(), and for
Substitute under a caller replace runner, from the main thread, a
non-main thread and inside a running event loop.

The route-resolution rows keep pinning which route each runner shape
takes and now pin that every route hands its body to the caller. The
interrupt-drain test delivers a real SIGINT instead of simulating one
in the kernel's completion wait, which the caller no longer reaches
while a body runs.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…r pool

Every synchronous body already returned to the caller's thread, but the
kernel still started a one-worker pool per invocation whose thread only
forwarded the body back to the caller, and the caller polled its queue
every 10 ms. Submit delegated bodies straight to the caller's queue
through a caller-thread submitter, wake the caller with a completion
marker instead of the poll, and drop the per-invocation pool and the
caller_callbacks switch, which every entry now sets.

Standalone use outside a facade invocation keeps the worker submitter
and BlockingPool. A Ctrl-C that lands after a body's result was set no
longer turns into an InvalidStateError.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
The patch replaced DataDesigner's scheduler wait process-wide so that a
graph cancellation reached a DataDesigner run waiting on a library
worker thread. Every DataDesigner wait now runs on the thread that
called the public entry point, where Ctrl-C reaches DataDesigner's own
handler as in 0.4.0, so the patch is no longer needed.

Extend the subprocess Ctrl-C test to builtin Redact, Redact under a
caller detector that delegates to the builtin run(), and Substitute
under such a detector.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
run(), preview() and evaluate() run every synchronous body, builtin or
supplied, on the thread that called them, at every depth, with a copy
of the caller's context variables. Say so in the graph SDK concepts
page and the cutover notes, correct the claim that callbacks get the
caller's context rather than a copy, and state where a builtin reached
through super() runs.

Builtin detection now always receives the caller handoff, so its
inline fallback is gone.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
A Ctrl-C that landed after the caller took a body from its queue, or
just before it settled the body's future, left that future unresolved.
The host then waited for it forever and the drain loop absorbed every
further Ctrl-C, so the process only ended on SIGKILL. Track every queued
body until it settles and, when an interrupt leaves serving, settle any
that remain as failed before re-raising.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Caller code and every DataDesigner wait run on the caller's thread, but
the per-invocation event-loop thread still runs internal bookkeeping and
DataDesigner's engine threads still run Anonymizer column generators,
as on main. Say so instead of claiming the loop thread runs no
Anonymizer code, and fix a comment that still said a nested handoff
raises.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
When a second Ctrl-C landed while the drain loop was serving, the
interrupt handler asked the host loop to cancel even after that loop had
closed, and the resulting RuntimeError replaced the KeyboardInterrupt.
Share one guarded stop request between the handler and the drain loop.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
A second Ctrl-C landing inside the interrupt handler, before it settled
the waiting bodies, could still leave one unsettled and the drain loop
waiting forever. Settle first, then request the stop, and settle again
on every drain pass so no interrupt window skips it.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
After an interrupt settles every waiting body, the drain loop could
still take one of them from the queue and set its result again, which
raised InvalidStateError in place of the KeyboardInterrupt. Skip bodies
that are already settled, and test the case directly.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Until the SDK is published, anonymizer.graph and the engine modules
behind it may change names, export order, signatures, defaults,
exception constructors, pickles and import cost. The tests that pin
them are refactor safety nets, re-recorded with the change they cover.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
binaryaaron and others added 4 commits November 9, 2026 17:07
…d record boundary measurement

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…emetry fields

Graph routes record failures under their operation names. Map "detect"
and "substitute-map" to the entity-detection and replace-map-generation
failure counts instead of the unknown-step count.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
DetectionScope is now built from the resolved label config the run uses, so
the reported scope cannot drift from the engine's policy. The open-mode flag
is replaced by allowed_labels (None when entity_labels is unset), matching the
allowed/excluded naming used elsewhere. Detect.resolved_scope is removed and
Detect.effective_labels is folded into the preflight check.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>

Copy link
Copy Markdown
Collaborator

I’d keep record as the default and run opt-in. I’m good with the placeholder fallback too. A less natural result is better than making two different entities look like the same one.

Codex spotted a catch with run: collision checks are per record, so Alice and Carl can both become “Maya” in different records. Placeholder counters reset per record too. I’d either enforce uniqueness across the run or drop the recommendation to use it for joins.

I’d also let an existing run-wide mapping win over another entity’s new proposal. Falling back because it matches a protected original makes sense; losing the mapping because another proposal picked the same name doesn’t.

In run mode a substitute emitted for one key is never emitted for another
key later in the run, placeholders are numbered across the run, and a key's
established run-wide substitute wins over another key's new proposal.
Record mode is unchanged.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…aceholder

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…ities

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…ts and report the resolved scope

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
@binaryaaron

Copy link
Copy Markdown
Collaborator Author

@andreatnvidia Both points are right. #394 fixes them: in run mode a substitute is now unique across the whole run, placeholders are numbered run-wide, and an established mapping keeps its substitute when a later record proposes the same one. Record mode is unchanged. Thanks for the catch.

binaryaaron and others added 8 commits November 10, 2026 04:01
…ue across the run

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Record one LlmReplaceWorkflow.fold measurement stage per generate_map_only call with per-label fallback counts (own, placeholder_taken, placeholder_protected) and the number of records that received a placeholder. Labels only; folded maps are unchanged.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
The failed-attempt loop tested membership in the ordered target list once
per attempt, which is quadratic in the number of rows. Keep the list for
ordering and test membership against a set of the same targets.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
The record-ID fallback tested each surviving ID against the source ID list,
which is quadratic. Build the ID-to-position lookup first and use it for
both the duplicate check and membership; the error is unchanged.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
A member withheld only by its group now refers to the group's causes
tuple instead of its own copy, so stored entries per group drop from
(G - C) * C to C. Every TargetOutcome keeps the same withheld_by
contents and order.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Calls where every row has no entities return before the fold, so an
active measurement session got no LlmReplaceWorkflow.fold record. Emit
a zero record on that path so every call records one.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Add a measured all-passthrough call and a three-record case where two
records propose one substitute for different names and a third reuses
the resulting run-wide placeholder without a new fallback count.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…fallbacks

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Comment on lines +135 to +142
with stage_timer("LlmReplaceWorkflow.fold", consistency=consistency, input_row_count=len(combined)) as fields:
maps = _fold_replacement_maps(combined[COL_REPLACEMENT_MAP].tolist(), consistency, requested, tally)
fields["entry_count"] = sum(len(m.get("replacements", [])) for m in maps)
fields["records_with_placeholder"] = tally.pop(("records_with_placeholder",), 0)
fallbacks: dict[str, dict[str, int]] = {}
for (label, step), n in sorted(tally.items()):
fallbacks.setdefault(label, {})[step] = n
fields["fallback_counts"] = fallbacks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fold measurements cannot be imported

generate_map_only() now records LlmReplaceWorkflow.fold on every call, but StageMeasurement.stage does not accept that name. The strict model also rejects the new consistency, entry_count, records_with_placeholder, and fallback_counts fields.

Measured Substitute and non-combined Rewrite runs therefore produce files that read_measurement_snapshot() rejects, blocking completion seals and W&B imports. Update the measurement readers alongside this record and add a test that passes it through the strict reader.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still reproduces at 5b764cab, including when there are no entities and no model calls. The emitted fold record fails strict validation on the stage name and all four new fields. The reader schema needs to land with the producer change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed—this confirms the finding, including the no-entity fast path. generate_map_only() emits LlmReplaceWorkflow.fold via record_stage() even when there are no entities or model calls; the strict StageMeasurement schema rejects that stage name and the additional fields (consistency, entry_count, records_with_placeholder, and fallback_counts). The reader schema and a strict-reader regression test need to land with the producer change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreatnvidia Fixed by #398, merged into this branch at cb37b27. The measurement reader and the W&B stage table accept the fold stage and its four fields, including on the no-entity path, and a test passes the record through the strict reader.

Replacement-map generation now records an LlmReplaceWorkflow.fold stage
with consistency, entry_count, records_with_placeholder and
fallback_counts. The strict ingress model and the W&B stage table row
did not know that stage or its fields, so measured Substitute and
non-combined Rewrite runs produced snapshots that could not be sealed
or imported. Both now accept the stage with typed optional fields; the
table row keeps the counts, with fallback_counts as compact sorted JSON
under the table opt-in policy. Malformed fold records are still
rejected.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Comment thread src/anonymizer/engine/replace/route.py Outdated
)
replace_elapsed = time.perf_counter() - started - sum(elapsed) # detection records its own stage
failures.extend(malformed)
if not detected: # no targets: the step never ran, but the detector still sees the (empty) input

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At 5b764cab, calling Anonymizer.run() from a graph callback after cancelling the outer token hits this fallback even with nonempty input. Detection then runs with target=(), and resolving the returned positions raises IndexError. A custom detector also executes despite cancellation. The same guard appears in Rewrite. Could we restrict this fallback to genuinely empty graphs and handle cancelled invocations separately?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreatnvidia Confirmed in Replace and Rewrite, and fixed in 7e83c01. After the outer token was cancelled the nested detect step was skipped, and the fallback meant for empty input then ran the detector outside the cancel check. The fallback now runs only when the graph has no targets (7015150), and a nested run() or preview() that the cancellation stops raises AnonymizerWorkflowError without calling the detector. The same rule now applies at every entry into the kernel: both Replace routes, evaluate(), which used to return unjudged rows after a cancel, and the staged detection and rewrite-evaluation runs (tests in 56389bc).

Comment thread src/anonymizer/interface/anonymizer.py Outdated
current, failed = judge.run(current)
failures.extend(failed)
present = [c for c in judge.columns if c in current.columns]
return current[present].to_dict("records") if present else [{} for _ in range(len(current))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A custom replace_runner.evaluate() returning one surviving row and one FailedRecord from two inputs now raises TypeError: invocation operation returned list of length 1, expected 2 entries. The same public Anonymizer.evaluate() call returns the partial result on 7605c13c. The graph keeps the original target count, but this publishes only the surviving rows. Keeping the previous path for custom evaluators would preserve compatibility; otherwise the outputs need explicit source alignment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreatnvidia Confirmed, and fixed in 82e4500. The judge step returned one entry per returned row instead of one per input row. It now maps returned rows back to their inputs, so a custom evaluate() that drops a row returns the partial result with its failed record, as on main. A test covers Replace and Rewrite.

binaryaaron and others added 6 commits November 11, 2026 15:59
… stage in strict ingress

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
A custom runner whose evaluate() returns fewer rows than it was given made
evaluate() raise a TypeError from the graph kernel's length check. The judge
body now maps the returned rows back to their targets, so evaluate() returns
the runner's partial frame and failed records again; a target with no
returned row reads as judge unavailable.

Fixes the #319 review comment on src/anonymizer/interface/anonymizer.py:185
(custom evaluate() returning 1 of 2 rows plus a FailedRecord); the result now
matches main.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
The Replace and Rewrite detect paths and evaluate() each called their
whole-frame step on an empty input when the graph had no targets, with two
different predicates. One helper now does this when the graph has no
targets. A test covers the detector seeing an empty input in both modes.

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
An Anonymizer.run(), preview() or evaluate() called from a graph callback
after the token was cancelled behaved differently per path: the detect path
called the detector anyway and raised an IndexError, the local Replace route
returned rows without replacements, the Substitute route returned no rows,
and evaluate() returned unjudged rows. Every entry into the graph kernel now
raises AnonymizerWorkflowError when the result shows a target stopped by the
cancellation, before any detector or judge call. Stage sequences raise the
same error instead of returning after skipped stages.

Fixes the #319 review comment on src/anonymizer/engine/replace/route.py:216
(nested run after a cancel reaching the empty-graph fallback; the same guard
was in Rewrite).

Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
Move the cancel check and the empty-input detector call next to per_target
in the engine backend module. run_stages now returns its result and the
detection and rewrite-evaluate callers raise on cancel. A test keeps
graph_core from importing other anonymizer modules; the one allowed
exception is the existing measurement timer import in execution.py.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>
…evaluation

The detection run path and rewrite evaluate each wrap their staged run in a
cancel check, but no test failed without it. Drive both inside a cancelled
frame, before the first stage and between stages, and assert the workflow
error and that no later stage ran.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aaron Gonzales <aagonzales@nvidia.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants