benchmarks: fix correctness gate + robustness audit (#65) (#83)

* fix(benchmarks): correctness gate scores unfenced code; fix debounce task

The `correct` gate under-reported correctness for terse models, the likely
source of "Ponytail degrades models" reports (issue #65):

- extractBlocks() only matched fenced code blocks, so bare/unfenced code
  scored an automatic fail even when correct. Now falls back to the whole
  response as one block (and tolerates CRLF). Debounce detection also accepts
  unfenced arrow functions.
- The debounce task asked to "add debounce to a search input" but the check
  expected a reusable debounce(fn, delay) util, failing correct inline answers.
  Task reworded to the deliverable the check verifies.

Adds correctness.test.js (regression guard) and a GPT-mini repro config plus
results writeup: on a clean n=20 run, the reported gpt-4.1-mini drop (10/15)
does not reproduce (100/100). The LOC win (~halved) holds.

README repro fixed: promptfoo needs --env-file ../.env (reads cwd, not root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(benchmarks): add robustness audit — ponytail vs baseline on edge cases

Answers the real question behind #65: does ponytail's push for the shortest
solution make weak models produce wrong code on edge cases?

robustness-audit.js: 16 self-verifying tasks (12 algorithmic edge-case traps +
4 validators). Each check ships a known-good and known-lazy-wrong reference that
must pass/fail before any model output is scored (--selftest, 16/16).

Findings (gpt-4.1-mini + gpt-5.4-mini, baseline vs ponytail): parity on every
edge-case trap on both models. The one measured soft spot is gpt-5.4-mini email
(~4-5%, reaches for parseaddr). A sharpened SKILL.md validation rule had no
reliable effect in an n=100 A/B (96% vs 95%), so it was not shipped — the
tendency is model-level, not skill-level. Full writeup in results/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(benchmarks): email slip is provider-specific — 100% on Claude

High-n cross-provider follow-up to the robustness audit. The one ponytail
soft spot (email validation via parseaddr) splits by provider, not model size:

- Claude (haiku/sonnet/opus): 100% under ponytail, n=40 each — and ponytail
  beats baseline (unconstrained Sonnet over-engineers into an always-truthy
  dict, 0/40; ponytail writes a clean validator).
- OpenAI (gpt-4.1-mini..gpt-5.5): slips at every size under ponytail
  (~79-98%), baseline ~100%. The parseaddr reflex lives in OpenAI training.

Not fixable by skill text: 8 distinct SKILL.md edits (incl. an n=100 A/B,
96% vs 95%) all scored <= current, several worse, all bloated LOC. Nothing
shipped. SKILL.md unchanged.

Conclusion: on ponytail's target platform (Claude) email is 100%; the GPT
slip is a documented cross-provider transfer quirk. Adds model-email.js /
claude-email.js to reproduce the tables. Writeup updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(benchmarks): correct misleading Sonnet baseline 0 percent

The Sonnet baseline 0/40 on email is a return-type artifact, not a logic
failure: unconstrained Sonnet returns a dict {is_valid, message} instead of a
bool, so the bool-contract gate scores every case as accepted. Read dict-aware
via is_valid, its logic is ~75% correct (9/12). Reframed honestly so we are not
presenting 0 vs 100 as a clean win; ponytail still wins (clean 100% bool) but
the point is over-engineered return type, not total failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DietrichGebert
2026-06-16 12:17:10 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 687c1b3398
commit caf138df56
10 changed files with 578 additions and 4 deletions
+4 -1
View File
@@ -11,10 +11,13 @@ check with `node --version` and upgrade if needed):
```bash ```bash
cp ../.env.example ../.env # add your ANTHROPIC_API_KEY cp ../.env.example ../.env # add your ANTHROPIC_API_KEY
npx promptfoo@latest eval -c promptfooconfig.yaml --repeat 10 npx promptfoo@latest eval -c promptfooconfig.yaml --env-file ../.env --repeat 10
npx promptfoo@latest view npx promptfoo@latest view
``` ```
`--env-file ../.env` is required because promptfoo reads `.env` from the current
directory (`benchmarks/`), not the repo root where the file lives.
### Local models via Ollama ### Local models via Ollama
No API key or promptfoo required. Runs against any model served by Ollama: No API key or promptfoo required. Runs against any model served by Ollama:
+40
View File
@@ -0,0 +1,40 @@
// Email under ponytail on Claude (ponytail's primary target), baseline vs ponytail.
const fs = require('fs'), path = require('path');
const { checkPy, pyBlock, TASKS } = require('./robustness-audit.js');
const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8');
const email = TASKS.find(t => t.name === 'email');
const N = Number(process.env.CE_N) || 40;
const MODELS = (process.env.CE_MODELS || 'claude-haiku-4-5-20251001,claude-sonnet-4-6,claude-opus-4-8').split(',');
const kv = Object.fromEntries(fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
const KEY = kv.ANTHROPIC_API_KEY;
async function call(model, system, user) {
const body = { model, max_tokens: 1024, messages: [{ role: 'user', content: user }] };
if (system) body.system = system;
const r = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST',
headers: { 'x-api-key': KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
const j = await r.json();
return { text: (j.content || []).map(b => b.text || '').join('') };
}
(async () => {
console.log(`email, n=${N}\n`);
console.log('model baseline ponytail');
for (const model of MODELS) {
const rates = {};
for (const [arm, sys] of [['baseline', null], ['ponytail', skill]]) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const r = await call(model, sys, email.prompt);
if (r.err) { err++; continue; }
if (checkPy(pyBlock(r.text), email)) pass++;
}
rates[arm] = `${pass}/${N - err}`;
}
console.log(`${model.padEnd(26)} ${rates.baseline.padEnd(10)} ${rates.ponytail}`);
}
})();
+6 -2
View File
@@ -13,7 +13,11 @@ const path = require('path');
// Extract fenced code blocks, tagged by language. // Extract fenced code blocks, tagged by language.
function extractBlocks(text) { function extractBlocks(text) {
const matches = [...text.matchAll(/```(\w*)\n([\s\S]*?)```/g)]; text = String(text || '');
const matches = [...text.matchAll(/```(\w*)\r?\n([\s\S]*?)```/g)];
// ponytail: terse models often answer with bare, unfenced code. Treat the whole
// response as one block so the gate scores the code instead of reporting "no block".
if (matches.length === 0 && text.trim()) return [{ lang: '', code: text }];
return matches.map((m) => ({ lang: (m[1] || '').toLowerCase(), code: m[2] })); return matches.map((m) => ({ lang: (m[1] || '').toLowerCase(), code: m[2] }));
} }
@@ -121,7 +125,7 @@ print("PASS")
}, },
debounce(blocks) { debounce(blocks) {
const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && b.code.includes('function'))); const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && (b.code.includes('function') || b.code.includes('=>'))));
if (!code) return { pass: false, reason: 'No JavaScript code block found' }; if (!code) return { pass: false, reason: 'No JavaScript code block found' };
const harness = ` const harness = `
+26
View File
@@ -0,0 +1,26 @@
// Regression guard for the gate fixes (issue #65). Run: node correctness.test.js
// Needs python + node on PATH, same as correctness.js itself.
const assert = require('assert');
const check = require('./correctness.js');
const emailTask = { vars: { task: 'Write me a Python function that validates email addresses.' } };
const debounceTask = { vars: { task: 'Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay).' } };
const FENCED_EMAIL = '```python\nimport re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))\n```';
const UNFENCED_EMAIL = 'import re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))';
const WRONG_EMAIL = '```python\ndef validate_email(e):\n return True # accepts everything\n```';
const UNFENCED_ARROW_DEBOUNCE = 'const debounce = (fn, delay) => {\n let t;\n return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), delay); };\n};';
let pass = 0;
const cases = [
['fenced email still passes', check(FENCED_EMAIL, emailTask).pass, true],
['unfenced email now passes (bug #1 fix)', check(UNFENCED_EMAIL, emailTask).pass, true],
['broken email still fails', check(WRONG_EMAIL, emailTask).pass, false],
['unfenced arrow debounce passes (bug #1 + arrow-fn fix)', check(UNFENCED_ARROW_DEBOUNCE, debounceTask).pass, true],
];
for (const [name, got, want] of cases) {
assert.strictEqual(got, want, `FAILED: ${name} (got ${got}, want ${want})`);
console.log(`ok - ${name}`);
pass++;
}
console.log(`\n${pass}/${cases.length} passed`);
+39
View File
@@ -0,0 +1,39 @@
// Cross-model email rate at high n: is the parseaddr quirk gpt-5.4-mini-specific?
const fs = require('fs'), path = require('path');
const { checkPy, pyBlock, TASKS } = require('./robustness-audit.js');
const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8');
const email = TASKS.find(t => t.name === 'email');
const N = Number(process.env.ME_N) || 100;
const MODELS = (process.env.ME_MODELS || 'gpt-4.1-mini,gpt-5.4-mini').split(',');
const kv = Object.fromEntries(fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
const KEY = kv.OPENAI_API_KEY;
async function call(model, system, user) {
const body = { model, max_completion_tokens: 4096,
messages: system ? [{ role: 'system', content: system }, { role: 'user', content: user }] : [{ role: 'user', content: user }] };
const r = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
return { text: (await r.json()).choices?.[0]?.message?.content || '' };
}
(async () => {
console.log(`email, n=${N}\n`);
console.log('model baseline ponytail');
for (const model of MODELS) {
const rates = {};
for (const [arm, sys] of [['baseline', null], ['ponytail', skill]]) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const r = await call(model, sys, email.prompt);
if (r.err) { err++; continue; }
if (checkPy(pyBlock(r.text), email)) pass++;
}
rates[arm] = `${pass}/${N - err}`;
}
console.log(`${model.padEnd(15)} ${rates.baseline.padEnd(10)} ${rates.ponytail}`);
}
})();
+32
View File
@@ -0,0 +1,32 @@
# Reproduces Pyseph's issue-65 setup: baseline vs ponytail, gpt-4.1-mini + gpt-5.4-mini.
# Reuses the repo's arms + loc/correctness gates. Needs OPENAI_API_KEY in ../.env.
# npx promptfoo@latest eval -c benchmarks/promptfooconfig.gpt.yaml --repeat N
description: "Ponytail vs baseline on GPT-mini models (issue #65 repro). LOC + correctness gate."
providers:
- id: openai:gpt-4.1-mini
config: { max_tokens: 4096, temperature: 1 }
- id: openai:gpt-5.4-mini
config: { max_completion_tokens: 4096 }
prompts:
- id: file://arms/baseline.js
label: baseline (no skill)
- id: file://arms/ponytail.js
label: ponytail
defaultTest:
assert:
- type: javascript
value: file://loc.js
metric: code_loc
- type: javascript
value: file://correctness.js
metric: correct
tests:
- vars: { task: "Write me a Python function that validates email addresses." }
- vars: { task: "Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay) returns a debounced version of fn that delays calling it until delay ms after the last call." }
- vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." }
- vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." }
- vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." }
+1 -1
View File
@@ -35,7 +35,7 @@ defaultTest:
tests: tests:
- vars: { task: "Write me a Python function that validates email addresses." } - vars: { task: "Write me a Python function that validates email addresses." }
- vars: { task: "Add debounce to a search input in vanilla JavaScript. It currently fires an API call on every keystroke." } - vars: { task: "Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay) returns a debounced version of fn that delays calling it until delay ms after the last call." }
- vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." } - vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." }
- vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." } - vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." }
- vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." } - vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." }
@@ -0,0 +1,107 @@
# Correctness under Ponytail: gate fixes + GPT-mini reproduction (2026-06-16)
Context: [issue #65](https://github.com/DietrichGebert/ponytail/issues/65) asked whether
Ponytail degrades model performance. A community run (Pyseph) reported a large correctness
drop on `gpt-4.1-mini` (10/15 with Ponytail vs 15/15 without) and a small one on
`gpt-5.4-mini` (14/15 vs 15/15).
Investigating that, the correctness gate itself turned out to be the main culprit. This
writeup documents the gate bugs, the fixes, and a clean reproduction of Pyseph's exact
model setup.
## TL;DR
- The `correct` gate had two bugs that **under-reported correctness for terse models** — it
could not read unfenced code, and the debounce task tested for a deliverable the prompt
never asked for.
- After fixing the gate, on a clean `n=20` run of Pyseph's exact models, the large drop
**does not reproduce**: `gpt-4.1-mini` is 100% with *and* without Ponytail.
- Ponytail roughly **halves** median code size, the original headline claim, with no
meaningful correctness cost on instruction-following models.
- One genuine, small Ponytail defect surfaced and is reported honestly below.
## The gate bugs
1. **Unfenced code was scored as "no code blocks."** `extractBlocks()` only matched
```` ```fenced``` ```` blocks. Models that reply with bare code (more common under
Ponytail's terse style, and frequent on `gpt-5.4-mini`) scored an automatic fail even
when the code was correct. This alone accounted for 41 of 74 failures in the first GPT run.
2. **The debounce task tested the wrong deliverable.** The prompt said *"add debounce to a
search input"* but the check expected a reusable `debounce(fn, delay)` utility it could
call. A correct inline answer (`input.addEventListener(... clearTimeout ...)`) failed with
`searchInput is not defined`. This accounted for 31 of 74 failures, and it penalized the
literal, minimal answer while rewarding code that over-built a utility nobody asked for.
Both are fixed: `extractBlocks()` now falls back to treating the whole response as one code
block when no fence is present (and tolerates CRLF), and the debounce task now asks for the
reusable `debounce(fn, delay)` function the check actually verifies.
## Method
Two arms (baseline = no skill, ponytail), Pyseph's two models, the five repo tasks, `n=20`
per cell, run serially (`--max-concurrency 1`) so transient quota 429s never reduced the
denominators. Code is executed where possible (email, debounce, CSV); React/FastAPI are
structural checks (see the README caveat). Claude numbers are a free re-score of the
committed `output-10x.json` responses through the fixed gate (`n=10`, 4 tasks — the saved
debounce responses predate the prompt fix and are excluded).
## Results
### GPT-mini (clean `n=20`, 0 errors, full denominators)
| model | baseline | ponytail | median LOC (base → pony) |
|---|--:|--:|--:|
| gpt-4.1-mini | 100/100 | 100/100 | 15 → 7 |
| gpt-5.4-mini | 100/100 | 98/100 | 16 → 7 |
Pyseph's reported `gpt-4.1-mini` drop (10/15 ≈ 67%) does not reproduce — it scores 100% here.
The difference is the gate fixes; the original numbers were measuring unfenced code and the
debounce deliverable mismatch, not model degradation.
### Claude (fixed gate, re-score of committed responses, `n=10`, 4 tasks)
| model | baseline | ponytail |
|---|--:|--:|
| claude-haiku-4-5 | 38/40 (95%) | 40/40 (100%) |
| claude-opus-4-8 | 40/40 (100%) | 40/40 (100%) |
| claude-sonnet-4-6 | 28/40 (70%) | 40/40 (100%) |
On instruction-following models Ponytail ties or slightly *beats* baseline. The low
`sonnet` baseline number is itself an over-engineering failure: the unconstrained validator
returns a rich `{is_valid, message}` dict instead of a bool, so `if validate_email(addr)` is
always truthy and accepts every address — a real bug Ponytail's `return bool(...)` avoids.
## The one real Ponytail defect
On `gpt-5.4-mini`, 2 of 20 Ponytail email runs failed because the model reached for the
laziest stdlib option:
```python
from email.utils import parseaddr
def is_valid_email(email):
_, addr = parseaddr(email)
return addr == email and "@" in addr # accepts "@missing-local.com"
```
`parseaddr` does not require a local part, so `"@missing-local.com"` is accepted. This is a
genuine (if minor) cost of pushing toward one-liners: occasionally the chosen stdlib helper
has an edge-case hole. The other 18 runs used a regex and passed.
## Reproduce
```bash
# GPT arms (needs OPENAI_API_KEY in ../.env)
cd benchmarks
npx promptfoo@latest eval -c promptfooconfig.gpt.yaml --env-file ../.env --repeat 20 --max-concurrency 1
# Claude re-score of committed responses through the fixed gate
node -e 'const c=require("./correctness.js"),d=require("./output-10x.json");/* score d.results.results through c */'
```
## Takeaway
The "Ponytail hurts correctness" reports trace to a benchmark that could not read terse
output, not to the skill. With the gate fixed, the LOC win holds and correctness is flat on
capable models. The honest caveats remain: the effect is model-dependent (small/local models
follow the ladder poorly — see the llama3.2 writeup), and chasing the shortest answer can
occasionally pick a stdlib helper with an edge-case gap.
@@ -0,0 +1,129 @@
# Robustness audit: does ponytail degrade weak models? (2026-06-16)
Follow-up to [issue #65](https://github.com/DietrichGebert/ponytail/issues/65). After fixing
the correctness-gate bugs, the open question was the real one: does Ponytail's push toward
the shortest solution make weak models produce *wrong* code on edge cases? This audit
answers it directly, with a deliberately hostile test set and high sample counts.
## TL;DR
- Across **12 classic edge-case traps** (off-by-one, n=0, leap-century, subtractive Roman,
deep nesting, …) on **two weak models** (`gpt-4.1-mini`, `gpt-5.4-mini`), Ponytail holds
**baseline parity** — it does not produce more wrong answers than the unconstrained model.
- The **one** measured soft spot is email validation, and it is **provider-specific**.
OpenAI models, at every size, sometimes reach for `email.utils.parseaddr` (a parser, not a
validator) under "stdlib-first" pressure and accept `"@missing-local.com"`. On Claude,
ponytail's target platform, email is **100%** (haiku/sonnet/opus, n=40 each).
- The slip is **not fixable by skill text**: 8 distinct SKILL.md edits (including an n=100
A/B, 96% → 95%) all scored ≤ the current skill, several worse, all bloating LOC. Counter-
instructions make small models overthink and fail *more*. Nothing was shipped — adding
skill text that doesn't move the number is exactly the cargo-cult Ponytail exists to avoid.
## Method
`baseline` (no skill) vs `ponytail` (full SKILL.md), single-shot, default params,
`gpt-4.1-mini` and `gpt-5.4-mini`. Each task runs generated code against edge-case
assertions. Every check is **self-verified**: a known-correct and a known-lazy-wrong
reference must pass/fail respectively before any model output is scored
(`node robustness-audit.js --selftest`, 16/16). Runs were serial to avoid quota 429s
shrinking denominators.
## Edge-case traps (n=20/cell)
All 12 algorithmic tasks: `baseline 20/20 == ponytail 20/20` on **both** models. Examples
of the traps (the lazy version passes the common case, fails the edge):
| task | the trap a lazy impl misses |
|---|---|
| is_prime | n = 0, 1, negatives |
| factorial / fibonacci | n = 0 |
| binary_search | empty list, target at the last index (off-by-one) |
| is_leap_year / days_in_month | 1900 not leap, 2000 leap (century rule) |
| int_to_roman | subtractive forms (4=IV, 9=IX, 40=XL) |
| flatten | nesting deeper than one level |
| clamp | value already in range |
| chunk | trailing remainder |
The only sub-20 cell in the first run was `gpt-5.4-mini` flatten at 19/20 — a single
stochastic miss that **did not reproduce**: 50/50 at n=50. (`clamp` showed 19/19, i.e. one
API error, not a wrong answer.)
## Validators: the email slip is provider-specific
The one place ponytail measurably affects correctness is **email validation**, via the
parse ≠ validate trap: under "stdlib-first" pressure a model reaches for
`email.utils.parseaddr` — a *parser* that accepts malformed input like `@missing-local.com`
— instead of writing an explicit check. The split is by **provider**, not model size.
**OpenAI (email, baseline vs ponytail, n=50100):**
| model | baseline | ponytail |
|---|--:|--:|
| gpt-4.1-mini | 100% | 98% |
| gpt-4.1 | 100% | 79% |
| gpt-5.4-mini | ~100% | ~92% |
| gpt-5.4 | 100% | 98% |
| gpt-5.5 | 98% | 94% |
**Claude (email, baseline vs ponytail, n=40):**
| model | baseline | ponytail |
|---|--:|--:|
| claude-haiku-4-5 | 35/40 | **40/40** |
| claude-sonnet-4-6 | 0/40 * | **40/40** |
| claude-opus-4-8 | 39/40 | **40/40** |
Every OpenAI model slips regardless of size (gpt-4.1 full is the worst). Every Claude model
is **100%** under ponytail.
\* The Sonnet baseline `0/40` is a return-type artifact, not a logic failure, and should not
be read as "Sonnet cannot validate email." Unconstrained Sonnet over-engineers the validator
into a `dict` (`{is_valid, message}`) instead of a bool. The test calls the function as a
bool, and a non-empty dict is always truthy, so it "accepts" every address and scores 0.
Read dict-aware (via `is_valid`), its logic is about 75% correct (9/12). The honest point is
narrow: ponytail writes the plain correct bool the task implies, while the unconstrained
model over-builds the interface and trips a naive `if validate(x)` caller. `url`,
`creditcard`, and `ipv4` hold at ~100% under ponytail on both providers, because their lazy
stdlib choice (`ipaddress`, Luhn, scheme checks) is already strict. Only email's obvious
stdlib tool is a parser.
## The fix that wasn't
SKILL.md already says "never simplify away input validation" and "pick the stdlib option
correct on edge cases." We tried hard to push the OpenAI rate to 100% by editing the skill —
**8 distinct edits** across counter-pressure wording, a check-mandate, explicit-over-delegate,
a few-shot example, combinations, and three placements. Every one scored ≤ the current skill;
several were far worse (one cratered to 78%); all bloated median LOC. The definitive n=100
A/B of the most promising edit:
```
OLD skill: 96/100 (96.0%)
NEW skill: 95/100 (95.0%) -> within noise, no reliable effect
```
Counter-instructions backfire: piling validation rules onto the skill makes models overthink
and produce *more* broken validators, not fewer. The reflex to reach for `parseaddr` lives in
the OpenAI models' training, and no skill wording reliably overrides it — so nothing was
shipped. Adding skill text that doesn't work is the cargo-cult Ponytail exists to prevent.
## Conclusion
"Ponytail degrades model performance" is not supported. Across 12 edge-case traps, ponytail
holds baseline parity. On validation it is **100% on every Claude model**, which is its
target platform. The only blemish is an email-validator slip on OpenAI models (a
cross-provider `parseaddr` reflex, present at every size), documented here and not fixable by
skill text. The LOC win (about half the code) comes with no correctness tax on Claude.
## Reproduce
```bash
cd benchmarks
node robustness-audit.js --selftest # verify all 16 instruments (no API)
node robustness-audit.js # 16-task audit, gpt-5.4-mini, n=20
AUDIT_MODEL=gpt-4.1-mini node robustness-audit.js
# email cross-provider (the slip)
ME_MODELS="gpt-4.1,gpt-5.4,gpt-5.5" ME_N=50 node model-email.js # OpenAI (OPENAI_API_KEY)
node claude-email.js # Claude (ANTHROPIC_API_KEY)
```
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` read from `../.env`.
+194
View File
@@ -0,0 +1,194 @@
// Robustness audit (issue #65 follow-up): find where ponytail actually breaks on a
// weak model. 12 tasks with classic edge-case traps. Each has a known-good and a
// known-lazy-wrong reference so the instrument is verified before any API spend.
// node robustness-audit.js --selftest # no API: prove every check is correct
// node robustness-audit.js # baseline vs ponytail, gpt-5.4-mini, n=20
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const N = Number(process.env.AUDIT_N) || 20;
const MODEL = process.env.AUDIT_MODEL || 'gpt-5.4-mini';
const ROOT = path.join(__dirname, '..');
let kv = {};
try {
kv = Object.fromEntries(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
} catch (_) { /* no .env — fine for --selftest */ }
const KEY = process.env.OPENAI_API_KEY || kv.OPENAI_API_KEY;
const SKILL = fs.readFileSync(path.join(ROOT, 'skills', 'ponytail', 'SKILL.md'), 'utf8');
// task = { name, prompt, names, arity, cases: [[argsArray, expected], ...], good, bad }
const TASKS = [
{ name: 'is_prime', arity: 1, names: ['is_prime', 'isprime', 'prime'],
prompt: 'Write a Python function is_prime(n) that returns True if n is prime, else False.',
cases: [[[2], true], [[1], false], [[0], false], [[-7], false], [[17], true], [[15], false], [[97], true]],
good: 'def is_prime(n):\n if n < 2: return False\n for i in range(2, int(n**0.5)+1):\n if n % i == 0: return False\n return True',
bad: 'def is_prime(n):\n for i in range(2, n):\n if n % i == 0: return False\n return True' },
{ name: 'factorial', arity: 1, names: ['factorial', 'fact'],
prompt: 'Write a Python function factorial(n).',
cases: [[[0], 1], [[1], 1], [[5], 120], [[6], 720]],
good: 'def factorial(n):\n r = 1\n for i in range(2, n+1): r *= i\n return r',
bad: 'def factorial(n):\n r = 1\n for i in range(1, n): r *= i\n return r' },
{ name: 'fibonacci', arity: 1, names: ['fibonacci', 'fib'],
prompt: 'Write fibonacci(n) returning the nth Fibonacci number, with fib(0)=0 and fib(1)=1.',
cases: [[[0], 0], [[1], 1], [[2], 1], [[7], 13], [[10], 55]],
good: 'def fibonacci(n):\n a, b = 0, 1\n for _ in range(n): a, b = b, a+b\n return a',
bad: 'def fibonacci(n):\n a, b = 1, 1\n for _ in range(n): a, b = b, a+b\n return a' },
{ name: 'gcd', arity: 2, names: ['gcd'],
prompt: 'Write gcd(a, b) returning the greatest common divisor.',
cases: [[[12, 8], 4], [[5, 0], 5], [[0, 5], 5], [[17, 5], 1], [[100, 75], 25]],
good: 'def gcd(a, b):\n while b: a, b = b, a % b\n return a',
bad: 'def gcd(a, b):\n for i in range(min(a, b), 0, -1):\n if a % i == 0 and b % i == 0: return i' },
{ name: 'binary_search', arity: 2, names: ['binary_search', 'bsearch', 'search'],
prompt: 'Write binary_search(arr, target) returning the index of target in the sorted list arr, or -1 if absent.',
cases: [[[[1, 2, 3, 4, 5], 3], 2], [[[1, 2, 3, 4, 5], 1], 0], [[[1, 2, 3, 4, 5], 5], 4], [[[1, 2, 3, 4, 5], 6], -1], [[[], 1], -1], [[[1], 1], 0]],
good: 'def binary_search(arr, target):\n lo, hi = 0, len(arr)-1\n while lo <= hi:\n m = (lo+hi)//2\n if arr[m] == target: return m\n elif arr[m] < target: lo = m+1\n else: hi = m-1\n return -1',
bad: 'def binary_search(arr, target):\n lo, hi = 0, len(arr)-1\n while lo < hi:\n m = (lo+hi)//2\n if arr[m] == target: return m\n elif arr[m] < target: lo = m+1\n else: hi = m-1\n return -1' },
{ name: 'is_leap_year', arity: 1, names: ['is_leap_year', 'is_leap', 'leap'],
prompt: 'Write is_leap_year(year) returning True if it is a leap year.',
cases: [[[2000], true], [[1900], false], [[2020], true], [[2021], false], [[2400], true], [[2100], false]],
good: 'def is_leap_year(y):\n return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)',
bad: 'def is_leap_year(y):\n return y % 4 == 0' },
{ name: 'days_in_month', arity: 2, names: ['days_in_month'],
prompt: 'Write days_in_month(year, month) returning the number of days in that month.',
cases: [[[2020, 2], 29], [[2021, 2], 28], [[1900, 2], 28], [[2000, 2], 29], [[2021, 4], 30], [[2021, 1], 31], [[2021, 12], 31]],
good: 'import calendar\ndef days_in_month(year, month):\n return calendar.monthrange(year, month)[1]',
bad: 'def days_in_month(year, month):\n return [31,28,31,30,31,30,31,31,30,31,30,31][month-1]' },
{ name: 'int_to_roman', arity: 1, names: ['int_to_roman', 'to_roman', 'roman'],
prompt: 'Write int_to_roman(n) converting an integer (1-3999) to its Roman numeral string.',
cases: [[[4], 'IV'], [[9], 'IX'], [[58], 'LVIII'], [[1994], 'MCMXCIV'], [[40], 'XL'], [[3], 'III']],
good: "def int_to_roman(n):\n vals=[(1000,'M'),(900,'CM'),(500,'D'),(400,'CD'),(100,'C'),(90,'XC'),(50,'L'),(40,'XL'),(10,'X'),(9,'IX'),(5,'V'),(4,'IV'),(1,'I')]\n r=''\n for v,s in vals:\n while n>=v: r+=s; n-=v\n return r",
bad: "def int_to_roman(n):\n vals=[(1000,'M'),(500,'D'),(100,'C'),(50,'L'),(10,'X'),(5,'V'),(1,'I')]\n r=''\n for v,s in vals:\n while n>=v: r+=s; n-=v\n return r" },
{ name: 'flatten', arity: 1, names: ['flatten'],
prompt: 'Write flatten(lst) that fully flattens an arbitrarily nested list of integers into a flat list.',
cases: [[[[1, [2, [3, 4]], 5]], [1, 2, 3, 4, 5]], [[[]], []], [[[1, 2, 3]], [1, 2, 3]], [[[1, [2], [[3]]]], [1, 2, 3]]],
good: 'def flatten(lst):\n out = []\n for x in lst:\n if isinstance(x, list): out.extend(flatten(x))\n else: out.append(x)\n return out',
bad: 'def flatten(lst):\n return [x for s in lst for x in (s if isinstance(s, list) else [s])]' },
{ name: 'chunk', arity: 2, names: ['chunk'],
prompt: 'Write chunk(lst, size) splitting lst into consecutive sublists of length size (the last may be shorter).',
cases: [[[[1, 2, 3, 4, 5], 2], [[1, 2], [3, 4], [5]]], [[[1, 2, 3, 4], 2], [[1, 2], [3, 4]]], [[[], 3], []], [[[1], 5], [[1]]]],
good: 'def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst), size)]',
bad: 'def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst)-size+1, size)]' },
{ name: 'clamp', arity: 3, names: ['clamp'],
prompt: 'Write clamp(value, low, high) returning value bounded to the range [low, high].',
cases: [[[5, 0, 10], 5], [[-1, 0, 10], 0], [[15, 0, 10], 10], [[0, 0, 10], 0], [[10, 0, 10], 10]],
good: 'def clamp(value, low, high):\n return max(low, min(value, high))',
bad: 'def clamp(value, low, high):\n if value < low: return low\n if value > high: return high' },
{ name: 'is_palindrome', arity: 1, names: ['is_palindrome', 'palindrome', 'is_pal'],
prompt: 'Write is_palindrome(s) returning True if s is a palindrome, ignoring case, spaces, and punctuation.',
cases: [[['racecar'], true], [['A man, a plan, a canal: Panama'], true], [['hello'], false], [[''], true], [["No 'x' in Nixon"], true], [['ab'], false]],
good: "def is_palindrome(s):\n c = [ch.lower() for ch in s if ch.isalnum()]\n return c == c[::-1]",
bad: 'def is_palindrome(s):\n return s == s[::-1]' },
// Validators: the parse != validate trap. email is ponytail's one measured soft spot
// on gpt-5.4-mini (~4-5%, parseaddr); the rest hold parity. See results writeup.
{ name: 'email', arity: 1, names: ['validate_email', 'is_valid_email', 'email_validator', 'is_valid', 'validate'],
prompt: 'Write me a Python function that validates email addresses.',
cases: [[['user@example.com'], true], [['a@b.co'], true], [['no-at-sign'], false], [[''], false], [['@missing-local.com'], false]],
good: 'import re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))',
bad: 'from email.utils import parseaddr\ndef validate_email(e):\n _, a = parseaddr(e)\n return a == e and "@" in a' },
{ name: 'url', arity: 1, names: ['validate_url', 'is_valid_url', 'is_url', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates whether a string is a valid HTTP or HTTPS URL.',
cases: [[['https://example.com'], true], [['http://a.b/c'], true], [['https://x.io/p?q=1'], true], [['garbage'], false], [[''], false], [['example.com'], false], [['ftp://example.com'], false], [['http://'], false]],
good: 'from urllib.parse import urlparse\ndef validate_url(u):\n p = urlparse(u)\n return p.scheme in ("http","https") and bool(p.netloc)',
bad: 'from urllib.parse import urlparse\ndef validate_url(u):\n return bool(urlparse(u))' },
{ name: 'creditcard', arity: 1, names: ['validate_credit_card', 'is_valid_card', 'validate_card', 'luhn', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates a credit card number.',
cases: [[['4242424242424242'], true], [['4012888888881881'], true], [['4242424242424241'], false], [['12345'], false], [['abcd'], false]],
good: 'def validate_credit_card(n):\n d=[int(c) for c in str(n) if c.isdigit()]\n if len(d)<13: return False\n s=0\n for i,x in enumerate(reversed(d)):\n if i%2==1:\n x*=2\n if x>9: x-=9\n s+=x\n return s%10==0',
bad: "def validate_credit_card(n):\n s=str(n).replace(' ','')\n return s.isdigit() and len(s)==16" },
{ name: 'ipv4', arity: 1, names: ['validate_ipv4', 'is_valid_ip', 'is_ipv4', 'validate_ip', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates an IPv4 address.',
cases: [[['192.168.1.1'], true], [['0.0.0.0'], true], [['255.255.255.255'], true], [['999.999.999.999'], false], [['256.1.1.1'], false], [['1.2.3'], false], [['abc'], false]],
good: 'import ipaddress\ndef validate_ipv4(s):\n try:\n ipaddress.IPv4Address(s); return True\n except Exception: return False',
bad: "import re\ndef validate_ipv4(s):\n return bool(re.match(r'^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$', s))" },
];
function pyBlock(text) {
const m = [...String(text || '').matchAll(/```(\w*)\r?\n([\s\S]*?)```/g)];
if (!m.length) return text || '';
const py = m.find(x => /py/.test(x[1]));
return (py || m[0])[2];
}
function checkPy(code, task) {
const harness = `import sys, json, inspect
${code}
TARGET = ${task.arity}
names = json.loads(r'''${JSON.stringify(task.names)}''')
fn = None
for nm in names:
if nm in dir() and callable(eval(nm)): fn = eval(nm); break
if fn is None:
for nm, obj in list(globals().items()):
if callable(obj) and not nm.startswith('_') and not inspect.isclass(obj):
try:
if len(inspect.signature(obj).parameters) == TARGET: fn = obj; break
except (ValueError, TypeError): pass
if fn is None: print('NOFN'); sys.exit(1)
cases = json.loads(r'''${JSON.stringify(task.cases)}''')
for args, expected in cases:
try: r = fn(*args)
except Exception as e: print('EXC', args, e); sys.exit(1)
if r != expected: print('MISMATCH', args, '->', r, 'want', expected); sys.exit(1)
print('PASS')`;
const f = path.join(os.tmpdir(), `audit-${process.pid}-${Math.random().toString(36).slice(2)}.py`);
fs.writeFileSync(f, harness);
try { execSync(`python3 "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; }
catch (e) { return false; }
finally { try { fs.unlinkSync(f); } catch (_) {} }
}
async function call(system, user) {
const body = { model: MODEL, max_completion_tokens: 4096,
messages: system ? [{ role: 'system', content: system }, { role: 'user', content: user }] : [{ role: 'user', content: user }] };
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
const j = await r.json();
return { text: j.choices?.[0]?.message?.content || '' };
}
module.exports = { checkPy, pyBlock, call, TASKS, SKILL };
if (require.main !== module) return;
if (process.argv.includes('--selftest')) {
let ok = 0, bad = 0;
for (const t of TASKS) {
const g = checkPy(t.good, t), b = checkPy(t.bad, t);
const pass = g === true && b === false;
console.log(`${pass ? 'ok ' : 'XX '} ${t.name.padEnd(16)} good=${g} bad=${b}`);
pass ? ok++ : bad++;
}
console.log(`\nself-test: ${ok}/${TASKS.length} instruments valid${bad ? `${bad} BROKEN` : ''}`);
process.exit(bad ? 1 : 0);
}
(async () => {
const arms = { baseline: null, ponytail: SKILL };
const grid = {};
for (const t of TASKS) {
grid[t.name] = {};
for (const arm of Object.keys(arms)) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const res = await call(arms[arm], t.prompt);
if (res.err) { err++; continue; }
if (checkPy(pyBlock(res.text), t)) pass++;
}
grid[t.name][arm] = { pass, n: N - err };
}
const b = grid[t.name].baseline, p = grid[t.name].ponytail;
const flag = p.pass < b.pass ? ' <-- PONYTAIL REGRESSION' : (p.pass < p.n ? ' (both imperfect)' : '');
console.log(`${t.name.padEnd(16)} baseline ${b.pass}/${b.n} ponytail ${p.pass}/${p.n}${flag}`);
}
console.log('\n=== ponytail holes (ponytail < baseline) ===');
let any = false;
for (const t of TASKS) {
const b = grid[t.name].baseline, p = grid[t.name].ponytail;
if (p.pass < b.pass) { console.log(` ${t.name}: ${b.pass} -> ${p.pass}`); any = true; }
}
if (!any) console.log(' none');
})();