diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 0375280..757baae 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "ponytail",
- "version": "4.6.0",
+ "version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index cdf6ea0..3595c65 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "ponytail",
- "version": "4.6.0",
+ "version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json
index 06f880c..c57f448 100644
--- a/.github/plugin/plugin.json
+++ b/.github/plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
- "version": "4.6.0",
+ "version": "4.7.0",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
diff --git a/.openclaw/skills/ponytail-audit/SKILL.md b/.openclaw/skills/ponytail-audit/SKILL.md
new file mode 100644
index 0000000..8bc9ca8
--- /dev/null
+++ b/.openclaw/skills/ponytail-audit/SKILL.md
@@ -0,0 +1,36 @@
+---
+name: ponytail-audit
+description: "Audit the whole repo for over-engineering. A ranked list of what to delete, simplify, or replace with stdlib or native features."
+homepage: https://github.com/DietrichGebert/ponytail
+license: MIT
+---
+
+ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank
+findings biggest cut first.
+
+## Tags
+
+Same as ponytail-review:
+
+- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
+- `stdlib:` hand-rolled thing the standard library ships. Name the function.
+- `native:` dependency or code doing what the platform already does. Name the feature.
+- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
+- `shrink:` same logic, fewer lines. Show the shorter form.
+
+## Hunt
+
+Deps the stdlib or platform already ships, single-implementation interfaces,
+factories with one product, wrappers that only delegate, files exporting one
+thing, dead flags and config, hand-rolled stdlib.
+
+## Output
+
+One line per finding, ranked: ` . . [path]`.
+End with `net: - lines, - deps possible.` Nothing to cut: `Lean already. Ship.`
+
+## Boundaries
+
+Complexity only, correctness bugs, security holes, and performance go to a
+normal review pass. Lists findings, applies nothing. One-shot.
+"stop ponytail-audit" or "normal mode" to revert.
diff --git a/.openclaw/skills/ponytail-debt/SKILL.md b/.openclaw/skills/ponytail-debt/SKILL.md
new file mode 100644
index 0000000..20d4fac
--- /dev/null
+++ b/.openclaw/skills/ponytail-debt/SKILL.md
@@ -0,0 +1,41 @@
+---
+name: ponytail-debt
+description: "Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report."
+homepage: https://github.com/DietrichGebert/ponytail
+license: MIT
+---
+
+Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming
+its ceiling and upgrade path. This collects them into one ledger so a deferral
+can't quietly become permanent.
+
+## Scan
+
+Grep the repo for comment markers, skipping `node_modules`, `.git`, and build
+output:
+
+`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them)
+
+Each hit is one ledger row. The comment prefix keeps prose that merely mentions
+the convention out of the ledger.
+
+## Output
+
+One row per marker, grouped by file:
+
+`: — . ceiling: . upgrade: .`
+
+The convention is `ponytail: , `, so pull the ceiling
+and the trigger straight from the comment. Want an owner per row too? add
+`git blame -L,`.
+
+Flag the rot risk: any `ponytail:` comment that names no upgrade path or
+trigger gets a `no-trigger` tag, those are the ones that silently rot.
+
+End with ` markers, with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.`
+
+## Boundaries
+
+Reads and reports only, changes nothing. To persist it, ask and it writes the
+ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or
+"normal mode" to revert.
diff --git a/.openclaw/skills/ponytail-help/SKILL.md b/.openclaw/skills/ponytail-help/SKILL.md
new file mode 100644
index 0000000..33684fa
--- /dev/null
+++ b/.openclaw/skills/ponytail-help/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: ponytail-help
+description: "Quick reference for ponytail's modes, skills, and commands. One-shot display."
+homepage: https://github.com/DietrichGebert/ponytail
+license: MIT
+---
+
+# Ponytail Help
+
+Display this reference card when invoked. One-shot, do NOT change mode,
+write flag files, or persist anything.
+
+## Levels
+
+| Level | Trigger | What change |
+|-------|---------|-------------|
+| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. |
+| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. |
+| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. |
+
+Level sticks until changed or session end.
+
+## Skills
+
+| Skill | Trigger | What it does |
+|-------|---------|--------------|
+| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
+| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
+| **ponytail-help** | `/ponytail-help` | This card. |
+
+Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
+and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
+`/ponytail-review`).
+
+## Deactivate
+
+Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
+`/ponytail off` also works.
+
+## Configure Default Mode
+
+Default mode = `full`, auto-active every session. Change it:
+
+**Environment variable** (highest priority):
+```bash
+export PONYTAIL_DEFAULT_MODE=ultra
+```
+
+**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`):
+```json
+{ "defaultMode": "lite" }
+```
+
+Set `"off"` to disable auto-activation on session start, activate manually
+with `/ponytail` when wanted.
+
+Resolution: env var > config file > `full`.
+
+## Update
+
+Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`.
+
+If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow.
+
+## More
+
+Full docs + examples: https://github.com/DietrichGebert/ponytail
diff --git a/.openclaw/skills/ponytail-review/SKILL.md b/.openclaw/skills/ponytail-review/SKILL.md
new file mode 100644
index 0000000..8037d68
--- /dev/null
+++ b/.openclaw/skills/ponytail-review/SKILL.md
@@ -0,0 +1,51 @@
+---
+name: ponytail-review
+description: "Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding."
+homepage: https://github.com/DietrichGebert/ponytail
+license: MIT
+---
+
+Review diffs for unnecessary complexity. One line per finding: location, what
+to cut, what replaces it. The diff's best outcome is getting shorter.
+
+## Format
+
+`L: . .`, or `:L: ...` for
+multi-file diffs.
+
+Tags:
+
+- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
+- `stdlib:` hand-rolled thing the standard library ships. Name the function.
+- `native:` dependency or code doing what the platform already does. Name the feature.
+- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
+- `shrink:` same logic, fewer lines. Show the shorter form.
+
+## Examples
+
+❌ "This EmailValidator class might be more complex than necessary, have you
+considered whether all these validation rules are needed at this stage?"
+
+✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.`
+
+✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
+
+✅ `repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.`
+
+✅ `L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.`
+
+✅ `L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.`
+
+## Scoring
+
+End with the only metric that matters: `net: - lines possible.`
+
+If there is nothing to cut, say `Lean already. Ship.` and stop.
+
+## Boundaries
+
+Complexity only, correctness bugs, security holes, and performance go to a
+normal review pass, not this one. A single smoke test or `assert`-based
+self-check is the ponytail minimum, not bloat, never flag it for deletion.
+Does not apply the fixes, only lists them.
+"stop ponytail-review" or "normal mode": revert to verbose review style.
diff --git a/.openclaw/skills/ponytail/SKILL.md b/.openclaw/skills/ponytail/SKILL.md
new file mode 100644
index 0000000..7506326
--- /dev/null
+++ b/.openclaw/skills/ponytail/SKILL.md
@@ -0,0 +1,92 @@
+---
+name: ponytail
+description: "Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions."
+homepage: https://github.com/DietrichGebert/ponytail
+license: MIT
+---
+
+# Ponytail
+
+You are a lazy senior developer. Lazy means efficient, not careless. You have
+seen every over-engineered codebase and been paged at 3am for one. The best
+code is the code never written.
+
+## Persistence
+
+ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
+unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
+Switch: `/ponytail lite|full|ultra`.
+
+## The ladder
+
+Stop at the first rung that holds:
+
+1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
+2. **Stdlib does it?** Use it.
+3. **Native platform feature covers it?** ` ` over a picker lib, CSS over JS, DB constraint over app code.
+4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
+5. **Can it be one line?** One line.
+6. **Only then:** the minimum code that works.
+
+The ladder is a reflex, not a research project. Two rungs work → take the
+higher one and move on. The first lazy solution that works is the right one.
+
+## Rules
+
+- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
+- No boilerplate, no scaffolding "for later", later can scaffold for itself.
+- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
+- Fewest files possible. Shortest working diff wins.
+- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
+- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
+- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
+
+## Output
+
+Code first. Then at most three short lines: what was skipped, when to add it.
+No essays, no feature tours, no design notes. If the explanation is longer
+than the code, delete the explanation, every paragraph defending a
+simplification is complexity smuggled back in as prose. Explanation the user
+explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
+give it in full, the rule is only against unrequested prose.
+
+Pattern: `[code] → skipped: [X], add when [Y].`
+
+## Intensity
+
+| Level | What change |
+|-------|------------|
+| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
+| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
+| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
+
+Example: "Add a cache for these API responses."
+- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
+- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
+- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
+
+## When NOT to be lazy
+
+Never simplify away: input validation at trust boundaries, error handling
+that prevents data loss, security measures, accessibility basics, anything
+explicitly requested. User insists on the full version → build it, no
+re-arguing.
+
+Hardware is never the ideal on paper: a real clock drifts, a real sensor
+reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
+just less code, the physical world needs tuning a minimal model can't see.
+
+Lazy code without its check is unfinished. Non-trivial logic (a branch, a
+loop, a parser, a money/security path) leaves ONE runnable check behind, the
+smallest thing that fails if the logic breaks: an `assert`-based
+`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
+fixtures, no per-function suites unless asked. Trivial one-liners need no
+test, YAGNI applies to tests too.
+
+## Boundaries
+
+Ponytail governs what you build, not how you talk (pair with Caveman for
+terse prose). "stop ponytail" / "normal mode": revert. Level persists until
+changed or session end.
+
+The shortest path to done is the right path.
diff --git a/README.md b/README.md
index 509a311..2cc16e5 100644
--- a/README.md
+++ b/README.md
@@ -19,8 +19,8 @@
- 80-94% less code · 3-6× faster · 47-77% cheaper
- Median of 10 runs across Haiku, Sonnet, and Opus. Reproduce it yourself.
+ 80-94% less code · 3-6× faster · 42-75% cheaper
+ Per-task code, latency, and cost on the Claude API, not your plan's quota. Median across Haiku, Sonnet, and Opus (10 runs for code and latency, 30 for the re-verified cost). Results vary by model and prompt: the ruleset re-injects each turn, so on a short prompt or a terse reasoning model that overhead can outweigh the savings. Reproduce it yourself.
---
@@ -50,7 +50,9 @@ Five everyday tasks (email validator, debounce, CSV sum, countdown timer, rate l
-**80-94% less code, 47-77% less cost, and 3-6× faster than a no-skill agent, on every model.** Every shortcut ponytail takes is marked in the code with a `ponytail:` comment naming its upgrade path. Reproduce it yourself: `npx promptfoo eval -c benchmarks/promptfooconfig.yaml`. Method and raw numbers: [benchmarks/](benchmarks/). Production-grade tasks, where an unconstrained agent bloats far more, are written up in [benchmarks/results/](benchmarks/results/).
+**80-94% less code, 42-75% less cost, and 3-6× faster than a no-skill agent, on every Claude model.** Every shortcut ponytail takes is marked in the code with a `ponytail:` comment naming its upgrade path. Reproduce it yourself: `npx promptfoo eval -c benchmarks/promptfooconfig.yaml`. Method and raw numbers: [benchmarks/](benchmarks/). Production-grade tasks, where an unconstrained agent bloats far more, are written up in [benchmarks/results/](benchmarks/results/).
+
+**That is the byproduct, not the pitch.** These are Claude numbers, and they vary by model. Capable instruction-following models follow the ladder and write far less, cheaper and faster. Terse reasoning models can go the other way: the ladder is a deliberation step, so the model spends thinking tokens working through the rungs before it saves any output, and together with the always-on ruleset that can cost more than the shorter code saves. On GPT-5.5 it does. And all of this is single-shot, one prompt in and one answer out: a real agent session re-injects the ruleset and runs the ladder every turn, which this benchmark does not measure, so per-session cost can land either way. The rule was never "fewest tokens." It is: write only what the task needs, and never cut validation, error handling, security, or accessibility. The code ends up small because it is necessary, not golfed, and that is the part that stays maintainable. Lower cost and latency are a side effect on the models that follow it.
## How it works
@@ -156,6 +158,13 @@ hermes plugins install DietrichGebert/ponytail --enable
```
Restart Hermes after installing. The plugin injects the active Ponytail mode before each LLM turn, registers the bundled skills as `ponytail:`, and adds `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, and `/ponytail-help`. In shared gateways, restrict `/ponytail` to trusted users with Hermes slash-command access controls; runtime mode is process-local.
+### OpenClaw
+
+```bash
+clawhub install ponytail
+```
+
+Installs ponytail as an OpenClaw skill from ClawHub; the review, audit, debt, and help skills install the same way (`clawhub install ponytail-review`, and so on). OpenClaw applies it on coding tasks and also exposes it as a `/ponytail` command. Without ClawHub, copy [`.openclaw/skills/ponytail`](.openclaw/skills/) into `~/.openclaw/skills/`.
That was it. He'd be proud. He won't say it.
@@ -194,6 +203,8 @@ node scripts/check-rule-copies.js
npm test
```
+The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale.
+
The correctness benchmark spawns Python for email and CSV checks; `python3` is tried before `python`. CSV checks need `pandas` installed locally.
## FAQ
diff --git a/assets/benchmark-3model.svg b/assets/benchmark-3model.svg
index 10c0969..bfaedb0 100644
--- a/assets/benchmark-3model.svg
+++ b/assets/benchmark-3model.svg
@@ -1,7 +1,7 @@
Median lines of code per arm across three models
Median lines of code. 10 runs per cell. Lower is leaner.
- Ponytail writes 80-94% less code, costs 47-77% less, and runs 3-6x faster than a no-skill agent.
+ Ponytail writes 80-94% less code, costs 42-75% less, and runs 3-6x faster than a no-skill agent.
baseline (no skill)
caveman
ponytail
diff --git a/benchmarks/README.md b/benchmarks/README.md
index a53c9df..9759b79 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -11,10 +11,13 @@ check with `node --version` and upgrade if needed):
```bash
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
```
+`--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
No API key or promptfoo required. Runs against any model served by Ollama:
@@ -30,7 +33,7 @@ models where the multi-step decision ladder isn't reliably followed.
Tasks: email validator, JS debounce, CSV sum, React countdown, FastAPI rate-limit (see `promptfooconfig.yaml`). Single-shot completions, default temperature.
-## Median results (10 runs, 2026-06-13)
+## Median results (10 runs, 2026-06-13; cost re-verified at 30 runs, 2026-06-17)
**Code (lines)**
@@ -40,13 +43,13 @@ Tasks: email validator, JS debounce, CSV sum, React countdown, FastAPI rate-limi
| caveman | 116 | 120 | 67 |
| **ponytail** | **39** | **44** | **51** |
-**Cost (USD, 5 tasks)**
+**Cost (USD, 5 tasks; 30 runs, 2026-06-17)**
| arm | Haiku | Sonnet | Opus |
|---|--:|--:|--:|
-| baseline (no skill) | 0.032 | 0.141 | 0.135 |
-| caveman | 0.014 | 0.045 | 0.075 |
-| **ponytail** | **0.010** | **0.032** | **0.071** |
+| baseline (no skill) | 0.030 | 0.137 | 0.137 |
+| caveman | 0.014 | 0.046 | 0.072 |
+| **ponytail** | **0.011** | **0.035** | **0.079** |
**Latency (seconds, 5 tasks)**
@@ -56,7 +59,7 @@ Tasks: email validator, JS debounce, CSV sum, React countdown, FastAPI rate-limi
| caveman | 14.9 | 34.7 | 23.1 |
| **ponytail** | **9.9** | **20.1** | **18.0** |
-Versus baseline, ponytail writes **80-94% less code**, costs **47-77% less**, and runs **3-6x faster**, on every model.
+Versus baseline, ponytail writes **80-94% less code**, costs **42-75% less**, and runs **3-6x faster**, on every Claude model. Cost re-verified at 30 reps, with OpenAI and Gemini arms, in [results/2026-06-17-cost-verification.md](results/2026-06-17-cost-verification.md).
## Metrics
@@ -76,5 +79,5 @@ Running the benchmark requires **Python 3**, **pandas**, and **Node.js** (18+).
## Notes
- Caveman is a prose-compression skill (it leaves code "normal"), so it lands between baseline and ponytail on code size and wins mainly on prose tokens.
-- Cost reflects single-shot calls that re-send the skill every time. In real sessions the skill is injected once and prompt-cached, so the cost gap widens further in ponytail's favor.
+- Cost reflects single-shot calls (one prompt, one completion), not real multi-turn agent sessions. In a session the ruleset re-injects and the ladder deliberates every turn across many turns, so per-session cost can come out higher or lower than these numbers. Prompt caching offsets some of the re-injection, but a measured agentic A/B ([#121](https://github.com/DietrichGebert/ponytail/issues/121)) found ponytail can also raise tool calls and cost on completion-forced tasks. Treat these as generation numbers, not a session-cost promise.
- These are everyday tasks. For production-grade specs, where an unconstrained agent bloats much harder, see the writeups in `results/`.
diff --git a/benchmarks/claude-email.js b/benchmarks/claude-email.js
new file mode 100644
index 0000000..4a73512
--- /dev/null
+++ b/benchmarks/claude-email.js
@@ -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}`);
+ }
+})();
diff --git a/benchmarks/correctness.js b/benchmarks/correctness.js
index 5ca1753..fc56611 100644
--- a/benchmarks/correctness.js
+++ b/benchmarks/correctness.js
@@ -13,7 +13,11 @@ const path = require('path');
// Extract fenced code blocks, tagged by language.
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] }));
}
@@ -121,7 +125,7 @@ print("PASS")
},
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' };
const harness = `
diff --git a/benchmarks/correctness.test.js b/benchmarks/correctness.test.js
new file mode 100644
index 0000000..e3103c4
--- /dev/null
+++ b/benchmarks/correctness.test.js
@@ -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`);
diff --git a/benchmarks/generate-examples.mjs b/benchmarks/generate-examples.mjs
new file mode 100644
index 0000000..4cda716
--- /dev/null
+++ b/benchmarks/generate-examples.mjs
@@ -0,0 +1,63 @@
+// Generate examples/*.md verbatim from a real benchmark run (output.json):
+// each file shows the same task answered with no skill vs with ponytail, same model.
+// node benchmarks/generate-examples.mjs
+import { readFileSync, writeFileSync } from 'node:fs';
+import loc from './loc.js';
+
+const j = JSON.parse(readFileSync(new URL('./output.json', import.meta.url), 'utf8'));
+const isHaiku = (id) => id.includes('haiku');
+
+const meta = [
+ [/validates email/, 'email-validation', 'Email Validation'],
+ [/debounce/, 'debounce', 'Debounce'],
+ [/sales\.csv/, 'csv-sum', 'CSV Sum'],
+ [/countdown timer/, 'react-countdown', 'Countdown Timer'],
+ [/rate limiting/, 'rate-limit', 'Rate Limiting'],
+];
+
+const pick = (re, armIdx) =>
+ j.results.results.find((r) => isHaiku(r.provider.id) && r.promptIdx === armIdx && re.test(r.vars.task));
+
+const rows = [];
+for (const [re, slug, title] of meta) {
+ const b = pick(re, 0), p = pick(re, 2);
+ if (!b || !p) { console.log('MISS', slug, !!b, !!p); continue; }
+ const bL = loc(b.response.output).score, pL = loc(p.response.output).score;
+ const md = `# ${title}
+
+**Task:** "${b.vars.task}"
+
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source \`benchmarks/output.json\`. Reproduce: \`npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml\`.
+
+## Without Ponytail — ${bL} lines of code
+
+${b.response.output.trim()}
+
+## With Ponytail — ${pL} lines of code
+
+${p.response.output.trim()}
+
+**${bL} → ${pL} lines of code** — same model, same prompt.
+`;
+ writeFileSync(new URL(`../examples/${slug}.md`, import.meta.url), md);
+ rows.push([title, slug, bL, pL]);
+ console.log('wrote examples/' + slug + '.md', bL, '->', pL);
+}
+
+const tbl = rows.map(([t, s, b, p]) => `| [${t}](${s}.md) | ${b} | ${p} |`).join('\n');
+const readme = `# Examples
+
+Real model output, verbatim from benchmark runs — the same task answered by the same model
+with no skill (\`## Without Ponytail\`) and with ponytail (\`## With Ponytail\`), so you can
+compare side by side. Model: Claude Haiku 4.5, temperature 1, source \`benchmarks/output.json\`.
+
+These are not hand-written. Reproduce them yourself:
+\`npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml\`. Method, all three models, and
+median-of-10 numbers: [../benchmarks/](../benchmarks/).
+
+| Example | Without (LOC) | With (LOC) |
+|---|--:|--:|
+${tbl}
+`;
+writeFileSync(new URL('../examples/README.md', import.meta.url), readme);
+console.log('wrote examples/README.md');
diff --git a/benchmarks/model-email.js b/benchmarks/model-email.js
new file mode 100644
index 0000000..ea8ee8c
--- /dev/null
+++ b/benchmarks/model-email.js
@@ -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}`);
+ }
+})();
diff --git a/benchmarks/promptfooconfig.gemini.yaml b/benchmarks/promptfooconfig.gemini.yaml
new file mode 100644
index 0000000..00c2e2d
--- /dev/null
+++ b/benchmarks/promptfooconfig.gemini.yaml
@@ -0,0 +1,32 @@
+# Ponytail vs baseline, latest Gemini: gemini-3.5-flash (mini) + gemini-3.1-pro-preview (top).
+# npx promptfoo@latest eval -c benchmarks/promptfooconfig.gemini.yaml --env-file .env --repeat 30
+# Needs GOOGLE_API_KEY in .env (AI Studio).
+description: "Ponytail vs baseline, latest Gemini (3.5-flash, 3.1-pro). LOC + correctness, cost telemetry."
+
+providers:
+ - id: google:gemini-3.5-flash
+ config: { temperature: 1, maxOutputTokens: 8192 }
+ - id: google:gemini-3.1-pro-preview
+ config: { maxOutputTokens: 8192 }
+
+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." }
diff --git a/benchmarks/promptfooconfig.gpt-newest.yaml b/benchmarks/promptfooconfig.gpt-newest.yaml
new file mode 100644
index 0000000..7055d5c
--- /dev/null
+++ b/benchmarks/promptfooconfig.gpt-newest.yaml
@@ -0,0 +1,33 @@
+# Ponytail vs baseline, newest OpenAI: gpt-5.5 (top) + gpt-4.1-mini, gpt-5.4-mini.
+# npx promptfoo@latest eval -c benchmarks/promptfooconfig.gpt-newest.yaml --env-file .env --repeat 30
+description: "Ponytail vs baseline, newest OpenAI (gpt-5.5 + minis). LOC + correctness, cost telemetry."
+
+providers:
+ - id: openai:gpt-5.5
+ config: { max_completion_tokens: 8192 }
+ - id: openai:gpt-4.1-mini
+ config: { max_tokens: 8192, temperature: 1 }
+ - id: openai:gpt-5.4-mini
+ config: { max_completion_tokens: 8192 }
+
+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." }
diff --git a/benchmarks/promptfooconfig.gpt.yaml b/benchmarks/promptfooconfig.gpt.yaml
new file mode 100644
index 0000000..f939723
--- /dev/null
+++ b/benchmarks/promptfooconfig.gpt.yaml
@@ -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." }
diff --git a/benchmarks/promptfooconfig.yaml b/benchmarks/promptfooconfig.yaml
index 3cb63ed..b624c3f 100644
--- a/benchmarks/promptfooconfig.yaml
+++ b/benchmarks/promptfooconfig.yaml
@@ -35,7 +35,7 @@ defaultTest:
tests:
- 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: "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." }
diff --git a/benchmarks/results/2026-06-16-correctness-gate-fix.md b/benchmarks/results/2026-06-16-correctness-gate-fix.md
new file mode 100644
index 0000000..7fa12db
--- /dev/null
+++ b/benchmarks/results/2026-06-16-correctness-gate-fix.md
@@ -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.
diff --git a/benchmarks/results/2026-06-16-robustness-audit.md b/benchmarks/results/2026-06-16-robustness-audit.md
new file mode 100644
index 0000000..c30bf73
--- /dev/null
+++ b/benchmarks/results/2026-06-16-robustness-audit.md
@@ -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=50–100):**
+
+| 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`.
diff --git a/benchmarks/results/2026-06-17-cost-verification.md b/benchmarks/results/2026-06-17-cost-verification.md
new file mode 100644
index 0000000..859455b
--- /dev/null
+++ b/benchmarks/results/2026-06-17-cost-verification.md
@@ -0,0 +1,93 @@
+# Cost verification: reproducing the "47-77% cheaper" claim (2026-06-17)
+
+Context: the README headline says ponytail is "47-77% cheaper." This is a fresh
+reproduction to back that number with current data: three pooled 10-run evals on Claude
+(30 reps per cell), plus OpenAI and Gemini arms to test how far the claim travels.
+
+## TL;DR
+
+- On Claude, ponytail is **42-75% cheaper** than no-skill across Haiku, Sonnet, and Opus
+ (pooled 30 reps). The published 47-77% is close but a few points optimistic at both ends:
+ the reproduced floor is 42% (Opus) and the ceiling 75% (Sonnet).
+- The cost win is **Claude-specific**. On OpenAI it mostly reverses: gpt-4.1-mini is 40%
+ cheaper, but gpt-5.4-mini is **26% more expensive** and the newest top model **gpt-5.5 is
+ 39% more expensive** and not faster. On the reasoning models the always-on ruleset (large
+ input, plus extra reasoning tokens) outweighs the shorter code.
+- Latency holds on Claude: **3.1-5.8x faster**, inside the README's "3-6x". On OpenAI it is
+ mixed (2.5x on gpt-4.1-mini, down to 0.9x on gpt-5.5).
+- Correctness is not hurt anywhere: ponytail scores **100%** on every Claude and OpenAI
+ model tested. The no-skill baseline drops to 76% on Claude Sonnet (a real over-engineering
+ bug, a dict returned instead of a bool).
+- Gemini (gemini-3.5-flash, gemini-3.1-pro-preview) is pending: the run hit the Google AI
+ Studio 600/day cap and is deferred to a fresh-quota day.
+
+## Method
+
+Three arms (no skill, caveman, ponytail) on Claude; baseline vs ponytail on OpenAI. Five
+everyday tasks, `--repeat 10` per run. Cost comes from promptfoo API telemetry
+(`response.cost`). Per task we take the median cost across reps, then sum the five
+task-medians for the "5 tasks" figure.
+
+- Claude: three runs pooled to **30 reps per cell**.
+- OpenAI: **10 reps**. Runs 2 and 3 could not be pooled because OpenAI's automatic prompt
+ caching collapsed the token telemetry on identical repeated prompts (reported as
+ `cached`, with `prompt`/`completion`/`cost` zeroed), so only run 1 has valid cost. The
+ 10-rep numbers are stable: an independent earlier 10-rep run agrees within ~4 points
+ (gpt-4.1-mini 35.7% vs 39.6%, gpt-5.4-mini 28.7% vs 26.2% more expensive). Claude pooled
+ cleanly because Anthropic caching is opt-in and never triggered.
+
+Reproduce:
+
+```bash
+npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml --env-file .env --repeat 10
+npx promptfoo@latest eval -c benchmarks/promptfooconfig.gpt-newest.yaml --env-file .env --repeat 10
+```
+
+## Results
+
+### Claude (pooled, 30 reps, USD for 5 tasks)
+
+| model | baseline | caveman | ponytail | ponytail vs baseline |
+|---|--:|--:|--:|--:|
+| Haiku | 0.0299 | 0.0139 | 0.0110 | **63.1% cheaper** |
+| Sonnet | 0.1367 | 0.0458 | 0.0348 | **74.5% cheaper** |
+| Opus | 0.1368 | 0.0724 | 0.0789 | **42.3% cheaper** |
+
+**Range: 42-75% cheaper** (vs the published 47-77%). Latency 3.1-5.8x faster; ponytail
+correctness 100% on all three.
+
+### OpenAI (10 reps, USD for 5 tasks)
+
+| model | baseline | ponytail | ponytail vs baseline | latency | correctness |
+|---|--:|--:|--:|--:|--:|
+| gpt-4.1-mini | 0.0026 | 0.0015 | **39.6% cheaper** | 2.5x faster | 100% |
+| gpt-5.4-mini | 0.0060 | 0.0075 | **26.2% more expensive** | 1.5x faster | 100% |
+| gpt-5.5 | 0.0714 | 0.0990 | **38.7% more expensive** | 0.9x (slower) | 100% |
+
+The reasoning models (gpt-5.4-mini, gpt-5.5) cost more under ponytail: the ruleset is
+re-sent as input every call and the baseline output is already terse, so the input and
+reasoning-token overhead outweighs the lines saved. Effective per-token rates derived from
+run 1: gpt-5.5 ~$5/$30 per M in/out, gpt-5.4-mini $0.75/$4.50, gpt-4.1-mini ~$0.13/$1.61.
+
+### Gemini
+
+Pending. The 30-rep run hit the Google AI Studio free-tier 600 requests/day cap mid-run, so
+results are polluted. Rerun on a fresh-quota day: gemini-3.5-flash (mini) and
+gemini-3.1-pro-preview (top), baseline vs ponytail.
+
+## Takeaway
+
+The Claude claim holds in direction but is a few points high: the reproduced, pooled range
+is **42-75% cheaper on Claude**, faster on every Claude model, with no correctness cost.
+Recommend changing the README headline from "47-77% cheaper" to **42-75% cheaper** and
+keeping the "Claude" scope, because cross-provider the picture flips: on OpenAI's reasoning
+models, including the newest top model gpt-5.5, ponytail costs more, not less. The number is
+about code generation cost on Claude, not a universal or cross-provider promise.
+
+## Notes
+
+- About 22 of 1350 Claude reps dropped on transient empty responses; excluded from medians,
+ immaterial at this n. OpenAI runs were 100% complete.
+- Reproduce from the committed configs: `promptfooconfig.yaml` (Claude),
+ `promptfooconfig.gpt-newest.yaml` (OpenAI), `promptfooconfig.gemini.yaml` (Gemini). The
+ raw eval JSON is gitignored and regenerable.
diff --git a/benchmarks/robustness-audit.js b/benchmarks/robustness-audit.js
new file mode 100644
index 0000000..1b05e08
--- /dev/null
+++ b/benchmarks/robustness-audit.js
@@ -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');
+})();
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..4dacc4e
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,17 @@
+# Examples
+
+Real model output, verbatim from benchmark runs — the same task answered by the same model
+with no skill (`## Without Ponytail`) and with ponytail (`## With Ponytail`), so you can
+compare side by side. Model: Claude Haiku 4.5, temperature 1, source `benchmarks/output.json`.
+
+These are not hand-written. Reproduce them yourself:
+`npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`. Method, all three models, and
+median-of-10 numbers: [../benchmarks/](../benchmarks/).
+
+| Example | Without (LOC) | With (LOC) |
+|---|--:|--:|
+| [Email Validation](email-validation.md) | 75 | 3 |
+| [Debounce](debounce.md) | 116 | 10 |
+| [CSV Sum](csv-sum.md) | 20 | 3 |
+| [Countdown Timer](react-countdown.md) | 267 | 9 |
+| [Rate Limiting](rate-limit.md) | 128 | 10 |
diff --git a/examples/api-endpoint.md b/examples/api-endpoint.md
deleted file mode 100644
index 4e26570..0000000
--- a/examples/api-endpoint.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# API Endpoint
-
-**Task:** "Add an endpoint that returns a user by id."
-
-## Without Ponytail
-
-```
-app/
-├── controllers/user_controller.py
-├── services/user_service.py
-├── repositories/user_repository.py
-├── schemas/user_schemas.py
-└── exceptions/user_exceptions.py
-```
-
-```python
-# schemas/user_schemas.py
-class UserResponseDTO(BaseModel):
- id: int
- name: str
- email: str
-
-# repositories/user_repository.py
-class UserRepository:
- def __init__(self, db: Session):
- self.db = db
- def find_by_id(self, user_id: int) -> User | None:
- return self.db.get(User, user_id)
-
-# services/user_service.py
-class UserService:
- def __init__(self, repo: UserRepository):
- self.repo = repo
- def get_user(self, user_id: int) -> User:
- user = self.repo.find_by_id(user_id)
- if user is None:
- raise UserNotFoundError(user_id)
- return user
-
-# controllers/user_controller.py
-@router.get("/users/{user_id}", response_model=UserResponseDTO)
-def get_user(user_id: int, service: UserService = Depends(get_user_service)):
- try:
- return service.get_user(user_id)
- except UserNotFoundError:
- raise HTTPException(status_code=404, detail="User not found")
-```
-
-Five files, three classes, a custom exception, and a dependency-injection chain, wrapping one database call.
-
-## With Ponytail
-
-```python
-# ponytail: drop the layers; keep the response schema, it whitelists what leaves the API
-class UserOut(BaseModel):
- id: int
- name: str
- email: str
-
-@app.get("/users/{user_id}", response_model=UserOut)
-def get_user(user_id: int, db: Session = Depends(get_db)):
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(404)
- return user
-```
-
-**5 files → 9 lines.** The repository, service, and custom exception were ceremony. The response schema was not: it whitelists which fields leave the API, so it stays. Returning the raw ORM model (`return user`) would leak every column, including the ones you never meant to expose. That is the line ponytail draws, and it is the same one the skill draws in "when NOT to be lazy": cut the layers, keep the trust boundary. Add a service layer when a second caller shows up, if it ever does.
diff --git a/examples/caching.md b/examples/caching.md
deleted file mode 100644
index 7dbe205..0000000
--- a/examples/caching.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Caching System
-
-**Task:** "We should cache these API responses."
-
-## Without Ponytail
-
-```python
-import time
-import threading
-from collections import OrderedDict
-from dataclasses import dataclass
-from typing import Any, Optional
-
-
-@dataclass
-class CacheEntry:
- value: Any
- expires_at: float
-
-
-class TTLCache:
- """Thread-safe LRU cache with per-entry TTL and max size."""
-
- def __init__(self, max_size: int = 1000, default_ttl: float = 300.0):
- self._store: OrderedDict[str, CacheEntry] = OrderedDict()
- self._lock = threading.Lock()
- self.max_size = max_size
- self.default_ttl = default_ttl
- self.hits = 0
- self.misses = 0
-
- def get(self, key: str) -> Optional[Any]:
- with self._lock:
- entry = self._store.get(key)
- if entry is None or entry.expires_at < time.monotonic():
- self.misses += 1
- self._store.pop(key, None)
- return None
- self._store.move_to_end(key)
- self.hits += 1
- return entry.value
-
- def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
- with self._lock:
- if len(self._store) >= self.max_size:
- self._store.popitem(last=False)
- self._store[key] = CacheEntry(
- value, time.monotonic() + (ttl or self.default_ttl)
- )
-
- # ... plus invalidation, stats endpoint, and unit tests for all of it
-```
-
-120 lines of custom infrastructure for a problem nobody has measured yet.
-
-## With Ponytail
-
-First question: **do you actually need a cache?**
-
-- **Unsure?** Ship without it. Add it when you measure the problem. (YAGNI)
-- **Pure function, hot path?** The standard library has it:
-
- ```python
- # ponytail: stdlib covers this
- from functools import lru_cache
-
- @lru_cache(maxsize=1000)
- def fetch(key): ...
- ```
-
-- **Real distributed caching needs?** Use Redis / memcached / your platform's
- cache. Infrastructure problems get infrastructure, not a homemade class.
-
-**120 lines → 0–3 lines.** The fastest cache is the one you didn't have to debug.
diff --git a/examples/csv-sum.md b/examples/csv-sum.md
new file mode 100644
index 0000000..6560688
--- /dev/null
+++ b/examples/csv-sum.md
@@ -0,0 +1,71 @@
+# CSV Sum
+
+**Task:** "Write Python code that reads sales.csv and sums the 'amount' column."
+
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
+
+## Without Ponytail — 20 lines of code
+
+# Python code to read sales.csv and sum the 'amount' column
+
+```python
+import pandas as pd
+
+# Read the CSV file
+df = pd.read_csv('sales.csv')
+
+# Sum the 'amount' column
+total_amount = df['amount'].sum()
+
+# Print the result
+print(f"Total amount: ${total_amount:,.2f}")
+```
+
+## Alternative methods:
+
+**Using CSV module (without pandas):**
+```python
+import csv
+
+total_amount = 0
+
+with open('sales.csv', 'r') as file:
+ reader = csv.DictReader(file)
+ for row in reader:
+ total_amount += float(row['amount'])
+
+print(f"Total amount: ${total_amount:,.2f}")
+```
+
+**Using pandas with error handling:**
+```python
+import pandas as pd
+
+try:
+ df = pd.read_csv('sales.csv')
+ total_amount = df['amount'].sum()
+ print(f"Total amount: ${total_amount:,.2f}")
+except FileNotFoundError:
+ print("Error: sales.csv not found")
+except KeyError:
+ print("Error: 'amount' column not found")
+```
+
+**The pandas method is recommended** because it's:
+- More concise and readable
+- Handles data types automatically
+- Provides built-in error handling
+- Efficient for large files
+
+## With Ponytail — 3 lines of code
+
+```python
+import csv
+
+total = sum(float(row['amount']) for row in csv.DictReader(open('sales.csv')))
+print(total)
+```
+
+Skipped: pandas, error handling, file closing — add when the CSV is large, malformed, or you need more analysis.
+
+**20 → 3 lines of code** — same model, same prompt.
diff --git a/examples/date-picker.md b/examples/date-picker.md
deleted file mode 100644
index c357fb7..0000000
--- a/examples/date-picker.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Date Picker
-
-**Task:** "Add a date picker to this form."
-
-## Without Ponytail
-
-```bash
-npm install flatpickr
-```
-
-```jsx
-import flatpickr from "flatpickr";
-import "flatpickr/dist/flatpickr.min.css";
-import { useEffect, useRef } from "react";
-
-export default function DatePicker({ value, onChange, minDate, maxDate }) {
- const inputRef = useRef(null);
- const instanceRef = useRef(null);
-
- useEffect(() => {
- instanceRef.current = flatpickr(inputRef.current, {
- defaultDate: value,
- minDate,
- maxDate,
- dateFormat: "Y-m-d",
- onChange: ([date]) => onChange(date),
- });
- return () => instanceRef.current?.destroy();
- }, []);
-
- useEffect(() => {
- instanceRef.current?.setDate(value, false);
- }, [value]);
-
- return ;
-}
-```
-
-One dependency, one wrapper component, two `useEffect` hooks, a cleanup function, and a CSS import, to pick a date.
-
-## With Ponytail
-
-```html
-
-
-```
-
-**1 dependency + 30 lines → 0 dependencies + 1 line.** Native, accessible, localized, keyboard-navigable, mobile-friendly. The browser team already did the work.
diff --git a/examples/debounce.md b/examples/debounce.md
new file mode 100644
index 0000000..829017c
--- /dev/null
+++ b/examples/debounce.md
@@ -0,0 +1,211 @@
+# Debounce
+
+**Task:** "Add debounce to a search input in vanilla JavaScript. It currently fires an API call on every keystroke."
+
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
+
+## Without Ponytail — 116 lines of code
+
+# Debounce Search Input
+
+Here's a practical implementation to prevent excessive API calls:
+
+## Basic Debounce Function
+
+```javascript
+function debounce(func, delay) {
+ let timeoutId;
+ return function (...args) {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => func(...args), delay);
+ };
+}
+
+// Usage
+const searchInput = document.getElementById('search');
+const resultsContainer = document.getElementById('results');
+
+const handleSearch = async (query) => {
+ if (!query.trim()) {
+ resultsContainer.innerHTML = '';
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
+ const data = await response.json();
+ displayResults(data);
+ } catch (error) {
+ console.error('Search failed:', error);
+ }
+};
+
+// Debounce the search function (300ms delay)
+const debouncedSearch = debounce(handleSearch, 300);
+
+searchInput.addEventListener('input', (e) => {
+ debouncedSearch(e.target.value);
+});
+```
+
+## Enhanced Version with Loading State
+
+```javascript
+const searchInput = document.getElementById('search');
+const resultsContainer = document.getElementById('results');
+const loadingIndicator = document.getElementById('loading');
+
+const handleSearch = async (query) => {
+ if (!query.trim()) {
+ resultsContainer.innerHTML = '';
+ loadingIndicator.style.display = 'none';
+ return;
+ }
+
+ loadingIndicator.style.display = 'block';
+
+ try {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
+ if (!response.ok) throw new Error('Search failed');
+
+ const data = await response.json();
+ displayResults(data);
+ } catch (error) {
+ resultsContainer.innerHTML = 'Search failed. Try again.
';
+ console.error('Search error:', error);
+ } finally {
+ loadingIndicator.style.display = 'none';
+ }
+};
+
+const debouncedSearch = debounce(handleSearch, 300);
+
+searchInput.addEventListener('input', (e) => {
+ debouncedSearch(e.target.value);
+});
+
+function displayResults(data) {
+ resultsContainer.innerHTML = data
+ .map(item => `${item.title}
`)
+ .join('');
+}
+```
+
+## Advanced: Debounce with Cancel & Immediate Options
+
+```javascript
+function debounce(func, delay, options = {}) {
+ let timeoutId;
+ let lastArgs;
+
+ const debounced = function (...args) {
+ lastArgs = args;
+ clearTimeout(timeoutId);
+
+ if (options.immediate && !timeoutId) {
+ func(...args);
+ }
+
+ timeoutId = setTimeout(() => {
+ if (!options.immediate) {
+ func(...lastArgs);
+ }
+ timeoutId = null;
+ }, delay);
+ };
+
+ // Allow manual cancellation
+ debounced.cancel = () => clearTimeout(timeoutId);
+
+ return debounced;
+}
+
+// Usage with options
+const debouncedSearch = debounce(handleSearch, 300, { immediate: false });
+
+// Cancel pending search if needed
+searchInput.addEventListener('blur', () => {
+ debouncedSearch.cancel();
+});
+```
+
+## HTML Example
+
+```html
+
+
+
+```
+
+## Key Benefits
+
+| Feature | Benefit |
+|---------|---------|
+| **Debounce** | Reduces API calls from 100+ to 1-2 per search |
+| **300ms delay** | Balances responsiveness with performance |
+| **Loading state** | Provides user feedback |
+| **Error handling** | Graceful failure management |
+| **Cancel method** | Cleanup when needed |
+
+**Typical keystroke reduction:** 10 characters = 10 API calls → 1-2 API calls with debounce ✅
+
+## With Ponytail — 10 lines of code
+
+```javascript
+const searchInput = document.querySelector('input[type="search"]');
+let debounceTimer;
+
+searchInput.addEventListener('input', (e) => {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => {
+ fetch(`/api/search?q=${encodeURIComponent(e.target.value)}`)
+ .then(r => r.json())
+ .then(data => console.log(data)); // replace with your render
+ }, 300);
+});
+```
+
+Skipped: debounce utility function, class wrapper, config object — `setTimeout` + `clearTimeout` is the debounce. Add a utility when you need it on 3+ inputs.
+
+**116 → 10 lines of code** — same model, same prompt.
diff --git a/examples/email-validation.md b/examples/email-validation.md
index 4b69aa6..eb71739 100644
--- a/examples/email-validation.md
+++ b/examples/email-validation.md
@@ -1,51 +1,156 @@
# Email Validation
-**Task:** "Validate an email address in Python."
+**Task:** "Write me a Python function that validates email addresses."
-## Without Ponytail
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
+
+## Without Ponytail — 75 lines of code
+
+# Email Validation Function
+
+Here's a practical Python function that validates email addresses:
```python
import re
-EMAIL_PATTERN = re.compile(
- r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
-)
-
-class EmailValidator:
- """Validates email addresses against RFC-like rules."""
-
- def __init__(self, pattern: re.Pattern = EMAIL_PATTERN):
- self.pattern = pattern
-
- def validate(self, email: str) -> bool:
- if not isinstance(email, str):
- raise TypeError("email must be a string")
- email = email.strip()
- if not email:
- return False
- return bool(self.pattern.match(email))
-
-
def validate_email(email: str) -> bool:
- """Convenience wrapper around EmailValidator."""
- return EmailValidator().validate(email)
+ """
+ Validates an email address using a regular expression.
+
+ Args:
+ email (str): The email address to validate
+
+ Returns:
+ bool: True if valid, False otherwise
+ """
+ # Regular expression pattern for email validation
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+
+ return re.match(pattern, email) is not None
+
+
+# Test cases
+if __name__ == "__main__":
+ test_emails = [
+ "user@example.com", # Valid
+ "john.doe@company.co.uk", # Valid
+ "test+tag@domain.org", # Valid
+ "invalid.email@", # Invalid
+ "missing@domain", # Invalid
+ "@nodomain.com", # Invalid
+ "spaces in@email.com", # Invalid
+ "user@domain..com", # Invalid (double dot)
+ ]
+
+ for email in test_emails:
+ result = validate_email(email)
+ print(f"{email:<30} -> {result}")
```
-A class, a wrapper, a regex that still rejects valid addresses and accepts invalid ones. Regex cannot validate email. Only a delivery attempt can.
-
-## With Ponytail
+## More Robust Version (with additional checks)
```python
-# ponytail: good enough, real validation is sending the mail
-"@" in email and "." in email.split("@")[-1]
+import re
+
+def validate_email_advanced(email: str) -> tuple[bool, str]:
+ """
+ Advanced email validation with detailed feedback.
+
+ Args:
+ email (str): The email address to validate
+
+ Returns:
+ tuple: (is_valid, message)
+ """
+ # Basic checks
+ if not email or not isinstance(email, str):
+ return False, "Email must be a non-empty string"
+
+ email = email.strip()
+
+ if len(email) > 254:
+ return False, "Email is too long (max 254 characters)"
+
+ # Regular expression pattern
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+
+ if not re.match(pattern, email):
+ return False, "Email format is invalid"
+
+ # Additional checks
+ local_part, domain = email.rsplit('@', 1)
+
+ if len(local_part) > 64:
+ return False, "Local part is too long (max 64 characters)"
+
+ if local_part.startswith('.') or local_part.endswith('.'):
+ return False, "Local part cannot start or end with a dot"
+
+ if '..' in email:
+ return False, "Email cannot contain consecutive dots"
+
+ return True, "Email is valid"
+
+
+# Test the advanced version
+if __name__ == "__main__":
+ test_emails = [
+ "user@example.com",
+ "invalid..email@domain.com",
+ ".invalid@domain.com",
+ "user@domain",
+ ]
+
+ for email in test_emails:
+ is_valid, message = validate_email_advanced(email)
+ print(f"{email:<35} -> {is_valid:5} ({message})")
```
-Or, if it must be thorough, the standard library has it:
+## Using a Third-Party Library (Recommended for Production)
```python
-# ponytail: stdlib covers this
-from email.utils import parseaddr
-"@" in parseaddr(email)[1]
+from email_validator import validate_email, EmailNotValidError
+
+def validate_email_production(email: str) -> bool:
+ """
+ Validates email using the email_validator library.
+ More reliable for production use.
+
+ Install with: pip install email-validator
+ """
+ try:
+ # Validate and normalize the email
+ valid = validate_email(email)
+ return True
+ except EmailNotValidError:
+ return False
+
+
+# Usage
+if __name__ == "__main__":
+ print(validate_email_production("user@example.com")) # True
+ print(validate_email_production("invalid@")) # False
```
-**27 lines → 1 line.** And the honest answer: let the confirmation email reject it. That's what confirmation emails are for.
+## Comparison
+
+| Method | Pros | Cons |
+|--------|------|------|
+| **Regex (Simple)** | Fast, no dependencies | Limited validation |
+| **Regex (Advanced)** | More thorough | Still not RFC-compliant |
+| **email-validator** | RFC-compliant, reliable | External dependency |
+
+**Recommendation**: Use the `email-validator` library for production applications, as it properly handles RFC 5321/5322 standards. Use the simple regex version for quick scripts or when you want to avoid dependencies.
+
+## With Ponytail — 3 lines of code
+
+```python
+import re
+
+def is_valid_email(email: str) -> bool:
+ return bool(re.match(r'^[^@]+@[^@]+\.[^@]+$', email))
+```
+
+Skipped: RFC 5322 parser, DNS MX lookup, confirmation email. Add when you actually need to reject `user+tag@sub.domain.co.uk` or catch typos — until then, this catches 99% of "oops I fat-fingered it" cases.
+
+**75 → 3 lines of code** — same model, same prompt.
diff --git a/examples/rate-limit.md b/examples/rate-limit.md
new file mode 100644
index 0000000..84ebe56
--- /dev/null
+++ b/examples/rate-limit.md
@@ -0,0 +1,272 @@
+# Rate Limiting
+
+**Task:** "Add rate limiting to my FastAPI endpoint so users can't spam it."
+
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
+
+## Without Ponytail — 128 lines of code
+
+# Rate Limiting in FastAPI
+
+Here are several approaches, from simplest to most robust:
+
+## 1. **Using `slowapi` (Recommended - Easiest)**
+
+```bash
+pip install slowapi
+```
+
+```python
+from fastapi import FastAPI, Request
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from fastapi.responses import JSONResponse
+
+app = FastAPI()
+
+limiter = Limiter(key_func=get_remote_address)
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, lambda request, exc: JSONResponse(
+ status_code=429,
+ content={"detail": "Rate limit exceeded"}
+))
+
+@app.get("/api/data")
+@limiter.limit("5/minute")
+async def get_data(request: Request):
+ return {"message": "Success"}
+```
+
+**Rate limit formats:**
+- `"5/minute"` - 5 requests per minute
+- `"100/hour"` - 100 requests per hour
+- `"1000/day"` - 1000 requests per day
+
+---
+
+## 2. **Using `limits` Library (More Control)**
+
+```bash
+pip install limits
+```
+
+```python
+from fastapi import FastAPI, HTTPException, Request
+from limits import parse
+from limits.storage import MemoryStorage
+from limits.strategies import MovingWindowRateLimiter
+
+app = FastAPI()
+
+storage = MemoryStorage()
+limiter = MovingWindowRateLimiter(storage)
+rate_limit = parse("5/minute")
+
+@app.get("/api/data")
+async def get_data(request: Request):
+ client_id = request.client.host
+
+ if not limiter.hit(rate_limit, client_id):
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
+
+ return {"message": "Success"}
+```
+
+---
+
+## 3. **Custom Middleware (Full Control)**
+
+```python
+from fastapi import FastAPI, HTTPException, Request
+from datetime import datetime, timedelta
+from collections import defaultdict
+import asyncio
+
+app = FastAPI()
+
+# Store request timestamps per IP
+request_history = defaultdict(list)
+RATE_LIMIT = 5 # requests
+TIME_WINDOW = 60 # seconds
+
+@app.middleware("http")
+async def rate_limit_middleware(request: Request, call_next):
+ client_ip = request.client.host
+ now = datetime.now()
+
+ # Clean old requests outside time window
+ request_history[client_ip] = [
+ req_time for req_time in request_history[client_ip]
+ if now - req_time < timedelta(seconds=TIME_WINDOW)
+ ]
+
+ # Check if limit exceeded
+ if len(request_history[client_ip]) >= RATE_LIMIT:
+ return JSONResponse(
+ status_code=429,
+ content={"detail": "Rate limit exceeded"}
+ )
+
+ # Record this request
+ request_history[client_ip].append(now)
+
+ return await call_next(request)
+
+@app.get("/api/data")
+async def get_data():
+ return {"message": "Success"}
+```
+
+---
+
+## 4. **Per-User Rate Limiting (With Authentication)**
+
+```python
+from fastapi import FastAPI, Depends, HTTPException
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
+app = FastAPI()
+limiter = Limiter(key_func=get_remote_address)
+
+def get_user_id(token: str = Header(None)) -> str:
+ # Your auth logic here
+ return token or "anonymous"
+
+@app.get("/api/data")
+@limiter.limit("10/minute")
+async def get_data(request: Request, user_id: str = Depends(get_user_id)):
+ return {"message": f"Success for {user_id}"}
+```
+
+---
+
+## 5. **Redis-Based Rate Limiting (Production)**
+
+```bash
+pip install slowapi redis
+```
+
+```python
+from fastapi import FastAPI, Request
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from slowapi.storage import RedisStorage
+from redis import Redis
+from fastapi.responses import JSONResponse
+
+redis_client = Redis.from_url("redis://localhost:6379")
+storage = RedisStorage(redis_client)
+limiter = Limiter(key_func=get_remote_address, storage=storage)
+
+app = FastAPI()
+app.state.limiter = limiter
+
+app.add_exception_handler(
+ RateLimitExceeded,
+ lambda request, exc: JSONResponse(
+ status_code=429,
+ content={"detail": "Rate limit exceeded"}
+ )
+)
+
+@app.get("/api/data")
+@limiter.limit("5/minute")
+async def get_data(request: Request):
+ return {"message": "Success"}
+```
+
+---
+
+## 6. **Complete Example with Multiple Endpoints**
+
+```python
+from fastapi import FastAPI, Request
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from fastapi.responses import JSONResponse
+
+app = FastAPI()
+limiter = Limiter(key_func=get_remote_address)
+app.state.limiter = limiter
+
+@app.exception_handler(RateLimitExceeded)
+async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
+ return JSONResponse(
+ status_code=429,
+ content={
+ "detail": "Too many requests",
+ "retry_after": exc.detail
+ }
+ )
+
+# Strict limit
+@app.post("/api/login")
+@limiter.limit("3/minute")
+async def login(request: Request):
+ return {"token": "xxx"}
+
+# Moderate limit
+@app.get("/api/data")
+@limiter.limit("30/minute")
+async def get_data(request: Request):
+ return {"data": []}
+
+# Generous limit
+@app.get("/api/public")
+@limiter.limit("100/minute")
+async def get_public(request: Request):
+ return {"public": "data"}
+```
+
+---
+
+## Comparison Table
+
+| Method | Pros | Cons | Best For |
+|--------|------|------|----------|
+| **slowapi** | Easy, decorator-based | In-memory only | Development, small apps |
+| **limits** | Flexible, multiple backends | More setup | Custom logic |
+| **Middleware** | Full control, no dependencies | Manual cleanup | Learning, simple cases |
+| **Redis** | Distributed, scalable | Requires Redis | Production, multi-server |
+
+---
+
+## Testing Rate Limits
+
+```python
+import httpx
+
+async def test_rate_limit():
+ async with httpx.AsyncClient(app=app, base_url="http://test") as client:
+ for i in range(6):
+ response = await client.get("/api/data")
+ print(f"Request {i+1}: {response.status_code}")
+ # First 5 should be 200, 6th should be 429
+```
+
+**Recommendation:** Use `slowapi` for most cases—it's the most popular and easiest to implement! 🚀
+
+## With Ponytail — 10 lines of code
+
+```python
+from fastapi import FastAPI, HTTPException
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
+app = FastAPI()
+limiter = Limiter(key_func=get_remote_address)
+app.state.limiter = limiter
+
+@app.get("/api/endpoint")
+@limiter.limit("10/minute")
+async def my_endpoint(request):
+ return {"status": "ok"}
+```
+
+Skipped: custom rate limit logic, Redis, sliding windows — `slowapi` handles it. Add when: you need distributed rate limiting across multiple servers (swap `Limiter` for Redis backend) or per-user limits (add `key_func=lambda r: r.headers.get("authorization")`).
+
+**128 → 10 lines of code** — same model, same prompt.
diff --git a/examples/react-countdown.md b/examples/react-countdown.md
new file mode 100644
index 0000000..5bfc8b9
--- /dev/null
+++ b/examples/react-countdown.md
@@ -0,0 +1,390 @@
+# Countdown Timer
+
+**Task:** "Build me a countdown timer component in React that counts down from a given number of seconds."
+
+Verbatim model output from a benchmark run — Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
+
+## Without Ponytail — 267 lines of code
+
+# React Countdown Timer Component
+
+Here's a complete countdown timer component with multiple variations:
+
+## Basic Countdown Timer
+
+```jsx
+import { useState, useEffect } from 'react';
+
+export function CountdownTimer({ initialSeconds = 60 }) {
+ const [seconds, setSeconds] = useState(initialSeconds);
+ const [isActive, setIsActive] = useState(false);
+
+ useEffect(() => {
+ let interval = null;
+
+ if (isActive && seconds > 0) {
+ interval = setInterval(() => {
+ setSeconds(seconds => seconds - 1);
+ }, 1000);
+ } else if (seconds === 0) {
+ setIsActive(false);
+ }
+
+ return () => clearInterval(interval);
+ }, [isActive, seconds]);
+
+ const toggle = () => setIsActive(!isActive);
+ const reset = () => {
+ setSeconds(initialSeconds);
+ setIsActive(false);
+ };
+
+ return (
+
+
{seconds}s
+
+
+ {isActive ? 'Pause' : 'Start'}
+
+ Reset
+
+
+ );
+}
+```
+
+## Advanced Timer with Formatted Display
+
+```jsx
+import { useState, useEffect } from 'react';
+
+export function AdvancedCountdownTimer({
+ initialSeconds = 3600,
+ onComplete = () => {},
+ showMilliseconds = false
+}) {
+ const [timeLeft, setTimeLeft] = useState(initialSeconds);
+ const [isActive, setIsActive] = useState(false);
+
+ useEffect(() => {
+ let interval = null;
+
+ if (isActive && timeLeft > 0) {
+ interval = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 1) {
+ setIsActive(false);
+ onComplete();
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ }
+
+ return () => clearInterval(interval);
+ }, [isActive, timeLeft, onComplete]);
+
+ const formatTime = (totalSeconds) => {
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ const parts = [];
+ if (hours > 0) parts.push(String(hours).padStart(2, '0'));
+ parts.push(String(minutes).padStart(2, '0'));
+ parts.push(String(seconds).padStart(2, '0'));
+
+ return parts.join(':');
+ };
+
+ const toggle = () => setIsActive(!isActive);
+ const reset = () => {
+ setTimeLeft(initialSeconds);
+ setIsActive(false);
+ };
+
+ const progressPercent = ((initialSeconds - timeLeft) / initialSeconds) * 100;
+
+ return (
+
+
+ {formatTime(timeLeft)}
+
+
+
+
+ {isActive ? '⏸ Pause' : '▶ Start'}
+
+
+ ↻ Reset
+
+
+
+ );
+}
+```
+
+## Custom Hook Version
+
+```jsx
+import { useState, useEffect, useCallback } from 'react';
+
+export function useCountdown(initialSeconds) {
+ const [seconds, setSeconds] = useState(initialSeconds);
+ const [isActive, setIsActive] = useState(false);
+
+ useEffect(() => {
+ let interval = null;
+
+ if (isActive && seconds > 0) {
+ interval = setInterval(() => {
+ setSeconds(prev => prev - 1);
+ }, 1000);
+ } else if (seconds === 0 && isActive) {
+ setIsActive(false);
+ }
+
+ return () => clearInterval(interval);
+ }, [isActive, seconds]);
+
+ const start = useCallback(() => setIsActive(true), []);
+ const pause = useCallback(() => setIsActive(false), []);
+ const reset = useCallback(() => {
+ setSeconds(initialSeconds);
+ setIsActive(false);
+ }, [initialSeconds]);
+
+ return {
+ seconds,
+ isActive,
+ start,
+ pause,
+ reset,
+ setSeconds
+ };
+}
+
+// Usage
+export function TimerWithHook() {
+ const { seconds, isActive, start, pause, reset } = useCountdown(60);
+
+ return (
+
+
{seconds}s
+
+ {isActive ? 'Pause' : 'Start'}
+
+
Reset
+
+ );
+}
+```
+
+## Styled Component with Animations
+
+```jsx
+import { useState, useEffect } from 'react';
+import styled from 'styled-components';
+
+const TimerContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+ padding: 30px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 15px;
+ color: white;
+ font-family: 'Arial', sans-serif;
+`;
+
+const Display = styled.div`
+ font-size: 48px;
+ font-weight: bold;
+ font-variant-numeric: tabular-nums;
+ animation: ${props => props.isWarning ? 'pulse 0.5s infinite' : 'none'};
+
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+`;
+
+const Controls = styled.div`
+ display: flex;
+ gap: 10px;
+`;
+
+const Button = styled.button`
+ padding: 10px 20px;
+ font-size: 16px;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ background-color: ${props => props.variant === 'danger' ? '#ff6b6b' : '#4ecdc4'};
+ color: white;
+ transition: all 0.3s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+`;
+
+export function StyledCountdownTimer({ initialSeconds = 60 }) {
+ const [seconds, setSeconds] = useState(initialSeconds);
+ const [isActive, setIsActive] = useState(false);
+
+ useEffect(() => {
+ let interval = null;
+
+ if (isActive && seconds > 0) {
+ interval = setInterval(() => {
+ setSeconds(prev => prev - 1);
+ }, 1000);
+ } else if (seconds === 0) {
+ setIsActive(false);
+ }
+
+ return () => clearInterval(interval);
+ }, [isActive, seconds]);
+
+ const isWarning = seconds <= 10 && seconds > 0;
+
+ return (
+
+ {seconds}s
+
+ setIsActive(!isActive)}>
+ {isActive ? 'Pause' : 'Start'}
+
+ {
+ setSeconds(initialSeconds);
+ setIsActive(false);
+ }}
+ >
+ Reset
+
+
+
+ );
+}
+```
+
+## CSS Styling
+
+```css
+.countdown-timer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+ padding: 20px;
+ border: 2px solid #333;
+ border-radius: 10px;
+ background-color: #f5f5f5;
+}
+
+.timer-display {
+ font-size: 48px;
+ font-weight: bold;
+ font-variant-numeric: tabular-nums;
+ color: #333;
+}
+
+.timer-controls {
+ display: flex;
+ gap: 10px;
+}
+
+.timer-controls button {
+ padding: 10px 20px;
+ font-size: 16px;
+ border: none;
+ border-radius: 5px;
+ cursor: pointer;
+ background-color: #667eea;
+ color: white;
+ transition: background-color 0.3s;
+}
+
+.timer-controls button:hover {
+ background-color: #5568d3;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background-color: #e0e0e0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background-color: #667eea;
+ transition: width 0.3s linear;
+}
+```
+
+## Usage Examples
+
+```jsx
+// Basic usage
+
+
+// Advanced with callback
+ alert('Time\'s up!')}
+/>
+
+// Using the hook
+
+
+// Styled version
+
+```
+
+## Features
+
+✅ **Basic Timer** - Simple countdown with start/pause/reset
+✅ **Advanced Timer** - Formatted display (HH:MM:SS), progress bar, callbacks
+✅ **Custom Hook** - Reusable logic for any component
+✅ **Styled Components** - Beautiful UI with animations
+✅ **Warning State** - Visual feedback when time is running out
+✅ **Completion Callback** - Execute code when timer finishes
+
+Choose the version that best fits your needs!
+
+## With Ponytail — 9 lines of code
+
+```jsx
+export function CountdownTimer({ seconds }) {
+ const [remaining, setRemaining] = React.useState(seconds);
+
+ React.useEffect(() => {
+ if (remaining <= 0) return;
+ const timer = setInterval(() => setRemaining(r => r - 1), 1000);
+ return () => clearInterval(timer);
+ }, [remaining]);
+
+ return {remaining}s
;
+}
+```
+
+Skipped: pause/resume, formatted display (mm:ss), sound on zero, styling — add when needed.
+
+**267 → 9 lines of code** — same model, same prompt.
diff --git a/examples/sorting.md b/examples/sorting.md
deleted file mode 100644
index aa31f3b..0000000
--- a/examples/sorting.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Sorting
-
-**Task:** "Sort this array of numbers."
-
-## Without Ponytail
-
-```js
-function quickSort(arr, compareFn = (a, b) => a - b) {
- if (arr.length <= 1) return arr;
-
- const pivot = arr[Math.floor(arr.length / 2)];
- const left = [];
- const right = [];
- const equal = [];
-
- for (const item of arr) {
- const cmp = compareFn(item, pivot);
- if (cmp < 0) left.push(item);
- else if (cmp > 0) right.push(item);
- else equal.push(item);
- }
-
- return [
- ...quickSort(left, compareFn),
- ...equal,
- ...quickSort(right, compareFn),
- ];
-}
-
-const sorted = quickSort(numbers);
-```
-
-A hand-rolled quicksort. It allocates three arrays per recursion level, blows the stack on large inputs, and replaces an engine-optimized native sort with homework.
-
-## With Ponytail
-
-```js
-// ponytail: this exists
-numbers.sort((a, b) => a - b)
-```
-
-**24 lines → 1 line.** Every runtime ships a sort tuned by people whose whole job is sorting. Use it.
diff --git a/gemini-extension.json b/gemini-extension.json
index fe95708..a8c1393 100644
--- a/gemini-extension.json
+++ b/gemini-extension.json
@@ -1,6 +1,6 @@
{
"name": "ponytail",
- "version": "4.6.0",
+ "version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"contextFileName": "AGENTS.md"
}
diff --git a/scripts/build-openclaw-skills.js b/scripts/build-openclaw-skills.js
new file mode 100644
index 0000000..bd4f504
--- /dev/null
+++ b/scripts/build-openclaw-skills.js
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// Generate the OpenClaw / ClawHub skill package (.openclaw/skills/) from the
+// canonical skills/. OpenClaw skills are SKILL.md (frontmatter + body), the same
+// format ponytail already uses, with one difference: `description` must be a
+// single line under 160 chars. The canonical descriptions are long (tuned for
+// Claude's skill picker), so each ships a short one here. The body is copied
+// verbatim from skills//SKILL.md so the ruleset never drifts; only the
+// frontmatter is rewritten.
+//
+// Run: node scripts/build-openclaw-skills.js
+// tests/openclaw-skills.test.js fails if the committed copies are stale.
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const HOMEPAGE = 'https://github.com/DietrichGebert/ponytail';
+
+const DESCRIPTIONS = {
+ 'ponytail': 'Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions.',
+ 'ponytail-review': 'Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding.',
+ 'ponytail-audit': 'Audit the whole repo for over-engineering. A ranked list of what to delete, simplify, or replace with stdlib or native features.',
+ 'ponytail-debt': 'Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report.',
+ 'ponytail-help': "Quick reference for ponytail's modes, skills, and commands. One-shot display.",
+};
+
+const NAMES = Object.keys(DESCRIPTIONS);
+
+function sourceBody(name) {
+ const src = fs.readFileSync(path.join(ROOT, 'skills', name, 'SKILL.md'), 'utf8').replace(/\r\n/g, '\n');
+ const fm = src.match(/^---\n[\s\S]*?\n---\n?/);
+ if (!fm) throw new Error(`skills/${name}/SKILL.md has no frontmatter`);
+ return src.slice(fm[0].length);
+}
+
+function render(name) {
+ const desc = DESCRIPTIONS[name];
+ if (desc.length > 160 || desc.includes('\n') || desc.includes('"')) {
+ throw new Error(`description for ${name} must be one line, no quotes, under 160 chars`);
+ }
+ const frontmatter =
+ `---\nname: ${name}\ndescription: "${desc}"\nhomepage: ${HOMEPAGE}\nlicense: MIT\n---\n`;
+ return frontmatter + sourceBody(name);
+}
+
+function outPath(name) {
+ return path.join(ROOT, '.openclaw', 'skills', name, 'SKILL.md');
+}
+
+module.exports = { DESCRIPTIONS, NAMES, render, outPath, sourceBody };
+
+if (require.main === module) {
+ for (const name of NAMES) {
+ const p = outPath(name);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, render(name));
+ console.log('wrote', path.relative(ROOT, p).replace(/\\/g, '/'));
+ }
+}
diff --git a/scripts/check-rule-copies.js b/scripts/check-rule-copies.js
index d80fbc2..0b5b524 100644
--- a/scripts/check-rule-copies.js
+++ b/scripts/check-rule-copies.js
@@ -43,7 +43,14 @@ const INVARIANTS = [
'naive heuristic', // ceiling-comment rule
'ONE runnable check', // test reflex
'flimsier algorithm', // robust-variant rule
- 'input validation at trust boundaries', // the "not lazy about" clause
+ // the four "not lazy about" safety carve-outs: pin each so a reword in either
+ // file can't silently drop one. Only validation was pinned before. These are the
+ // continuous substrings present in both files ("prevents data loss" because the
+ // full "error handling that prevents data loss" wraps a line in SKILL.md).
+ 'input validation at trust boundaries',
+ 'prevents data loss',
+ 'security',
+ 'accessibility',
'Lazy code without its check is unfinished', // one-check promoted to headline
];
diff --git a/tests/openclaw-skills.test.js b/tests/openclaw-skills.test.js
new file mode 100644
index 0000000..64443da
--- /dev/null
+++ b/tests/openclaw-skills.test.js
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+// The OpenClaw skill package (.openclaw/skills/) is generated from skills/ by
+// scripts/build-openclaw-skills.js. These tests fail if the committed copies are
+// stale (ruleset drift) or if a description breaks OpenClaw's one-line <160 rule.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const { NAMES, render, outPath, sourceBody, DESCRIPTIONS } = require('../scripts/build-openclaw-skills');
+
+for (const name of NAMES) {
+ test(`${name}: committed OpenClaw skill matches the generator`, () => {
+ const onDisk = fs.readFileSync(outPath(name), 'utf8').replace(/\r\n/g, '\n');
+ assert.equal(onDisk, render(name), 'stale — run: node scripts/build-openclaw-skills.js');
+ });
+
+ test(`${name}: body is the canonical skills/${name} body, verbatim`, () => {
+ const onDisk = fs.readFileSync(outPath(name), 'utf8').replace(/\r\n/g, '\n');
+ assert.ok(onDisk.endsWith(sourceBody(name)), 'body drifted from skills/' + name);
+ });
+
+ test(`${name}: description is one line under 160 chars`, () => {
+ const d = DESCRIPTIONS[name];
+ assert.ok(d.length <= 160 && !d.includes('\n'), 'description too long or multiline');
+ });
+}