Open-source memorization/privacy auditor for Trainer/TRL fine-tunes — canary MIA + regurgitation testing (with a real case study)

What it does

It answers two questions every fine-tune in a regulated setting increasingly has to document (see EDPB Opinion 28/2024, para 55/58):

**1.	Membership** — can an attacker with logprob access tell what was trained on?

**2.	Regurgitation** — does the model emit training content verbatim when prompted with a prefix?

It works by injecting pre-registered “canary” records into your training data before training starts, running a PEFT-aware pre-flight check when training begins (this catches a nasty silent-failure mode — frozen LoRA embeddings can make certain canary types untrainable, which would otherwise make an audit falsely report “no leak”), and writing a full report when training ends.

It’s fully local — no phone-home, no account, no SaaS. And it’s honest about its own limits: it refuses to report a headline detection rate if you don’t have enough held-out controls, rather than fabricating a misleadingly confident number.

A real case study, not just a demo

I ran it on a TinyLlama-1.1B-Chat LoRA fine-tune on 20,000 real rows from the Stanford Alpaca dataset — a recognizable, reproducible setup, not a synthetic toy example. Config: LoRA r=8, 1 epoch, honest canary token budget of 0.907% (100 inserted canaries, 200 held-out controls).

Results:

**•	Membership inference: TPR @ 1% FPR = 0.180** (18/100 canaries detected), 95% CI \[0.110, 0.269\], AUC 0.776

**•	Regurgitation: 0/100** — none of the leaked canaries were reproduced verbatim from a prefix

**•	Negative controls: 0.00 regurgitation**, confirming the detection isn’t noise

The interesting part: membership and regurgitation disagreed. The model showed real, statistically meaningful signs of having memorized specific canaries (detectable via loss-based membership inference) without ever actually reciting them back verbatim. This is exactly why the tool reports both signals rather than just one — a regurgitation-only test would have said “all clear” here, which would’ve been misleading.

Full report + reproduction script: examples/alpaca_case_study.py in the repo. pip install “memaudit[peft,trl]” and run it yourself.

What it doesn’t do

To be upfront: this isn’t a compliance certificate, and it doesn’t cover model inversion, attribute inference, or shadow-model-level attacks (those are more expensive and out of scope for v0.1). It’s LoRA-aware but not LoRA-only — full fine-tunes are supported with an explicit reference-model flag. Real per-record risk flagging is currently exploratory (published AUC ~0.72-0.78 on honest fine-tunes) — I’d trust the set-level result over any single flagged record right now.

Looking for feedback specifically on:

**•**	Whether the LoRA pre-flight check holds up against other model families/PEFT configs people are using

**•**	Whether the canary defaults (100 members / 200 controls, {1,4,16} repetition tiers) feel right for real workflows, or too conservative/aggressive

**•**	Any silent-failure modes in Trainer/TRL/PEFT integration I haven’t hit yet

Repo: GitHub - mem-audit/memaudit: Training-data memorization auditor for fine-tuned LLMs — Trainer/TRL plugin, canary MIA + regurgitation audit, Apache-2.0 · GitHub

Install: pip install memaudit

Happy to answer questions about the methodology or the pre-flight/injection mechanics.

The design direction looks promising:


My short answers to the three things you asked about would be:

  1. I would keep the LoRA/PEFT pre-flight as a first-class part of the tool. In fact, I think it may be one of the more practically distinctive parts of the project. Before expanding mainly by model family, though, I would test it against a small matrix of semantic failure modes — truncation, completion/assistant masking, packing, chat-template masks, trainable embeddings/tokens, tied weights, etc. Those seem more likely to expose false “all clear” paths than simply trying more architectures.

  2. 100 members / 200 controls and {1,4,16} look reasonable to me as a powered stress-test profile, but I would hesitate to call them a universal default. The useful operating point depends on canary construction, repetition/exposure, the training recipe, the membership score, and the FPR you want to resolve. I would probably expose something like smoke / routine / powered profiles rather than searching for one magic count.

  3. There are real Trainer/TRL/PEFT-style silent-failure classes worth turning into regression fixtures. The highest-information ones look like “the record survived, but the intended tokens did not receive loss” rather than exotic model-specific failures.

