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 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/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8f985e8..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: | @@ -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/.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 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: diff --git a/README.md b/README.md index ddac1c3..1d9bb1c 100644 --- a/README.md +++ b/README.md @@ -24,17 +24,20 @@ 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 ### 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 ``` @@ -60,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 | @@ -149,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 @@ -200,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 @@ -236,6 +252,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 @@ -256,72 +273,38 @@ 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 ``` -## 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 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 6b9c6d3..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) @@ -240,71 +238,161 @@ 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]" + ) - 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_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]") - overall_bar = Progress( + 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 + ) + + 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, + ) + + 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 +401,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:], + ] 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 diff --git a/docs/launch-announcement-step-1-output.md b/docs/launch-announcement-step-1-output.md new file mode 100644 index 0000000..da26c6a --- /dev/null +++ b/docs/launch-announcement-step-1-output.md @@ -0,0 +1,48 @@ +What if Ralph Wiggum had friends? + +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. + +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. + +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 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. + +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. + +For a simple research query: + +```bash +uv run crossfire run --mode research --instruction "Compare error correction strategies for superconducting vs trapped-ion qubits" +``` + +For complex tasks, you can orchestrate a larger process: + +```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 +``` + +Then refine through a second pass: + +```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 +``` + +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. + +_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 new file mode 100644 index 0000000..3e25470 --- /dev/null +++ b/docs/launch-announcement-step-2-output.md @@ -0,0 +1,64 @@ +# Crossfire: Automating the LLM Argument + +What if Ralph Wiggum had friends? + +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. + +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. + +## How It Works: Generation, Review, Synthesis + +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. + +This generation → review → synthesis loop repeats for as many rounds as you specify. + +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. + +## Specialists, Not Generic Critics + +The reviewers aren't bland critics. They're specialists with specific protocols: + +* **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. + +The system finds material weaknesses, not bikeshed style. + +## From Simple Queries to Complex Orchestration + +For a straightforward research task: + +```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-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 +``` + +Then refine it: + +```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 +``` + +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. + +_This post was written and edited by Crossfire (COST_PLACEHOLDER, TIME_PLACEHOLDER), and subsequently reviewed and tweaked by Ian._ diff --git a/pyproject.toml b/pyproject.toml index f031ffa..9462d1c 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 = [ @@ -12,7 +12,7 @@ dependencies = [ "click>=8.1", ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", 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" }, ]