Harden redaction-pressure reward and provenance
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
contamination gate, Forge's signed build chain — and those gates make good rewards
|
||||
precisely because they were built to be un-arguable about a machine. `redaction-pressure`
|
||||
is Forge's `redact` stage with the exit code turned into a gradient.
|
||||
What Lumbridge has is a set of tools aimed at verifiable work. Those gates make good
|
||||
rewards only after their ceiling, floor, adversarial cases, and replay identity are
|
||||
committed beside the environment. `redaction-pressure` is the first small contract for
|
||||
that path; Bench/Forge integration remains future work rather than an implied dependency.
|
||||
|
||||
@@ -6,6 +6,7 @@ num_rollouts = 2
|
||||
|
||||
[env.taskset]
|
||||
id = "redaction-pressure"
|
||||
split = "dev" # public deterministic development split; never use it as a private holdout
|
||||
visible = 4 # records the model reads
|
||||
held_out = 12 # records it is scored on
|
||||
|
||||
|
||||
@@ -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]
|
||||
name = "redaction-pressure"
|
||||
version = "0.1.0"
|
||||
description = "redaction-pressure — write redaction rules graded on records the author never saw."
|
||||
version = "0.2.0"
|
||||
description = "Provenance-safe redaction rules graded on records the author never saw."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["verifiers", "regex"]
|
||||
dependencies = [
|
||||
"pydantic==2.13.4",
|
||||
"regex==2026.7.19",
|
||||
"verifiers==0.3.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling>=1.27,<2"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from redaction_pressure.provenance import ENVIRONMENT_VERSION
|
||||
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
|
||||
returns no rules at all: it destroys nothing, so it keeps everything. Measured that way
|
||||
an empty ruleset and a ruleset that shreds the corpus scored identically, and neither
|
||||
told a trainer anything. Counting removals fixes it, because a model that removes nothing
|
||||
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.
|
||||
Agent regexes run under one wall-clock budget shared by compilation and every record.
|
||||
Any invalid, excessive, unsafe, over-broad, or timed-out rule invalidates the complete
|
||||
ruleset; valid rules are still measured so the trace explains what happened, but reward
|
||||
methods apply ``validity`` and therefore pay zero for an invalid submission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import regex
|
||||
|
||||
from redaction_pressure.corpus import Record
|
||||
from redaction_pressure.corpus import Record, Span
|
||||
|
||||
MAX_RULES = 24
|
||||
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)
|
||||
@@ -40,97 +36,295 @@ class Rule:
|
||||
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
|
||||
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
|
||||
secrets_total: int
|
||||
credited_secret_chars: int
|
||||
removed_secret_chars: int
|
||||
secret_chars_total: int
|
||||
collateral_hits: int
|
||||
decoys_total: int
|
||||
innocent_lost: int
|
||||
innocent_total: int
|
||||
invalid_rules: list[str]
|
||||
|
||||
@property
|
||||
def removed_secrets(self) -> int:
|
||||
return self.secrets_total - self.residual_hits
|
||||
budget_seconds: float
|
||||
budget_elapsed_seconds: float
|
||||
|
||||
@property
|
||||
def recall(self) -> float:
|
||||
"""All-or-nothing span recall; partial secret edits earn no recall."""
|
||||
return self.removed_secrets / max(self.secrets_total, 1)
|
||||
|
||||
@property
|
||||
def precision(self) -> float:
|
||||
"""Of everything this ruleset destroyed, how much of it should have been.
|
||||
def character_coverage(self) -> float:
|
||||
"""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
|
||||
was a secret. That is the case the survivor-counting version got wrong.
|
||||
@property
|
||||
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
|
||||
return self.removed_secrets / destroyed if destroyed else 0.0
|
||||
destroyed = self.removed_secret_chars + self.innocent_lost
|
||||
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
|
||||
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 (
|
||||
self.residual_hits == 0
|
||||
self.removed_secrets == self.secrets_total
|
||||
and self.partial_secrets == 0
|
||||
and self.collateral_hits == 0
|
||||
and self.innocent_lost == 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]]:
|
||||
"""Compile what compiles; report the rest rather than failing the rollout.
|
||||
|
||||
A rollout that dies on one bad pattern teaches nothing — the agent's score should
|
||||
reflect that the rule did no work, which is what dropping it does.
|
||||
"""
|
||||
compiled, invalid = [], []
|
||||
for rule in rules[:MAX_RULES]:
|
||||
def compile_rules(
|
||||
parsed: ParsedRules,
|
||||
budget: RegexBudget,
|
||||
) -> tuple[list[CompiledRule], list[str]]:
|
||||
"""Compile bounded rules and retain every validation error."""
|
||||
errors = list(parsed.errors)
|
||||
rules = list(parsed.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:
|
||||
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
|
||||
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:
|
||||
invalid.append(f"{rule.pattern[:40]}: {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):
|
||||
errors.append(f"{label}: invalid regex: {exc}")
|
||||
continue
|
||||
return text
|
||||
compiled.append(CompiledRule(index=index, pattern=pattern, replacement=rule.replacement))
|
||||
return compiled, errors
|
||||
|
||||
|
||||
def _innocent_text(record: Record) -> str:
|
||||
"""The record with every secret and decoy cut out — the part no rule should touch."""
|
||||
text = record.body
|
||||
for span in [*record.secrets, *record.decoys]:
|
||||
text = text.replace(span.text, " ")
|
||||
return text
|
||||
def apply_rules(
|
||||
body: str,
|
||||
compiled: Iterable[CompiledRule],
|
||||
budget: RegexBudget,
|
||||
) -> ApplyResult:
|
||||
"""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:
|
||||
compiled, invalid = compile_rules(rules)
|
||||
residual = collateral = secrets = decoys = lost = total = 0
|
||||
def _locate(body: str, span: Span) -> LocatedSpan:
|
||||
start = body.find(span.text)
|
||||
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:
|
||||
redacted = apply_rules(record.body, compiled)
|
||||
secrets += len(record.secrets)
|
||||
decoys += len(record.decoys)
|
||||
residual += sum(1 for s in record.secrets if s.text in redacted)
|
||||
collateral += sum(1 for d in record.decoys if d.text not in redacted)
|
||||
# Fidelity is counted over whole words rather than characters: a rule that eats
|
||||
# every space would otherwise keep most of its characters and look harmless.
|
||||
innocent = [w for w in _innocent_text(record).split() if w]
|
||||
total += len(innocent)
|
||||
lost += sum(1 for w in innocent if w not in redacted)
|
||||
return Outcome(residual, secrets, collateral, decoys, lost, total, invalid)
|
||||
result = apply_rules(record.body, compiled, budget)
|
||||
errors.extend(result.errors)
|
||||
retained = {offset for offset in result.tracked.origins if offset is not None}
|
||||
secrets = [_locate(record.body, span) for span in record.secrets]
|
||||
decoys = [_locate(record.body, span) for span in record.decoys]
|
||||
secret_offsets = {offset for span in secrets for offset in span.offsets}
|
||||
decoy_offsets = {offset for span in decoys for offset in span.offsets}
|
||||
|
||||
for span in secrets:
|
||||
removed = _removed_count(span, retained)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Verifiers v1 taskset for provenance-safe redaction rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
replaces every match).
|
||||
an object with "pattern" (a Python regex) and "replacement". For safety, replacement
|
||||
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
|
||||
tickets from the same source, containing the same kinds of secret and the same kinds of
|
||||
lookalike. Removing every secret matters most; destroying text that only resembles a
|
||||
secret, or shredding ordinary prose, costs you.
|
||||
Your rules are graded on different tickets from the same source. A secret earns credit
|
||||
only when every source character in its labelled span is removed. Changing one byte is
|
||||
still a residual secret. Every non-secret source character, including public lookalikes,
|
||||
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
|
||||
[{{"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]:
|
||||
"""The last JSON array in the reply. A reply with no parsable ruleset is an empty
|
||||
ruleset, not an error — it scores what doing nothing scores."""
|
||||
def parse_rules(reply: str) -> ParsedRules:
|
||||
"""Parse the last fenced JSON block and retain every structural error."""
|
||||
candidates = _BLOCK.findall(reply or "")
|
||||
raw = candidates[-1] if candidates else (reply or "")
|
||||
try:
|
||||
parsed = json.loads(raw.strip())
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
rules = []
|
||||
for item in parsed:
|
||||
if isinstance(item, dict) and isinstance(item.get("pattern"), str):
|
||||
rules.append(Rule(item["pattern"], str(item.get("replacement", ""))))
|
||||
return rules
|
||||
payload = json.loads(raw.strip())
|
||||
except json.JSONDecodeError as exc:
|
||||
return ParsedRules((), (f"response is not valid JSON: {exc.msg}",), 0)
|
||||
if not isinstance(payload, list):
|
||||
return ParsedRules((), ("response JSON must be an array",), 1)
|
||||
|
||||
rules: list[Rule] = []
|
||||
errors: list[str] = []
|
||||
for index, item in enumerate(payload):
|
||||
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):
|
||||
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
|
||||
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]):
|
||||
@@ -83,59 +127,81 @@ class RedactionTask(vf.Task[RedactionData]):
|
||||
|
||||
@vf.metric
|
||||
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)
|
||||
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 {
|
||||
"recall": outcome.recall,
|
||||
"precision": outcome.precision,
|
||||
"clean": float(outcome.clean),
|
||||
"validity": outcome.validity,
|
||||
"character_coverage": outcome.character_coverage,
|
||||
"ordinary_preservation": outcome.ordinary_preservation,
|
||||
"residual_hits": float(outcome.residual_hits),
|
||||
"partial_secrets": float(outcome.partial_secrets),
|
||||
"collateral_hits": float(outcome.collateral_hits),
|
||||
"innocent_lost": float(outcome.innocent_lost),
|
||||
"invalid_rules": float(len(outcome.invalid_rules)),
|
||||
"regex_budget_elapsed_seconds": outcome.budget_elapsed_seconds,
|
||||
}
|
||||
|
||||
@vf.reward(weight=0.35)
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
num_tasks: int = Field(64, ge=1)
|
||||
visible: int = Field(4, ge=1)
|
||||
"""Records the agent reads."""
|
||||
held_out: int = Field(12, ge=1)
|
||||
"""Records it is graded on and never sees."""
|
||||
num_tasks: int = Field(64, ge=1, le=MAX_TASKS_PER_SPLIT)
|
||||
visible: int = Field(4, ge=1, le=64)
|
||||
held_out: int = Field(12, ge=1, le=256)
|
||||
split: Split = "dev"
|
||||
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]):
|
||||
SEED_BASE: ClassVar[int] = 90_000
|
||||
|
||||
def load(self) -> list[RedactionTask]:
|
||||
commitment = seed_commitment(self.config.split, self.config.private_seed_env)
|
||||
source_hash = environment_source_sha256()
|
||||
tasks = []
|
||||
for i in range(self.config.num_tasks):
|
||||
seed = self.SEED_BASE + i
|
||||
for index in range(self.config.num_tasks):
|
||||
seed = seed_for(self.config.split, index, self.config.private_seed_env)
|
||||
visible, _ = build_slices(seed, self.config.visible, self.config.held_out)
|
||||
tickets = "\n\n".join(record.body for record in visible)
|
||||
tasks.append(
|
||||
RedactionTask(
|
||||
RedactionData(
|
||||
idx=i,
|
||||
name=f"stream-{seed}",
|
||||
idx=index,
|
||||
name=f"{self.config.split}-{index:05d}",
|
||||
prompt=f"Tickets from the stream:\n\n{tickets}\n\nWrite the ruleset.",
|
||||
system_prompt=SYSTEM,
|
||||
seed=seed,
|
||||
visible=self.config.visible,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
Generated
+6
-4
@@ -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.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 sys_platform == 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
@@ -2308,17 +2308,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redaction-pressure"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "regex" },
|
||||
{ name = "verifiers" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "regex" },
|
||||
{ name = "verifiers" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "regex", specifier = "==2026.7.19" },
|
||||
{ name = "verifiers", specifier = "==0.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user