I tried a few small CPU probes against the current repo, and they mostly reinforced that direction rather than changing it.

One especially encouraging result: when I deliberately constructed a canary whose secret completion would not fit inside a very small max_length, the pre-flight stopped before training with a fatal “record exceeds max_length” finding. That is exactly the kind of failure I would want an audit tool to catch before interpreting a later zero-leak result.

The main thing I would tighten is the meaning of each pre-flight success state.

A useful progression might be something like:

record observed → token span aligned → labels inspected → secret span directly supervised

rather than allowing all of those to collapse into a single “found/trainable” concept.

That distinction matters because the current TRL SFT pipeline can transform the original example substantially: it constructs completion/assistant masks, converts excluded labels to -100, truncates to max_length, and may pack examples into fixed-length sequences. So “I can still find the string” is useful evidence, but it is not necessarily the same evidence as “I verified that these exact secret tokens participate in the training loss.”

A compact default route I would probably use is:

  1. Keep the static/config pre-flight.
  2. Add a few known-bad semantic regression fixtures.
  3. Report the level of verification achieved for each canary.
  4. Report scan coverage explicitly.
  5. Then widen across representative PEFT/model configurations.

That seems relatively cheap while directly protecting the central goal: avoiding a falsely reassuring audit caused by the audit probe itself not participating in training.

Why I would test semantic failure modes before adding lots of model families

There are several useful historical/current TRL examples here.

A recent SFTTrainer issue #6668 demonstrates a particularly relevant failure shape: with prompt-completion data and completion_only_loss=True, truncation can remove every completion token. The resulting batch has every label set to -100; the underlying model forward loss becomes NaN, while the training result can appear as loss: 0.0.

I would not treat that issue as proof that the same thing happens inside memaudit, and library behavior can change. But it is an excellent known-bad fixture for a privacy pre-flight:

if an audit canary’s supervised span disappears, can the auditor fail loudly before training?

Similarly, older TRL regressions show why configuration-space tests can be more informative than a list of model names:

Some of these are fixed historical bugs, not claims about current TRL. That is actually what makes them useful here: they can become permanent regression fixtures without requiring the bugs to still exist upstream.

Current TRL also has several semantics that are worth making explicit in the matrix:

Axis Why it matters for a canary audit
completion_only_loss Presence in the prompt does not imply direct loss on the secret
assistant_only_loss Depends on correct assistant-token masking
chat template Assistant-only training depends on generation markers / supported templates
max_length Can remove the intended supervised span
packing Changes sequence boundaries and mask handling
packing_strategy bfd, bfd_split, and wrapped have different overflow semantics
pre-tokenized datasets User-supplied labels may bypass some preparation assumptions
skip_prepare_dataset Moves responsibility from SFT preparation to the supplied collator
padding-free / optimized kernels Adds another route by which masks and representations can differ

The current SFTTrainer documentation is unusually helpful here because it spells out most of these transformations.

On the PEFT side, I would similarly choose representative configuration semantics, not just “Llama / Qwen / Gemma”.

The current PEFT LoRA documentation has fairly detailed behavior around:

  • modules_to_save,
  • trainable_token_indices,
  • tied embeddings / lm_head,
  • ensure_weight_tying,
  • targeting embeddings or lm_head,
  • whether the same or different token indices are trained on tied layers.

It even notes that some of the tying logic depends on conventional layer names such as embed_tokens and lm_head, so correct behavior cannot be guaranteed for arbitrary naming schemes.

That suggests a compact matrix like:

plain LoRA
LoRA + modules_to_save
LoRA + trainable_token_indices
tied embedding/lm_head + ensure_weight_tying
untied embedding/lm_head
quantized LoRA/QLoRA
one MoE/target_parameters configuration

You probably do not need every combination. A few deliberately adversarial representatives would tell you more than a large compatibility badge table.

One small control I tried was the ordinary LoRA case: train a tiny adapter for a few steps, compare active logits, then enter disable_adapter(). In that ordinary path, adapter-disabled logits returned exactly to the pre-LoRA base logits and the active adapter state was restored afterward.

So I would not treat disable_adapter() itself as suspicious in normal LoRA. If desired, a few-token “base-equivalence” doctor check could simply be a cheap guard for unusual PEFT configurations rather than another required feature.

