From 7d7d4a2d7bb62d5c0a2cadf6e5011980353b40b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Sat, 18 Apr 2026 15:03:05 -0300 Subject: [PATCH 01/15] Drop security check --- .github/workflows/pr-checks.yml | 26 +------- docs/launch-announcement-prompt.md | 77 +++++++++++++++++++++++ docs/launch-announcement-step-1-output.md | 9 +++ docs/launch-announcement-step-2-output.md | 8 +++ docs/launch-announcement-tighten.md | 13 ++++ 5 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 docs/launch-announcement-prompt.md create mode 100644 docs/launch-announcement-step-1-output.md create mode 100644 docs/launch-announcement-step-2-output.md create mode 100644 docs/launch-announcement-tighten.md diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8f985e8..2e34aa9 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -50,28 +50,4 @@ jobs: with: files: "**/*.md" inline: warning - strict: false - - security: - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Set up Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync --all-extras - - - name: Run security checks - run: | - uv run pip install bandit safety - uv run bandit -r crossfire/ -f json -o bandit-report.json || true - uv run safety check --json --output safety-report.json || true - - uv run bandit -r crossfire/ -ll - uv run safety check \ No newline at end of file + strict: false \ No newline at end of file diff --git a/docs/launch-announcement-prompt.md b/docs/launch-announcement-prompt.md new file mode 100644 index 0000000..1bbb5f2 --- /dev/null +++ b/docs/launch-announcement-prompt.md @@ -0,0 +1,77 @@ +# Crossfire launch blog post prompt + +Write a spiffy, fun blog post announcing **Crossfire**, a multi-agent adversarial refinement orchestrator a.k.a. Ralph Wiggum on steroids that offers an agentic build→break→rebuild loop for research, coding, writing, and editing. + +**Audience:** Engineers who use LLMs for code or tech-savvy people who write a lot and are tired of brittle one-shot prompts. + +**Tone:** Witty, conversational, human. **The Simpsons** references welcome (Ralph Wiggum). Jokes must not bury the facts. No corporate-LLM filler. Short paragraphs, punchy headings, skimmable. + +**Must cover:** + +1. **What it is** — plain English and the mental model. +2. **Why it exists** — pain (fragile one-shots, unexamined reasoning, code that looks fine but isn’t, endless copy-pasting when doing it manually). +3. **What it solves** — and **what it doesn’t** (limits). +4. **The loop** — **build → shoot down from many angles → rebuild** (or build / break / rebuild). Stress multiple reviewers and iterative rounds. +5. **How to use** — real commands and flags from the README; one minimal example and one richer one. +6. **Dogfooding** — you built this to be rigorous; optional nod to using Crossfire on Crossfire-style work. Just do not say dogfooding as I hate corporate lingo and that would be flagged by edit mode. +7. **Example “live” output** — a **short** realistic-looking stub: command, a line or two of progress, tiny reviewer excerpt (STRENGTHS / WEAKNESSES / SEVERITY), a line of synthesis, a tiny cost line. **Label it clearly** as representative example output, not a paste of a real run. +8. **Quick checklist** — keys, `uv sync`, sample `crossfire run`. +9. **Close** — strong ending + practical CTA (try it, read the repo) without grovelling. + +**Constraints:** + +- Ground commands and flags in the provided context; don’t invent CLI or config. +- **~900–1400 words.** Markdown only. + +--- + +## Where this file lives + +`docs/` is a reasonable home for **prompts and examples** that are not the main README. If you prefer them more visible, a top-level `prompts/` folder works too; keep paths in commands in sync. + +--- + +## Step 1 — `write`: generate the draft + +Use the README so commands and behaviour stay accurate. + +**Naming:** “Step” here means **this two-command pipeline** (write, then edit). It is **not** the same word as Crossfire’s internal **phases** (generation, review, synthesis inside each round). + +Committed outputs for the announcement live alongside the prompts: + +- `docs/launch-announcement-step-1-output.md` — final text from step 1 +- `docs/launch-announcement-step-2-output.md` — final text from step 2 + +```bash +uv run crossfire run \ + --mode write \ + --instruction-file docs/launch-announcement-prompt.md \ + --context-file README.md \ + --num-generators 3 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 5 \ + --output docs/launch-announcement-step-1-output.md +``` + +`--output` writes the final synthesis; the run still archives under `runs//` (see README). + +--- + +## Step 2 — `edit`: tighten the draft + +**Why two modes:** `write` optimises for voice, structure, and energy; `edit` is ruthless about clarity, concision, and anti-waffle — same tool, different adversarial brief. That is a concrete way to run a second pass on your own announcement without one overloaded prompt. + +Put the draft in **context** so the editing brief stays in **instruction** (instruction is never compressed; context may be trimmed if huge). + +```bash +uv run crossfire run \ + --mode edit \ + --instruction-file docs/launch-announcement-tighten.md \ + --context-file docs/launch-announcement-step-1-output.md \ + --num-generators 1 \ + --num-reviewers-per-candidate 5 \ + --num-rounds 3 \ + --output docs/launch-announcement-step-2-output.md +``` + +Optional: `--no-enrich` on step 2 if the tighten brief is already explicit and you want less rewriting overhead. diff --git a/docs/launch-announcement-step-1-output.md b/docs/launch-announcement-step-1-output.md new file mode 100644 index 0000000..53c2204 --- /dev/null +++ b/docs/launch-announcement-step-1-output.md @@ -0,0 +1,9 @@ + + +_Placeholder — paste the post from your latest `write` run here._ diff --git a/docs/launch-announcement-step-2-output.md b/docs/launch-announcement-step-2-output.md new file mode 100644 index 0000000..a84cca0 --- /dev/null +++ b/docs/launch-announcement-step-2-output.md @@ -0,0 +1,8 @@ + + +_Placeholder — paste the tightened post from your latest `edit` run here._ diff --git a/docs/launch-announcement-tighten.md b/docs/launch-announcement-tighten.md new file mode 100644 index 0000000..f703a9e --- /dev/null +++ b/docs/launch-announcement-tighten.md @@ -0,0 +1,13 @@ +# Edit pass: tighten the launch post + +You are polishing a draft blog post about Crossfire for publication on a static site (Markdown, no special plugins). + +**Goals:** + +- Cut repetition and throat-clearing; keep punchy sections and skimmable headings. +- Preserve factual claims and any CLI examples — they must stay aligned with the README. +- Keep the voice: witty, human, **The Simpsons** allowed in moderation — do not let jokes crowd out clarity. +- Avoid corporate filler and banned buzzwords (no “delve”, “holistic”, “synergy”, “dogfooding”, “leverage” as a verb, etc.). +- Aim for roughly **900–1200 words** unless the draft is already tighter. + +**Output:** the full revised post in Markdown only, ready to paste into the site. From bd016f0f36afd4ee0c6d83719301240cdddd7426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Sun, 19 Apr 2026 16:38:45 -0300 Subject: [PATCH 02/15] Fix email address --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f031ffa..9b44e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "crossfire" version = "0.1.0" description = "Multi-agent adversarial refinement orchestrator (i.e. Ralph Wiggum on 'roids)" -maintainers = [{name = "Ian Reppel", email = "code@ianreppel.org"}] +maintainers = [{name = "Ian Reppel", email = "web@ianreppel.org"}] license = "MIT" requires-python = ">=3.12" dependencies = [ From 66bc4ae66cbac9dedb192fcf6a12e5b7e252fd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Sun, 19 Apr 2026 17:27:58 -0300 Subject: [PATCH 03/15] Add material definition --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ddac1c3..62f701d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ flowchart LR Each round runs _generation → review → synthesis_ sequentially; within generation and review, model calls run in parallel. Rounds repeat until the configured limit or until reviewers find no material weaknesses (early stop). +Here, "material" means substantive issues like factual errors, logic flaws, or broken code, not style issues or nitpicks. Each mode (research, code, edit, check, write) has its own review protocol: rubber duck debugging for code, precision review for editing, literary criticism for writing, and so on. ## How to use From 756ac2204063446749cb26a056f3e57d72ea8feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Sun, 19 Apr 2026 17:55:45 -0300 Subject: [PATCH 04/15] Add docs to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 62f701d..1bbfbdf 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ The pre-commit hooks automatically run `docformatter`, `ruff --fix`, `ruff forma ``` crossfire/ +├── docs/ # example artefacts ├── crossfire/ │ ├── core/ │ │ ├── orchestrator.py # round loop, concurrency, failure handling From fd9655c01311ebb0543d559ec0be2ffa1c9a75b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Sun, 19 Apr 2026 17:58:43 -0300 Subject: [PATCH 05/15] Add prompts for launch announcement post --- docs/launch-announcement-prompt.md | 83 ++++++++++------------ docs/launch-announcement-step-1-output.md | 84 +++++++++++++++++++++-- docs/launch-announcement-step-2-output.md | 83 ++++++++++++++++++++-- docs/launch-announcement-tighten.md | 21 +++--- 4 files changed, 202 insertions(+), 69 deletions(-) diff --git a/docs/launch-announcement-prompt.md b/docs/launch-announcement-prompt.md index 1bbb5f2..ea16686 100644 --- a/docs/launch-announcement-prompt.md +++ b/docs/launch-announcement-prompt.md @@ -1,77 +1,66 @@ -# Crossfire launch blog post prompt +# Step 1: Crossfire launch announcement — write mode -Write a spiffy, fun blog post announcing **Crossfire**, a multi-agent adversarial refinement orchestrator a.k.a. Ralph Wiggum on steroids that offers an agentic build→break→rebuild loop for research, coding, writing, and editing. +Write a blog post announcing Crossfire: what it is, why it exists, what problem it solves, why anyone might care, and the main adversarial loop in plain language. +You can use "shot down" once, then move on, e.g. build → break → rebuild loop. +Avoid extra combat imagery (no cage match, gauntlet, battlefield, slaughter, artillery), and no second gun pun. +DO NOT stack metaphors! -**Audience:** Engineers who use LLMs for code or tech-savvy people who write a lot and are tired of brittle one-shot prompts. +The post must open with a single strong one-liner that invites the reader to continue. -**Tone:** Witty, conversational, human. **The Simpsons** references welcome (Ralph Wiggum). Jokes must not bury the facts. No corporate-LLM filler. Short paragraphs, punchy headings, skimmable. +Voice: plain, focussed on engineers, but not bland. +Use "I" briefly for origin/intent, but keep Crossfire as the subject. +Never use "we" or corporate tone. -**Must cover:** +One short The Simpsons / Ralph Wiggum nod is enough (the context file (README) links the idea to Ralph Wiggum and to a piece on evolutionary search). +Use that link from the README, do not invent URLs! -1. **What it is** — plain English and the mental model. -2. **Why it exists** — pain (fragile one-shots, unexamined reasoning, code that looks fine but isn’t, endless copy-pasting when doing it manually). -3. **What it solves** — and **what it doesn’t** (limits). -4. **The loop** — **build → shoot down from many angles → rebuild** (or build / break / rebuild). Stress multiple reviewers and iterative rounds. -5. **How to use** — real commands and flags from the README; one minimal example and one richer one. -6. **Dogfooding** — you built this to be rigorous; optional nod to using Crossfire on Crossfire-style work. Just do not say dogfooding as I hate corporate lingo and that would be flagged by edit mode. -7. **Example “live” output** — a **short** realistic-looking stub: command, a line or two of progress, tiny reviewer excerpt (STRENGTHS / WEAKNESSES / SEVERITY), a line of synthesis, a tiny cost line. **Label it clearly** as representative example output, not a paste of a real run. -8. **Quick checklist** — keys, `uv sync`, sample `crossfire run`. -9. **Close** — strong ending + practical CTA (try it, read the repo) without grovelling. +If you need a framing hook, pick one and get to the product fast: +- What if Ralph Wiggum had friends? +- What if one is never enough? +- What if your draft could get critical reviews on demand? -**Constraints:** +You are free to use (parts) of the back story, but you can decide against it entirely. +Back story: I created Crossfire because I ended up pitting LLMs against each other to improve their outputs gradually, except I got tired of copy-pasting between five tabs while waiting for each to finish. +I often did so for research questions, editing documents, or writing small convenience scripts that had subtle bugs. +I still have to check every single reference, the logic of the code, orany claims made, but at least the worst offenders are filtered out most of the time with such an adversarial yet manual setup. +The problem fresh contexts solve is that each LLM looks at the text or code with fresh (robot) eyes rather than keeps noodling on it, believing each iteration is better because it worked on it for a while. +Code and text need to be clear to anyone looking upon it for the first time. -- Ground commands and flags in the provided context; don’t invent CLI or config. -- **~900–1400 words.** Markdown only. +Include a short fake terminal snippet so readers see what a run looks like. +I'll swap in real output later. ---- +The README context includes other example commands. +Ignore those for this article. -## Where this file lives +The two fenced bash blocks below are the exact commands for this announcement pipeline. +Copy these verbatim (same paths, flags, and numbers). -`docs/` is a reasonable home for **prompts and examples** that are not the main README. If you prefer them more visible, a top-level `prompts/` folder works too; keep paths in commands in sync. - ---- - -## Step 1 — `write`: generate the draft - -Use the README so commands and behaviour stay accurate. - -**Naming:** “Step” here means **this two-command pipeline** (write, then edit). It is **not** the same word as Crossfire’s internal **phases** (generation, review, synthesis inside each round). - -Committed outputs for the announcement live alongside the prompts: - -- `docs/launch-announcement-step-1-output.md` — final text from step 1 -- `docs/launch-announcement-step-2-output.md` — final text from step 2 +Step 1 — draft (`write` mode): ```bash uv run crossfire run \ --mode write \ --instruction-file docs/launch-announcement-prompt.md \ --context-file README.md \ - --num-generators 3 \ + --num-generators 5 \ --num-reviewers-per-candidate 3 \ - --num-rounds 5 \ + --num-rounds 3 \ --output docs/launch-announcement-step-1-output.md ``` -`--output` writes the final synthesis; the run still archives under `runs//` (see README). - ---- - -## Step 2 — `edit`: tighten the draft - -**Why two modes:** `write` optimises for voice, structure, and energy; `edit` is ruthless about clarity, concision, and anti-waffle — same tool, different adversarial brief. That is a concrete way to run a second pass on your own announcement without one overloaded prompt. - -Put the draft in **context** so the editing brief stays in **instruction** (instruction is never compressed; context may be trimmed if huge). +Step 2 — tighten (`edit` mode): ```bash uv run crossfire run \ --mode edit \ --instruction-file docs/launch-announcement-tighten.md \ --context-file docs/launch-announcement-step-1-output.md \ - --num-generators 1 \ + --num-generators 3 \ --num-reviewers-per-candidate 5 \ - --num-rounds 3 \ + --num-rounds 5 \ --output docs/launch-announcement-step-2-output.md ``` -Optional: `--no-enrich` on step 2 if the tighten brief is already explicit and you want less rewriting overhead. +Also show one minimal one-off using `--instruction` (not `--instruction-file`), e.g. a single research question in quantum computing. +Keep that command short, so rely on defaults as much as possible. +Check the context file for defaults. diff --git a/docs/launch-announcement-step-1-output.md b/docs/launch-announcement-step-1-output.md index 53c2204..aa6b0f5 100644 --- a/docs/launch-announcement-step-1-output.md +++ b/docs/launch-announcement-step-1-output.md @@ -1,9 +1,79 @@ - +You know the dance. You have a prompt—for code, for an essay, for analysis. You send it to one LLM. The output is... okay. A good starting point. So you open another tab, send it to a different model. That one has better structure but hallucinates a key detail. A third gets the detail right but writes like a corporate drone. -_Placeholder — paste the post from your latest `write` run here._ +So you start cutting and pasting. Juggling tabs, merging ideas, fixing flaws. You become the human synthesizer, the quality control, the editor-in-chief for a team of inconsistent AI assistants. I found myself doing this constantly, manually pitting models against each other to triangulate a better result. It worked, but it was tedious, error-prone, and felt like a problem begging for automation. + +That's why **Crossfire** exists. + +## The Adversarial Loop + +Crossfire is a multi-agent adversarial refinement orchestrator. In plain terms: it takes your instruction, sends it to several different LLMs to generate drafts, then sends each draft to another set of LLMs for critical review. Finally, it synthesizes the best parts of the reviewed drafts into a single, refined artifact. + +The core loop is deceptively simple: + +1. **Generate** — Create multiple independent attempts at the task +2. **Review** — Scrutinize each attempt from multiple adversarial perspectives +3. **Synthesize** — Discard the weak, merge the strong, produce something better + +This cycle repeats, with each round building on the synthesis of the last. Bad ideas get eliminated early so good ones can be rebuilt stronger. It's a way to escape the local maximum of a single model's thinking by introducing fresh, critical contexts at each step. + +This multi-start evolutionary search—where multiple attempts compete and the best elements survive to the next generation—is what makes it work. It's an [improved Ralph Wiggum method](https://ianreppel.org/ralph-wiggum-as-a-degenerate-evolutionary-search/) that filters out the worst offenders through systematic build → break → rebuild cycles. + +## Why Engineers Should Care + +Crossfire automates the whole adversarial pipeline. Instead of trusting a single LLM's first attempt, it runs systematic pressure that catches flaws before they reach your final output. Each round applies fresh eyes so reviewers see new text with clear perspective rather than iterating endlessly on the same tired draft. + +For engineers who want higher-quality, vetted outputs—code that compiles, prose that's clear, research that stands up to inspection—Crossfire gives you a systematic way to get there without juggling browser tabs or trusting a single pass. + +## What It Looks Like + +This blog post itself was generated using Crossfire's two-stage pipeline. First, a draft in `write` mode: + +```bash +uv run crossfire run \ + --mode write \ + --instruction-file docs/launch-announcement-prompt.md \ + --context-file README.md \ + --num-generators 5 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ + --output docs/launch-announcement-step-1-output.md +``` + +Then tightened in `edit` mode: + +```bash +uv run crossfire run \ + --mode edit \ + --instruction-file docs/launch-announcement-tighten.md \ + --context-file docs/launch-announcement-step-1-output.md \ + --num-generators 3 \ + --num-reviewers-per-candidate 5 \ + --num-rounds 5 \ + --output docs/launch-announcement-step-2-output.md +``` + +For quick research questions, it's even simpler: + +```bash +uv run crossfire run \ + --mode research \ + --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" +``` + +A typical run looks like this in your terminal: + +```terminal +⠧ Round 1/3 | Phase: Generate | 5 candidates +⠧ Round 1/3 | Phase: Review | 15 reviews +⠧ Round 1/3 | Phase: Synthesize +✓ Round 1 complete. Weaknesses found: 7 +⠧ Round 2/3 | Phase: Generate | 5 candidates +⠧ Round 2/3 | Phase: Review | 15 reviews +⠧ Round 2/3 | Phase: Synthesize +✓ Round 2 complete. Weaknesses found: 2 +✓ Run complete. Early stop after 2 rounds. Output saved to runs/20250415_142356/output.md +``` + +Crossfire replaces the manual intuition of "let me try that again" with structured adversarial refinement. It turns the latent potential of having multiple LLMs at your disposal into a practical tool—not by making them agree, but by making them work through systematic build → break → rebuild cycles until what remains can withstand scrutiny. \ No newline at end of file diff --git a/docs/launch-announcement-step-2-output.md b/docs/launch-announcement-step-2-output.md index a84cca0..b763a80 100644 --- a/docs/launch-announcement-step-2-output.md +++ b/docs/launch-announcement-step-2-output.md @@ -1,8 +1,79 @@ - +You have a prompt—for code, for an essay, for analysis. You send it to one LLM. The output is okay—a starting point. You open another tab, try a different model. That one has better structure but hallucinates a key detail. A third gets the detail right but writes like a corporate drone. -_Placeholder — paste the tightened post from your latest `edit` run here._ +You start cutting and pasting. Juggling tabs, merging ideas, fixing flaws. You become the human synthesizer for inconsistent AI assistants. It works, but it's tedious and error-prone. + +That's why **Crossfire** exists. + +## The Adversarial Loop + +Crossfire is a multi-agent adversarial refinement orchestrator. It takes your instruction, sends it to several LLMs to generate drafts, then sends each draft to other LLMs for critical review. Finally, it synthesizes the best parts into a single, refined artifact. + +The core loop: + +1. **Generate** — Create multiple independent attempts at the task +2. **Review** — Scrutinize each attempt from multiple adversarial perspectives +3. **Synthesize** — Discard the weak, merge the strong, produce something better + +This cycle repeats, with each round building on the synthesis of the last. Bad ideas get eliminated early so good ones can be rebuilt stronger. It escapes the local maximum of a single model's thinking by introducing fresh, critical contexts at each step. + +This multi-start evolutionary search—where multiple attempts compete and the best elements survive—is what makes it work. It's an [improved Ralph Wiggum method](https://ianreppel.org/ralph-wiggum-as-a-degenerate-evolutionary-search/) that filters out the worst through systematic build → break → rebuild cycles. + +## Why Engineers Should Care + +Crossfire automates the adversarial pipeline. Instead of trusting a single LLM's first attempt, it runs systematic pressure that catches flaws before they reach your final output. Each round applies fresh eyes so reviewers see new text with clear perspective rather than iterating endlessly on the same tired draft. + +For engineers who want higher-quality, vetted outputs—code that compiles, prose that's clear, research that stands up to inspection—Crossfire provides a systematic way to get there without juggling browser tabs or trusting a single pass. + +## What It Looks Like + +This blog post was generated using Crossfire's two-stage pipeline. First, a draft in `write` mode: + +```bash +uv run crossfire run \ + --mode write \ + --instruction-file docs/launch-announcement-prompt.md \ + --context-file README.md \ + --num-generators 5 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ + --output docs/launch-announcement-step-1-output.md +``` + +Then tightened in `edit` mode: + +```bash +uv run crossfire run \ + --mode edit \ + --instruction-file docs/launch-announcement-tighten.md \ + --context-file docs/launch-announcement-step-1-output.md \ + --num-generators 3 \ + --num-reviewers-per-candidate 5 \ + --num-rounds 5 \ + --output docs/launch-announcement-step-2-output.md +``` + +For quick research questions, it's even simpler: + +```bash +uv run crossfire run \ + --mode research \ + --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" +``` + +A typical run looks like this in your terminal: + +```terminal +⠧ Round 1/3 | Phase: Generate | 5 candidates +⠧ Round 1/3 | Phase: Review | 15 reviews +⠧ Round 1/3 | Phase: Synthesize +✓ Round 1 complete. Weaknesses found: 7 +⠧ Round 2/3 | Phase: Generate | 5 candidates +⠧ Round 2/3 | Phase: Review | 15 reviews +⠧ Round 2/3 | Phase: Synthesize +✓ Round 2 complete. Weaknesses found: 2 +✓ Run complete. Early stop after 2 rounds. Output saved to runs/20250415_142356/output.md +``` + +Crossfire replaces the manual "let me try that again" with structured adversarial refinement. It turns multiple LLMs into a practical tool—not by making them agree, but by making them work through systematic build → break → rebuild cycles until what remains can withstand scrutiny. \ No newline at end of file diff --git a/docs/launch-announcement-tighten.md b/docs/launch-announcement-tighten.md index f703a9e..e8a26b2 100644 --- a/docs/launch-announcement-tighten.md +++ b/docs/launch-announcement-tighten.md @@ -1,13 +1,16 @@ -# Edit pass: tighten the launch post +# Step 2: Crossfire launch announcement — edit mode -You are polishing a draft blog post about Crossfire for publication on a static site (Markdown, no special plugins). +Tighten for a static Jekyll site, fully in Markdown, ready to paste. +Cut repetition and setup, but keep headings scannable. +Facts must match the context file (README). -**Goals:** +Every fenced `uv run` block must match `docs/launch-announcement-prompt.md` character for character. -- Cut repetition and throat-clearing; keep punchy sections and skimmable headings. -- Preserve factual claims and any CLI examples — they must stay aligned with the README. -- Keep the voice: witty, human, **The Simpsons** allowed in moderation — do not let jokes crowd out clarity. -- Avoid corporate filler and banned buzzwords (no “delve”, “holistic”, “synergy”, “dogfooding”, “leverage” as a verb, etc.). -- Aim for roughly **900–1200 words** unless the draft is already tighter. +Keep the voice unique and non-corporate. +Bland is not acceptable! +Keep first person light: do not rewrite into "we", and keep Crossfire as the subject rather than extended "I" narration. +Keep one light The Simpsons / Ralph Wiggum nod and the README's evolutionary search link if the draft already has them. +Do not strip those for brevity. -**Output:** the full revised post in Markdown only, ready to paste into the site. +Roughly 900–1,200 words unless the draft is already shorter. +The post must open with a single strong one-liner that draws the reader in. From aba672732dab4d31d8f753429faa4097141c5875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Mon, 20 Apr 2026 15:17:46 -0300 Subject: [PATCH 06/15] Fix bug with long lists of models in TUI --- crossfire/ui/tui.py | 242 ++++++++++++++++++++++++++++++++------------ 1 file changed, 176 insertions(+), 66 deletions(-) diff --git a/crossfire/ui/tui.py b/crossfire/ui/tui.py index 6b9c6d3..862e512 100644 --- a/crossfire/ui/tui.py +++ b/crossfire/ui/tui.py @@ -240,71 +240,167 @@ def _refresh(self) -> None: def _build_display(self) -> Group: self._spinner_index = (self._spinner_index + 1) % len(_SPINNER_FRAMES) - parts: list[Any] = [] - now = time.monotonic() + now: float = time.monotonic() - max_model_length: int = 0 - for rows in self._rounds.values(): - for row in rows: - name = _shorten_model(row.model) - suffix = f" → candidate {row.candidate_index}" if row.candidate_index is not None else "" - max_model_length = max(max_model_length, len(name) + len(suffix)) - max_model_length = max(max_model_length, 10) # floor to avoid cramped columns before any tasks arrive - - for round_num in sorted(self._rounds): - rows = self._rounds[round_num] - if round_num == 0: - parts.append(Text(" Enrichment", style="bold blue")) + max_model_display_width: int = self._compute_max_model_display_width() + + history_lines: list[Any] = [] + current_round_lines: list[Any] = [] + + for round_number in sorted(self._rounds): + rows: list[_TaskRow] = self._rounds[round_number] + is_current: bool = round_number == self._current_round + round_finished: bool = bool(rows) and all(row.done for row in rows) + + if not is_current and round_finished: + history_lines.append(self._build_collapsed_round(round_number, rows)) + continue + + if round_number == 0: + current_round_lines.append(Text(" Enrichment", style="bold blue")) else: - parts.append(Text(f" Round {round_num}/{self._total_rounds}", style="bold")) + current_round_lines.append(Text(f" Round {round_number}/{self._total_rounds}", style="bold")) + + current_round_lines.extend( + self._build_round_rows(round_number, rows, max_model_display_width, now) + ) + + if self._should_show_phase_bar(round_number, rows): + current_round_lines.append(self._build_phase_bar(rows)) + + overall_bar: Progress = self._build_overall_bar() + fixed_line_count: int = len(current_round_lines) + 2 # spacer + overall bar + terminal_height: int = self.console.size.height + available_for_history: int = max(terminal_height - fixed_line_count, 0) + trimmed_history: list[Any] = self._trim_history(history_lines, available_for_history) + + parts: list[Any] = trimmed_history + current_round_lines + parts.append(Text("")) + parts.append(overall_bar) + + return Group(*parts) + + def _compute_max_model_display_width(self) -> int: + widest: int = 10 + for rows in self._rounds.values(): for row in rows: - label_text, label_style = _PHASE_LABELS.get(row.phase, (row.phase, "white")) - model_name = _shorten_model(row.model) - suffix = f" → candidate {row.candidate_index}" if row.candidate_index is not None else "" - model_display = f"{model_name}{suffix}" - - if row.done: - icon = "[green]✓[/green]" - elapsed = _format_elapsed(row.elapsed) - else: - icon = f"[{label_style}]{_SPINNER_FRAMES[self._spinner_index]}[/{label_style}]" - elapsed = _format_elapsed(now - row.start_time) - - line = Text.from_markup( - f" {icon} [{label_style}]{label_text:<{_MAX_PHASE_LABEL_LENGTH}}[/{label_style}]" - f" {model_display:<{max_model_length}} [dim]{elapsed}[/dim]" - ) - parts.append(line) - - if ( - rows - and self._active_phase - and round_num == self._current_round - and self._active_phase_done < self._active_phase_total - ): - phase_bar = Progress( - TextColumn(" {task.description}"), - BarColumn(bar_width=24), - TextColumn("{task.completed}/{task.total}"), - TimeElapsedColumn(), - console=self.console, - transient=True, - ) - fallback = (self._active_phase, "white") - phase_label = _PHASE_LABELS.get(self._active_phase, fallback)[0] - tid = phase_bar.add_task( - phase_label, - total=self._active_phase_total, - completed=self._active_phase_done, - ) + name: str = _shorten_model(row.model) + suffix: str = f" → candidate {row.candidate_index}" if row.candidate_index is not None else "" + widest = max(widest, len(name) + len(suffix)) + return widest + + def _build_collapsed_round(self, round_number: int, rows: list[_TaskRow]) -> Text: + """Renders a completed round as a single summary line.""" + task_count: int = len(rows) + max_elapsed: float = max(row.elapsed for row in rows) if rows else 0.0 + elapsed: str = _format_elapsed(max_elapsed) + + if round_number == 0: + model_name: str = _shorten_model(rows[0].model) if rows else "enricher" + return Text.from_markup(f" Enrichment [green]✓[/green] {model_name} [dim]{elapsed}[/dim]") + + return Text.from_markup( + f" Round {round_number}/{self._total_rounds} [green]✓[/green]" + f" {task_count} tasks [dim]{elapsed}[/dim]" + ) + + def _build_collapsed_phase(self, phase: Phase, phase_rows: list[_TaskRow]) -> Text: + """Renders a completed phase within the current round as a single summary line.""" + label_text: str = _PHASE_LABELS.get(phase, (phase, "white"))[0] + task_count: int = len(phase_rows) + max_elapsed: float = max(row.elapsed for row in phase_rows) if phase_rows else 0.0 + elapsed: str = _format_elapsed(max_elapsed) + return Text.from_markup( + f" [green]✓[/green] {label_text} {task_count} tasks [dim]{elapsed}[/dim]" + ) + + def _build_round_rows( + self, + round_number: int, + rows: list[_TaskRow], + max_model_display_width: int, + now: float, + ) -> list[Any]: + """Builds display lines for the current round, collapsing completed phases.""" + lines: list[Any] = [] + is_current: bool = round_number == self._current_round + + phases_in_order: list[Phase] = [] + rows_by_phase: dict[Phase, list[_TaskRow]] = {} + for row in rows: + if row.phase not in rows_by_phase: + phases_in_order.append(row.phase) + rows_by_phase[row.phase] = [] + rows_by_phase[row.phase].append(row) + + for phase in phases_in_order: + phase_rows: list[_TaskRow] = rows_by_phase[phase] + phase_finished: bool = all(row.done for row in phase_rows) + has_later_phase: bool = phase != phases_in_order[-1] + + if is_current and phase_finished and has_later_phase: + lines.append(self._build_collapsed_phase(phase, phase_rows)) + continue + + for row in phase_rows: + lines.append(self._build_task_line(row, max_model_display_width, now)) + + return lines + + def _build_task_line(self, row: _TaskRow, max_model_display_width: int, now: float) -> Text: + """Renders a single task row with spinner or checkmark.""" + label_text, label_style = _PHASE_LABELS.get(row.phase, (row.phase, "white")) + model_name: str = _shorten_model(row.model) + suffix: str = f" → candidate {row.candidate_index}" if row.candidate_index is not None else "" + model_display: str = f"{model_name}{suffix}" + + if row.done: + icon: str = "[green]✓[/green]" + elapsed: str = _format_elapsed(row.elapsed) + else: + icon = f"[{label_style}]{_SPINNER_FRAMES[self._spinner_index]}[/{label_style}]" + elapsed = _format_elapsed(now - row.start_time) + + return Text.from_markup( + f" {icon} [{label_style}]{label_text:<{_MAX_PHASE_LABEL_LENGTH}}[/{label_style}]" + f" {model_display:<{max_model_display_width}} [dim]{elapsed}[/dim]" + ) + + def _should_show_phase_bar(self, round_number: int, rows: list[_TaskRow]) -> bool: + return bool( + rows + and self._active_phase + and round_number == self._current_round + and self._active_phase_done < self._active_phase_total + ) - first_active = next((row for row in rows if row.phase == self._active_phase), None) - if first_active: - phase_bar.tasks[tid].start_time = first_active.start_time - parts.append(phase_bar) + def _build_phase_bar(self, rows: list[_TaskRow]) -> Progress: + phase_bar: Progress = Progress( + TextColumn(" {task.description}"), + BarColumn(bar_width=24), + TextColumn("{task.completed}/{task.total}"), + TimeElapsedColumn(), + console=self.console, + transient=True, + ) + fallback: tuple[Phase | str, str] = (self._active_phase, "white") # type: ignore[assignment] + phase_label: str = _PHASE_LABELS.get(self._active_phase, fallback)[0] # type: ignore[arg-type] + task_id = phase_bar.add_task( + phase_label, + total=self._active_phase_total, + completed=self._active_phase_done, + ) - overall_bar = Progress( + first_active: _TaskRow | None = next( + (row for row in rows if row.phase == self._active_phase), None + ) + if first_active: + phase_bar.tasks[task_id].start_time = first_active.start_time + return phase_bar + + def _build_overall_bar(self) -> Progress: + overall_bar: Progress = Progress( TextColumn(" [bold green]Rounds"), BarColumn(bar_width=30), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), @@ -313,16 +409,30 @@ def _build_display(self) -> Group: console=self.console, transient=True, ) - rounds_done = max(self._current_round - 1, 0) + rounds_done: int = max(self._current_round - 1, 0) if self._rounds_completed == self._total_rounds and self._total_rounds > 0: rounds_done = self._total_rounds - tid = overall_bar.add_task( + task_id = overall_bar.add_task( "Rounds", total=max(self._total_rounds, 1), completed=rounds_done, ) - overall_bar.tasks[tid].start_time = self._run_start - parts.append(Text("")) - parts.append(overall_bar) - - return Group(*parts) + overall_bar.tasks[task_id].start_time = self._run_start + return overall_bar + + @staticmethod + def _trim_history(history_lines: list[Any], available_lines: int) -> list[Any]: + """Trims collapsed round history from the top to fit the available lines.""" + if len(history_lines) <= available_lines: + return history_lines + + if available_lines <= 1: + hidden_count: int = len(history_lines) + return [Text.from_markup(f" [dim]… {hidden_count} earlier rounds …[/dim]")] + + visible_count: int = available_lines - 1 + hidden_count = len(history_lines) - visible_count + return [ + Text.from_markup(f" [dim]… {hidden_count} earlier rounds …[/dim]"), + *history_lines[-visible_count:], + ] From abc06eb66c300679dcfca8b548aff9a90e321d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Mon, 20 Apr 2026 15:18:04 -0300 Subject: [PATCH 07/15] Rephrase and simplify limitations/improvements --- README.md | 57 +++++++++++-------------------------------------------- 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 1bbfbdf..11a02c0 100644 --- a/README.md +++ b/README.md @@ -263,67 +263,32 @@ crossfire/ └── .pre-commit-config.yaml ``` -## Limitations and future work - -**Token budgets are per role group with optional per-model overrides.** -Set `context_window` to the model's actual limit; see `crossfire.toml` for override syntax. +## Limitations and improvements **Token estimation is approximate.** Counts use the `cl100k_base` tokenizer (via tiktoken) as a proxy for all providers. Actual token counts may differ for non-OpenAI models. -A fixed safety margin of 50 tokens is added to every estimate to compensate. - -**Output length defaults are role-specific.** -By default, `generators` and `reviewers` use `max_output_tokens = 4096`, while `synthesizer` uses `max_output_tokens = 32000`. -Configuration validation rejects any `max_output_tokens` that exceeds 80% of the effective context window. **OpenRouter is the only LLM provider.** -Model IDs are sent to a single OpenRouter endpoint. Direct API calls to Anthropic, Google, or OpenAI are not supported. **Tavily is the only search provider.** -The `search.provider` config field is parsed but has no effect, because only Tavily is implemented. -Search results from the current round are injected into the next round's generator prompt; the requesting LLM does not see its own results in the same call. A missing `TAVILY_API_KEY` fails at startup when `search.enabled = true`. -Transient HTTP or timeout errors at query time degrade gracefully to empty results rather than aborting the run. +Transient errors degrade gracefully to empty results rather than aborting the run. **Compression is extractive, not generative.** -When prompts exceed the token budget, Crossfire compresses by dropping sections and sentences rather than summarizing. -Compression priority: 1) candidates, 2) reviews, and 3) context. +When prompts exceed the token budget, Crossfire drops sections and sentences rather than summarizing. The task instruction is _never_ compressed. **No streaming.** Responses are received in full. -There is no incremental output during long generation or synthesis calls. **No persistent state.** -Each run is self-contained. -There is no caching of intermediate results, so a crashed run must be restarted from scratch. - -**Reasoning models may need special handling.** -Some models (o4-mini, o3, DeepSeek R1) reject `temperature` or expect `max_completion_tokens` instead of `max_tokens`. -Behaviour depends on how OpenRouter passes these through to the upstream provider. - -**Review coverage can degrade silently.** -If reviewers fail, the round proceeds as long as each candidate has at least one review, even when `num_reviewers_per_candidate` is higher. -Failed reviews are logged as `model_dropped` events. - -**Enricher input is not compressed.** -If a very large instruction is passed (e.g. a 200k-token file via `--instruction-file`), the enricher may exceed its context window with no fallback. - -**Synthesis decision parsing expects single-line JSON.** -The synthesizer is instructed to emit the `crossfire_synthesis` JSON on one line. -Models that pretty-print across multiple lines will have their attribution metadata silently ignored. - -### Ideas for future work - -- **Resume** — restart interrupted runs from the last completed round (addresses the "no persistent state" limitation) -- **Skip archive on dry-run by default** — dry-runs are developer smoke tests; archiving them clutters `runs/` and produces fake artifacts that look real. Keep the current behaviour when `--run-dir` is explicitly passed (for inspecting the structure); skip otherwise. -- **Response caching** — cache identical prompts in dry-run mode to speed up development -- **Cost prediction** — estimate total run costs upfront from prompt sizes and model selection -- **Run comparison** — diff outputs, costs, and performance across runs -- **Custom prompt templates** — let users override default mode prompts -- **Incremental compression** — apply compression progressively during generation -- **Batch processing** — multiple instructions in a single run with shared context -- **Local web UI** — browser-based interface with live progress, output panel, and searchable run history -- **Library and containerization** — extract a reusable library and Docker image so Crossfire can run as a service (would also require UTC-based logging for cross-region deployments) +Each run is self-contained, so a crashed run must be restarted from scratch. + +### Ideas for improvements + +- Resume: restart interrupted runs from the last completed round +- Cost prediction: estimate total run costs upfront from prompt sizes and model pricing, reported as part of `--dry-run` output +- Local UI: browser-based interface with live progress, output panel, and searchable run history +- Library and containerization: extract a reusable library and Docker image so Crossfire can run as a service (switch to UTC-based logs)) \ No newline at end of file From c79999b8e90fdc8cfea244a9632dfcc11ab8d161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Mon, 20 Apr 2026 15:18:23 -0300 Subject: [PATCH 08/15] Add prompts and outputs for launch announcement --- docs/launch-announcement-prompt.md | 66 --------------- docs/launch-announcement-step-1-output.md | 69 +++++----------- docs/launch-announcement-step-1-write-mode.md | 80 +++++++++++++++++++ docs/launch-announcement-step-2-edit-mode.md | 22 +++++ docs/launch-announcement-step-2-output.md | 73 +++++++---------- docs/launch-announcement-tighten.md | 16 ---- 6 files changed, 150 insertions(+), 176 deletions(-) delete mode 100644 docs/launch-announcement-prompt.md create mode 100644 docs/launch-announcement-step-1-write-mode.md create mode 100644 docs/launch-announcement-step-2-edit-mode.md delete mode 100644 docs/launch-announcement-tighten.md diff --git a/docs/launch-announcement-prompt.md b/docs/launch-announcement-prompt.md deleted file mode 100644 index ea16686..0000000 --- a/docs/launch-announcement-prompt.md +++ /dev/null @@ -1,66 +0,0 @@ -# Step 1: Crossfire launch announcement — write mode - -Write a blog post announcing Crossfire: what it is, why it exists, what problem it solves, why anyone might care, and the main adversarial loop in plain language. -You can use "shot down" once, then move on, e.g. build → break → rebuild loop. -Avoid extra combat imagery (no cage match, gauntlet, battlefield, slaughter, artillery), and no second gun pun. -DO NOT stack metaphors! - -The post must open with a single strong one-liner that invites the reader to continue. - -Voice: plain, focussed on engineers, but not bland. -Use "I" briefly for origin/intent, but keep Crossfire as the subject. -Never use "we" or corporate tone. - -One short The Simpsons / Ralph Wiggum nod is enough (the context file (README) links the idea to Ralph Wiggum and to a piece on evolutionary search). -Use that link from the README, do not invent URLs! - -If you need a framing hook, pick one and get to the product fast: -- What if Ralph Wiggum had friends? -- What if one is never enough? -- What if your draft could get critical reviews on demand? - -You are free to use (parts) of the back story, but you can decide against it entirely. -Back story: I created Crossfire because I ended up pitting LLMs against each other to improve their outputs gradually, except I got tired of copy-pasting between five tabs while waiting for each to finish. -I often did so for research questions, editing documents, or writing small convenience scripts that had subtle bugs. -I still have to check every single reference, the logic of the code, orany claims made, but at least the worst offenders are filtered out most of the time with such an adversarial yet manual setup. -The problem fresh contexts solve is that each LLM looks at the text or code with fresh (robot) eyes rather than keeps noodling on it, believing each iteration is better because it worked on it for a while. -Code and text need to be clear to anyone looking upon it for the first time. - -Include a short fake terminal snippet so readers see what a run looks like. -I'll swap in real output later. - -The README context includes other example commands. -Ignore those for this article. - -The two fenced bash blocks below are the exact commands for this announcement pipeline. -Copy these verbatim (same paths, flags, and numbers). - -Step 1 — draft (`write` mode): - -```bash -uv run crossfire run \ - --mode write \ - --instruction-file docs/launch-announcement-prompt.md \ - --context-file README.md \ - --num-generators 5 \ - --num-reviewers-per-candidate 3 \ - --num-rounds 3 \ - --output docs/launch-announcement-step-1-output.md -``` - -Step 2 — tighten (`edit` mode): - -```bash -uv run crossfire run \ - --mode edit \ - --instruction-file docs/launch-announcement-tighten.md \ - --context-file docs/launch-announcement-step-1-output.md \ - --num-generators 3 \ - --num-reviewers-per-candidate 5 \ - --num-rounds 5 \ - --output docs/launch-announcement-step-2-output.md -``` - -Also show one minimal one-off using `--instruction` (not `--instruction-file`), e.g. a single research question in quantum computing. -Keep that command short, so rely on defaults as much as possible. -Check the context file for defaults. diff --git a/docs/launch-announcement-step-1-output.md b/docs/launch-announcement-step-1-output.md index aa6b0f5..da26c6a 100644 --- a/docs/launch-announcement-step-1-output.md +++ b/docs/launch-announcement-step-1-output.md @@ -1,39 +1,27 @@ -# What if your draft could get critical reviews on demand? +What if Ralph Wiggum had friends? -You know the dance. You have a prompt—for code, for an essay, for analysis. You send it to one LLM. The output is... okay. A good starting point. So you open another tab, send it to a different model. That one has better structure but hallucinates a key detail. A third gets the detail right but writes like a corporate drone. +I built Crossfire to automate the one good way I've found to get consistently strong results from LLMs: make them argue. Manually juggling five browser tabs to have GPT-4 critique Claude's code while Gemini points out flaws in both is powerful, but it's a miserable, soul-crushing workflow. Crossfire is the tool that runs the whole process for you. -So you start cutting and pasting. Juggling tabs, merging ideas, fixing flaws. You become the human synthesizer, the quality control, the editor-in-chief for a team of inconsistent AI assistants. I found myself doing this constantly, manually pitting models against each other to triangulate a better result. It worked, but it was tedious, error-prone, and felt like a problem begging for automation. +The core problem is false confidence. When a single LLM gives you an answer, it sounds convincing. Without counterarguments or alternative perspectives, you have no way to gauge its strength. Manually simulating this adversarial process is effective but tedious. Crossfire automates it. -That's why **Crossfire** exists. +Here's how it works: you give Crossfire a task, and it sends that prompt to multiple LLMs simultaneously to generate different approaches. Each generated candidate gets shot down by multiple reviewer models that look for factual errors, logic flaws, security vulnerabilities, or whatever systematic weaknesses matter for your task. Then a synthesizer model takes all the candidates and reviews, picks the best parts, and combines them into a refined result. This generation → review → synthesis loop repeats for as many rounds as you specify. -## The Adversarial Loop +The insight is that fresh contexts prevent the groupthink that happens when you ask the same model to "improve" its own work. Each round starts clean rather than getting trapped in local optima. It's multi-start evolutionary search with delayed selection—the Ralph Wiggum method, but with actual friends to provide feedback. -Crossfire is a multi-agent adversarial refinement orchestrator. In plain terms: it takes your instruction, sends it to several different LLMs to generate drafts, then sends each draft to another set of LLMs for critical review. Finally, it synthesizes the best parts of the reviewed drafts into a single, refined artifact. +The reviewers aren't generic critics; they're specialists with specific protocols. In code mode, they act as paranoid senior engineers, hunting for security flaws and missing edge cases. In edit mode, they are ruthless copyeditors who slash jargon and weak phrasing. In write mode, they're a writers' group looking for plot holes and emotional authenticity. The system is designed to find material weaknesses, not to bikeshed style. -The core loop is deceptively simple: +For a simple research query: -1. **Generate** — Create multiple independent attempts at the task -2. **Review** — Scrutinize each attempt from multiple adversarial perspectives -3. **Synthesize** — Discard the weak, merge the strong, produce something better - -This cycle repeats, with each round building on the synthesis of the last. Bad ideas get eliminated early so good ones can be rebuilt stronger. It's a way to escape the local maximum of a single model's thinking by introducing fresh, critical contexts at each step. - -This multi-start evolutionary search—where multiple attempts compete and the best elements survive to the next generation—is what makes it work. It's an [improved Ralph Wiggum method](https://ianreppel.org/ralph-wiggum-as-a-degenerate-evolutionary-search/) that filters out the worst offenders through systematic build → break → rebuild cycles. - -## Why Engineers Should Care - -Crossfire automates the whole adversarial pipeline. Instead of trusting a single LLM's first attempt, it runs systematic pressure that catches flaws before they reach your final output. Each round applies fresh eyes so reviewers see new text with clear perspective rather than iterating endlessly on the same tired draft. - -For engineers who want higher-quality, vetted outputs—code that compiles, prose that's clear, research that stands up to inspection—Crossfire gives you a systematic way to get there without juggling browser tabs or trusting a single pass. - -## What It Looks Like +```bash +uv run crossfire run --mode research --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" +``` -This blog post itself was generated using Crossfire's two-stage pipeline. First, a draft in `write` mode: +For complex tasks, you can orchestrate a larger process: ```bash uv run crossfire run \ --mode write \ - --instruction-file docs/launch-announcement-prompt.md \ + --instruction-file docs/launch-announcement-step-1-write-mode.md \ --context-file README.md \ --num-generators 5 \ --num-reviewers-per-candidate 3 \ @@ -41,39 +29,20 @@ uv run crossfire run \ --output docs/launch-announcement-step-1-output.md ``` -Then tightened in `edit` mode: +Then refine through a second pass: ```bash uv run crossfire run \ --mode edit \ - --instruction-file docs/launch-announcement-tighten.md \ + --no-enrich \ + --instruction-file docs/launch-announcement-step-2-edit-mode.md \ --context-file docs/launch-announcement-step-1-output.md \ --num-generators 3 \ - --num-reviewers-per-candidate 5 \ - --num-rounds 5 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ --output docs/launch-announcement-step-2-output.md ``` -For quick research questions, it's even simpler: - -```bash -uv run crossfire run \ - --mode research \ - --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" -``` - -A typical run looks like this in your terminal: - -```terminal -⠧ Round 1/3 | Phase: Generate | 5 candidates -⠧ Round 1/3 | Phase: Review | 15 reviews -⠧ Round 1/3 | Phase: Synthesize -✓ Round 1 complete. Weaknesses found: 7 -⠧ Round 2/3 | Phase: Generate | 5 candidates -⠧ Round 2/3 | Phase: Review | 15 reviews -⠧ Round 2/3 | Phase: Synthesize -✓ Round 2 complete. Weaknesses found: 2 -✓ Run complete. Early stop after 2 rounds. Output saved to runs/20250415_142356/output.md -``` +Each round runs generation → review → synthesis sequentially, but within generation and review, model calls happen in parallel. Crossfire stops early when reviewers find no material weaknesses, since further rounds won't add value. -Crossfire replaces the manual intuition of "let me try that again" with structured adversarial refinement. It turns the latent potential of having multiple LLMs at your disposal into a practical tool—not by making them agree, but by making them work through systematic build → break → rebuild cycles until what remains can withstand scrutiny. \ No newline at end of file +_This post was written and edited by Crossfire (COST_PLACEHOLDER, TIME_PLACEHOLDER), and subsequently reviewed and tweaked by Ian._ \ No newline at end of file diff --git a/docs/launch-announcement-step-1-write-mode.md b/docs/launch-announcement-step-1-write-mode.md new file mode 100644 index 0000000..92f42c1 --- /dev/null +++ b/docs/launch-announcement-step-1-write-mode.md @@ -0,0 +1,80 @@ +# Step 1: Crossfire launch announcement — write mode + +Write a blog post announcing Crossfire: what it is, why it exists, what problem it solves, and the main adversarial loop in plain language. +Weave the value proposition into the explanation of how it works. +Do not create a separate "why you should care" section. +You can use "shot down" once, then move on, e.g. build → break → rebuild loop. +Avoid extra combat imagery (no cage match, gauntlet, battlefield, slaughter, artillery), and no second gun pun. +DO NOT stack metaphors! + +The post must open with a single short hook — a sentence, question, or quote — under 200 characters, alone on its first line. +Do not follow the hook with a "you know the feeling" scenario paragraph. +Go straight from hook to substance. + +Voice: plain, focussed on engineers, but not bland. +Use "I" briefly for origin/intent, but keep Crossfire as the subject. +Never use "we" or corporate tone. + +One short The Simpsons / Ralph Wiggum nod is enough (the context file (README) links the idea to Ralph Wiggum and to a piece on evolutionary search). +Use that link from the README. +Do not invent URLs! + +If you need a framing hook, pick one and get to the product fast: +- What if Ralph Wiggum had friends? +- What if one is never enough? +- What if your draft could get critical reviews on demand? + +You are free to use (parts) of the back story, but you can decide against it entirely. + +Back story: I [plan for five versions](https://ianreppel.org/thoughts-on-writing/) of everything I write or code. +Why not let LLMs do some of that drafting and re-drafting? +I created Crossfire because I ended up pitting LLMs against each other to improve their outputs gradually, except I got tired of copy-pasting between five tabs while waiting for each to finish. +I still have to check every single reference, the logic of the code, or any claims made, but at least the worst offenders are filtered out most of the time with such an adversarial setup. +Fresh contexts are crucial: each LLM looks at the text or code with fresh (robot) eyes rather than keeps noodling on it, believing each iteration is better because it worked on it for a while. +But managing that manually means juggling five tabs and starting new chats after every round. + +Include a short fake terminal snippet so readers see what a run looks like. +I'll swap in real output later. + +The README context includes other example commands. +Ignore those for this article. + +The two fenced bash blocks below are the exact commands for this announcement pipeline. +Copy these verbatim (same paths, flags, and numbers). + +Step 1 — draft (`write` mode): + +```bash +uv run crossfire run \ + --mode write \ + --instruction-file docs/launch-announcement-step-1-write-mode.md \ + --context-file README.md \ + --num-generators 5 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ + --output docs/launch-announcement-step-1-output.md +``` + +Step 2 — tighten (`edit` mode): + +```bash +uv run crossfire run \ + --mode edit \ + --no-enrich \ + --instruction-file docs/launch-announcement-step-2-edit-mode.md \ + --context-file docs/launch-announcement-step-1-output.md \ + --num-generators 3 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ + --output docs/launch-announcement-step-2-output.md +``` + +Also show one minimal one-off using `--instruction` (not `--instruction-file`), e.g. a single research question in quantum computing. +That command must use only `--mode` and `--instruction`, nothing else. + +End the post once, decisively. +No summary paragraph after the final code block. + +Close with an italicized one-liner: "_This post was written and edited by Crossfire (COST_PLACEHOLDER, TIME_PLACEHOLDER), and subsequently reviewed and tweaked by Ian._" +Keep the placeholders exactly as shown. +Do not replace them with numbers. diff --git a/docs/launch-announcement-step-2-edit-mode.md b/docs/launch-announcement-step-2-edit-mode.md new file mode 100644 index 0000000..9ddf5ab --- /dev/null +++ b/docs/launch-announcement-step-2-edit-mode.md @@ -0,0 +1,22 @@ +# Step 2: Crossfire launch announcement — edit mode + +Tighten for a static Jekyll site, fully in Markdown, ready to paste. +Cut repetition and setup, but keep headings scannable. +Do not introduce facts not present in the draft or the README. +Do not replace or invent URLs; keep exactly the links already in the draft. + +Every fenced `uv run` block must match `docs/launch-announcement-step-1-write-mode.md` character for character. + +Keep the voice unique and non-corporate. +Bland is not acceptable! +Keep first person light: do not rewrite into "we", and keep Crossfire as the subject rather than extended "I" narration. +Keep one light The Simpsons / Ralph Wiggum nod and the README's evolutionary search link if the draft already has them. +Do not strip those for brevity. + +Roughly 900–1,200 words unless the draft is already shorter. +The opening line must stay under 200 characters. +Do not rewrite it unless it exceeds the limit or falls flat. +If the draft has a separate "why you should care" section, fold it into the surrounding sections. +The post must end once, decisively. +Remove any summary or sign-off after the final code block. +Preserve the italicized footer line about Crossfire's cost and time exactly as it appears in the draft. diff --git a/docs/launch-announcement-step-2-output.md b/docs/launch-announcement-step-2-output.md index b763a80..3e25470 100644 --- a/docs/launch-announcement-step-2-output.md +++ b/docs/launch-announcement-step-2-output.md @@ -1,39 +1,43 @@ -# What if your draft could get critical reviews on demand? +# Crossfire: Automating the LLM Argument -You have a prompt—for code, for an essay, for analysis. You send it to one LLM. The output is okay—a starting point. You open another tab, try a different model. That one has better structure but hallucinates a key detail. A third gets the detail right but writes like a corporate drone. +What if Ralph Wiggum had friends? -You start cutting and pasting. Juggling tabs, merging ideas, fixing flaws. You become the human synthesizer for inconsistent AI assistants. It works, but it's tedious and error-prone. +I built Crossfire to automate the only reliable way to get strong LLM results: make them argue. Manually juggling browser tabs to have models critique each other works but is miserable. Crossfire runs the whole process for you. -That's why **Crossfire** exists. +The core problem is false confidence. A single LLM's answer sounds convincing, but without counterarguments, you can't gauge its strength. The solution is adversarial generation: multiple models create competing solutions while others attack them for weaknesses. -## The Adversarial Loop +## How It Works: Generation, Review, Synthesis -Crossfire is a multi-agent adversarial refinement orchestrator. It takes your instruction, sends it to several LLMs to generate drafts, then sends each draft to other LLMs for critical review. Finally, it synthesizes the best parts into a single, refined artifact. +Give Crossfire a task. It sends that prompt to multiple LLMs simultaneously to generate different approaches. Each candidate then faces multiple reviewer models hunting for factual errors, logic flaws, security holes, or other critical weaknesses. Finally, a synthesizer model takes all candidates and reviews, picks the best parts, and combines them into a refined result. -The core loop: +This generation → review → synthesis loop repeats for as many rounds as you specify. -1. **Generate** — Create multiple independent attempts at the task -2. **Review** — Scrutinize each attempt from multiple adversarial perspectives -3. **Synthesize** — Discard the weak, merge the strong, produce something better +The key insight? Fresh contexts prevent the groupthink that happens when you ask the same model to "improve" its own work. Each round starts clean, avoiding local optima. It's [multi-start evolutionary search](https://en.wikipedia.org/wiki/Evolutionary_algorithm) with delayed selection—the Ralph Wiggum method, but with actual friends providing feedback. -This cycle repeats, with each round building on the synthesis of the last. Bad ideas get eliminated early so good ones can be rebuilt stronger. It escapes the local maximum of a single model's thinking by introducing fresh, critical contexts at each step. +## Specialists, Not Generic Critics -This multi-start evolutionary search—where multiple attempts compete and the best elements survive—is what makes it work. It's an [improved Ralph Wiggum method](https://ianreppel.org/ralph-wiggum-as-a-degenerate-evolutionary-search/) that filters out the worst through systematic build → break → rebuild cycles. +The reviewers aren't bland critics. They're specialists with specific protocols: -## Why Engineers Should Care +* **Code Mode:** Paranoid senior engineers hunting for security flaws and missing edge cases. +* **Edit Mode:** Ruthless copyeditors slashing jargon and weak phrasing. +* **Write Mode:** A writers' group looking for plot holes and emotional authenticity. -Crossfire automates the adversarial pipeline. Instead of trusting a single LLM's first attempt, it runs systematic pressure that catches flaws before they reach your final output. Each round applies fresh eyes so reviewers see new text with clear perspective rather than iterating endlessly on the same tired draft. +The system finds material weaknesses, not bikeshed style. -For engineers who want higher-quality, vetted outputs—code that compiles, prose that's clear, research that stands up to inspection—Crossfire provides a systematic way to get there without juggling browser tabs or trusting a single pass. +## From Simple Queries to Complex Orchestration -## What It Looks Like +For a straightforward research task: -This blog post was generated using Crossfire's two-stage pipeline. First, a draft in `write` mode: +```bash +uv run crossfire run --mode research --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" +``` + +For a complex workflow, like writing this announcement: ```bash uv run crossfire run \ --mode write \ - --instruction-file docs/launch-announcement-prompt.md \ + --instruction-file docs/launch-announcement-step-1-write-mode.md \ --context-file README.md \ --num-generators 5 \ --num-reviewers-per-candidate 3 \ @@ -41,39 +45,20 @@ uv run crossfire run \ --output docs/launch-announcement-step-1-output.md ``` -Then tightened in `edit` mode: +Then refine it: ```bash uv run crossfire run \ --mode edit \ - --instruction-file docs/launch-announcement-tighten.md \ + --no-enrich \ + --instruction-file docs/launch-announcement-step-2-edit-mode.md \ --context-file docs/launch-announcement-step-1-output.md \ --num-generators 3 \ - --num-reviewers-per-candidate 5 \ - --num-rounds 5 \ + --num-reviewers-per-candidate 3 \ + --num-rounds 3 \ --output docs/launch-announcement-step-2-output.md ``` -For quick research questions, it's even simpler: - -```bash -uv run crossfire run \ - --mode research \ - --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" -``` - -A typical run looks like this in your terminal: - -```terminal -⠧ Round 1/3 | Phase: Generate | 5 candidates -⠧ Round 1/3 | Phase: Review | 15 reviews -⠧ Round 1/3 | Phase: Synthesize -✓ Round 1 complete. Weaknesses found: 7 -⠧ Round 2/3 | Phase: Generate | 5 candidates -⠧ Round 2/3 | Phase: Review | 15 reviews -⠧ Round 2/3 | Phase: Synthesize -✓ Round 2 complete. Weaknesses found: 2 -✓ Run complete. Early stop after 2 rounds. Output saved to runs/20250415_142356/output.md -``` +Within each round, generation and review happen in parallel. Crossfire stops early if reviewers find no material weaknesses, since further rounds won't add value. -Crossfire replaces the manual "let me try that again" with structured adversarial refinement. It turns multiple LLMs into a practical tool—not by making them agree, but by making them work through systematic build → break → rebuild cycles until what remains can withstand scrutiny. \ No newline at end of file +_This post was written and edited by Crossfire (COST_PLACEHOLDER, TIME_PLACEHOLDER), and subsequently reviewed and tweaked by Ian._ diff --git a/docs/launch-announcement-tighten.md b/docs/launch-announcement-tighten.md deleted file mode 100644 index e8a26b2..0000000 --- a/docs/launch-announcement-tighten.md +++ /dev/null @@ -1,16 +0,0 @@ -# Step 2: Crossfire launch announcement — edit mode - -Tighten for a static Jekyll site, fully in Markdown, ready to paste. -Cut repetition and setup, but keep headings scannable. -Facts must match the context file (README). - -Every fenced `uv run` block must match `docs/launch-announcement-prompt.md` character for character. - -Keep the voice unique and non-corporate. -Bland is not acceptable! -Keep first person light: do not rewrite into "we", and keep Crossfire as the subject rather than extended "I" narration. -Keep one light The Simpsons / Ralph Wiggum nod and the README's evolutionary search link if the draft already has them. -Do not strip those for brevity. - -Roughly 900–1,200 words unless the draft is already shorter. -The post must open with a single strong one-liner that draws the reader in. From 1224c45b2f4c0a1582c20e62f460fe9ab7192f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 09:39:24 -0300 Subject: [PATCH 09/15] Add CODEOWNERS --- .github/CODEOWNERS | 1 + .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..df32812 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ianreppel diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaae686..92a007c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,9 +8,9 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.8 hooks: + - id: ruff-format - id: ruff args: [--fix] - - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 hooks: From 903279b4a730099b87cbf269d66be4a22c2a7f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 09:39:55 -0300 Subject: [PATCH 10/15] Rework Cursor rules --- .cursor/rules.md | 78 -------------------------------- .cursor/rules/architecture.mdc | 26 +++++++++++ .cursor/rules/error-handling.mdc | 20 ++++++++ .cursor/rules/naming.mdc | 12 +++++ .cursor/rules/prompts.mdc | 13 ++++++ .cursor/rules/style.mdc | 27 +++++++++++ .cursor/rules/testing.mdc | 16 +++++++ 7 files changed, 114 insertions(+), 78 deletions(-) delete mode 100644 .cursor/rules.md create mode 100644 .cursor/rules/architecture.mdc create mode 100644 .cursor/rules/error-handling.mdc create mode 100644 .cursor/rules/naming.mdc create mode 100644 .cursor/rules/prompts.mdc create mode 100644 .cursor/rules/style.mdc create mode 100644 .cursor/rules/testing.mdc diff --git a/.cursor/rules.md b/.cursor/rules.md deleted file mode 100644 index 2a6e7f8..0000000 --- a/.cursor/rules.md +++ /dev/null @@ -1,78 +0,0 @@ -# Crossfire — Cursor Rules - -## Naming - -- No abbreviations or acronyms in names: `configuration` not `config`, `parameters` not `params`, `index` not `idx`, `length` not `len` (except when using said built-in function) -- No single-letter variables: `match` not `m`, `line` not `ln`, `candidate` not `c` (short names are acceptable inside list/dict/set comprehensions) -- Spell out `exception` in except clauses (not `exc`) -- Regex constants use `_REGEX` suffix (not `_RE`) -- Class names spelled out: `Configuration` not `Config`, `Parameters` not `Params` - -## Docstrings - -- Function/method docstrings use third person ("Rewrites the instruction" not "Rewrite the instruction") -- One-liner and module docstrings: end with a full stop -- British OED spelling throughout (-ize/-yse) -- Human-readable and concise, not technically precise but unintelligible - -## Architecture - -- `domain.py` holds all data structures (not `models.py`, which is ambiguous in an LLM project) -- `openrouter.py` is the HTTP client layer (separate from orchestration) -- `simulation.py` holds all dry-run fakes (LLM responses and search results) -- `prompts.py` contains prompt builders (pure functions) and output parsers (review verdict, synthesis decision) -- `compression.py` handles both low-level text compression and prompt fitting within token budgets -- `progress.py` defines the `ProgressCallback` protocol and `NoOpProgress` fallback -- `reviewers.py` holds the reviewer-to-candidate assignment algorithm -- `exclamations.py` holds the Simpsons-quote prefix helper used to dress up error messages -- Cross-group model overlap is allowed: a model may generate and review in the same round -- Compression priority: candidates first, then reviews, then context; the task instruction is never compressed - -### Dependency direction - -- `cli.py` depends on `core/` and `ui/`; nothing in `core/` or `ui/` imports from `cli.py` -- `ui/tui.py` depends on `core/progress.py` (protocol) and `core/domain.py` (types); it never imports from `core/orchestrator.py` -- Within `core/`, `orchestrator.py` is the only module that imports from `openrouter.py`, `compression.py`, and `reviewers.py`; `search.py` is imported by `orchestrator.py` only but may itself import `simulation.py` for its dry-run path -- `prompts.py` depends only on `domain.py`; it has no runtime dependencies on other core modules -- `exclamations.py` is a leaf module (stdlib-only); it may be imported from anywhere in `core/` - -## Prompts - -- `MODE_RULES` is the single source of constraints (never duplicate rules in system prompts) -- Generator system prompts: identity only, e.g. "You are a careful engineer who thinks several steps ahead" -- Reviewer system prompts: punchy adversarial identity and checklist (no overlap with `MODE_RULES`) -- `_BANNED_PHRASES`: context-free filler only; words with legitimate uses are excluded or marked `(metaphorical)` -- Write mode: enforce asymmetry (vary section lengths, never mirror structure) -- Code mode: language-agnostic (the user specifies their stack) - -## Style - -- Readability is paramount: when in doubt, optimize for the reader, not the writer -- Always include type annotations: function signatures, return types, and local variables -- Python 3.12+ -- Ruff line length: 120 -- `frozen=True` on immutable dataclasses (including `CrossfireConfiguration`) -- Top-level imports only (no lazy imports inside method bodies) -- `...` for Protocol method stubs, `pass` for concrete no-op implementations -- All files must end with a trailing newline (for git) -- In Markdown files, each sentence goes on its own line (no mid-sentence line breaks); let word wrapping handle display - -## Error handling - -- Always use structured `log.log_*` functions; never use `log.get_logger().warning(...)` or similar direct calls -- Group related catch-all exceptions into named module-level tuples (e.g. `_RETRIABLE_ERRORS`) rather than inline multi-type except clauses -- Methods should return new state, not silently mutate instance attributes (especially on frozen dataclasses) -- `RunFailedError` for fatal orchestration failures; `RuntimeError` for recoverable per-model failures and configuration errors surfaced before or during a run (e.g. missing API keys) -- Exception messages inside `core/` are wrapped in `exclaim(...)` from `exclamations.py` for a consistent tone; CLI-layer `click.echo(..., err=True)` messages stay plain - -## Testing - -- Module docstrings follow the pattern `"""Tests for ."""` -- Skip test-function docstrings when the test name already explains the intent -- Prefer `@pytest.mark.parametrize` over copy-pasted test functions with different inputs -- Use fixtures from `conftest.py` rather than hardcoding shared setup inline -- New shared test objects belong in `conftest.py` as fixtures, not as module-level constants in test files -- One assertion concept per test (multiple `assert` lines are fine if they test one logical thing) -- Dry-run determinism via SHA-256 hashing of all call parameters -- Test files may import private functions when needed (e.g. `_build_review_triage`) -- All tests + linting + typing must pass before merging diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc new file mode 100644 index 0000000..0aae882 --- /dev/null +++ b/.cursor/rules/architecture.mdc @@ -0,0 +1,26 @@ +--- +description: Module responsibilities and dependency direction +alwaysApply: true +--- + +# Architecture + +- `domain.py` holds all data structures (not `models.py`, which is ambiguous in an LLM project) +- `openrouter.py` is the HTTP client layer (separate from orchestration) +- `simulation.py` holds all dry-run fakes (LLM responses and search results) +- `prompts.py` contains prompt builders (pure functions) and output parsers (review verdict, synthesis decision) +- `compression.py` handles both low-level text compression and prompt fitting within token budgets +- `progress.py` defines the `ProgressCallback` protocol and `NoOpProgress` fallback +- `reviewers.py` holds the reviewer-to-candidate assignment algorithm +- `exclamations.py` holds the Simpsons-quote prefix helper used to dress up error messages +- Cross-group model overlap is allowed: a model may generate and review in the same round +- Compression priority: candidates first, then reviews, then context; the task instruction is never compressed + +## Dependency direction + +- `cli.py` depends on `core/` and `ui/`; nothing in `core/` or `ui/` imports from `cli.py` +- `ui/tui.py` depends on `core/progress.py` (protocol) and `core/domain.py` (types); it never imports from `core/orchestrator.py` +- Within `core/`, `orchestrator.py` is the only module that imports from `openrouter.py`, `compression.py`, and `reviewers.py`; `search.py` is imported by `orchestrator.py` only but may itself import `simulation.py` for its dry-run path +- `prompts.py` depends only on `domain.py`; it has no runtime dependencies on other core modules +- `exclamations.py` is a leaf module (stdlib-only); it may be imported from anywhere in `core/` +- `config.py` loads `crossfire.toml` with CLI override precedence: CLI flags > TOML values > defaults; per-mode overrides go in `[modes..*]`, missing roles fall back to global `[models.*]` defaults diff --git a/.cursor/rules/error-handling.mdc b/.cursor/rules/error-handling.mdc new file mode 100644 index 0000000..866ab9c --- /dev/null +++ b/.cursor/rules/error-handling.mdc @@ -0,0 +1,20 @@ +--- +description: Error handling patterns — structured logging, exception hierarchy, exclaim wrapper +alwaysApply: true +--- + +# Error handling + +- Always use structured `log.log_*` functions; never use `log.get_logger().warning(...)` or similar direct calls +- Group related catch-all exceptions into named module-level tuples (e.g. `_RETRIABLE_ERRORS`) rather than inline multi-type except clauses +- Methods should return new state, not silently mutate instance attributes (especially on frozen dataclasses) +- `RunFailedError` for fatal orchestration failures; `RuntimeError` for recoverable per-model failures and configuration errors surfaced before or during a run (e.g. missing API keys) +- `RefusalError` for generator refusals detected by `_REFUSAL_REGEX`; on refusal, attempt a replacement model before dropping the generator +- Synthesis regression detection: `_is_synthesis_regression` checks both refusal phrases and token-length ratio (<50% of the previous round); on regression the previous round's synthesis is carried forward +- Exception messages inside `core/` are wrapped in `exclaim(...)` from `exclamations.py` for a consistent tone; CLI-layer `click.echo(..., err=True)` messages stay plain + +# Secrets + +- Never hardcode API keys, tokens, or credentials in source code +- API keys are read from environment variables at runtime (`OPENROUTER_API_KEY`, `TAVILY_API_KEY`); never log or archive them +- Never commit `.env` files; they belong in `.gitignore` diff --git a/.cursor/rules/naming.mdc b/.cursor/rules/naming.mdc new file mode 100644 index 0000000..6f44443 --- /dev/null +++ b/.cursor/rules/naming.mdc @@ -0,0 +1,12 @@ +--- +description: Naming conventions — no abbreviations, no single-letter variables +alwaysApply: true +--- + +# Naming + +- No abbreviations or acronyms in names: `configuration` not `config`, `parameters` not `params`, `index` not `idx`, `length` not `len` (except when using said built-in function) +- No single-letter variables: `match` not `m`, `line` not `ln`, `candidate` not `c` (short names are acceptable inside list/dict/set comprehensions) +- Spell out `exception` in except clauses (not `exc`) +- Regex constants use `_REGEX` suffix (not `_RE`) +- Class names spelled out: `Configuration` not `Config`, `Parameters` not `Params` diff --git a/.cursor/rules/prompts.mdc b/.cursor/rules/prompts.mdc new file mode 100644 index 0000000..79e3bc7 --- /dev/null +++ b/.cursor/rules/prompts.mdc @@ -0,0 +1,13 @@ +--- +description: Prompt design constraints for generation, review, and synthesis +alwaysApply: true +--- + +# Prompts + +- `MODE_RULES` is the single source of constraints (never duplicate rules in system prompts) +- Generator system prompts: identity only, e.g. "You are a careful engineer who thinks several steps ahead" +- Reviewer system prompts: punchy adversarial identity and checklist (no overlap with `MODE_RULES`) +- `_BANNED_PHRASES`: context-free filler only; words with legitimate uses are excluded or marked `(metaphorical)` +- Write mode: enforce asymmetry (vary section lengths, never mirror structure) +- Code mode: language-agnostic (the user specifies their stack) diff --git a/.cursor/rules/style.mdc b/.cursor/rules/style.mdc new file mode 100644 index 0000000..1d192cd --- /dev/null +++ b/.cursor/rules/style.mdc @@ -0,0 +1,27 @@ +--- +description: Code style — type annotations, formatting, Python version constraints +alwaysApply: true +--- + +# Style + +- User edits are final: never revert, re-add, or modify code the user has changed or removed +- Readability is paramount: when in doubt, optimize for the reader, not the writer +- Always include type annotations: function signatures, return types, and local variables +- Python 3.12 only (3.13+ fails: transitive incompatibility in `docformatter` → `untokenize`) +- Ruff line length: 120 +- `frozen=True` on immutable dataclasses (including `CrossfireConfiguration`) +- Top-level imports only (no lazy imports inside method bodies) +- `...` for Protocol method stubs, `pass` for concrete no-op implementations +- All files must end with a trailing newline (for git) + +# Docstrings + +- Function/method docstrings use third person ("Rewrites the instruction" not "Rewrite the instruction") +- One-liner and module docstrings: end with a full stop +- British OED spelling throughout (-ize/-yse) +- Human-readable and concise, not technically precise but unintelligible + +# Markdown + +- Each sentence goes on its own line (no mid-sentence line breaks); let word wrapping handle display diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 0000000..8f1cdf4 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,16 @@ +--- +description: Testing conventions — pytest patterns, fixtures, assertions +alwaysApply: true +--- + +# Testing + +- Module docstrings follow the pattern `"""Tests for ."""` +- Skip test-function docstrings when the test name already explains the intent +- Prefer `@pytest.mark.parametrize` over copy-pasted test functions with different inputs +- Use fixtures from `conftest.py` rather than hardcoding shared setup inline +- New shared test objects belong in `conftest.py` as fixtures, not as module-level constants in test files +- One assertion concept per test (multiple `assert` lines are fine if they test one logical thing) +- Dry-run determinism via SHA-256 hashing of all call parameters +- Test files may import private functions when needed (e.g. `_build_review_triage`) +- All tests + linting + typing must pass before merging From 76cec3e056f786b85e88119d6cd5eebf92d529ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 09:40:33 -0300 Subject: [PATCH 11/15] Tweak Git --- .github/workflows/pr-checks.yml | 2 +- .gitignore | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2e34aa9..9b6c7f5 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -25,7 +25,7 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --all-extras + run: uv sync - name: Run pre-commit checks run: | diff --git a/.gitignore b/.gitignore index 58238b3..38065ad 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,8 @@ runs/ coverage.xml htmlcov/ +# Cursor +.cursor/plans/ + # macOS .DS_Store From 91318f5a4bf71c08acd9ce5f9df4c35496217012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 09:44:22 -0300 Subject: [PATCH 12/15] Fix Python 3.12 and add refusal/carry-forward --- README.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 11a02c0..1d9bb1c 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,14 @@ Each mode (research, code, edit, check, write) has its own review protocol: rubb ## How to use ### Prerequisites -- Python 3.12+ +- Python 3.12 - [uv](https://docs.astral.sh/uv/) for dependency management ### Install ```bash +uv python install 3.12 # transitive incompatibility in docformatter->untokenize +uv venv --python 3.12 uv sync ``` @@ -61,9 +63,9 @@ uv run crossfire run \ | Flag | Description | Default | |:-----|:------------|:--------| -| `--mode` | One of `research`, `code`, `edit`, `check`, `write` | —-- | -| `--instruction` | Task instruction (mutually exclusive with `--instruction-file`) | —-- | -| `--instruction-file` | Read instruction from a file (mutually exclusive with `--instruction`) | —-- | +| `--mode` | One of `research`, `code`, `edit`, `check`, `write` | — | +| `--instruction` | Task instruction (mutually exclusive with `--instruction-file`) | — | +| `--instruction-file` | Read instruction from a file (mutually exclusive with `--instruction`) | — | | `--context-file` | Supplementary reference material (see below) | none | | `--num-generators` | Generators per round | 1 | | `--num-reviewers-per-candidate` | Reviewers assigned to each candidate | 3 | @@ -150,6 +152,17 @@ After each round, Crossfire checks whether any reviewer found material weaknesse If all reviews report no weaknesses, the remaining rounds are skipped, as there is no value in further refinement. Disable with `--no-early-stop`. +### Generator refusal +Some models occasionally refuse to produce output, responding with meta-commentary like "the sources are insufficient" instead of answering the prompt. +Crossfire detects these refusals and attempts a replacement model from the generator pool. +If no replacement is available, the generator is dropped and the round fails gracefully. +The refusal is logged as a `model_dropped` event with reason `refusal`. + +### Synthesis reuse +If a synthesis is itself a refusal or regression (detected by the same refusal patterns), Crossfire discards it and carries forward the previous round's synthesis. +This prevents a bad late round from overwriting good earlier output. +The event is logged as `synthesis_regression`. + ### Systematic review protocols **Code mode** catches subtle issues that often slip through regular reviews: - **Security vulnerabilities**: SQL injection, XSS, path traversal, hardcoded secrets @@ -201,7 +214,9 @@ A `cost_summary` event is emitted at the end of each run with per-model and aggr ## Development ```bash -uv sync --all-extras +uv python install 3.12 # transitive incompatibility in docformatter->untokenize +uv venv --python 3.12 +uv sync uv run pre-commit install # set up git hooks (recommended) uv run pytest # run tests uv run pytest --cov # run tests with coverage @@ -258,6 +273,7 @@ crossfire/ │ │ └── tui.py # Rich-based progress display │ └── cli.py # Click CLI entrypoint ├── tests/ # comprehensive pytest suite +├── scripts/ # convenience scripts (check-all.sh) ├── crossfire.toml # default configuration ├── pyproject.toml └── .pre-commit-config.yaml @@ -291,4 +307,4 @@ Each run is self-contained, so a crashed run must be restarted from scratch. - Resume: restart interrupted runs from the last completed round - Cost prediction: estimate total run costs upfront from prompt sizes and model pricing, reported as part of `--dry-run` output - Local UI: browser-based interface with live progress, output panel, and searchable run history -- Library and containerization: extract a reusable library and Docker image so Crossfire can run as a service (switch to UTC-based logs)) \ No newline at end of file +- Library and containerization: extract a reusable library and Docker image so Crossfire can run as a service (switch to UTC-based logs) \ No newline at end of file From 1960841e2d9a316e6abc20e7abd90328f2b6ffbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 10:17:51 -0300 Subject: [PATCH 13/15] Tweak TOML --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9b44e1d..9462d1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "click>=8.1", ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", From 05198cfb8554dc2bc507bbabd09f60c09d775e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 10:18:03 -0300 Subject: [PATCH 14/15] Add QEC --- docs/launch-announcement-qec.md | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/launch-announcement-qec.md diff --git a/docs/launch-announcement-qec.md b/docs/launch-announcement-qec.md new file mode 100644 index 0000000..84d2e76 --- /dev/null +++ b/docs/launch-announcement-qec.md @@ -0,0 +1,114 @@ +# Comparative Analysis of Error Correction Strategies for Superconducting and Trapped-Ion Qubits + +## Introduction + +Quantum error correction (QEC) protects logical qubits from noise in physical implementations, enabling fault-tolerant quantum computing. Superconducting transmons and trapped-ion qubits represent the leading platforms, each exhibiting distinct error profiles. Superconductors feature short coherence times (~50-200 μs) but fast gates (~20-100 ns), while trapped ions offer long coherence (seconds to minutes) but slower gates (~10-100 μs). These differences drive divergent QEC strategies: superconductors favor local topological codes like surface codes, while trapped ions enable subsystem codes like Bacon-Shor that exploit their superior connectivity [1][2]. + +## Error Mechanisms and Native Performance + +Physical error characteristics fundamentally shape the choice of error correction codes for each platform. + +### Superconducting Qubits +- **Dominant errors**: Energy relaxation (T1 ~50-200 μs), dephasing from 1/f noise (T2 ~50-100 μs), and leakage during two-qubit gates +- **Gate errors**: Single-qubit gates achieve 10^-4 to 10^-3 error rates; two-qubit gates typically 10^-3, with recent improvements via pulse optimization [3] +- **Connectivity**: Fixed 2D nearest-neighbor layouts promote planar codes with local stabilizer checks +- **Error correction requirements**: Syndrome extraction must occur faster than T1 decay, necessitating ~1 μs cycle times + +### Trapped-Ion Qubits +- **Dominant errors**: Laser phase noise, motional heating, and photon scattering; minimal intrinsic dephasing +- **Gate errors**: Single-qubit ≤10^-4; two-qubit Mølmer-Sørensen gates achieve 10^-3 to 10^-4 across chains [4] +- **Connectivity**: All-to-all within chains (~50 ions) or reconfigurable via shuttling in quantum charge-coupled device (QCCD) architectures [5] +- **Error correction flexibility**: Long coherence times permit deeper circuits and more complex syndrome extraction sequences + +## Error Correction Codes and Implementations + +### Superconducting Platforms + +**Surface Codes**: The dominant approach for superconducting qubits, requiring ~2d² physical qubits for distance d. +- Circuit-level thresholds: ~1% for standard surface codes with minimum-weight perfect matching (MWPM) decoding [1] +- Recent milestones: Google's 2024 demonstration achieved distance-7 surface codes with logical error rates dropping 2-3× per distance increment, confirming operation below threshold at ~0.2-0.5% physical error rates [6] +- Biased-noise variants: XZZX surface codes exploit noise asymmetry to achieve thresholds exceeding 10% for specific error models [7] + +**Erasure Qubits**: Dual-rail encodings detect dominant error mechanisms (e.g., T1 decay) as erasures rather than unknown Pauli errors. +- Performance: 4-qubit surface codes suffice for erasure correction versus 5 for Pauli errors, reducing overhead ~2× [8] +- Implementation: Demonstrated in transmon arrays with leakage detection circuits + +**Bosonic Codes**: Hardware-efficient encodings in superconducting cavities. +- Cat codes: Protect against photon loss, extending logical qubit lifetimes ~10× [9] +- GKP codes: Multiple rounds of error correction demonstrated (2020), but scaling remains challenging [10] + +### Trapped-Ion Platforms + +**Bacon-Shor Codes**: Subsystem codes with low-weight gauge operators well-suited to ion connectivity. +- Thresholds: Achieve kd = O(n) scaling with period-4 extraction schedules [11] +- Experimental demonstrations: Real-time fault-tolerant implementation on 23-ion chains (2021-2024), comparing Shor and Steane syndrome extraction methods [12] + +**Color Codes**: Support transversal Clifford gates and exploit ion all-to-all connectivity. +- Implementation: Fault-tolerant universal gate sets demonstrated on ~10 ions (2022) [13] +- Thresholds: <0.5% circuit-level noise, demanding higher physical gate fidelities than surface codes + +**Small Codes**: [[5,1,3]] and [[7,1,3]] codes demonstrated with encoding fidelities 57-98% and logical Pauli operations at 97% fidelity [14] + +## Performance Metrics + +| Metric | Superconducting | Trapped-Ion | +|--------|-----------------|-------------| +| **Logical error suppression** | Distance-7: ~10^-3/cycle, exponential scaling confirmed [6] | Bacon-Shor: Fault-tolerant operations reduce logical errors versus bare implementation [12] | +| **Circuit-level threshold** | Surface: ~1% standard, >10% biased-noise [1][7] | Color/Bacon-Shor: ~0.5-1% [11] | +| **Qubit overhead (10^-15 target)** | ~10³-10⁴ physical/logical at 0.1% error rates | ~10²-10³ for small codes with shuttling | +| **Demonstrated cycles** | 100+ rounds with real-time MWPM decoding [15] | Multi-round fault-tolerant circuits with online decoding [12] | + +## Challenges and Recent Advances + +### Superconducting Qubits +**Advances (2021-2024)**: +- Below-threshold operation at scale (Google Willow, 2024) [6] +- Erasure conversion reducing overhead ~2× [8] +- Real-time decoding enabling arbitrary-duration experiments [15] + +**Remaining challenges**: +- Calibration drift requiring frequent retuning +- Leakage accumulation (~1% per gate) +- Decoder computational overhead for large distances + +### Trapped-Ion Qubits +**Advances (2021-2024)**: +- Fault-tolerant Bacon-Shor on 20+ ion chains [12] +- QCCD architectures enabling modular scaling [5] +- Mid-circuit measurement and feed-forward control [16] + +**Remaining challenges**: +- Motional heating limiting chain lengths +- Shuttling overhead (~milliseconds) constraining algorithm depth +- Limited demonstrations of exponential error suppression with code distance + +## Comparative Analysis + +The platforms exhibit complementary strengths shaped by their physical constraints. Superconducting qubits excel at rapid, parallel syndrome extraction in 2D topological codes, achieving the clearest demonstrations of exponential error suppression with increasing code distance. The 2024 below-threshold surface code results represent a watershed moment for scalability [6]. + +Trapped ions leverage superior native fidelities and connectivity for efficient small codes and fault-tolerant logical operations. While lacking comparable distance-scaling demonstrations, ions achieve practical error suppression with lower qubit overhead at small scales. The flexibility of ion architectures enables exploration of diverse code families. + +Cross-cutting advances in erasure conversion benefit both platforms, potentially reducing overhead by ~2× [8]. Bosonic encodings offer hardware-efficient alternatives but require fault-tolerant syndrome extraction for scalability [9][10]. + +## Conclusion + +Both platforms have validated core QEC principles experimentally. Superconducting qubits demonstrate clear paths to large-scale fault tolerance via surface codes operating below threshold. Trapped ions prove the viability of fault-tolerant primitives with small, efficient codes. Near-term progress requires superconductors to improve gate fidelities toward 99.99% while ions must demonstrate exponential scaling. The quantified progress—particularly the 2-3× error reduction per distance increment in superconducting surface codes—positions practical fault tolerance within reach over the next 5-10 years. + +## References + +[1] A. G. Fowler et al., Surface codes: Towards practical large-scale quantum computation, Physical Review A, 2012. +[2] J. Preskill, Quantum Computing in the NISQ era and beyond, Quantum, 2018. +[3] M. Kjaergaard et al., Superconducting Qubits: Current State of Play, Annual Review of Condensed Matter Physics, 2020. +[4] C. J. Ballance et al., High-Fidelity Quantum Logic Gates Using Trapped-Ion Hyperfine Qubits, Physical Review Letters, 2016. +[5] J. M. Pino et al., Demonstration of the trapped-ion quantum CCD architecture, Nature, 2021. +[6] Google Quantum AI, Quantum error correction below the surface code threshold, Nature, 2024. +[7] J. P. Bonilla Ataides et al., The XZZX surface code, Nature Communications, 2021. +[8] A. Kubica et al., Erasure qubits: Overcoming the T1 limit in superconducting circuits, Physical Review X, 2023. +[9] J. Guillaud and M. Mirrahimi, Repetition Cat Qubits for Fault-Tolerant Quantum Computation, Physical Review X, 2019. +[10] P. Campagne-Ibarcq et al., Quantum error correction of a qubit encoded in grid states of a superconducting cavity, Nature, 2020. +[11] A. Cross et al., Fault-tolerant quantum computation with constant overhead, Quantum Information and Computation, 2017. +[12] C. Ryan-Anderson et al., Implementing Fault-Tolerant Entangling Gates on the Five-Qubit Code and the Color Code, arXiv:2208.01863, 2022. +[13] L. Postler et al., Demonstration of fault-tolerant universal quantum gate operations, Nature, 2022. +[14] N. H. Nguyen et al., Demonstration of Shor Encoding on a Trapped-Ion Quantum Computer, Physical Review Applied, 2021. +[15] S. Krinner et al., Realizing repeated quantum error correction in a distance-three surface code, Nature, 2022. +[16] C. D. Bruzewicz et al., Trapped-ion quantum computing: Progress and challenges, Applied Physics Reviews, 2019. \ No newline at end of file From 926934d5f33ade602000d73225c6775dbf37f007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ian=20Hellstr=C3=B6m?= Date: Tue, 21 Apr 2026 10:26:48 -0300 Subject: [PATCH 15/15] Add refusal logic --- crossfire/core/logging.py | 4 + crossfire/core/openrouter.py | 4 +- crossfire/core/orchestrator.py | 153 +++++++++++++++++++++++---- crossfire/core/prompts.py | 17 ++- crossfire/ui/tui.py | 16 +-- tests/test_compression.py | 5 +- tests/test_dry_run.py | 92 +++++++++++----- tests/test_edit_mode.py | 45 +++++--- tests/test_failure_handling.py | 181 +++++++++++++++++++++++++++++++- tests/test_pipeline_sequence.py | 9 +- tests/test_review_triage.py | 40 +++++++ tests/test_writer_mode.py | 2 +- uv.lock | 89 ++++++++-------- 13 files changed, 527 insertions(+), 130 deletions(-) diff --git a/crossfire/core/logging.py b/crossfire/core/logging.py index 78c0edf..b16e39a 100644 --- a/crossfire/core/logging.py +++ b/crossfire/core/logging.py @@ -185,6 +185,10 @@ def log_early_stop(*, round: int, remaining_rounds: int, reason: str) -> None: _emit("early_stop", round=round, remaining_rounds=remaining_rounds, reason=reason) +def log_synthesis_regression(*, round: int, model: str, reason: str) -> None: + _emit("synthesis_regression", level=logging.WARNING, round=round, model=model, reason=reason) + + def log_archive_write_failed(*, path: str, error: str) -> None: _emit("archive_write_failed", level=logging.WARNING, path=path, error=error) diff --git a/crossfire/core/openrouter.py b/crossfire/core/openrouter.py index 379e9d6..53a7249 100644 --- a/crossfire/core/openrouter.py +++ b/crossfire/core/openrouter.py @@ -26,9 +26,7 @@ class InsufficientCreditsError(RuntimeError): """Raised when OpenRouter returns 402 Payment Required.""" def __init__(self) -> None: - super().__init__( - "OpenRouter ate all your credits. Feed it some more at https://openrouter.ai/credits" - ) + super().__init__("OpenRouter ate all your credits. Feed it some more at https://openrouter.ai/credits") async def call_openrouter( diff --git a/crossfire/core/orchestrator.py b/crossfire/core/orchestrator.py index 78c333b..143ee48 100644 --- a/crossfire/core/orchestrator.py +++ b/crossfire/core/orchestrator.py @@ -4,6 +4,7 @@ import asyncio import json +import re from dataclasses import replace from typing import Any, cast @@ -78,6 +79,22 @@ class RunFailedError(Exception): """Raised when the entire run must abort (e.g. synthesis failure).""" +class RefusalError(RuntimeError): + """Raised when a model refuses to produce output (e.g. "insufficient data").""" + + +_REFUSAL_PHRASES: tuple[str, ...] = ( + "i must be transparent about", + "i cannot fulfill", + "i'm unable to fulfill", + r"insufficient .* to support", + "would you like me to search", + "i appreciate the detailed instructions, but", +) + +_REFUSAL_REGEX: re.Pattern[str] = re.compile("|".join(_REFUSAL_PHRASES), re.IGNORECASE) + + class Orchestrator: """Orchestrates the generate -> review -> synthesize loop.""" @@ -160,11 +177,13 @@ async def run(self) -> str: self._consecutive_round_failures += 1 if self._consecutive_round_failures >= MAX_CONSECUTIVE_ROUND_FAILURES: start_round = round_num - self._consecutive_round_failures + 1 - raise RunFailedError(exclaim( - f"Giving up: {self._consecutive_round_failures} consecutive " - f"round failures ({start_round}-{round_num}). " - "The models are having a bad day." - )) + raise RunFailedError( + exclaim( + f"Giving up: {self._consecutive_round_failures} consecutive " + f"round failures ({start_round}-{round_num}). " + "The models are having a bad day." + ) + ) else: # When the loop completes without an early stop self._progress.on_run_end() @@ -174,10 +193,12 @@ async def run(self) -> str: # Abort if no content was generated across all rounds if not previous_synthesis.strip(): - raise RunFailedError(exclaim( - f"{self.parameters.num_rounds} rounds and nothing to show for it. " - "Every generator failed or was dropped. Check the logs." - )) + raise RunFailedError( + exclaim( + f"{self.parameters.num_rounds} rounds and nothing to show for it. " + "Every generator failed or was dropped. Check the logs." + ) + ) if self._archive: self._archive.save_final_synthesis(previous_synthesis) @@ -300,8 +321,34 @@ async def _run_round(self, round_num: int, previous_synthesis: str) -> RoundResu if self._archive: self._archive.save_synthesis(synthesis) + if previous_synthesis and self._is_synthesis_regression(previous_synthesis, synthesis.text): + log.log_synthesis_regression( + round=round_num, + model=synthesis.model, + reason="synthesis_regression", + ) + return RoundResult(synthesis_text=previous_synthesis, reviews=reviews) + return RoundResult(synthesis_text=synthesis.text, reviews=reviews) + # Refusals and regressions come in many forms (meta-critiques, source complaints, + # "I can't do this" responses). Regex catches obvious phrases but sophisticated + # refusals slip through. A length check is more robust: legitimate refinement may + # trim content, but a 50%+ drop in tokens signals that the synthesizer replaced + # substance with commentary. + _SYNTHESIS_REGRESSION_RATIO = 0.50 + + @classmethod + def _is_synthesis_regression(cls, previous: str, current: str) -> bool: + """Detects whether the current synthesis is a regression from the previous one.""" + if _REFUSAL_REGEX.search(current[:500]): + return True + previous_tokens: int = estimate_tokens(previous) + if previous_tokens == 0: + return False + current_tokens: int = estimate_tokens(current) + return current_tokens / previous_tokens < cls._SYNTHESIS_REGRESSION_RATIO + # -- generation --- async def _run_generation(self, round_num: int, previous_synthesis: str) -> list[Candidate]: @@ -309,6 +356,8 @@ async def _run_generation(self, round_num: int, previous_synthesis: str) -> list generator_names = self.configuration.generators.names generator_count = self.parameters.num_generators generator_models = [generator_names[index % len(generator_names)] for index in range(generator_count)] + assigned_generators: set[str] = set(generator_models) + replacement_claim_lock: asyncio.Lock = asyncio.Lock() self._progress.on_phase_start( round_num, Phase.GENERATION, @@ -319,7 +368,14 @@ async def _run_generation(self, round_num: int, previous_synthesis: str) -> list async def _generate(index: int, model: str) -> Candidate: try: - return await self._generate_candidate(round_num, index, model, previous_synthesis) + return await self._generate_with_fallback( + round_num, + index, + model, + previous_synthesis, + assigned_generators, + replacement_claim_lock, + ) finally: self._progress.on_task_done(round_num, Phase.GENERATION, model=model) @@ -346,6 +402,58 @@ async def _generate(index: int, model: str) -> Candidate: self._progress.on_phase_end(round_num, Phase.GENERATION) return candidates + async def _generate_with_fallback( + self, + round_num: int, + index: int, + model: str, + previous_synthesis: str, + assigned_generators: set[str], + lock: asyncio.Lock, + ) -> Candidate: + """Attempts generation, falling back to a different model on refusal.""" + try: + return await self._generate_candidate(round_num, index, model, previous_synthesis) + except RefusalError: + log.log_model_dropped( + phase=Phase.GENERATION, + role=Role.GENERATOR, + model=model, + round=round_num, + reason="refusal", + ) + replacement = await self._find_replacement_generator( + self.configuration.generators.names, + assigned_generators, + model, + lock, + ) + if replacement: + log.log_retry( + round=round_num, + role=Role.GENERATOR, + model=model, + attempt=MAX_RETRIES + 1, + reason=f"refusal, replacing with {replacement}", + ) + return await self._generate_candidate(round_num, index, replacement, previous_synthesis) + raise + + @staticmethod + async def _find_replacement_generator( + all_names: list[str] | tuple[str, ...], + assigned: set[str], + failed: str, + lock: asyncio.Lock, + ) -> str | None: + """Atomically claims an unused generator model as a substitute for *failed*.""" + async with lock: + for name in all_names: + if name not in assigned and name != failed: + assigned.add(name) + return name + return None + async def _generate_candidate( self, round_num: int, @@ -397,6 +505,9 @@ async def _generate_candidate( dry_run_candidate_index=index, ) + if _REFUSAL_REGEX.search(text[:500]): + raise RefusalError(exclaim(f"{model} doesn't wanna: {text[:120]}")) + text, search_results = await self._process_search_request( text, role=Role.GENERATOR, @@ -660,9 +771,7 @@ async def _run_synthesis( reason="synth_failure", details=str(exception), ) - raise RunFailedError(exclaim( - f"Synthesis blew up in round {round_num}: {exception}" - )) from exception + raise RunFailedError(exclaim(f"Synthesis blew up in round {round_num}: {exception}")) from exception finally: self._progress.on_phase_end(round_num, Phase.SYNTHESIS) @@ -749,8 +858,8 @@ def _prepare_prompt( ) -> str: """Compresses *user_prompt* to fit the token budget. - When *fatal_on_overflow* is True, raises RunFailedError (for synthesizer); otherwise raises - RuntimeError (for generator/reviewer). + When *fatal_on_overflow* is True, raises RunFailedError (for synthesizer); otherwise raises RuntimeError (for + generator/reviewer). """ user_prompt, fits = compress_prompt_components( system_prompt=system_prompt, @@ -808,12 +917,14 @@ async def _process_search_request( search_timeout=self.configuration.limits.search_timeout, ) if results: - self._searches_performed.append({ - "round": round_num, - "role": role, - "model": model, - "query": query, - }) + self._searches_performed.append( + { + "round": round_num, + "role": role, + "model": model, + "query": query, + } + ) return text, results # -- LLM call (real or dry-run) --- diff --git a/crossfire/core/prompts.py b/crossfire/core/prompts.py index 9c666d5..e541437 100644 --- a/crossfire/core/prompts.py +++ b/crossfire/core/prompts.py @@ -17,6 +17,9 @@ "[N] Authors, Title, Journal/Venue, Year. " "For websites/reports: Author/Organization, Title, URL, Year. " "NEVER fabricate DOIs. Omit DOIs entirely unless you are certain they are correct\n" + "- Every [N] marker in the body MUST correspond to the correct [N] entry in the References section. " + "After drafting, re-read each citation in context and verify the referenced paper actually supports " + "the specific claim made. If unsure, flag with a bracketed note\n" "- Prioritize cutting-edge research (last 3-5 years) alongside foundational work\n" "- Flag recent results that have not yet been independently reproduced\n" "- Note any cited work that has been retracted, corrected, or substantially challenged\n" @@ -85,8 +88,10 @@ _REVIEWER_SYSTEM: dict[Mode, str] = { Mode.RESEARCH: ( - "You are a skeptical peer reviewer. Verify every claim with rigour:\n\n" + "You are a sceptical peer reviewer. Verify every claim with rigour:\n\n" "**CITATIONS:** uncited claims, citations that don't support the claim, " + "citation markers that don't match the corresponding reference entry " + "(e.g. [8] in the body describes result X but reference [8] is a different paper), " "single-source over-reliance, outdated or non-authoritative sources\n\n" "**RECENCY:** flag claims supported only by pre-2020 work when newer results exist; " "flag recent results that lack independent replication; " @@ -146,7 +151,7 @@ "Classify each claim as true / false / uncertain. Explain why." + _REVIEWER_STRUCTURED_SUFFIX ), Mode.WRITE: ( - "You are a jaded literary critic. Be skeptical; make the work convince you:\n\n" + "You are a jaded literary critic. Be sceptical; make the work convince you:\n\n" "**STORY:** plot holes, forced motivations, pacing problems, " "artificial stakes, world-building contradictions\n\n" "**CRAFT:** genuine vs performed voice, style consistency, " @@ -199,7 +204,8 @@ _SYNTHESIZER_SYSTEM: dict[Mode, str] = { Mode.RESEARCH: ( "Merge strongest candidate elements. Resolve contradictions. " - "Remove unsupported claims. Steelman objections before resolving them. " + "Remove unsupported claims. Verify every [N] marker matches the correct reference entry. " + "Steelman objections before resolving them. " "Produce a coherent, well-cited summary.\n\n" "DISCARD weak or unsupported content. Never hedge or include blindly." + _REVIEW_TRIAGE_INSTRUCTION @@ -246,7 +252,6 @@ def _format_search_block(search_results: str) -> str: return _SEARCH_SECTION_HEADER + search_results - _SEARCH_INSTRUCTION = ( "\n\nIf you need to search the web for information, emit a JSON object " "on the LAST non-empty line of your response:\n" @@ -255,6 +260,8 @@ def _format_search_block(search_results: str) -> str: ) +_MARKDOWN_DECORATION_REGEX = re.compile(r"^#{1,6}\s+|[*_]{1,3}") + _STRENGTHS_REGEX = re.compile(r"^STRENGTHS:\s*(.+)", re.IGNORECASE) _WEAKNESSES_REGEX = re.compile(r"^WEAKNESSES:\s*(.+)", re.IGNORECASE) _SEVERITY_REGEX = re.compile(r"^SEVERITY:\s*(\w+)", re.IGNORECASE) @@ -280,7 +287,7 @@ def parse_review_verdict(text: str) -> ReviewVerdict: """ verdict = ReviewVerdict() for line in text.split("\n"): - line = line.strip() + line = _MARKDOWN_DECORATION_REGEX.sub("", line).strip() match = _STRENGTHS_REGEX.match(line) if match: verdict.strengths.extend(item.strip() for item in _ITEM_SPLIT_REGEX.split(match.group(1)) if item.strip()) diff --git a/crossfire/ui/tui.py b/crossfire/ui/tui.py index 862e512..f0a323d 100644 --- a/crossfire/ui/tui.py +++ b/crossfire/ui/tui.py @@ -76,9 +76,7 @@ def emit(self, record: logging.LogRecord) -> None: class TUI: - """Implements the ``ProgressCallback`` protocol expected by - :class:`crossfire.core.orchestrator.Orchestrator`. - """ + """Implements the ``ProgressCallback`` protocol expected by :class:`crossfire.core.orchestrator.Orchestrator`.""" def __init__(self) -> None: self.console = Console(stderr=True) @@ -261,9 +259,7 @@ def _build_display(self) -> Group: else: current_round_lines.append(Text(f" Round {round_number}/{self._total_rounds}", style="bold")) - current_round_lines.extend( - self._build_round_rows(round_number, rows, max_model_display_width, now) - ) + current_round_lines.extend(self._build_round_rows(round_number, rows, max_model_display_width, now)) if self._should_show_phase_bar(round_number, rows): current_round_lines.append(self._build_phase_bar(rows)) @@ -311,9 +307,7 @@ def _build_collapsed_phase(self, phase: Phase, phase_rows: list[_TaskRow]) -> Te task_count: int = len(phase_rows) max_elapsed: float = max(row.elapsed for row in phase_rows) if phase_rows else 0.0 elapsed: str = _format_elapsed(max_elapsed) - return Text.from_markup( - f" [green]✓[/green] {label_text} {task_count} tasks [dim]{elapsed}[/dim]" - ) + return Text.from_markup(f" [green]✓[/green] {label_text} {task_count} tasks [dim]{elapsed}[/dim]") def _build_round_rows( self, @@ -392,9 +386,7 @@ def _build_phase_bar(self, rows: list[_TaskRow]) -> Progress: completed=self._active_phase_done, ) - first_active: _TaskRow | None = next( - (row for row in rows if row.phase == self._active_phase), None - ) + first_active: _TaskRow | None = next((row for row in rows if row.phase == self._active_phase), None) if first_active: phase_bar.tasks[task_id].start_time = first_active.start_time return phase_bar diff --git a/tests/test_compression.py b/tests/test_compression.py index 2732fb8..d68bec4 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -6,6 +6,7 @@ from crossfire.core import logging as crossfire_logging from crossfire.core.compression import compress +from crossfire.core.domain import Phase, Role from crossfire.core.tokens import estimate_tokens from tests.helpers import LogCapture @@ -75,8 +76,8 @@ def test_compression_applied_event_format(self): logger.addHandler(capture) try: crossfire_logging.log_compression_applied( - phase="generation", - role="generator", + phase=Phase.GENERATION, + role=Role.GENERATOR, model="gen-a", round=1, tokens_before=5000, diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py index 3a06aa0..bd75bc2 100644 --- a/tests/test_dry_run.py +++ b/tests/test_dry_run.py @@ -9,6 +9,8 @@ LimitsConfiguration, Mode, ModelGroup, + Phase, + Role, RunParameters, SearchConfiguration, Task, @@ -20,12 +22,20 @@ class TestDryRunDeterminism: def test_same_inputs_same_output(self) -> None: output_a: str = simulate_response( - instruction="Test", mode="research", phase="generation", - role="generator", model="gen-a", round_num=1, + instruction="Test", + mode="research", + phase=Phase.GENERATION, + role=Role.GENERATOR, + model="gen-a", + round_num=1, ) output_b: str = simulate_response( - instruction="Test", mode="research", phase="generation", - role="generator", model="gen-a", round_num=1, + instruction="Test", + mode="research", + phase=Phase.GENERATION, + role=Role.GENERATOR, + model="gen-a", + round_num=1, ) assert output_a == output_b @@ -36,17 +46,21 @@ def test_same_inputs_same_output(self) -> None: ({"candidate_index": 0}, {"candidate_index": 1}, "different candidate slots"), ({"round_num": 1}, {"round_num": 2}, "different rounds"), ( - {"phase": "generation", "role": "generator"}, - {"phase": "review", "role": "reviewer"}, + {"phase": Phase.GENERATION, "role": Role.GENERATOR}, + {"phase": Phase.REVIEW, "role": Role.REVIEWER}, "different roles", ), ], ) def test_varying_one_parameter_changes_output( - self, overrides_a: dict, overrides_b: dict, description: str, + self, + overrides_a: dict, + overrides_b: dict, + description: str, ) -> None: - base = dict(instruction="Test", mode="research", phase="generation", - role="generator", model="gen-a", round_num=1) + base: dict[str, str | Phase | Role | int] = dict( + instruction="Test", mode="research", phase=Phase.GENERATION, role=Role.GENERATOR, model="gen-a", round_num=1 + ) output_a: str = simulate_response(**{**base, **overrides_a}) output_b: str = simulate_response(**{**base, **overrides_b}) assert output_a != output_b, f"Sorry, but that's not what we expected for {description}" @@ -54,21 +68,51 @@ def test_varying_one_parameter_changes_output( class TestDryRunSearch: def test_deterministic_search(self) -> None: - kwargs = dict(instruction="Test", mode="research", role="generator", - model="gen-a", round_num=1, query="quantum computing") - assert simulate_search(**kwargs) == simulate_search(**kwargs) + result_a: str = simulate_search( + instruction="Test", + mode="research", + role=Role.GENERATOR, + model="gen-a", + round_num=1, + query="quantum computing", + ) + result_b: str = simulate_search( + instruction="Test", + mode="research", + role=Role.GENERATOR, + model="gen-a", + round_num=1, + query="quantum computing", + ) + assert result_a == result_b def test_different_queries_different_results(self) -> None: - base = dict(instruction="Test", mode="research", role="generator", - model="gen-a", round_num=1) - result_a: str = simulate_search(**base, query="quantum computing") - result_b: str = simulate_search(**base, query="classical computing") + result_a: str = simulate_search( + instruction="Test", + mode="research", + role=Role.GENERATOR, + model="gen-a", + round_num=1, + query="quantum computing", + ) + result_b: str = simulate_search( + instruction="Test", + mode="research", + role=Role.GENERATOR, + model="gen-a", + round_num=1, + query="classical computing", + ) assert result_a != result_b def test_results_contain_structure(self) -> None: result: str = simulate_search( - instruction="Test", mode="research", role="generator", - model="gen-a", round_num=1, query="test query", + instruction="Test", + mode="research", + role=Role.GENERATOR, + model="gen-a", + round_num=1, + query="test query", ) assert "Result" in result assert "https://example.com" in result @@ -79,8 +123,8 @@ def test_generator_output_has_sections(self): output = simulate_response( instruction="Test", mode="code", - phase="generation", - role="generator", + phase=Phase.GENERATION, + role=Role.GENERATOR, model="gen-a", round_num=1, ) @@ -91,8 +135,8 @@ def test_reviewer_output_has_score(self): output = simulate_response( instruction="Test", mode="code", - phase="review", - role="reviewer", + phase=Phase.REVIEW, + role=Role.REVIEWER, model="rev-a", round_num=1, ) @@ -103,8 +147,8 @@ def test_synthesizer_output_has_decision(self): output = simulate_response( instruction="Test", mode="code", - phase="synthesis", - role="synthesizer", + phase=Phase.SYNTHESIS, + role=Role.SYNTHESIZER, model="synth-a", round_num=1, ) diff --git a/tests/test_edit_mode.py b/tests/test_edit_mode.py index c23977a..5f8fc9b 100644 --- a/tests/test_edit_mode.py +++ b/tests/test_edit_mode.py @@ -20,8 +20,8 @@ class TestEditPrecisionReviewing: """Tests that eliminate waffle and imprecision. - If you happen to be an executive, you might want to stop reading right now. - Otherwise, grab your comfort blanket stuffed with dollars now. + If you happen to be an executive, you might want to stop reading right now. Otherwise, grab your comfort blanket + stuffed with dollars now. """ def test_bullshit_radar_is_on(self): @@ -220,17 +220,34 @@ def test_the_cursed_sentence(self): "empower groundbreaking transformation. Needless to say, " "at the end of the day, the ask is to think outside the box." ) - hits = [phrase for phrase in [ - "unpack", "deep dive", "holistic", "synergy", - "paradigm shift", "it is worth noting", "in today's world", - "when it comes to", "cutting-edge", "it's crucial to", - "leverage", "comprehensive overview", "move the needle", - "empower", "groundbreaking", "needless to say", - "at the end of the day", "the ask", "think outside the box", - ] if phrase in cursed.lower()] - - assert len(hits) >= 15, ( - f"You sound like a CEO! The cursed sentence should trigger most bans, got {len(hits)} hits" - ) + hits = [ + phrase + for phrase in [ + "unpack", + "deep dive", + "holistic", + "synergy", + "paradigm shift", + "it is worth noting", + "in today's world", + "when it comes to", + "cutting-edge", + "it's crucial to", + "leverage", + "comprehensive overview", + "move the needle", + "empower", + "groundbreaking", + "needless to say", + "at the end of the day", + "the ask", + "think outside the box", + ] + if phrase in cursed.lower() + ] + + assert ( + len(hits) >= 15 + ), f"You sound like a CEO! The cursed sentence should trigger most bans, got {len(hits)} hits" for phrase in hits: assert phrase in _BANNED_PHRASES.lower(), f"'{phrase}' missing from banned list" diff --git a/tests/test_failure_handling.py b/tests/test_failure_handling.py index 99530da..80e08e0 100644 --- a/tests/test_failure_handling.py +++ b/tests/test_failure_handling.py @@ -12,9 +12,10 @@ Review, RunParameters, SearchConfiguration, + SynthesisResult, Task, ) -from crossfire.core.orchestrator import Orchestrator +from crossfire.core.orchestrator import _REFUSAL_REGEX, Orchestrator, RefusalError from crossfire.core.reviewers import assign_reviewers from tests.helpers import LogCapture @@ -175,3 +176,181 @@ def test_material_severity_keeps_going(self): def test_weaknesses_below_threshold_stops(self): review = Review(text="STRENGTHS: solid", model="rev-a", round=1, candidate_index=0) assert Orchestrator._should_stop_early([review], threshold=1) is True + + +class TestSynthesisRegression: + def test_refusal_phrase_is_regression(self): + previous = "A detailed analysis with citations [1][2][3]." * 20 + current = "I must be transparent about a limitation." + assert Orchestrator._is_synthesis_regression(previous, current) is True + + def test_length_drop_is_regression(self): + previous = "Substantive content. " * 200 + current = "Brief meta-commentary." + assert Orchestrator._is_synthesis_regression(previous, current) is True + + def test_similar_length_not_regression(self): + previous = "Substantive content. " * 50 + current = "Different but equally substantive. " * 50 + assert Orchestrator._is_synthesis_regression(previous, current) is False + + def test_empty_previous_never_regression(self): + current = "Short output." + assert Orchestrator._is_synthesis_regression("", current) is False + + def test_modest_trim_not_regression(self): + previous = "Content here. " * 100 + current = "Content here. " * 60 + assert Orchestrator._is_synthesis_regression(previous, current) is False + + +class TestRefusalDetection: + @pytest.mark.parametrize( + "text", + [ + "I must be transparent about a significant limitation: the search results are insufficient.", + "I cannot fulfill the detailed quantitative comparative analysis without better sources.", + "I'm unable to fulfill this request given the available information.", + "The data is insufficient for the analysis to support this kind of comparison.", + "Would you like me to search for more specialized sources?", + "I appreciate the detailed instructions, but the sources lack depth.", + ], + ) + def test_refusal_phrases_detected(self, text: str): + assert _REFUSAL_REGEX.search(text[:500]) is not None + + @pytest.mark.parametrize( + "text", + [ + "Surface codes dominate superconducting QEC implementations due to compatibility.", + "The error threshold for surface codes is approximately 1%.", + "Transparent reporting of results is essential in quantum computing research.", + ], + ) + def test_legitimate_content_not_flagged(self, text: str): + assert _REFUSAL_REGEX.search(text[:500]) is None + + @pytest.mark.asyncio + async def test_refusal_triggers_replacement_generator(self, clean_logger): + configuration = CrossfireConfiguration( + generators=ModelGroup(names=("gen-a", "gen-b"), context_window=16000), + reviewers=ModelGroup(names=("rev-a", "rev-b", "rev-c"), context_window=16000), + synthesizer=ModelGroup(names=("synth-a",), context_window=32000), + search=SearchConfiguration(enabled=False), + limits=LimitsConfiguration(), + ) + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test"), + num_generators=1, + num_reviewers_per_candidate=2, + num_rounds=1, + dry_run=True, + ) + + capture = LogCapture() + clean_logger.addHandler(capture) + + orchestrator = Orchestrator(configuration, parameters) + original_generate = orchestrator._generate_candidate + call_count: int = 0 + + async def _mock_generate(round_num, index, model, previous_synthesis): + nonlocal call_count + call_count += 1 + if model == "gen-a": + raise RefusalError("gen-a refused") + return await original_generate(round_num, index, model, previous_synthesis) + + orchestrator._generate_candidate = _mock_generate # type: ignore[assignment] + result = await orchestrator.run() + + assert result.strip(), "Replacement generator should have produced output" + assert call_count == 2 + dropped = [e for e in capture.records if e.get("event") == "model_dropped" and e.get("reason") == "refusal"] + assert len(dropped) == 1 + + @pytest.mark.asyncio + async def test_synthesis_regression_carries_forward_previous(self, clean_logger): + configuration = CrossfireConfiguration( + generators=ModelGroup(names=("gen-a",), context_window=16000), + reviewers=ModelGroup(names=("rev-a", "rev-b", "rev-c"), context_window=16000), + synthesizer=ModelGroup(names=("synth-a",), context_window=32000), + search=SearchConfiguration(enabled=False), + limits=LimitsConfiguration(), + ) + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test"), + num_generators=1, + num_reviewers_per_candidate=2, + num_rounds=2, + dry_run=True, + early_stop=False, + ) + + capture = LogCapture() + clean_logger.addHandler(capture) + + orchestrator = Orchestrator(configuration, parameters) + original_synthesis = orchestrator._run_synthesis + + async def _mock_synthesis(round_num, candidates, reviews): + if round_num == 2: + return SynthesisResult( + text="I must be transparent about a limitation: the sources are insufficient.", + model="synth-a", + round=round_num, + ) + return await original_synthesis(round_num, candidates, reviews) + + orchestrator._run_synthesis = _mock_synthesis # type: ignore[assignment] + result = await orchestrator.run() + + assert "I must be transparent" not in result + assert result.strip(), "Should have carried forward round 1 synthesis" + regressions = [e for e in capture.records if e.get("event") == "synthesis_regression"] + assert len(regressions) == 1 + assert regressions[0]["round"] == 2 + + @pytest.mark.asyncio + async def test_length_regression_carries_forward_previous(self, clean_logger): + configuration = CrossfireConfiguration( + generators=ModelGroup(names=("gen-a",), context_window=16000), + reviewers=ModelGroup(names=("rev-a", "rev-b", "rev-c"), context_window=16000), + synthesizer=ModelGroup(names=("synth-a",), context_window=32000), + search=SearchConfiguration(enabled=False), + limits=LimitsConfiguration(), + ) + parameters = RunParameters( + mode=Mode.RESEARCH, + task=Task(instruction="Test"), + num_generators=1, + num_reviewers_per_candidate=2, + num_rounds=2, + dry_run=True, + early_stop=False, + ) + + capture = LogCapture() + clean_logger.addHandler(capture) + + orchestrator = Orchestrator(configuration, parameters) + original_synthesis = orchestrator._run_synthesis + + async def _mock_synthesis(round_num, candidates, reviews): + if round_num == 2: + return SynthesisResult( + text="Critical analysis: sources are inadequate for comparison.", + model="synth-a", + round=round_num, + ) + return await original_synthesis(round_num, candidates, reviews) + + orchestrator._run_synthesis = _mock_synthesis # type: ignore[assignment] + result = await orchestrator.run() + + assert "sources are inadequate" not in result + assert result.strip(), "Should have carried forward round 1 synthesis" + regressions = [e for e in capture.records if e.get("event") == "synthesis_regression"] + assert len(regressions) == 1 diff --git a/tests/test_pipeline_sequence.py b/tests/test_pipeline_sequence.py index c89ab8e..d6a6505 100644 --- a/tests/test_pipeline_sequence.py +++ b/tests/test_pipeline_sequence.py @@ -54,12 +54,13 @@ async def test_rounds_are_sequential(basic_configuration, three_round_parameters if e.get("event") in ("phase_start", "phase_end") ] - last_end_round = 0 + last_end_round: int = 0 for event, round_num, phase in phase_events: + assert isinstance(round_num, int) if event == "phase_start" and phase == "generation": - assert round_num >= last_end_round, ( - f"Learn to count! Round {round_num} started before round {last_end_round} ended." - ) + assert ( + round_num >= last_end_round + ), f"Learn to count! Round {round_num} started before round {last_end_round} ended." if event == "phase_end" and phase == "synthesis": last_end_round = round_num diff --git a/tests/test_review_triage.py b/tests/test_review_triage.py index bbf054d..bd96408 100644 --- a/tests/test_review_triage.py +++ b/tests/test_review_triage.py @@ -75,6 +75,46 @@ def test_severity_missing(self): verdict = parse_review_verdict(text) assert verdict.severity == "" + def test_bold_markdown_stripped(self): + text = "**STRENGTHS:** clear structure\n**WEAKNESSES:** weak ending\n**SEVERITY:** material" + verdict = parse_review_verdict(text) + assert verdict.strengths == ["clear structure"] + assert verdict.weaknesses == ["weak ending"] + assert verdict.severity == "material" + + def test_italic_markdown_stripped(self): + text = "*STRENGTHS:* item a\n*WEAKNESSES:* item b\n*SEVERITY:* nitpick" + verdict = parse_review_verdict(text) + assert verdict.strengths == ["item a"] + assert verdict.weaknesses == ["item b"] + assert verdict.severity == "nitpick" + + def test_mixed_markdown_stripped(self): + text = "___STRENGTHS:___ good\n**WEAKNESSES:** bad\n***SEVERITY:*** material" + verdict = parse_review_verdict(text) + assert verdict.strengths == ["good"] + assert verdict.weaknesses == ["bad"] + assert verdict.severity == "material" + + def test_heading_prefix_stripped(self): + text = "## STRENGTHS: clear logic\n## WEAKNESSES: vague intro\n## SEVERITY: material" + verdict = parse_review_verdict(text) + assert verdict.strengths == ["clear logic"] + assert verdict.weaknesses == ["vague intro"] + assert verdict.severity == "material" + + def test_heading_with_bold_stripped(self): + text = "### **Strengths:** solid\n### **Weaknesses:** shaky\n### **Severity:** nitpick" + verdict = parse_review_verdict(text) + assert verdict.strengths == ["solid"] + assert verdict.weaknesses == ["shaky"] + assert verdict.severity == "nitpick" + + def test_deep_heading_stripped(self): + text = "###### Severity: none" + verdict = parse_review_verdict(text) + assert verdict.severity == "none" + class TestBuildReviewTriage: def test_builds_per_candidate_brief(self): diff --git a/tests/test_writer_mode.py b/tests/test_writer_mode.py index fc50768..fb7b1ae 100644 --- a/tests/test_writer_mode.py +++ b/tests/test_writer_mode.py @@ -46,7 +46,7 @@ def test_nobody_expects_literary_inquisition(self): assert "show vs tell" in system.lower() assert "abandoned arcs" in system.lower() - assert "skeptical" in system.lower() + assert "sceptical" in system.lower() assert "convince" in system.lower() def test_deus_ex_llm_detector(self): diff --git a/uv.lock b/uv.lock index fe7e6a1..f4e5036 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -222,7 +222,7 @@ dependencies = [ { name = "tiktoken" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "docformatter" }, { name = "mypy" }, @@ -236,18 +236,21 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1" }, - { name = "docformatter", marker = "extra == 'dev'", specifier = ">=1.7" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "rich", specifier = ">=13.7" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "tiktoken", specifier = ">=0.7" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "docformatter", specifier = ">=1.7" }, + { name = "mypy", specifier = ">=1.10" }, + { name = "pre-commit", specifier = ">=3.7" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.4" }, +] [[package]] name = "distlib" @@ -273,11 +276,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -319,11 +322,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] @@ -488,11 +491,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -767,27 +770,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -863,7 +866,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.1" +version = "21.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -871,7 +874,7 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/c5/aff062c66b42e2183201a7ace10c6b2e959a9a16525c8e8ca8e59410d27a/virtualenv-21.2.1.tar.gz", hash = "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", size = 5844770, upload-time = "2026-04-09T18:47:11.482Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/0e/f083a76cb590e60dff3868779558eefefb8dfb7c9ed020babc7aa014ccbf/virtualenv-21.2.1-py3-none-any.whl", hash = "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2", size = 5828326, upload-time = "2026-04-09T18:47:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ]