Skip to content

Metamorphic properties ​

Golden tests check answers to fixed inputs. A metamorphic property checks how the answer behaves when the input changes in a controlled way, so it needs no extra labels and catches failures a golden set cannot see.

Most properties are invariances: listing the options in a different order, formatting the labels differently, adding an irrelevant sentence or extra whitespace must not change the decision. Two are not: inversion expects a yes/no answer to swap, and monotonic expects a score to move in one direction.

Properties run with decguard fuzz (properties only) or decguard test --all (golden gates and properties in one report). How runs are seeded, minimized and replayed is described in Fuzzing and replay.

How a property is checked ​

  1. Each dataset case is decided as is: the original.
  2. The property derives one or more transformed cases from it, deterministically from the seed.
  3. Each transformed case is sent to the same backend.
  4. The two probability distributions are compared, and the comparison is checked against the property's limits.

Everything is recorded in the report: the transformation steps, what was sent, the result (mapped back to contract labels), the comparison and any violation.

Option presentation is separate from the input. option_order and label_format do not edit the input text; they change how the labels are presented to the model. Backends receive the labels in the order and form shown (request.decision.labels in Python, decision.labels over HTTP, the question's criteria for System One) and answer with probabilities keyed by those labels. DecGuard maps them back to the contract labels before comparing.

Configuring properties ​

yaml
fuzz:
  seed: 42

properties:
  option_order:
    samples: 3
    max_tv_distance: 0.03  # per transformed case
    max_flip_rate: 0       # share of transformed cases whose answer changes
  irrelevant_context:
    insertions: 2
    max_tv_distance: 0.05
    level: warning         # requirement (default) fails the run; warning only warns

A property is enabled by being present; enabled: false turns it off while keeping its settings. Every property except monotonic needs at least one limit. Contract validation rejects a property that does not apply to the decision type, for example monotonic on a choice decision.

Settings shared by every property ​

KeyDefaultMeaning
enabledtrueturn the property off without deleting it
levelrequirementrequirement fails the run on violation; warning turns PASS into WARN
samplesper propertytransformed cases generated per dataset case

Limits ​

KeyScopeMeaning
max_tv_distanceper transformed casetotal variation distance, ½ Σ |p − q|, 0–1
max_js_divergenceper transformed caseJensen-Shannon divergence in bits, 0–1 (symmetric, finite)
max_abs_deltaper transformed caselargest change of any single label's probability
max_confidence_deltaper transformed casechange of the selected label's confidence
max_violation_rateaggregate, default 0share of transformed cases allowed to break a per-case limit
max_flip_rateaggregateshare of transformed cases whose selected label may change

Every comparison also records the confidence delta, whether the answer flipped and whether the ranking of the labels changed. A failing check shows how far the distribution moved as well as whether the top answer flipped: a model can keep the same answer and still shift 20% of its probability mass because of the option order.

Checks produced ​

Each enabled property adds up to three checks to the report:

  • <name>.max_violation_rate — when a per-case limit is set;
  • <name>.max_flip_rate — when set;
  • <name>.max_error_rate — always, as a requirement fixed at 0. If the backend fails on a transformed case, or the original case failed, the property was not demonstrated. If no case could be transformed at all, the check fails too.

Property catalog ​

PropertyDecisionsTransformationExpected
option_orderchoice, noulpresent the options in another orderinvariant
label_formatallreformat the labels, or use aliasesinvariant
irrelevant_contextallinsert irrelevant sentencesinvariant
repeated_contextallinsert one irrelevant sentence several timesinvariant
whitespaceallchange whitespaceinvariant
paraphraseallreplace the text with a paraphraseinvariant
inversionnoulnegate the input with a literal rewriteanswer swaps
monotonicscoreincrease or decrease a numeric input fieldscore does not move against the direction

option_order ​

Presents the options as distinct permutations of the contract order. Catches position bias: models that favour whichever option is listed first.

KeyDefault
samples3 distinct permutations (1–100)

label_format ​

Changes the surface form of the labels.

KeyDefaultMeaning
stylesall sevenany of upper (REFUND), lower, capitalized (Refund), lettered (A) refund), numbered (1. refund), quoted ("refund"), bracketed ([refund])
aliases{}alternative wordings per label; variant i uses every label's i-th alias
samples2variants per case