Pre-flight reporting: presence and supervision are slightly different questions

This is the part I would most strongly consider separating in the report schema.

In the current preflight.py, token-level matches can support stronger checking than the string fallback. A token-level path can inspect the relevant token positions and their labels; a string fallback mainly establishes that the textual material is still observable in the processed record.

In my small truncation probe, the useful combination was:

record found: yes
token-level hit: no
string-level hit: yes
pre-flight fatal: yes (sequence exceeds max_length)

That is actually a nice demonstration of why separate evidence levels help. The tool correctly stopped the run, but “found” by itself would not tell the whole story.

I might make the report terminology something like:

Status Meaning
record_observed The canary can still be located after preprocessing
secret_token_aligned The expected token span can be located unambiguously
loss_mask_checked Labels for the span were actually inspected
directly_supervised At least the intended secret span receives training loss
verification_unknown The pipeline representation does not allow a stronger conclusion

The names can obviously differ; the useful part is the separation.

One terminology caution: I would avoid turning

“the secret span has labels -100

into

“the information is untrainable.”

Those are not equivalent in the general case.

There is now work specifically on input-only PII memorization, where private information appears in fine-tuning inputs but is not an intended training target, yet can still become extractable after fine-tuning. See the EACL 2026 study on unintended input-only PII memorization.

So for this tool I would phrase the property narrowly:

“the canary protocol expected this secret span to be directly supervised, and pre-flight verified/did not verify that condition.”

That gives the pre-flight a crisp contract without claiming that all other forms of memorization are impossible.

One concrete low-cost boundary I also noticed is the survival-scan window.

Using the current injection/scan logic on a synthetic 80k-row host dataset with 100 canaries, the default 50k-row scan did not necessarily observe every inserted canary. On one seed I got 83/100 found in the default scan versus 100/100 with a full scan. Repeating the placement simulation over 100 seeds gave a mean of about 86.4/100 observed in the first 50k.

The misses were overwhelmingly the low-repetition probes, which is exactly what one would expect: a 16x canary has many opportunities to land inside the scan window; a 1x canary has only one.

I therefore would not necessarily remove the limit — a bounded pre-flight may be a sensible performance choice — but I would distinguish:

missing after full/known-complete inspection

from

not observed within pre-flight scan window

and add something like:

rows_scanned: 50000
rows_total: 80100
scan_complete: false

That is a very small reporting change with a fairly large interpretability payoff.


On the 100 members / 200 controls / {1,4,16} question, I think the repetition tiers are useful, but I would show them as a stress-response curve in addition to the pooled headline.

Using the same global threshold from the checked-in powered report, the 18 detected members decompose as:

Repetitions Detected
1x 0 / 34
4x 2 / 33
16x 16 / 33
pooled 18 / 100

I do not think this weakens the case-study result. If anything, it makes the experiment more informative.

It tells the reader that the pooled 18% result is substantially a duplication/exposure stress signal, not evidence that an ordinary single-exposure record in that run had an 18% detection probability.

That is consistent with the broader memorization literature: repetition/duplication can strongly increase extractability and memorization. A useful classic reference is Kandpal et al., “Deduplicating Training Data Mitigates Privacy Risks in Language Models”, which found a strong relationship between sequence duplication and regeneration.

So I would probably keep {1,4,16}, but make the semantics explicit:

1x     = closer to a single-exposure probe
4x     = moderate stress
16x    = high-exposure stress
pooled = overall powered-audit headline

Then report both the pooled result and the tier curve.

How I would think about 100/200 as defaults

I would avoid choosing the counts independently of the rest of the audit design.

The power of a canary audit depends on at least:

canary construction
× repetition/exposure
× training recipe
× model/adaptation method
× membership score
× target FPR
× number of members
× number of controls

The ICLR 2025 paper “Privacy Auditing of Large Language Models” is relevant here. One of its main results is that canary construction itself can change low-FPR detection substantially. Their stronger canaries achieved much higher TPR at 1% FPR than earlier canary designs under the same broad auditing objective.

So “200 controls” cannot really be classified as conservative/aggressive by itself.

