Skip to content

feat: tolerate small-model drift on detection + replace schemas#174

Open
lipikaramaswamy wants to merge 7 commits into
mainfrom
lipikaramaswamy/feat/small-model-detection-schemas
Open

feat: tolerate small-model drift on detection + replace schemas#174
lipikaramaswamy wants to merge 7 commits into
mainfrom
lipikaramaswamy/feat/small-model-detection-schemas

Conversation

@lipikaramaswamy

@lipikaramaswamy lipikaramaswamy commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

First of two stacked PRs reworking the LLM-facing schemas so small models (gemma4-e2b/e4b, nemotron-3-nano:4b, qwen3.5:4b) no longer drop records when their structured output drifts from the strict contract. This PR covers the detection schemas plus the replace (Substitute) wire schema; the rewrite schemas + server-side disposition follow in stacked PR #175.

This supersedes the schema work in #130, rebased onto current main and split for reviewability.

Detection

  • RawValidationDecisionSchema / ValidationDecisionSchema: coerce free-form decision prose into keep/reclass/drop, preserve None as "no answer" (required by the chunked-merge logic), and drop value/label from the wire (re-filled downstream from the trusted candidate lookup by enrich_validation_decisions).
  • LatentEntitySchema: loosen category/confidence/evidence/rationale with before-validators; verbose rationales truncate instead of failing.
  • LatentEntitiesSchema + detection_workflow: pad empty latent cells with a sentinel so PyArrow can write the parquet column. (Latent entities are produced here but only consumed in rewrite mode — they land as inert-but-correct columns until feat(rewrite): small-model drift tolerance + server-side disposition #175 reads them.)
  • AugmentedEntitySchema: drop value/label min_length — a single empty entry no longer fails the whole augmentation batch (empties are already skipped by apply_augmented_entities).

Replace

  • EntityReplacementSchema: drop min_length on original/label/synthetic. This is the LLM output_format for Substitute mode, so a single drifted entry used to fail-validate the whole replacement map and drop the record. Empty original/label are filtered downstream (cannot match a requested entity); an empty synthetic is applied as a deletion — privacy-safe (no leak), if utility-poor.

Test plan

  • make test green (837 passing on this branch)
  • make format-check clean
  • New test_small_model_drift.py (detection drift classes); test_chunked_validation updated for the slimmed wire shape; new replace drift tests in test_llm_replace_workflow
  • Live small-model run (validation/augmentation/latent + substitute paths) before merge

Loosen the LLM-facing detection wire schemas so small models (gemma4-e2b/e4b,
nemotron-3-nano:4b, qwen3.5:4b) that drift from the strict contract no longer
drop records during validation/augmentation/latent tagging:

- RawValidationDecisionSchema / ValidationDecisionSchema: coerce free-form
  decision prose to keep/reclass/drop, preserve None as "no answer", drop
  value/label from the wire (re-filled downstream from trusted candidates).
- LatentEntitySchema: loosen category/confidence/evidence/rationale with
  before-validators; truncate verbose rationale instead of failing.
- LatentEntitiesSchema + detection_workflow: pad empty latent cells with a
  sentinel so PyArrow can write the parquet column.
- AugmentedEntitySchema: drop value/label min_length; a single empty entry no
  longer fails the whole augmentation batch (empties are skipped downstream).

Adds test_small_model_drift.py (detection drift classes) and updates
test_chunked_validation for the slimmed wire shape.
EntityReplacementSchema is the LLM output_format for Substitute mode, so a
single drifted entry (small models occasionally emit an empty synthetic, or a
blank original/label) used to fail-validate the whole replacement map and drop
the record. Drop min_length on original/label/synthetic:

- empty original/label cannot match a requested entity and are filtered out by
  _filter_replacement_map_to_input_entities (valid siblings are preserved);
- an empty synthetic keys on (original, label), survives the filter, and is
  applied as a deletion at rewrite time -- privacy-safe (no PII leak), even if
  utility-poor.

Adds regression tests for both drift modes.
@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR loosens Pydantic wire schemas for detection (RawValidationDecisionSchema, ValidationDecisionSchema, LatentEntitySchema) and replace (EntityReplacementSchema) so that small-model output drift (free-form prose in enum slots, null in typed fields, empty strings) no longer invalidates a whole LLM response and drops records. It also adds a _pad_empty_latent_column helper to keep the latent-entities parquet column writable when every cell is empty.

  • Detection schemas: decision fields get before-validators that coerce free-form prose into keep/reclass/drop, with diverging None-handling semantics carefully separated between the chunked-merge path (RawValidationDecisionSchema, preserves None) and the wire path (ValidationDecisionSchema, defaults to "keep"); LatentEntitySchema fields are fully loosened with per-field coercion validators.
  • Replace schema: min_length=1 dropped from EntityReplacementSchema.original/label/synthetic; empty entries are handled gracefully downstream (filtered or applied as deletion).
  • Parquet padding: _pad_empty_latent_column inserts a sentinel struct when all cells are empty, closing a PyArrow schema-inference failure that the Pydantic-level _ensure_parquet_writable couldn't cover for partial-failure rows.

Confidence Score: 5/5

Safe to merge; all coercion paths are backed by regression tests pinned to observed small-model drift cases.

Each loosening is accompanied by a downstream filter or coercion that preserves the existing semantic contract: None-preserving vs. keep-defaulting decision normalizers are carefully separated, empty replacement entries are filtered before apply, and the parquet sentinel is invisible to readers.

The class docstring in src/anonymizer/engine/schemas/detection.py (LatentEntitySchema) has a minor inversion but no functional concern.

Important Files Changed

Filename Overview
src/anonymizer/engine/schemas/detection.py Core schema changes: adds before-validators for decision normalization, latent-entity field coercion, and parquet-writable sentinel; one docstring has an inverted clause ("if too short" should be "if too long").
src/anonymizer/engine/detection/detection_workflow.py Adds _pad_empty_latent_column with None/NaN guards and correct dict/list shape handling; applied at detect_latent_entities return site.
src/anonymizer/engine/schemas/replace.py Drops min_length=1 from EntityReplacementSchema; empty entries are downstream-filtered or applied as deletions, which is privacy-safe.
src/anonymizer/engine/replace/llm_replace_workflow.py Prompt text clarified to reduce small-model literal copying of reference examples; example format changed from "(e.g. …)" to "such as …".
tests/engine/test_small_model_drift.py New regression test file pinning all observed small-model drift classes for RawValidationDecisionSchema, ValidationDecisionSchema, and LatentEntitySchema.
tests/engine/test_chunked_validation.py Updated to reflect that value/label are now stripped at merge time and re-filled downstream by enrich_validation_decisions.
tests/engine/test_detection_workflow.py New TestPadEmptyLatentColumn class covering dict, bare-list, None, NaN, and missing-column cases.
tests/engine/test_llm_replace_workflow.py New tests verify that empty synthetic/original/label entries no longer fail-validate the whole replacement map.

Reviews (5): Last reviewed commit: "fix(detection): wrap bare-string latent ..." | Re-trigger Greptile

@lipikaramaswamy lipikaramaswamy changed the title feat(detection): tolerate small-model drift on detection schemas feat: tolerate small-model drift on detection + replace schemas Jun 1, 2026
Comment thread src/anonymizer/engine/schemas/detection.py Outdated
Comment on lines +704 to +711
def _fix(cell):
if isinstance(cell, dict):
if not cell.get("latent_entities"):
return {**cell, "latent_entities": sentinel}
return cell
if isinstance(cell, list) and not cell:
return sentinel
return cell

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 None/NaN cells returned unchanged will still fail PyArrow

The _fix helper covers dict with empty latent_entities and empty list, but silently passes through any other value — including None or float('nan') that pandas places in a column during partial-failure fallback. PyArrow will still raise a type error on those cells. An early None/NaN guard returning sentinel closes this gap.

Suggested change
def _fix(cell):
if isinstance(cell, dict):
if not cell.get("latent_entities"):
return {**cell, "latent_entities": sentinel}
return cell
if isinstance(cell, list) and not cell:
return sentinel
return cell
def _fix(cell):
if cell is None or (isinstance(cell, float) and pd.isna(cell)):
return sentinel
if isinstance(cell, dict):
if not cell.get("latent_entities"):
return {**cell, "latent_entities": sentinel}
return cell
if isinstance(cell, list) and not cell:
return sentinel
return cell

Comment on lines +190 to 193
proposed_label: str | None = Field(
default="",
description="Correct label when decision is 'reclass', otherwise empty",
)

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.

P2 str | None annotation never resolves to None at the Python level

_coerce_proposed_label always converts None"", so the runtime type of proposed_label is always str. The str | None annotation is intentional for JSON Schema emission (so null passes DD's pre-check), but it should be documented inline — otherwise readers will reasonably wonder whether downstream code needs to guard against None.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

_normalize_category referenced LatentCategory.latent_sensitive_attribute,
which is not a member of the enum (it only defines latent_identifier; sensitive
attributes were folded into quasi_identifier on the rewrite side). Any latent
category string containing "sensitive" — exactly the kind of drift small models
emit — would raise AttributeError at runtime. Non-canonical category drift now
normalizes to latent_identifier, and the field description no longer advertises
the nonexistent value.

Also clarifies _pad_empty_latent_column's shape-preservation intent (the
downstream reader tolerates both dict and bare-list cells, so no mixing occurs).

Adds regression tests for the sensitive/unknown category drift paths.
_pad_empty_latent_column previously passed None/NaN cells through unchanged. A
row absent from a partial-failure fallback merge (reintroduced as NaN by a
pandas reindex) could leave the latent column without an inferable struct
schema. None/NaN now normalize to the canonical sentinel struct, consistent
with the existing empty-cell handling.

Adds TestPadEmptyLatentColumn covering empty-struct, populated, bare-list,
None/NaN, and missing-column cases.
Greptile P2: the str | None annotation on ValidationDecisionSchema.proposed_label
is intentional for jsonschema null-tolerance; _coerce_proposed_label always
normalizes None to "" so the runtime value is a str. Document inline so readers
do not add unnecessary None guards.
@lipikaramaswamy

Copy link
Copy Markdown
Collaborator Author

Live small-model validation

These changes make detection + replacement tolerate small-model output drift without dropping records. Validated live against real small models on build.nvidia.com.

Setup

  • Pipeline: GLiNER (nvidia/gliner-pii) detection → LLM validation + augmentation → Substitute replacement → anonymizer.evaluate() judges
  • Worker roles (validator / augmenter / replacement_generator) pointed at a small model; judge fixed at gpt-oss-120b so verdicts are comparable across runs
  • Dataset: 4 synthetic PII-dense records (clinical, financial/PII, employment, sensitive-attribute/latent)
  • Pass criterion: zero dropped rows, zero validation failures (the P1-class bug these schema changes fix)

Resilience results — the core goal

Worker model Size Detection Replacement Rows in→out Failures Dropped
gemma-3n-e2b-it ~2B 0 failed 0 failed 4→4 0 0
nvidia-nemotron-nano-9b-v2 ~9B 0 failed 0 failed 4→4 0 0
gpt-oss-120b (default) ~120B 0 failed 0 failed 4→4 0 0

Zero record loss at every model size, even though the 2B drifted heavily. Pre-change, a single drifted field could fail-validate an entire batch. Drifted output was always privacy-safe — original PII was replaced, never leaked back.

Quality (LLM-as-judge, graded by gpt-oss-120b)

Worker model Judge passes /16 Character of failures
gemma-3n-e2b (~2B) 11 detection mislabels; one geo mismatch
nemotron-nano-9b (~9B) 9 gender flips, one geo mismatch, mislabels
gpt-oss-120b (~120B) 14 residual gender flip + one detection false-positive

Failures split into two classes:

  • Model-size-dependent (geo inconsistency, grammar) — improve with bigger models.
  • Model-independent (gender preservation on name swaps, occasional detection false-positives like "meatpacking plant"→company_name) — present even at 120B, so not regressions from this PR.

Replacement-prompt clarification (included here)

engine/replace/llm_replace_workflow.py: per-label hints now render as such as {examples} and instruct the model to generate a new realistic value of that kind, not reuse the examples or copy the reference text. This keeps small models from emitting the reference text instead of a real value. Confirmed on the 2B run — type_fidelity and attribute_fidelity both score 4/4.=

…al values

Small models echoed the "(e.g. X, Y, Z)" wrapper verbatim as the synthetic
value (e.g. an age replaced with the literal string "(e.g. 52, 38, 45, 31)"),
and "one of: ..." made them copy a canned example value instead. Render the
per-label hints as "such as {examples}" and instruct the model to generate a
NEW value of that kind without reusing the examples or copying the reference
text. Validated live on gemma-3n-e2b: type_fidelity and attribute_fidelity
both clean (4/4), no record drops.
_clamp_evidence returned [] for a bare-string evidence value, silently
dropping the quote when a small model emits a single evidence string instead
of a one-element list. Treat a bare string as a single-item list, consistent
with the drift-tolerance strategy. Addresses Greptile review note on #174.

Adds test_bare_string_evidence_wrapped_not_dropped.
@lipikaramaswamy

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining Greptile note (non-blocking) from the review summary in c15896c: _clamp_evidence previously returned [] for a bare-string evidence value, silently dropping the quote when a small model emits a single string instead of a one-element list. It now wraps a bare string into a one-element list — consistent with the drift-tolerance strategy — with a regression test (test_bare_string_evidence_wrapped_not_dropped).

The three inline comments (P0 latent_sensitive_attribute, P1 _fix None/NaN guard, P2 proposed_label docs) were already handled in 926bf8c / b287340 / 81ecba0.

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.

1 participant