For noul decisions this covers affirmative/negative surface variation, such as YES / NO, "yes", or aliases like true / false:

yaml
properties:
  label_format:
    styles: [upper, capitalized, quoted]
    aliases: {spam: [junk], ham: [legitimate]}
    max_tv_distance: 0.02

irrelevant_context ​

Inserts sentences that should not affect the decision at sentence boundaries of the text.

KeyDefaultMeaning
fragmentseight neutral English sentencesthe sentences to insert
insertions1 (1–10)sentences inserted per transformed case
samples2transformed cases per case

The default fragments are generic ("The weather was mild that day."). Supply your own that are truly irrelevant for your decision; a sentence about shipping is not irrelevant to a refund decision.

repeated_context ​

Inserts one fragment several times, to catch models distracted by repetition.

KeyDefault
fragmentsthe same defaults as irrelevant_context
repeats3 (2–20)
samples1

whitespace ​

Applies whitespace edits: doubled spaces, line breaks, tabs, leading and trailing blanks.

KeyDefault
edits3 (1–20) edits per transformed case
samples2

paraphrase ​

Replaces the text with paraphrases from a provider.

yaml
properties:
  paraphrase:
    source: {provider: identity}   # the default
    max_tv_distance: 0.0
KeyDefault
source{provider: identity}
samples1 (1–20) paraphrases per case

Providers:

  • identity — re-asks the same input. No model needed; it catches backends that answer differently each time (non-determinism).

  • file — curated paraphrases, deterministic and reviewable: source: {provider: file, path: paraphrases.jsonl}, relative to the contract, with rows {"id": "<case id>", "paraphrases": ["...", "..."]} or {"input": "<text>", "paraphrases": [...]}.

  • openai — any OpenAI-compatible chat-completions API:

    yaml
    source:
      provider: openai
      model: gpt-4o-mini
      url: https://api.openai.com/v1   # default
      api_key_env: OPENAI_API_KEY      # default; null for local servers without a key
      timeout_s: 60                    # default
      temperature: 0.0                 # default

    The run seed is sent with the request, and the model and system fingerprint are recorded. LLM output is not guaranteed to be reproducible, so replay uses the stored paraphrase text.

  • Plugins — a decguard.fuzz.paraphrase.Paraphraser subclass registered under the decguard.paraphrasers entry-point group.

A provider failure is recorded as a paraphrase_error for the case.

inversion (noul) ​

Applies the first matching literal rewrite (find → replace, first occurrence) that negates the input. The answer must swap: the comparison is made with the labels swapped, so max_tv_distance measures the distance from the perfectly inverted answer.

yaml
properties:
  inversion:
    rewrites:
      - {find: " is eligible", replace: " is not eligible"}
    max_tv_distance: 0.1
    max_flip_rate: 0      # the answer must invert
KeyDefaultMeaning
rewritesrequired, at least onetried in order
samples1 (1–20)applicable rewrites used per case

Only use rewrites whose meaning you know inverts the decision. This is a separate property from the invariances, so a model that ignores negation fails here while it may pass everything else.

monotonic (score) ​

Adds each of deltas to a numeric field of object inputs. The score must not move against direction.

yaml
fuzz:
  text_field: text          # other properties edit input.text

properties:
  monotonic:
    field: affected_users
    direction: increasing   # more affected users must not lower the severity
    deltas: [100, 10000]
    max_level_decrease: 0.05
    max_flip_rate: 0        # the selected level must not move against the direction
KeyDefaultMeaning
fieldrequiredtop-level numeric key of object inputs
directionincreasingincreasing: a larger value must not lower the score; decreasing: the opposite
deltas[1.0]positive amounts added to the field, one transformed case each
max_level_decrease0.0allowed move of the expected level against the direction, in scale steps

The expected level is Σ index × probability over the ordered levels, so this compares the whole distribution, not only the top answer.

Inputs a property cannot transform ​

Text transformations need string inputs, or fuzz.text_field to name the key to edit in object inputs. A case a property cannot transform — no text, no matching rewrite, a missing numeric field — is listed under properties.skipped in the report with the reason. It is never dropped silently.

Released under the Apache-2.0 License.