At a 1% operating point, 200 controls also means you are estimating a fairly extreme tail from a small number of observations. Your existing refusal to produce a headline when controls are clearly insufficient is a good design choice, and the bootstrap/stability information already present in the project points in the right direction.

I might turn this into user-facing profiles instead:

Profile Purpose Example behavior
smoke catch obvious memorization/integration failures cheaply fewer probes, clearly marked exploratory
routine recurring fine-tune audit moderate members/controls
powered publishable/internal review stress run larger controls, repetition tiers, calibration stability

The actual numbers can evolve without changing the conceptual contract.

For the powered profile, I would expose two separate uncertainties:

  1. member-side detection uncertainty — e.g. the CI around the detected fraction;
  2. threshold/calibration stability — how much the low-FPR decision boundary moves under plausible resampling of controls.

Those answer different questions, so showing both makes the headline easier to interpret.

I also agree with the decision to keep membership and regurgitation as separate outputs.

That is not just a presentation choice: they are different attack surfaces.

The EDPB’s Opinion 28/2024 explicitly lists membership inference and training-data regurgitation separately, and makes the broader point that successful testing is evidence about the attacks actually tested rather than a universal privacy guarantee.

There is also fine-tuning-specific empirical work using both signals. For example, “Memorization in Fine-Tuned Large Language Models” uses membership inference and prompted-prefix verbatim reproduction as separate measurements.

So the disagreement in your case study is interesting in its own right.

I would only scope the wording of the second result fairly tightly:

0/100 regurgitation under this prefix / decoding / exact-match protocol

rather than “no extraction risk”.

Other prompting strategies, approximate matches, longer/shorter prefixes, sampling, or paraphrased variants are different tests. Those could become optional attack profiles later, but I would not expand v0.1 just to cover them.


There is one additional branch I would keep explicitly separate from the controlled-canary audit: the real-record exploratory path.

Real-record set-level analysis: I would separate this contract from the canary audit

In a small check of the current path, I found a semantic distinction that may be worth making explicit.

run_audit() can work with a real held-out set, but in the path I tested, when no explicit held-out dataset was supplied, the real-record sampling helper split sampled training records and used one side as the comparison side.

At the same time, the standard MemorizationAuditCallback did not expose a held_out= argument in that checkout.

If that remains the intended API, I would avoid describing the fallback result as “training records versus held-out non-members”, because both sides originate from the training dataset.

This does not affect the TinyLlama powered canary result you posted — that case uses real_sample=0.

So I would treat it as a separable future-facing contract:

controlled-canary audit
    -> has known member/non-member assignment by construction
    -> headline-capable

real-record ranking
    -> exploratory score/ranking

real-record set-level inference
    -> only inferential when a genuine held-out/non-member population is supplied

A minimal implementation option would be:

if true held-out is supplied:
    run set-level member-vs-nonmember comparison

otherwise:
    return ranking/descriptive scores
    skip or rename the inferential set-level test

This would actually strengthen your own distinction between “trust the set-level result more” and “per-record flagging is exploratory”, because the set-level result would then have an explicit population contract.

There is a useful conceptual reason for keeping controlled canaries separate from arbitrary real-record membership claims.

Zhang et al., “Membership Inference Attacks Cannot Prove that a Model Was Trained On Your Data” points out the difficulty of using arbitrary-record MIA as a training-data proof when the required null distribution cannot be sampled. Importantly, the paper identifies special pre-registered canaries as one path around that problem.

That seems very compatible with your architecture: the canary audit can stay the controlled, interpretable core, while real-record scoring can remain a separately labelled exploratory layer.

A much smaller provenance detail: when I tested family="high_ppl" without a model or corpus, the generated canaries reported an actual source of uniform_vocab.

Again, I do not think this invalidates the audit. I would just record both:

requested_family: high_ppl
actual_generator: uniform_vocab

because canary construction can materially affect audit power.

That makes reports easier to compare across versions and prevents a fallback implementation from silently changing the meaning of a named audit profile. The relevant implementation is in canaries.py.

A possible compact report/provenance schema

Something along these lines would make the report fairly self-describing:

audit_profile:
  name: powered
  target_fpr: 0.01

canaries:
  requested_family: high_ppl
  actual_generator: uniform_vocab
  repetitions: [1, 4, 16]
  requested_members: 100
  controls: 200

