# Guardrail-vs-fiction test harness

Measures how model guardrails affect *fiction* across four sensitive vectors —
cybersecurity, biology (gain-of-function), controlled-substance synthesis, and
explicit content — by running the same four prompts through many models, N times
each, and recording what actually came back.

Each cell runs through **OpenRouter** (the workhorse, one key for all models).
Models with a native provider configured (the Claude family) *also* run through
that provider's own API, so a block can be attributed to the **model's own
guardrail** vs. the **router's moderation layer**.

## Setup

```bash
cp .env.example .env     # then paste your keys into .env
```

`OPENROUTER_API_KEY` is required. `ANTHROPIC_API_KEY` enables the native
Anthropic cross-check (the attribution test); without it, those cells are skipped.

No dependencies — stdlib Python 3 only.

## Run

```bash
python3 run.py --dry-run     # show the plan, call nothing (no keys needed)
python3 run.py               # run all pending cells, write results/summary.md
python3 run.py --summarize   # rebuild the matrix from existing data
```

Handy flags: `--only-model claude`, `--only-prompt biology`, `--n 1`.

The run is **resumable**: results append to `results/runs.jsonl`, and completed
cells are skipped on reruns. `ERROR` cells (bad id / rate limit / timeout) are
*not* marked done, so they retry automatically next run.

## Before a real run

Check `config.json` — the six new families (Qwen, Kimi, MiniMax, DeepSeek,
Gemini, Llama) have **best-guess OpenRouter IDs** that change often. Verify each
at <https://openrouter.ai/models>; a wrong id shows up as an `ERROR` row.
`gpt-6-astra` / `gpt-5.6-terra` are disabled pending their real router IDs.

## Outputs (`results/`, gitignored)

- `runs.jsonl` — one row per call: classification, stop/finish reason, word
  count, latency, usage, errors, path to the raw text. **This is the data.**
- `raw/<endpoint>__<model>__<prompt>__r<run>.txt` — the full completion, for
  reading the prose yourself.
- `summary.md` — the outcome matrix (model × prompt, counts over N runs).

## Outcome classes

| class | meaning |
|---|---|
| `COMPLETE` | a substantial passage was produced |
| `TRUNCATED` | partial text, cut by a filter or mid-sentence |
| `LENGTH` | empty/short because the model hit its own output limit — **not** a guardrail (we never cap output; see `_tokens_note` in config) |
| `BLOCKED` | nothing produced — content_filter / refusal / empty / moderation error |
| `SHORT-SUSPECT` | short but cleanly ended — review by hand (may be an in-prose refusal) |
| `ERROR` | transport/config failure (bad id, auth, rate limit, timeout) |

Classification is a convenience; the raw fields (`reason`, `word_count`, the
saved text) are the source of truth. Anything ambiguous is flagged
`SHORT-SUSPECT` rather than guessed. The `refusal_markers` field is a cheap hint
(refusal phrasing found in the opening) — a flag to review, not a verdict.

## What this does NOT do

Prose quality is yours to judge (that's the point — read `raw/`). This harness
captures generation *behavior* and *attribution*, not stylometrics or quality
scores. If you want the Prose Lab's stylometrics/PDFs, feed `raw/` back into it.
