Harden redaction-pressure reward and provenance

This commit is contained in:
2026-08-19 00:52:56 -07:00
parent c28d864766
commit 9f8768acca
14 changed files with 825 additions and 155 deletions
+31
View File
@@ -0,0 +1,31 @@
name: arena-environments
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
redaction-pressure:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: "0.12.5"
enable-cache: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Verify lockfile
run: uv lock --project environments/redaction_pressure --check
- name: Install locked environment
run: uv sync --project environments/redaction_pressure --frozen
- name: Run environment tests
run: uv run --project environments/redaction_pressure python -m unittest discover -s environments/redaction_pressure/tests -v
- name: Verify documented root entrypoint
run: uv run --project environments/redaction_pressure eval --help >/dev/null
+15 -6
View File
@@ -24,18 +24,27 @@ Three rules follow, and every environment in this repository is held to them:
| | | | | |
|---|---| |---|---|
| [`redaction-pressure`](environments/redaction_pressure) | Write redaction rules for a ticket corpus. Graded on the next records the generator would have produced. Every secret class is shape-matched to a decoy, so a rule keyed on shape earns on one and pays on the other. Reward is a scanner — no judge model, no runtime, milliseconds per rollout. | | [`redaction-pressure`](environments/redaction_pressure) | Write redaction rules for a ticket corpus. Source-character provenance makes partial edits residual secrets, while the clean gate requires character-for-character preservation of all non-secret source content. Hostile regexes share one episode budget. |
## Running one ## Running one
```bash ```bash
uv run eval @ configs/redaction_pressure.toml --model <model-id> uv run --project environments/redaction_pressure eval @ configs/redaction_pressure.toml --model <model-id>
```
Run its release gate from the repository root:
```bash
uv lock --project environments/redaction_pressure --check
uv sync --project environments/redaction_pressure --frozen
uv run --project environments/redaction_pressure \
python -m unittest discover -s environments/redaction_pressure/tests -v
``` ```
## Why these, and not benchmark reimplementations ## Why these, and not benchmark reimplementations
The Hub is filling with reimplementations, which are worth doing and are not what we have. The Hub is filling with reimplementations, which are worth doing and are not what we have.
What Lumbridge has is a set of tools that already do verifiable work — Bench's What Lumbridge has is a set of tools aimed at verifiable work. Those gates make good
contamination gate, Forge's signed build chain — and those gates make good rewards rewards only after their ceiling, floor, adversarial cases, and replay identity are
precisely because they were built to be un-arguable about a machine. `redaction-pressure` committed beside the environment. `redaction-pressure` is the first small contract for
is Forge's `redact` stage with the exit code turned into a gradient. that path; Bench/Forge integration remains future work rather than an implied dependency.
+1
View File
@@ -6,6 +6,7 @@ num_rollouts = 2
[env.taskset] [env.taskset]
id = "redaction-pressure" id = "redaction-pressure"
split = "dev" # public deterministic development split; never use it as a private holdout
visible = 4 # records the model reads visible = 4 # records the model reads
held_out = 12 # records it is scored on held_out = 12 # records it is scored on
+52
View File
@@ -0,0 +1,52 @@
# redaction-pressure v0.2
A one-turn Verifiers v1 environment. The model returns regex rules; the scanner runs
them against later records from the same deterministic corpus stream.
## Reward contract
- **Recall (0.35):** a labelled secret counts only when every original character in
its span is gone. Partial edits earn zero span recall.
- **Precision (0.35):** source characters belonging to fully removed secret spans,
divided by all removed source characters. Partial damage stays in the denominator.
- **Clean gate (0.30):** every secret fully removed, every non-secret character and
decoy preserved, and no validation or timeout errors.
- **Validity:** malformed JSON, bad entries, more than 24 rules, unsafe replacements,
invalid regexes, excessive matches, timeouts, or an exhausted episode budget multiply
every reward by zero.
Replacement is intentionally limited to `"[REDACTED]"` or `""`. That prevents a rule
from matching a complete secret and then leaking it through a capture expansion.
## Splits
`train` and `dev` have separate, deterministic public seed ranges. `private_eval` derives
seeds with HMAC from `REDACTION_PRESSURE_PRIVATE_SEED`, which must contain at least 32
characters. The secret is never task data; traces carry its SHA-256 commitment and reveal
the derived task seed only in the completed trace so the episode can be reproduced by an
authorized evaluator.
Never present public `dev` scores as private evaluation.
## Trace identity
Each task records the environment, corpus, and reward-schema versions, split commitment,
and a SHA-256 digest of the behavior-bearing installed Python sources. Scoring repeats
the identity under `trace.info.environment` and records validation failures under
`trace.info.redaction_errors`.
## Checks
From the Arena repository root:
```bash
uv lock --project environments/redaction_pressure --check
uv sync --project environments/redaction_pressure --frozen
uv run --project environments/redaction_pressure \
python -m unittest discover -s environments/redaction_pressure/tests -v
```
The committed tests demonstrate oracle 1.0 and inaction 0.0, reject the historical
first-character exploit across all 64 public development tasks, exercise parser and
rule validation, prove deterministic/private split behavior, and interrupt hostile
regexes under the shared episode budget.
@@ -1,12 +1,16 @@
[project] [project]
name = "redaction-pressure" name = "redaction-pressure"
version = "0.1.0" version = "0.2.0"
description = "redaction-pressure — write redaction rules graded on records the author never saw." description = "Provenance-safe redaction rules graded on records the author never saw."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["verifiers", "regex"] dependencies = [
"pydantic==2.13.4",
"regex==2026.7.19",
"verifiers==0.3.0",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
@@ -1,3 +1,4 @@
from redaction_pressure.provenance import ENVIRONMENT_VERSION
from redaction_pressure.taskset import RedactionTaskset from redaction_pressure.taskset import RedactionTaskset
__all__ = ["RedactionTaskset"] __all__ = ["ENVIRONMENT_VERSION", "RedactionTaskset"]
@@ -0,0 +1,37 @@
"""Stable environment identity recorded with every task and trace."""
from __future__ import annotations
import hashlib
from functools import lru_cache
from pathlib import Path
ENVIRONMENT_VERSION = "0.2.0"
CORPUS_VERSION = "redaction-pressure-corpus-v2"
REWARD_SCHEMA_VERSION = "redaction-pressure-reward-v2"
SOURCE_FILES = ("corpus.py", "provenance.py", "scan.py", "taskset.py")
@lru_cache(maxsize=1)
def environment_source_sha256() -> str:
"""Hash the behavior-bearing installed sources, independent of checkout paths."""
root = Path(__file__).resolve().parent
digest = hashlib.sha256()
for name in SOURCE_FILES:
payload = (root / name).read_bytes()
digest.update(name.encode("utf-8"))
digest.update(b"\0")
digest.update(len(payload).to_bytes(8, "big"))
digest.update(payload)
return digest.hexdigest()
def trace_provenance(*, split: str, seed_commitment: str) -> dict[str, str]:
return {
"environment_version": ENVIRONMENT_VERSION,
"corpus_version": CORPUS_VERSION,
"reward_schema_version": REWARD_SCHEMA_VERSION,
"source_sha256": environment_source_sha256(),
"split": split,
"seed_commitment": seed_commitment,
}
@@ -1,37 +1,33 @@
"""Applying the agent's rules, and measuring what they did. """Apply hostile redaction rules while preserving source-character provenance.
Everything is counted as *removals*, and that choice is the whole reward. The scorer never infers redaction from an output substring disappearing. Every
character starts with its source offset, replacements carry no source offsets, and a
secret counts as removed only when *all* offsets in its labelled span are gone. This
makes deleting or changing one byte of a secret worth zero recall. The same provenance
requires every ordinary and decoy character to survive for the clean gate.
The obvious framing — secrets caught, decoys survived, prose kept — pays a model that Agent regexes run under one wall-clock budget shared by compilation and every record.
returns no rules at all: it destroys nothing, so it keeps everything. Measured that way Any invalid, excessive, unsafe, over-broad, or timed-out rule invalidates the complete
an empty ruleset and a ruleset that shreds the corpus scored identically, and neither ruleset; valid rules are still measured so the trace explains what happened, but reward
told a trainer anything. Counting removals fixes it, because a model that removes nothing methods apply ``validity`` and therefore pay zero for an invalid submission.
has no true positives and therefore no precision.
removed_secrets true positives — a seeded secret gone from the held-out text.
residual_hits the ones still there. Forge's rule applies to the gate: a residual
hit is exit 1, not a deduction.
collateral_hits decoys destroyed. Each is shape-matched to a secret class, so this is
what a rule keyed on shape pays and one keyed on meaning does not.
innocent_lost ordinary words destroyed. The floor that stops `\\S+ -> [X]`, which
otherwise has perfect recall.
The agent supplies the patterns, so they are hostile input: `regex` is used rather than
`re` for its `timeout=`, since `re` cannot be interrupted and one nested quantifier would
otherwise hang a rollout until the harness killed it.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import time
from dataclasses import dataclass, field
from typing import Callable, Iterable
import regex import regex
from redaction_pressure.corpus import Record from redaction_pressure.corpus import Record, Span
MAX_RULES = 24 MAX_RULES = 24
MAX_PATTERN_CHARS = 200 MAX_PATTERN_CHARS = 200
PATTERN_TIMEOUT_S = 0.25 MAX_MATCHES_PER_RULE_RECORD = 128
PER_OPERATION_TIMEOUT_S = 0.025
EPISODE_REGEX_BUDGET_S = 0.75
ALLOWED_REPLACEMENTS = frozenset({"", "[REDACTED]"})
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -40,97 +36,295 @@ class Rule:
replacement: str replacement: str
@dataclass(frozen=True)
class ParsedRules:
rules: tuple[Rule, ...]
errors: tuple[str, ...] = ()
submitted: int = 0
@dataclass(frozen=True)
class LocatedSpan:
kind: str
text: str
start: int
end: int
@property
def offsets(self) -> range:
return range(self.start, self.end)
@dataclass(frozen=True)
class TrackedText:
text: str
origins: tuple[int | None, ...]
@classmethod
def original(cls, text: str) -> "TrackedText":
return cls(text=text, origins=tuple(range(len(text))))
@dataclass(frozen=True)
class CompiledRule:
index: int
pattern: regex.Pattern
replacement: str
@dataclass
class RegexBudget:
seconds: float = EPISODE_REGEX_BUDGET_S
clock: Callable[[], float] = time.monotonic
started: float = field(init=False)
def __post_init__(self) -> None:
self.started = self.clock()
@property
def elapsed(self) -> float:
return max(0.0, self.clock() - self.started)
@property
def remaining(self) -> float:
return max(0.0, self.seconds - self.elapsed)
def operation_timeout(self) -> float:
remaining = self.remaining
if remaining <= 0:
raise RegexBudgetExceeded("episode regex budget exhausted")
return min(PER_OPERATION_TIMEOUT_S, remaining)
class RegexBudgetExceeded(RuntimeError):
pass
@dataclass(frozen=True)
class ApplyResult:
tracked: TrackedText
errors: tuple[str, ...]
@dataclass @dataclass
class Outcome: class Outcome:
"""What one ruleset did to one slice.""" """Measurements for one submitted ruleset over one held-out slice."""
removed_secrets: int
partial_secrets: int
residual_hits: int residual_hits: int
secrets_total: int secrets_total: int
credited_secret_chars: int
removed_secret_chars: int
secret_chars_total: int
collateral_hits: int collateral_hits: int
decoys_total: int decoys_total: int
innocent_lost: int innocent_lost: int
innocent_total: int innocent_total: int
invalid_rules: list[str] invalid_rules: list[str]
budget_seconds: float
@property budget_elapsed_seconds: float
def removed_secrets(self) -> int:
return self.secrets_total - self.residual_hits
@property @property
def recall(self) -> float: def recall(self) -> float:
"""All-or-nothing span recall; partial secret edits earn no recall."""
return self.removed_secrets / max(self.secrets_total, 1) return self.removed_secrets / max(self.secrets_total, 1)
@property @property
def precision(self) -> float: def character_coverage(self) -> float:
"""Of everything this ruleset destroyed, how much of it should have been. """Diagnostic only: never used as reward because partial deletion is unsafe."""
return self.removed_secret_chars / max(self.secret_chars_total, 1)
Zero for an empty ruleset — it destroyed nothing, so none of what it destroyed @property
was a secret. That is the case the survivor-counting version got wrong. def precision(self) -> float:
"""Of removed source characters, the share in fully removed secret spans.
Characters removed from a merely damaged secret stay in the denominator but are
not true positives. This keeps the historical one-byte exploit at precision zero.
""" """
destroyed = self.removed_secrets + self.collateral_hits + self.innocent_lost destroyed = self.removed_secret_chars + self.innocent_lost
return self.removed_secrets / destroyed if destroyed else 0.0 return self.credited_secret_chars / destroyed if destroyed else 0.0
@property
def ordinary_preservation(self) -> float:
return 1.0 - self.innocent_lost / max(self.innocent_total, 1)
@property
def validity(self) -> float:
"""Binary by design: invalid hostile input is never a partially paid policy."""
return float(not self.invalid_rules)
@property @property
def clean(self) -> bool: def clean(self) -> bool:
"""The gate: every secret gone AND no decoy taken with it — the ruleset you could
actually ship. `forge redact`'s exit 1, with the collateral clause it implies."""
return ( return (
self.residual_hits == 0 self.removed_secrets == self.secrets_total
and self.partial_secrets == 0
and self.collateral_hits == 0 and self.collateral_hits == 0
and self.innocent_lost == 0
and self.secrets_total > 0 and self.secrets_total > 0
and self.validity == 1.0
) )
def compile_rules(rules: list[Rule]) -> tuple[list[tuple[regex.Pattern, str]], list[str]]: def compile_rules(
"""Compile what compiles; report the rest rather than failing the rollout. parsed: ParsedRules,
budget: RegexBudget,
A rollout that dies on one bad pattern teaches nothing — the agent's score should ) -> tuple[list[CompiledRule], list[str]]:
reflect that the rule did no work, which is what dropping it does. """Compile bounded rules and retain every validation error."""
""" errors = list(parsed.errors)
compiled, invalid = [], [] rules = list(parsed.rules)
for rule in rules[:MAX_RULES]: if len(rules) > MAX_RULES:
errors.append(f"too many rules: received {len(rules)}, maximum is {MAX_RULES}")
compiled: list[CompiledRule] = []
for index, rule in enumerate(rules[:MAX_RULES]):
label = f"rule[{index}]"
if not rule.pattern:
errors.append(f"{label}: pattern must not be empty")
continue
if len(rule.pattern) > MAX_PATTERN_CHARS: if len(rule.pattern) > MAX_PATTERN_CHARS:
invalid.append(f"{rule.pattern[:40]}: over {MAX_PATTERN_CHARS} characters") errors.append(f"{label}: pattern exceeds {MAX_PATTERN_CHARS} characters")
continue
if rule.replacement not in ALLOWED_REPLACEMENTS:
errors.append(
f"{label}: replacement must be empty or [REDACTED]; captures and arbitrary text are unsafe"
)
continue continue
try: try:
compiled.append((regex.compile(rule.pattern), rule.replacement)) budget.operation_timeout()
pattern = regex.compile(rule.pattern)
budget.operation_timeout()
except RegexBudgetExceeded as exc:
errors.append(f"{label}: {exc}")
break
except regex.error as exc: except regex.error as exc:
invalid.append(f"{rule.pattern[:40]}: {exc}") errors.append(f"{label}: invalid regex: {exc}")
return compiled, invalid
def apply_rules(body: str, compiled: list[tuple[regex.Pattern, str]]) -> str:
"""Rules in the order given, each over the whole text. A pattern that times out is
skipped for that record rather than aborting the measurement."""
text = body
for pattern, replacement in compiled:
try:
text = pattern.sub(replacement, text, timeout=PATTERN_TIMEOUT_S)
except (TimeoutError, regex.error):
continue continue
return text compiled.append(CompiledRule(index=index, pattern=pattern, replacement=rule.replacement))
return compiled, errors
def _innocent_text(record: Record) -> str: def apply_rules(
"""The record with every secret and decoy cut out — the part no rule should touch.""" body: str,
text = record.body compiled: Iterable[CompiledRule],
for span in [*record.secrets, *record.decoys]: budget: RegexBudget,
text = text.replace(span.text, " ") ) -> ApplyResult:
return text """Apply rules atomically per pattern and carry source offsets through unchanged text."""
tracked = TrackedText.original(body)
errors: list[str] = []
for rule in compiled:
label = f"rule[{rule.index}]"
try:
timeout = budget.operation_timeout()
matches = list(rule.pattern.finditer(tracked.text, timeout=timeout))
budget.operation_timeout()
except (TimeoutError, regex.error):
errors.append(f"{label}: regex timed out")
continue
except RegexBudgetExceeded as exc:
errors.append(f"{label}: {exc}")
break
if len(matches) > MAX_MATCHES_PER_RULE_RECORD:
errors.append(
f"{label}: produced {len(matches)} matches in one record; maximum is "
f"{MAX_MATCHES_PER_RULE_RECORD}"
)
continue
text_parts: list[str] = []
origins: list[int | None] = []
cursor = 0
for match in matches:
start, end = match.span()
text_parts.append(tracked.text[cursor:start])
origins.extend(tracked.origins[cursor:start])
text_parts.append(rule.replacement)
origins.extend([None] * len(rule.replacement))
cursor = end
text_parts.append(tracked.text[cursor:])
origins.extend(tracked.origins[cursor:])
tracked = TrackedText(text="".join(text_parts), origins=tuple(origins))
return ApplyResult(tracked=tracked, errors=tuple(errors))
def measure(records: list[Record], rules: list[Rule]) -> Outcome: def _locate(body: str, span: Span) -> LocatedSpan:
compiled, invalid = compile_rules(rules) start = body.find(span.text)
residual = collateral = secrets = decoys = lost = total = 0 if start < 0 or body.find(span.text, start + 1) >= 0:
raise ValueError(f"ground-truth span {span.kind!r} must occur exactly once")
return LocatedSpan(span.kind, span.text, start, start + len(span.text))
def _removed_count(span: LocatedSpan, retained: set[int]) -> int:
return sum(1 for offset in span.offsets if offset not in retained)
def measure(
records: list[Record],
parsed: ParsedRules | list[Rule],
*,
budget_seconds: float = EPISODE_REGEX_BUDGET_S,
clock: Callable[[], float] = time.monotonic,
) -> Outcome:
"""Measure one ruleset under one episode-wide hostile-regex budget."""
if isinstance(parsed, list):
parsed = ParsedRules(tuple(parsed), submitted=len(parsed))
budget = RegexBudget(seconds=budget_seconds, clock=clock)
compiled, errors = compile_rules(parsed, budget)
removed_secrets = partial_secrets = residual = 0
secrets_total = credited_secret_chars = removed_secret_chars = secret_chars_total = 0
collateral = decoys_total = innocent_lost = innocent_total = 0
for record in records: for record in records:
redacted = apply_rules(record.body, compiled) result = apply_rules(record.body, compiled, budget)
secrets += len(record.secrets) errors.extend(result.errors)
decoys += len(record.decoys) retained = {offset for offset in result.tracked.origins if offset is not None}
residual += sum(1 for s in record.secrets if s.text in redacted) secrets = [_locate(record.body, span) for span in record.secrets]
collateral += sum(1 for d in record.decoys if d.text not in redacted) decoys = [_locate(record.body, span) for span in record.decoys]
# Fidelity is counted over whole words rather than characters: a rule that eats secret_offsets = {offset for span in secrets for offset in span.offsets}
# every space would otherwise keep most of its characters and look harmless. decoy_offsets = {offset for span in decoys for offset in span.offsets}
innocent = [w for w in _innocent_text(record).split() if w]
total += len(innocent) for span in secrets:
lost += sum(1 for w in innocent if w not in redacted) removed = _removed_count(span, retained)
return Outcome(residual, secrets, collateral, decoys, lost, total, invalid) removed_secret_chars += removed
secret_chars_total += span.end - span.start
secrets_total += 1
if removed == span.end - span.start:
removed_secrets += 1
credited_secret_chars += removed
elif removed > 0:
partial_secrets += 1
residual += 1
else:
residual += 1
for span in decoys:
decoys_total += 1
if _removed_count(span, retained) > 0:
collateral += 1
# Every non-secret source character is protected. Decoys are included here as well
# as in their span-level gate, so one altered byte is both visible and costly.
protected = set(range(len(record.body))) - secret_offsets
innocent_total += len(protected)
innocent_lost += len(protected - retained)
# Keep this assertion close to the accounting: every decoy must be protected.
if not decoy_offsets <= protected:
raise AssertionError("decoy offsets must be non-secret protected content")
unique_errors = list(dict.fromkeys(errors))
return Outcome(
removed_secrets=removed_secrets,
partial_secrets=partial_secrets,
residual_hits=residual,
secrets_total=secrets_total,
credited_secret_chars=credited_secret_chars,
removed_secret_chars=removed_secret_chars,
secret_chars_total=secret_chars_total,
collateral_hits=collateral,
decoys_total=decoys_total,
innocent_lost=innocent_lost,
innocent_total=innocent_total,
invalid_rules=unique_errors,
budget_seconds=budget.seconds,
budget_elapsed_seconds=budget.elapsed,
)
@@ -1,79 +1,123 @@
"""redaction-pressure: write redaction rules that survive records you never read. """Verifiers v1 taskset for provenance-safe redaction rules."""
The agent reads a handful of support tickets seeded with secrets, and returns a ruleset.
The ruleset is then run against the *next* records the same generator would have produced.
That gap is the environment: rules fitted to the visible examples score well on the
visible examples and are worth nothing, and only the held-out slice can tell the two apart.
Three rewards, all counted over what the ruleset *destroyed*:
recall seeded secrets removed from the held-out text.
precision of everything destroyed, the share that was a secret. An empty ruleset
destroys nothing and therefore scores zero here — which is the point. A
first pass scored survivors instead, and an empty ruleset tied the best
real attempt at 0.500 because it had kept every decoy and every word.
gate binary: every secret gone and no decoy taken with it. `forge redact`'s
exit 1, which is a verdict and not a deduction.
No judge model and no runtime: the reward is a scanner, so a rollout costs one completion
and grades in milliseconds. Deterministic from the task seed.
"""
from __future__ import annotations from __future__ import annotations
import hashlib
import hmac
import json import json
import os
import re import re
from typing import ClassVar from typing import Literal
from pydantic import Field from pydantic import Field, model_validator
import verifiers.v1 as vf import verifiers.v1 as vf
from redaction_pressure.corpus import build_slices from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import MAX_RULES, Rule, measure from redaction_pressure.provenance import (
CORPUS_VERSION,
ENVIRONMENT_VERSION,
REWARD_SCHEMA_VERSION,
environment_source_sha256,
trace_provenance,
)
from redaction_pressure.scan import MAX_RULES, ParsedRules, Rule, measure
Split = Literal["train", "dev", "private_eval"]
PUBLIC_SPLIT_BASES = {"train": 100_000, "dev": 200_000}
PRIVATE_SEED_ENV_DEFAULT = "REDACTION_PRESSURE_PRIVATE_SEED"
MAX_TASKS_PER_SPLIT = 10_000
SYSTEM = f"""You are writing redaction rules for a support-ticket corpus. SYSTEM = f"""You are writing redaction rules for a support-ticket corpus.
Return a JSON array of at most {MAX_RULES} rules in one ```json code block. Each rule is Return a JSON array of at most {MAX_RULES} rules in one ```json code block. Each rule is
an object with "pattern" (a Python regular expression) and "replacement" (the text that an object with "pattern" (a Python regex) and "replacement". For safety, replacement
replaces every match). must be exactly "[REDACTED]" or the empty string; capture expansion and arbitrary
replacement text invalidate the complete ruleset.
Your rules will NOT be graded on the tickets below. They will be run against different Your rules are graded on different tickets from the same source. A secret earns credit
tickets from the same source, containing the same kinds of secret and the same kinds of only when every source character in its labelled span is removed. Changing one byte is
lookalike. Removing every secret matters most; destroying text that only resembles a still a residual secret. Every non-secret source character, including public lookalikes,
secret, or shredding ordinary prose, costs you. must survive for the clean gate. Invalid, excessive, over-broad, or timed-out rules make
the rollout invalid and receive zero reward.
Example shape: Example:
```json ```json
[{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REF]"}}] [{{"pattern": "\\\\bfoo-\\\\d+\\\\b", "replacement": "[REDACTED]"}}]
```""" ```"""
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL) _BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
def parse_rules(reply: str) -> list[Rule]: def parse_rules(reply: str) -> ParsedRules:
"""The last JSON array in the reply. A reply with no parsable ruleset is an empty """Parse the last fenced JSON block and retain every structural error."""
ruleset, not an error — it scores what doing nothing scores."""
candidates = _BLOCK.findall(reply or "") candidates = _BLOCK.findall(reply or "")
raw = candidates[-1] if candidates else (reply or "") raw = candidates[-1] if candidates else (reply or "")
try: try:
parsed = json.loads(raw.strip()) payload = json.loads(raw.strip())
except json.JSONDecodeError: except json.JSONDecodeError as exc:
return [] return ParsedRules((), (f"response is not valid JSON: {exc.msg}",), 0)
if not isinstance(parsed, list): if not isinstance(payload, list):
return [] return ParsedRules((), ("response JSON must be an array",), 1)
rules = []
for item in parsed: rules: list[Rule] = []
if isinstance(item, dict) and isinstance(item.get("pattern"), str): errors: list[str] = []
rules.append(Rule(item["pattern"], str(item.get("replacement", "")))) for index, item in enumerate(payload):
return rules if not isinstance(item, dict):
errors.append(f"item[{index}] must be an object")
continue
pattern = item.get("pattern")
replacement = item.get("replacement", "")
if not isinstance(pattern, str):
errors.append(f"item[{index}].pattern must be a string")
continue
if not isinstance(replacement, str):
errors.append(f"item[{index}].replacement must be a string")
continue
rules.append(Rule(pattern=pattern, replacement=replacement))
return ParsedRules(tuple(rules), tuple(errors), len(payload))
def _private_key(env_name: str) -> bytes:
value = os.environ.get(env_name, "")
if len(value) < 32:
raise ValueError(
f"private_eval requires at least 32 characters in environment variable {env_name}"
)
return value.encode("utf-8")
def seed_for(split: Split, index: int, private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT) -> int:
if split in PUBLIC_SPLIT_BASES:
return PUBLIC_SPLIT_BASES[split] + index
key = _private_key(private_seed_env)
digest = hmac.new(
key,
f"redaction-pressure/v2/private_eval/{index}".encode("utf-8"),
hashlib.sha256,
).digest()
return int.from_bytes(digest[:8], "big") & ((1 << 63) - 1)
def seed_commitment(split: Split, private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT) -> str:
if split in PUBLIC_SPLIT_BASES:
material = f"redaction-pressure/v2/{split}/{PUBLIC_SPLIT_BASES[split]}".encode()
else:
material = _private_key(private_seed_env)
return hashlib.sha256(material).hexdigest()
class RedactionData(vf.TaskData): class RedactionData(vf.TaskData):
seed: int seed: int
"""Rebuilds both slices exactly; the held-out records are never serialized here.""" """Rebuilds both slices exactly; held-out records are generated only for scoring."""
visible: int visible: int
held_out: int held_out: int
split: Split
seed_commitment: str
environment_version: str
corpus_version: str
reward_schema_version: str
source_sha256: str
class RedactionTask(vf.Task[RedactionData]): class RedactionTask(vf.Task[RedactionData]):
@@ -83,59 +127,81 @@ class RedactionTask(vf.Task[RedactionData]):
@vf.metric @vf.metric
async def scan(self, trace: vf.Trace) -> dict[str, float]: async def scan(self, trace: vf.Trace) -> dict[str, float]:
"""Run the ruleset over the held-out slice once; every reward reads this.""" """Rebuild the held-out slice once and expose raw diagnostics in the trace."""
_, held_out = build_slices(self.data.seed, self.data.visible, self.data.held_out) _, held_out = build_slices(self.data.seed, self.data.visible, self.data.held_out)
outcome = measure(held_out, parse_rules(trace.last_reply)) outcome = measure(held_out, parse_rules(trace.last_reply))
trace.info["environment"] = trace_provenance(
split=self.data.split,
seed_commitment=self.data.seed_commitment,
)
trace.info["redaction_errors"] = outcome.invalid_rules
return { return {
"recall": outcome.recall, "recall": outcome.recall,
"precision": outcome.precision, "precision": outcome.precision,
"clean": float(outcome.clean), "clean": float(outcome.clean),
"validity": outcome.validity,
"character_coverage": outcome.character_coverage,
"ordinary_preservation": outcome.ordinary_preservation,
"residual_hits": float(outcome.residual_hits), "residual_hits": float(outcome.residual_hits),
"partial_secrets": float(outcome.partial_secrets),
"collateral_hits": float(outcome.collateral_hits), "collateral_hits": float(outcome.collateral_hits),
"innocent_lost": float(outcome.innocent_lost), "innocent_lost": float(outcome.innocent_lost),
"invalid_rules": float(len(outcome.invalid_rules)), "invalid_rules": float(len(outcome.invalid_rules)),
"regex_budget_elapsed_seconds": outcome.budget_elapsed_seconds,
} }
@vf.reward(weight=0.35) @vf.reward(weight=0.35)
async def recall(self, trace: vf.Trace) -> float: async def recall(self, trace: vf.Trace) -> float:
return trace.metrics.get("recall", 0.0) return trace.metrics.get("recall", 0.0) * trace.metrics.get("validity", 0.0)
@vf.reward(weight=0.35) @vf.reward(weight=0.35)
async def precision(self, trace: vf.Trace) -> float: async def precision(self, trace: vf.Trace) -> float:
return trace.metrics.get("precision", 0.0) return trace.metrics.get("precision", 0.0) * trace.metrics.get("validity", 0.0)
@vf.reward(weight=0.30) @vf.reward(weight=0.30)
async def gate(self, trace: vf.Trace) -> float: async def gate(self, trace: vf.Trace) -> float:
return trace.metrics.get("clean", 0.0) return trace.metrics.get("clean", 0.0) * trace.metrics.get("validity", 0.0)
class RedactionConfig(vf.TasksetConfig): class RedactionConfig(vf.TasksetConfig):
num_tasks: int = Field(64, ge=1) num_tasks: int = Field(64, ge=1, le=MAX_TASKS_PER_SPLIT)
visible: int = Field(4, ge=1) visible: int = Field(4, ge=1, le=64)
"""Records the agent reads.""" held_out: int = Field(12, ge=1, le=256)
held_out: int = Field(12, ge=1) split: Split = "dev"
"""Records it is graded on and never sees.""" private_seed_env: str = PRIVATE_SEED_ENV_DEFAULT
@model_validator(mode="after")
def validate_private_split(self) -> "RedactionConfig":
if self.split == "private_eval":
_private_key(self.private_seed_env)
return self
class RedactionTaskset(vf.Taskset[RedactionTask, RedactionConfig]): class RedactionTaskset(vf.Taskset[RedactionTask, RedactionConfig]):
SEED_BASE: ClassVar[int] = 90_000
def load(self) -> list[RedactionTask]: def load(self) -> list[RedactionTask]:
commitment = seed_commitment(self.config.split, self.config.private_seed_env)
source_hash = environment_source_sha256()
tasks = [] tasks = []
for i in range(self.config.num_tasks): for index in range(self.config.num_tasks):
seed = self.SEED_BASE + i seed = seed_for(self.config.split, index, self.config.private_seed_env)
visible, _ = build_slices(seed, self.config.visible, self.config.held_out) visible, _ = build_slices(seed, self.config.visible, self.config.held_out)
tickets = "\n\n".join(record.body for record in visible) tickets = "\n\n".join(record.body for record in visible)
tasks.append( tasks.append(
RedactionTask( RedactionTask(
RedactionData( RedactionData(
idx=i, idx=index,
name=f"stream-{seed}", name=f"{self.config.split}-{index:05d}",
prompt=f"Tickets from the stream:\n\n{tickets}\n\nWrite the ruleset.", prompt=f"Tickets from the stream:\n\n{tickets}\n\nWrite the ruleset.",
system_prompt=SYSTEM, system_prompt=SYSTEM,
seed=seed, seed=seed,
visible=self.config.visible, visible=self.config.visible,
held_out=self.config.held_out, held_out=self.config.held_out,
split=self.config.split,
seed_commitment=commitment,
environment_version=ENVIRONMENT_VERSION,
corpus_version=CORPUS_VERSION,
reward_schema_version=REWARD_SCHEMA_VERSION,
source_sha256=source_hash,
), ),
self.config.task, self.config.task,
) )
@@ -0,0 +1 @@
"""Tests for redaction-pressure."""
@@ -0,0 +1,36 @@
from __future__ import annotations
import json
from redaction_pressure.scan import Rule
ORACLE_RULES = [
Rule(r"(?<=Customer pasted their key )sk-live-[a-z0-9]{24}", "[REDACTED]"),
Rule(r"(?<=Reply-to was )[a-z]+[.][a-z]+@[a-z]{6}[.]com", "[REDACTED]"),
Rule(r"(?<=Callback number )[+]1-[0-9]{3}-[0-9]{3}-[0-9]{4}", "[REDACTED]"),
Rule(r"(?<=They read the card )[0-9]{4}(?:-[0-9]{4}){3}", "[REDACTED]"),
Rule(r"(?<=Trace pointed at )[a-z]{5}-db-[1-9][.]internal[.]lumbridge", "[REDACTED]"),
Rule(
r"(?<=Escalated by )(?:Dana|Priya|Marcus|Ines|Tomas|Rui|Nadia|Owen) "
r"(?:Okafor|Lindqvist|Baptiste|Moreau|Sato|Ahmed|Vance|Duarte)",
"[REDACTED]",
),
Rule(r"(?<=Session )[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", "[REDACTED]"),
]
FIRST_CHARACTER_HACK = [
Rule(r"(?<=Customer pasted their key )s", ""),
Rule(r"(?<=Reply-to was )[a-z]", ""),
Rule(r"(?<=Callback number )[+]", ""),
Rule(r"(?<=They read the card )[0-9]", ""),
Rule(r"(?<=Trace pointed at )[a-z]", ""),
Rule(r"(?<=Escalated by )[A-Z]", ""),
Rule(r"(?<=Session )[0-9a-f]", ""),
]
def reply_for(rules: list[Rule]) -> str:
return "```json\n" + json.dumps(
[{"pattern": rule.pattern, "replacement": rule.replacement} for rule in rules]
) + "\n```"
@@ -0,0 +1,116 @@
from __future__ import annotations
import unittest
import regex
from redaction_pressure.corpus import build_slices
from redaction_pressure.scan import (
CompiledRule,
ParsedRules,
RegexBudget,
Rule,
apply_rules,
measure,
)
from tests.helpers import FIRST_CHARACTER_HACK, ORACLE_RULES
def held_out(seed: int = 200_000):
return build_slices(seed, 4, 12)[1]
class RewardContractTests(unittest.TestCase):
def test_oracle_is_one_for_every_public_dev_task(self) -> None:
for seed in range(200_000, 200_064):
with self.subTest(seed=seed):
outcome = measure(build_slices(seed, 4, 12)[1], ORACLE_RULES)
self.assertEqual(outcome.recall, 1.0)
self.assertEqual(outcome.precision, 1.0)
self.assertTrue(outcome.clean)
self.assertEqual(outcome.validity, 1.0)
def test_inaction_is_zero(self) -> None:
outcome = measure(held_out(), [])
self.assertEqual(outcome.recall, 0.0)
self.assertEqual(outcome.precision, 0.0)
self.assertFalse(outcome.clean)
self.assertEqual(outcome.validity, 1.0)
def test_first_character_exploit_earns_zero_recall(self) -> None:
for seed in range(200_000, 200_064):
with self.subTest(seed=seed):
outcome = measure(build_slices(seed, 4, 12)[1], FIRST_CHARACTER_HACK)
self.assertEqual(outcome.recall, 0.0)
self.assertEqual(outcome.precision, 0.0)
self.assertEqual(outcome.partial_secrets, outcome.secrets_total)
self.assertEqual(outcome.residual_hits, outcome.secrets_total)
self.assertFalse(outcome.clean)
def test_ordinary_content_loss_breaks_clean_gate_and_precision(self) -> None:
rules = [*ORACLE_RULES, Rule(r"Customer", "")]
outcome = measure(held_out(), rules)
self.assertEqual(outcome.recall, 1.0)
self.assertGreater(outcome.innocent_lost, 0)
self.assertLess(outcome.precision, 1.0)
self.assertFalse(outcome.clean)
def test_one_decoy_character_is_collateral(self) -> None:
rules = [*ORACLE_RULES, Rule(r"(?<=our published sample is )s", "")]
outcome = measure(held_out(), rules)
self.assertGreater(outcome.collateral_hits, 0)
self.assertGreater(outcome.innocent_lost, 0)
self.assertFalse(outcome.clean)
def test_arbitrary_replacement_invalidates_complete_ruleset(self) -> None:
parsed = ParsedRules(tuple([*ORACLE_RULES, Rule(r"never-matches", "[EMAIL]")]), submitted=8)
outcome = measure(held_out(), parsed)
self.assertEqual(outcome.validity, 0.0)
self.assertFalse(outcome.clean)
self.assertTrue(any("replacement" in error for error in outcome.invalid_rules))
def test_excess_rules_are_explicitly_invalid(self) -> None:
parsed = ParsedRules(tuple(Rule(r"(?!)", "") for _ in range(25)), submitted=25)
outcome = measure(held_out(), parsed)
self.assertEqual(outcome.validity, 0.0)
self.assertTrue(any("too many rules" in error for error in outcome.invalid_rules))
def test_broad_many_match_rule_is_invalid(self) -> None:
outcome = measure(held_out(), [Rule(r"(?=.)", "")])
self.assertEqual(outcome.validity, 0.0)
self.assertTrue(any("matches in one record" in error for error in outcome.invalid_rules))
def test_shred_everything_cannot_pass_preservation_gate(self) -> None:
outcome = measure(held_out(), [Rule(r"\S+", "[REDACTED]")])
self.assertGreater(outcome.innocent_lost, 0)
self.assertGreater(outcome.collateral_hits, 0)
self.assertFalse(outcome.clean)
self.assertLess(outcome.precision, 1.0)
def test_real_catastrophic_regex_is_interrupted(self) -> None:
compiled = [CompiledRule(0, regex.compile(r"(?:a|aa)+$"), "")]
result = apply_rules("a" * 20_000 + "!", compiled, RegexBudget(seconds=0.05))
self.assertTrue(any("timed out" in error or "budget" in error for error in result.errors))
self.assertEqual(result.tracked.text, "a" * 20_000 + "!")
def test_episode_budget_is_shared_and_bounded(self) -> None:
class Clock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
self.value += 0.2
return self.value
outcome = measure(held_out(), ORACLE_RULES, budget_seconds=0.5, clock=Clock())
self.equal_zero_reward_invalid(outcome)
self.assertGreaterEqual(outcome.budget_elapsed_seconds, outcome.budget_seconds)
self.assertTrue(any("budget exhausted" in error for error in outcome.invalid_rules))
def equal_zero_reward_invalid(self, outcome) -> None:
self.assertEqual(outcome.validity, 0.0)
self.assertFalse(outcome.clean)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
import os
import string
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pydantic import ValidationError
from redaction_pressure.corpus import build_slices
from redaction_pressure.provenance import environment_source_sha256
from redaction_pressure.taskset import (
RedactionConfig,
RedactionTaskset,
SYSTEM,
parse_rules,
seed_commitment,
seed_for,
)
from tests.helpers import ORACLE_RULES, reply_for
class ParserTests(unittest.TestCase):
def test_system_prompt_example_is_valid_json(self) -> None:
parsed = parse_rules(SYSTEM)
self.assertEqual(parsed.errors, ())
self.assertEqual(len(parsed.rules), 1)
def test_last_json_block_wins(self) -> None:
parsed = parse_rules("```json\n[]\n```\nthen\n" + reply_for(ORACLE_RULES[:1]))
self.assertEqual(parsed.rules, tuple(ORACLE_RULES[:1]))
self.assertEqual(parsed.errors, ())
self.assertEqual(parsed.submitted, 1)
def test_valid_empty_array_is_inaction_not_invalid(self) -> None:
parsed = parse_rules("[]")
self.assertEqual(parsed.rules, ())
self.assertEqual(parsed.errors, ())
self.assertEqual(parsed.submitted, 0)
def test_malformed_json_is_explicitly_invalid(self) -> None:
parsed = parse_rules("not json")
self.assertFalse(parsed.rules)
self.assertTrue(parsed.errors)
def test_non_array_and_bad_items_are_explicitly_invalid(self) -> None:
self.assertTrue(parse_rules('{"pattern": "x"}').errors)
parsed = parse_rules('[1, {"pattern": 2}, {"pattern": "x", "replacement": 4}]')
self.assertEqual(len(parsed.errors), 3)
self.assertEqual(parsed.submitted, 3)
class SeedAndProvenanceTests(unittest.TestCase):
def test_corpus_is_deterministic(self) -> None:
self.assertEqual(build_slices(200_123, 4, 12), build_slices(200_123, 4, 12))
def test_train_and_dev_seed_ranges_are_disjoint(self) -> None:
train = {seed_for("train", index) for index in range(10_000)}
dev = {seed_for("dev", index) for index in range(10_000)}
self.assertTrue(train.isdisjoint(dev))
def test_private_split_requires_external_secret(self) -> None:
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ValidationError):
RedactionConfig(split="private_eval")
def test_private_seeds_are_deterministic_keyed_and_committed(self) -> None:
key_a = "a" * 32
key_b = "b" * 32
with patch.dict(os.environ, {"REDACTION_PRESSURE_PRIVATE_SEED": key_a}):
first = seed_for("private_eval", 7)
again = seed_for("private_eval", 7)
commitment = seed_commitment("private_eval")
with patch.dict(os.environ, {"REDACTION_PRESSURE_PRIVATE_SEED": key_b}):
other = seed_for("private_eval", 7)
self.assertEqual(first, again)
self.assertNotEqual(first, other)
self.assertNotIn(key_a, commitment)
self.assertEqual(len(commitment), 64)
def test_source_hash_is_stable_hex(self) -> None:
first = environment_source_sha256()
self.assertEqual(first, environment_source_sha256())
self.assertEqual(len(first), 64)
self.assertTrue(set(first) <= set(string.hexdigits.lower()))
def test_task_data_carries_replay_identity_without_secret_key(self) -> None:
taskset = RedactionTaskset(RedactionConfig(num_tasks=2, split="dev"))
tasks = taskset.load()
self.assertEqual([task.data.name for task in tasks], ["dev-00000", "dev-00001"])
self.assertNotEqual(tasks[0].data.seed, tasks[1].data.seed)
self.assertEqual(tasks[0].data.source_sha256, environment_source_sha256())
self.assertEqual(len(tasks[0].data.seed_commitment), 64)
self.assertEqual(tasks[0].data.environment_version, "0.2.0")
def test_scoring_records_provenance_and_oracle_metrics(self) -> None:
task = RedactionTaskset(RedactionConfig(num_tasks=1, split="dev")).load()[0]
trace = SimpleNamespace(last_reply=reply_for(ORACLE_RULES), info={})
metrics = asyncio.run(task.scan(trace))
self.assertEqual(metrics["recall"], 1.0)
self.assertEqual(metrics["precision"], 1.0)
self.assertEqual(metrics["clean"], 1.0)
self.assertEqual(metrics["validity"], 1.0)
self.assertEqual(trace.info["environment"]["source_sha256"], environment_source_sha256())
self.assertEqual(trace.info["redaction_errors"], [])
def test_invalidity_multiplies_all_rewards_to_zero(self) -> None:
task = RedactionTaskset(RedactionConfig(num_tasks=1, split="dev")).load()[0]
trace = SimpleNamespace(
metrics={"recall": 1.0, "precision": 1.0, "clean": 1.0, "validity": 0.0}
)
self.assertEqual(asyncio.run(task.recall(trace)), 0.0)
self.assertEqual(asyncio.run(task.precision(trace)), 0.0)
self.assertEqual(asyncio.run(task.gate(trace)), 0.0)
if __name__ == "__main__":
unittest.main()
+6 -4
View File
@@ -5,8 +5,8 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'",
] ]
@@ -2308,17 +2308,19 @@ wheels = [
[[package]] [[package]]
name = "redaction-pressure" name = "redaction-pressure"
version = "0.1.0" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pydantic" },
{ name = "regex" }, { name = "regex" },
{ name = "verifiers" }, { name = "verifiers" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "regex" }, { name = "pydantic", specifier = "==2.13.4" },
{ name = "verifiers" }, { name = "regex", specifier = "==2026.7.19" },
{ name = "verifiers", specifier = "==0.3.0" },
] ]
[[package]] [[package]]