preflight:
  rows_total: ...
  rows_scanned: ...
  scan_complete: ...
  record_observed: ...
  token_aligned: ...
  loss_mask_verified: ...
  directly_supervised: ...
  fatal: ...

membership:
  scorer: ...
  overall_tpr: ...
  ci: ...
  calibration_stability: ...
  by_repetition:
    1: ...
    4: ...
    16: ...

regurgitation:
  prefix_policy: ...
  decoding: ...
  match_rule: exact
  detected: ...

Not all of those need to be mandatory fields. The useful design property is that a future reader can reconstruct what was actually tested, not just see a single privacy number.

That also lines up with the EDPB emphasis on documenting the threat model, tests and controls rather than treating one successful test as a certificate.

One more architectural choice I would preserve is keeping the membership scorer replaceable.

Min-K%++ is a perfectly reasonable backend to have, but the MIA literature is moving quickly, especially for fine-tuned models. For example, the ACL 2026 paper EZ-MIA reports substantially stronger low-FPR detection than previous approaches in several fine-tuning settings while still requiring only a small number of forward passes.

I would not interpret that as “replace Min-K%++ now”.

I would interpret it as:

memaudit
├── audit orchestration
├── canary construction/injection
├── pre-flight validity checks
├── reporting/calibration
└── membership scorer backend

If the last piece is pluggable, the valuable Trainer/TRL/PEFT integration survives changes in the attack literature.

That separation also resembles the design of broader audit libraries such as Privacy Meter, which separates the auditing workflow from particular membership signals/attack variants.

Again, I would keep this as a design boundary rather than adding multiple sophisticated attacks to v0.1.


So if I were prioritizing by implementation cost × information gain, my order would be roughly:

  1. Add known-bad preprocessing/masking fixtures to pre-flight tests.

    • truncation removing the supervised canary span;
    • assistant/completion mask disappearance;
    • one packing case.
  2. Separate pre-flight evidence levels.

    • observed;
    • token-aligned;
    • label-checked;
    • directly supervised.
  3. Expose scan coverage / distinguish scan-window misses from true misses.

  4. Report repetition tiers alongside the pooled MIA headline.

  5. Keep true-held-out real-record inference separate from exploratory real-record ranking.

  6. Record requested versus actual canary-generator provenance.

  7. Then broaden across PEFT/model configurations and, later, alternative MIA scorers.

That feels like a fairly small amount of work relative to the amount of ambiguity it removes.

Most importantly, I would not broaden the scope into inversion, shadow models, approximate extraction, DP auditing, paraphrase/range membership, etc. just because those things exist. You already stated that v0.1 is not a compliance certificate and deliberately has a narrower attack surface. That seems like the right boundary.

The strongest part of the project, to me, is not that any single MIA score is final; it is that the audit is being treated as an instrument whose own validity has to be checked before its result is trusted.

The small truncation test was a good example: the pre-flight really did stop a case where the planned canary supervision would not survive the configured sequence length.

If that same fail-closed philosophy is carried through the mask-verification levels, scan coverage, and true-held-out distinction, I think the tool becomes easier to reason about without needing to become much larger.

Fixed, directly from your findings:

The canary-generator provenance gap is closed. high_ppl can now do real rejection sampling against the base model (perplexity scored in float32), and every report now records actual_generator explicitly (model_scored_high_ppl vs uniform_vocab) rather than silently falling back. This surfaced a real infrastructure bug along the way — the MPS allocator was causing multi-hour generation stalls on Apple Silicon during rejection sampling, now fixed with a per-draw cache release.

Re-running the flagship Alpaca case study with real (not fallback) canaries actually changed the headline: TPR@1%FPR went from 0.180 to 0.100 (10/100, CI [0.049, 0.176]), AUC 0.837. I’ve archived the old uniform_vocab report rather than deleting it, since the provenance system exists precisely so a reader can see what changed and why. I think this is informative rather than embarrassing — it suggests some of the original detection was an artifact of weaker canaries, not purely a property of the fine-tune, which is exactly the kind of thing your review was pointing at.

On the mask-verification concern specifically: I added live TRL integration tests that assert pre-flight verdicts against real SFTTrainer-prepared datasets and collators across both label eras (0.29.x mask era and >=1.9 labels-column era), plus a new fatal check for the case you flagged as highest-information — a secret present in the raw text column but missing from the tokenized stream. That’s the “found ≠ supervised” gap you called out directly.

Still open, and I want to keep your ordering:

I haven’t yet implemented the full evidence ladder (record_observed → token_aligned → loss_mask_checked → directly_supervised) as separate reportable states — right now the new fatal check is binary (blocks or doesn’t), not yet the graduated verification levels you proposed. That’s next.

Scan coverage (rows_scanned/rows_total/scan_complete) isn’t in the report schema yet either — your 83/100 vs 100/100 finding on the 80k-row host was a genuinely good catch and I don’t want to lose it to the backlog.

Repetition-tier breakdown alongside the pooled headline is partially there (I have it in changelog notes for this run) but not yet a structured report field — agreed it should be, for exactly the reason you gave: the pooled number alone overstates what a single-exposure record’s real risk looks like.

One question for you directly: given how deep this review went, would you be interested in opening an issue (or a PR, if you’re up for it) for the scan-coverage and evidence-ladder items specifically? You clearly already have working probes against the repo — I’d rather build on what you’ve already validated than re-derive it myself, and I think the project would benefit from your judgment being in the commit history, not just this thread.

Really appreciate the rigor here — this is the kind of review that actually makes the tool more trustworthy, not just more feature-complete.

Hmm… I found something that looks like a bug, but the right fix doesn’t seem uniquely determined, and while I do use GitHub occasionally, I’m not very used to it​:joy:, so I’ll put this here on the forum first:


While checking the current state, I noticed that the scan-coverage / evidence-ladder side has already moved quite a bit since my previous reply, so I did not want to rehash that.

Instead, I found one fairly narrow reporting-state edge case that seems worth separating out:

With skip_generation=True, “regurgitation was not run” currently appears to become “regurgitation ran and detected zero.”

I reproduced this on a fresh main checkout. At the time of the probe, main resolved to 36b0f6a216bfcda408a458a176d1fb8dd206582c.

The probe was deliberately small:

skip_generation = True
real_sample = 0

1 inserted member canary
1 held-out control

CPU only
no training
no model download

I kept the real run_audit() reporting/orchestration path and stubbed only the unrelated model/scoring/statistical pieces, so the test was specifically asking:

If generation is explicitly skipped,
does the report preserve "not run",
or does that state become "negative"?

The observed result was effectively:

{
  "member": {
    "regurgitated": false
  },

  "regurgitation": {
    "detected": {
      "n": 1,
      "n_detected": 0,
      "rate": 0.0,
      "wording": "0/1 under this prefix/decoding/exact-match protocol"
    },
    "overall": {
      "n": 1,
      "n_regurgitated": 0,
      "rate": 0.0
    }
  },

  "negative_controls": {
    "n": 1,
    "regurgitation_rate": 0.0
  }
}

But generation had not been executed.

So the one invariant I would preserve fairly strongly is:

not run != tested negative

A genuine result like:

generation executed
100 canaries evaluated
0 exact matches
-> 0/100, rate 0.0

is useful information and should remain exactly that.

The different case is:

generation not executed
0 canaries evaluated
-> no regurgitation measurement

My default direction would therefore be to preserve execution state explicitly, rather than allowing a skipped test to enter the same state space as a completed test with zero detections.

Where I think the state is getting lost

The relevant part of audit.py seems quite local.

The generation block starts with an explicit skipped state:

gen_block: dict[str, Any] = {"skipped": True}

if not skip_generation:
    gen_block = generate_canary_completions(...)

So at that point the distinction still exists:

generation performed
    -> generation result

generation skipped
    -> {"skipped": True}

But later, when the per-canary report row is constructed, the value becomes roughly:

"regurgitation": {
    "regurgitated": gen_block.get("regurgitated", False),
    ...
}

For the skipped case:

{"skipped": True}
        |
        v
no "regurgitated" key
        |
        v
.get("regurgitated", False)
        |
        v
False

Once that happens, downstream code cannot distinguish:

False because generation ran and found no exact match

from:

False because generation never ran

The aggregate then sees an ordinary boolean:

flag = bool((row.get("regurgitation") or {}).get("regurgitated"))
overall_flags.append(flag)

and the held-out control side follows the same general pattern.

That explains the observed chain:

skip_generation=True
        ↓
{"skipped": True}
        ↓
skipped state becomes False
        ↓
member contributes a negative observation
        ↓
control contributes a negative observation
        ↓
0/N
rate = 0.0
negative-control rate = 0.0

So I would think of this less as several separate bugs and more as one early state collapse propagating through the report.

The reason I am less sure about the fix than the reproduction is that this distinction appears in several layers of the report, and there are multiple defensible ways to represent it.

At the conceptual level I think the state machine wants something more like:

regurgitation protocol configured
        |
        +-- executed
        |      |
        |      +-- evaluated = N
        |      +-- detected = K
        |      +-- rate = K/N
        |
        +-- not executed
               |
               +-- evaluated = 0
               +-- rate = unmeasured

rather than letting both branches collapse into a boolean result.

The downstream surfaces I would keep aligned

The 0/N headline is the easiest place to notice the problem, but it seems to propagate farther.

Surface Skipped run currently looks like Distinction I would preserve
per-canary regurgitated: false not evaluated
exact-match aggregate 0/N, rate: 0.0 no measurement
overall aggregate 0/N, rate: 0.0 no measurement
repetition-tier breakdown negative observations no evaluated observations
held-out controls regurgitation rate 0.0 no regurgitation test
compliance-facing output regurgitation can look like a tested attack supported vs actually executed
multi-seed explanatory text can say deterministic generation was computed generation was skipped

I would not treat these as seven unrelated changes.

If the report retains one canonical execution state, the other surfaces can derive their wording/numeric behavior from that state.

That seems safer than independently special-casing:

headline
negative controls
compliance table
multi-seed prose
...

because independent special cases could drift later.

The part I am unsure about: what should the report contract actually be?

I see two separate concepts that may be worth keeping orthogonal:

1. capability / threat-model scope
2. execution state for this particular audit run

For example, regurgitation can be an attack class that memaudit supports while still being deliberately skipped in one particular run.

That suggests something conceptually like:

regurgitation:
    scope: in_scope
    execution: skipped

rather than forcing a single status to answer both questions.

This also avoids an ambiguity in the compliance-facing side.

The current compliance.py distinguishes attacks that are in scope for the auditor from attacks such as inversion / reconstruction that are out of scope.

There are at least two sensible interpretations of that field:

"in scope" = supported by the tool / threat model

or:

"in scope" = actually tested in this particular run

If the first interpretation is intended, I would keep in_scope exactly as a static capability statement and add a separate execution dimension.

If the second interpretation is intended, then a skipped generation run probably wants a human-facing state such as NOT RUN rather than IN SCOPE plus a numeric zero.

I slightly prefer separating the two dimensions because they answer genuinely different questions:

What attack does this audit know how to test?

versus:

What attack did this report actually test?

That separation also scales if more optional protocols appear later, without requiring any of those protocols to be added now.

Possible JSON/report representations

I can see at least three reasonable approaches.

A. First-class execution state + unmeasured result

Conceptually:

{
  "regurgitation": {
    "execution": {
      "status": "skipped",
      "reason": "skip_generation"
    },
    "detected": {
      "n": 0,
      "rate": null
    }
  }
}

Advantages:

  • 0.0 continues to mean a real measured zero;
  • skipped records do not enter a denominator;
  • renderers have one explicit state to inspect;
  • configured protocol metadata can remain available.

For example, it is still useful for a report to say:

prefix policy: ...
decoding: greedy
match rule: exact

even when generation was skipped.

Those fields describe what the configured test would have been. They just should not imply that it ran.

This is probably the representation I find easiest to reason about.


B. Add an execution flag/state while retaining the existing numeric fields

For example:

{
  "regurgitation": {
    "performed": false,
    "detected": {
      "n": 1,
      "rate": 0.0
    }
  }
}

This is more conservative for existing consumers.

The weakness is that a consumer that does not know about the new field still sees:

rate = 0.0

and therefore still receives the old interpretation.

So this is attractive from a compatibility perspective, but weaker as a semantic fix.


C. Omit measured-result blocks when generation is skipped

For example:

{
  "regurgitation": {
    "status": "skipped",
    "prefix_policy": {...},
    "decoding": {...}
  }
}

That is very difficult to misread.

But it can also be more disruptive to existing report consumers that assume detected / overall always exist.


There is a versioning wrinkle here too.

report.py documents the report schema/versioning behavior, and the current project history in CHANGELOG.md has already been adding report fields as the audit semantics become more explicit.

An execution-state field is naturally additive.

Changing an existing field from:

"rate": 0.0

to:

"rate": null

is a stronger observable change for consumers, even if null is semantically cleaner for “not measured”.

So I can see two reasonable migration policies:

compatibility-first:
    add execution state;
    make first-party renderers honor it;
    decide separately how legacy numeric fields evolve

or:

measurement-semantics-first:
    add execution state;
    exclude skipped records from denominators immediately;
    return an unmeasured/null rate

I lean toward the second semantically, but I do not think that preference is strong enough to assume it is the right project-level compatibility choice.

There is a small connection to the regulatory motivation of the project, although I would keep the point narrow.

The EDPB’s Opinion 28/2024, especially the discussion around para. 55 and the documentation points around para. 58, explicitly treats attack testing as evidence about the attacks actually tested; it lists membership inference and training-data regurgitation among distinct attack classes.

So these two records communicate different evidence:

regurgitation test executed
0/100 exact matches

versus:

regurgitation test not executed

The second one is not a failed audit and not necessarily a problem. It simply means that particular measurement is absent.

That seems compatible with a design principle memaudit already uses elsewhere: when the statistical conditions for a membership headline are not present, it is better to preserve “cannot support this headline” than manufacture a reassuring number.

I think the same general principle fits here:

preserve what was actually measured.

One smaller downstream wording case

I also noticed a related human-readable edge in the multi-seed explanation.

The current stability description can explain that the likelihood scoring and deterministic regurgitation generation were computed once because they do not vary with the bootstrap seed.

That is correct when generation actually ran.

With skip_generation=True, though, generation was not “computed once”; it was not computed.

So if execution state becomes first-class, I would let this wording derive from it too:

generation executed:
    membership scoring and deterministic regurgitation generation
    were computed once

generation skipped:
    membership scoring was computed once;
    regurgitation generation was not run

I would consider that part of the same reporting-state cleanup rather than a separate issue.

Scope-wise, I would keep this very small

I would not use this as a reason to broaden the auditor.

This does not require:

- another privacy attack,
- approximate extraction,
- model inversion,
- shadow models,
- a different MIA scorer,
- another pre-flight redesign,
- revisiting the evidence ladder,
- revisiting scan-coverage policy,
- a broader compliance claim.

It is just about carrying one state accurately through the existing pipeline:

performed
vs
not performed

A useful regression boundary would therefore be small too:

skip_generation=True
    -> generation function is not called
    -> member is not recorded as tested-negative
    -> held-out control is not recorded as tested-negative
    -> skipped rows do not create a measured 0/N rate
    -> human-readable/compliance output does not imply execution

skip_generation=False
    -> existing behavior remains unchanged
    -> a genuine zero-detection run still reports 0/N and 0.0

That last control seems especially important. The goal is not to weaken or hide a real zero result; it is to distinguish it from no result.

So, if I reduce the design choice to a small decision tree, mine would be:

If attack scope is a static capability/threat-model property:
    keep regurgitation in scope;
    add a separate per-run execution state.

If attack scope is meant to mean "actually executed in this report":
    skipped regurgitation should propagate to a NOT RUN-like state there too.

If existing consumer compatibility is the strongest constraint:
    add execution state first;
    let renderers use it;
    treat numeric-field migration separately.

If exact measurement semantics are the stronger constraint:
    add execution state;
    exclude skipped observations from denominators;
    use an unmeasured/null rate rather than 0.0.

The reproduction makes me fairly confident about the problem boundary.

The part I am deliberately leaving open is the representation boundary: whether the project’s preferred convention is status, performed, null, omitted measured fields, or some other additive state representation.

But whichever representation fits the report contract best, I think preserving this distinction would make the generated audit easier for both humans and downstream tooling to interpret:

“we tested it and found zero” and “we did not run that test” should remain two different states.