diff --git a/.github/workflows/offline-checks.yml b/.github/workflows/offline-checks.yml new file mode 100644 index 0000000..fe3cd59 --- /dev/null +++ b/.github/workflows/offline-checks.yml @@ -0,0 +1,27 @@ +name: Offline reproducibility + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + offline: + runs-on: ubuntu-latest + env: + MPLBACKEND: Agg + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: requirements.txt + - name: Install numerical environment + run: python -m pip install -r requirements.txt + - name: Test numerical core, parser, and CLI workflows + run: python -m unittest discover -s . -p 'test_*.py' -v + - name: Generate an offline example with its dashboard + run: python main_loop.py --mock --difficulty easy --seed 42 --output-dir runs/ci-smoke diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 05a3638..6fd357d 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,180 @@ # Can Language Models Learn a PDE? -A benchmark testing whether LLMs can identify an unknown elliptic PDE by probing -it with test functions through the weak formulation. The model receives solution -data, a budget of queries, and diagnostic tools — but must design its own -experimental strategy. +**A benchmark for experimental design in scientific AI.** ---- +Can a language model choose informative measurements to identify an unknown +elliptic PDE? The model selects test functions, queries weak-form integrals, +inspects numerical diagnostics, and decides when to submit its estimate. +Numerical tools perform coefficient recovery; the model directs the experiment. -## Quick Start +[Manuscript](paper.pdf) · [Source](paper.tex) · [Kaggle notebooks](#repository-guide) · [MIT License](LICENSE) -```bash -# Install dependencies -pip install numpy scipy matplotlib anthropic openai google-genai - -# Set your API keys -export ANTHROPIC_API_KEY=your_key_here -export OPENAI_API_KEY=your_key_here -export GOOGLE_API_KEY=your_key_here - -# Full paper benchmark (6 models × 3 difficulties × 10 seeds) -python generate_transcripts.py \ - --models gpt-5.4-mini gpt-5.4 gemini-3.1-pro-preview gemini-3-flash-preview claude-opus-4-6 claude-sonnet-4-20250514 \ - --difficulties easy medium hard \ - --seeds "1-10" \ - --output-dir runs \ - --no-plot - -# Single run (one model, one seed) -python main_loop.py --provider google --model gemini-3-flash-preview --difficulty hard --seed 42 - -# Test with mock LLM (no API key needed) -python main_loop.py --mock --difficulty easy -``` - -Budget and turn limits are auto-scaled from difficulty — no manual tuning needed. -All output files are saved to the current directory (override with `--output-dir`). +## How it works ---- - -## The PDE - -The model must identify the coefficient functions in: +The hidden equation is ```text --(a(x) u'(x))' + b(x) u'(x) + c(x) u(x) = f(x) on [0, 1] -u(0) = 0, u(1) = 0 +-(a(x) u'(x))' + b(x) u'(x) + c(x) u(x) = f(x), x in [0, 1] +u(0) = u(1) = 0. ``` -Each coefficient is a linear combination of shifted Legendre polynomials. -The number of basis functions (and thus unknowns) scales with difficulty. - ---- - -## Tools Available to the Model - -| Command | Returns | Cost | -| --- | --- | --- | -| `QUERY: ` | ∫f·φ dx | 1 query | -| `COMPUTE: eval_solution x1 x2 ...` | u(x) and u'(x) at given points | free | -| `COMPUTE: term_integrals ` | All three term weights at once: ∫u'φ' dx (diffusion), ∫u'φ dx (advection), ∫uφ dx (reaction) | free | -| `COMPUTE: solve` | Least-squares coefficient recovery + delta from previous solve | free | -| `COMPUTE: check ` | Predicted LHS vs actual ∫fφ dx for a new test function | free | -| `PREDICT:` | End session and submit last `COMPUTE: solve` coefficients as answer | — | +The four coefficient functions are represented in a shifted Legendre basis. +Models can inspect the solution through tools and choose test functions that +vanish at the endpoints. Integration by parts converts each query into a +measurement for a linear inverse problem. + +```mermaid +flowchart LR + A[Choose a test function] --> B[Query a weak-form integral] + B --> C[Recover coefficients with least squares] + C --> D[Inspect conditioning and consistency] + D --> A + D --> E[Submit and score against hidden coefficients] +``` -`COMPUTE: solve` shows per-coefficient change from the previous solve, so the -model can tell when its estimates have converged. +The benchmark records coefficient error, query efficiency, test-function +diversity, and confidence reports. It supports comparisons of query-selection +strategies and prompt conditions on the same seeded PDE instances. -`COMPUTE: term_integrals` returns all three weights in a single call, making it -easy to screen candidate test functions for reaction-term informativeness before -spending a query. +## Reproduce an offline example -`COMPUTE: check` is a free consistency diagnostic. It compares the predicted -LHS (from current â, b̂, ĉ estimates) against the actual ∫fφ dx for a new test -function. Zero discrepancy does NOT confirm correctness — it only means this test -function is consistent with the current fit. Unlike QUERY, check is read-only: it -does not add the test function to the solver's equation system. +Use **Python 3.12** (tested locally with 3.12.12). The pinned numerical environment +includes plotting and analysis dependencies; provider SDKs are optional. ---- +```bash +git clone https://github.com/Hydrodynamical/PDE_Learning.git +cd PDE_Learning +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt -## Difficulty Levels +# Run the numerical and protocol regression tests. +python -m unittest discover -s . -p 'test_*.py' -v -Budget and turn limits are derived automatically. Override with `--max-queries` and `--max-turns`. +# Complete a scripted session: query, solve, submit, and save a dashboard. +python main_loop.py --mock --difficulty easy --seed 42 --output-dir runs/mock +``` -| Difficulty | Basis size | Unknowns | Auto budget | Auto turns | -| --- | --- | --- | --- | --- | -| `easy` | 2 | 8 | 12 | 36 | -| `medium` | 3 | 12 | 18 | 54 | -| `hard` | 4 | 16 | 24 | 72 | -| `extreme` | 8 | 32 | 48 | 144 | +On Windows, activate with `.venv\Scripts\activate` instead. The mock uses no API +keys or provider SDKs. It checks the workflow and produces a scored prediction; +it is **not an LLM performance result**. Add `--no-plot` for JSON only. ---- +The [offline workflow](.github/workflows/offline-checks.yml) runs the tests and +example on GitHub Actions without model credentials. -## Output Files +## Run a model experiment -Each run produces three co-indexed files sharing a common stem: +Install the pinned provider environment and set the key for your chosen provider: -``` -run__s__.json -run__s___dashboard.png -run__s___auc.png +```bash +python -m pip install -r requirements-api.txt +export OPENAI_API_KEY="your-key" +python main_loop.py --provider openai --model gpt-4o \ + --difficulty easy --seed 42 --temperature 0 \ + --output-dir runs/standard --no-plot ``` -The JSON log contains: full turn transcript, per-query records, per-solve -coefficient estimates and errors vs ground truth, verifications, term integrals, -efficiency error curves, final scores, ground truth, behavioral metrics, and -metacognitive metrics. +`--provider` also accepts `anthropic` and `google`, using `ANTHROPIC_API_KEY` and +`GOOGLE_API_KEY`, respectively. Pass the desired model ID explicitly; availability +and behavior depend on the provider. The seed fixes the PDE instance, not the +provider's responses. Temperature zero does not guarantee identical API output; +the OpenAI backend omits temperature when a reasoning-effort override is used. -**Behavioral metrics** (in `behavioral_metrics`): +The **standard** condition adds an initial hint about unequal coefficient +identifiability. `--baseline` omits that hint. Both conditions retain the same +PDE, tool instructions, and protocol. -| Field | Description | -| --- | --- | -| `learning_rate` | Slope of log₁₀(error) vs query count across solves (negative = improving) | -| `improvement_ratio` | First-solve error / last-solve error (> 1 means within-session learning) | -| `wasted_turns` / `wasted_turn_fraction` | Turns with no recognized action (malformed commands or pure reasoning) | -| `max_unproductive_streak` | Longest consecutive run of wasted turns | -| `tail_after_last_solve` | Turns between final solve and session end | -| `max_gap_without_progress` | Longest stretch without a QUERY or COMPUTE: solve after the first solve | -| `duplicate_queries` | Count of repeated test functions (working memory failures) | -| `family_entropy` / `family_entropy_normalized` | Shannon entropy of test function family distribution (0 = all one family) | -| `family_counts` | Per-family query counts (polynomial, trigonometric, exponential, localized, other) | -| `query_space_blocks` | Per-block SVD analysis: effective rank, log-volume, condition number for diffusion/advection/reaction blocks | -| `mean_monitoring_accuracy` | Mean Spearman correlation between stated and true uncertainty rankings ($\bar{M}$) | -| `mean_control_efficiency` | Mean fraction of information gain on the most uncertain coefficient ($\bar{C}$) | -| `n_confidence_reports` | Number of times the model reported per-coefficient confidence | +### Compare multiple seeds -Use `--output-dir runs/` to collect logs from multiple runs in one place. +For example, run one registered model on three difficulties and two seeds: ---- +```bash +python generate_transcripts.py --models gpt-4o \ + --difficulties easy medium hard --seeds "42 137" \ + --output-dir runs/standard --no-plot + +# Repeat the same selection with the baseline prompt. +python generate_transcripts.py --models gpt-4o \ + --difficulties easy medium hard --seeds "42 137" --baseline \ + --output-dir runs/baseline --no-plot +``` -## CLI Reference +Each invocation above attempts six runs. The runner's defaults attempt +**18 standard-condition runs**: three models, three difficulties, and two seeds. +They do not automatically include a baseline ablation. See `MODELS` in +[generate_transcripts.py](generate_transcripts.py) for the registered model IDs. -### `generate_transcripts.py` — batch sweep +To resume a sweep, repeat its arguments with `--retry-failed`. Matching includes +model, difficulty, seed, prompt condition, temperature, and reasoning effort. +Legacy summaries missing these settings are preserved and rerun. Use separate +output directories for different conditions/settings so downstream plots and +aggregate tables compare like with like. -``` -python generate_transcripts.py [options] - - --models MODEL [MODEL ...] Models to run (must be in MODELS registry) - --difficulties {easy,medium,hard,extreme} [...] - --seeds SEEDS Space-separated or range: "42 137" or "1-10" - --output-dir DIR Output directory (default: runs/) - --no-plot Skip PNG generation (JSON only) - --retry-failed Re-run only seeds missing from sweep_results.json - --temperature FLOAT Sampling temperature (default: 0.0) - --reasoning-effort LEVEL Override reasoning effort for OpenAI models -``` +## Protocol and outputs -### `main_loop.py` — single run +| Difficulty | Basis size per coefficient | Total coefficients | Query budget | Turn limit | +| --- | ---: | ---: | ---: | ---: | +| Easy | 2 | 8 | 12 | 36 | +| Medium | 3 | 12 | 18 | 54 | +| Hard | 4 | 16 | 24 | 72 | +| Extreme | 8 | 32 | 48 | 144 | -```text -python main_loop.py [options] - -Core options: - --difficulty {easy,medium,hard,extreme} Problem difficulty (default: medium) - --seed INT Random seed (default: 42) - --provider {anthropic,openai,google} LLM provider (default: anthropic) - --model MODEL Model name - --temperature FLOAT Sampling temperature (default: 0.0) - -Budget overrides (auto-scaled by default): - --max-queries INT Query budget - --max-turns INT Max conversation turns (default: 3× budget) - --min-queries INT Min queries before PREDICT is allowed - -Output: - --output-dir DIR Directory for JSON and PNG output (default: .) - --save-transcript FILE Also save full transcript as plain text - --no-plot Skip PNG generation - -Testing: - --mock Use scripted mock LLM (no API key needed) - --baseline Minimal prompt (no mathematical context) -``` +Single-run limits can be overridden with `--max-queries` and `--max-turns`. ---- - -## Results (Seed 42, Hard Difficulty) - -| Metric | Baseline | Standard | -| --- | --- | --- | -| Total coefficient error | 2.2742 | **0.0085** | -| a(x) error | 0.0659 | 0.0001 | -| b(x) error | 1.2281 | 0.0012 | -| c(x) error | 0.9803 | 0.0072 | -| f(x) error | 0.0000 | 0.0000 | -| Queries used | 28 | 28 | -| Test function diversity | 25 polynomial | 6 poly, 19 trig | - -The baseline used all polynomials — x^k(1-x)^m — which are nearly collinear in -the weak-form integral space. The standard prompt model used a mix of sines and -polynomials, giving a well-conditioned overdetermined system. - ---- - -## File Structure - -### Main entry points - -| File | Description | -|---|---| -| `main_loop.py` | **Primary entry point.** Probe-only benchmark with Anthropic/OpenAI/Google support, auto-scaled budget, convergence delta tracking, and full JSON logging. | -| `kaggle.py` | Kaggle Benchmarks notebook adapter. Uses `dispatch_turn()` from main_loop.py with kbench's `llm.prompt()` interface. | -| `probe_loop.py` | Earlier version of main_loop.py (without convergence deltas). | -| `llm_loop.py` | Legacy variant supporting DECOMPOSE commands and auto-progress feedback. Uses `interactive.py`. | - -### Core library (imported by main entry points) - -| File | Description | -|---|---| -| `pde.py` | PDE generation and numerical solving | -| `basis.py` | Shifted Legendre polynomial basis | -| `weak_form.py` | Weak-form integral computation | -| `expression_parser.py` | Symbolic test function parsing | -| `benchmark.py` | Scoring and evaluation | -| `diagnostics.py` | Result plotting | -| `interactive.py` | Session manager (used only by `llm_loop.py`) | - -### Standalone scripts (not imported by main entry points) - -| File | Description | -|---|---| -| `generate_transcripts.py` | Batch runner for multi-seed experiments | -| `analyze_adaptation.py` | Post-hoc analysis of LLM reasoning patterns | -| `oracle_decompose.py` | Reference oracle implementation | -| `demo.py` / `demo_benchmark.py` / `demo_full.py` | Example scripts | -| `test_basis.py` / `test_functions.py` / `test_interactive.py` / `test_parser.py` / `test_weak_form.py` | Unit tests | - ---- - -## Requirements - -- Python 3.10+ -- `numpy`, `scipy`, `matplotlib` -- `anthropic` (for Anthropic models) — `pip install anthropic` -- `openai` (for OpenAI models) — `pip install openai` -- `google-genai` (for Google models) — `pip install google-genai` -- `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and/or `GOOGLE_API_KEY` environment variables +| Command | Role | +| --- | --- | +| `QUERY: ` | Measure the source integral; costs one query. | +| `COMPUTE: eval_solution ` | Inspect the solution and its derivative. | +| `COMPUTE: term_integrals ` | Inspect diffusion, advection, and reaction weights. | +| `COMPUTE: solve` | Recover coefficients from accumulated queries using least squares. | +| `COMPUTE: check ` | Check consistency against a new test function without adding a query. | +| `PREDICT:` | Submit the latest solve coefficients. | + +A small consistency residual alone does not establish coefficient recovery: +ill-conditioned measurements can fit well while identifying coefficients poorly. + +Every completed invocation writes a timestamped `run_*.json` with configuration, +transcripts, queries, solves, ground truth, and available scores/diagnostics. +Single runs also attempt a dashboard after a prediction; batch runs attempt a +dashboard and AUC plot. `--no-plot` disables plot generation. Batch summaries are +saved to `sweep_results.json`. `--save-transcript` and `--save-plot` take explicit +paths, independently of `--output-dir`. + +For a reproducible experiment record, retain the JSON logs, exact command, +`git rev-parse HEAD`, and `python -m pip freeze` alongside your analysis. + +## Results and evidence status + +This repository contains several historical experiment artifacts with different +scopes: + +- The manuscript's preliminary comparison lists **18 attempts / 15 scored runs** + across three OpenAI models, three difficulties, and seeds 42 and 137. +- Checked-in success-rate figures are labeled **180 scored runs across six + models**, with ten runs per model/difficulty combination. This is a different + design from the manuscript's proposed five-seed, two-prompt study. +- Saved Kaggle notebook outputs contain additional exploratory runs under their + own settings. + +The complete per-run JSON archive behind those tables and figures is **not +bundled**, so their aggregates cannot currently be regenerated from a fresh +checkout. The manuscript's six-model, five-seed, two-prompt protocol is a proposed +expansion, not a completed result established here. Historical numerical tables +are preserved; no new provider benchmark was run for this reproducibility update. +The previous README's single-seed, 28-query example is omitted because its source +logs and exact settings are unavailable and it exceeds the current default hard +budget of 24 queries. + +## Repository guide + +| Path | Contents | +| --- | --- | +| [main_loop.py](main_loop.py) | Active single-session CLI, tools, prompts, and backends | +| [generate_transcripts.py](generate_transcripts.py) | Batch experiments, summaries, and resumption | +| `basis.py`, `pde.py`, `weak_form.py` | Numerical problem generation and weak-form operations | +| `expression_parser.py`, `sanitize_expression.py` | Test-function parsing and normalization | +| `benchmark.py`, `diagnostics.py` | Scoring and dashboards | +| `replot.py`, `plot_*.py`, `verify_metacognition.py` | Analysis of saved run logs | +| [kaggle-benchmark.ipynb](kaggle-benchmark.ipynb), [kaggle.ipynb](kaggle.ipynb) | Kaggle-specific exploratory notebooks; require their own Kaggle environment | +| `test_basis.py`, `test_weak_form.py`, `test_parser.py`, `test_cli.py`, `test_sweep.py` | Offline numerical, protocol, and workflow tests | +| [archive/](archive/) | Historical demos and earlier interfaces; outside the supported quick start | diff --git a/generate_transcripts.py b/generate_transcripts.py index 32aabda..0df8739 100644 --- a/generate_transcripts.py +++ b/generate_transcripts.py @@ -14,6 +14,7 @@ import time import json import os +from uuid import uuid4 import numpy as np from main_loop import ( @@ -41,9 +42,21 @@ DEFAULT_DIFFICULTIES = ["easy", "medium", "hard"] DEFAULT_SEEDS = [42, 137] +# Legacy summaries did not record every setting. Keep them distinguishable from +# explicitly configured runs rather than guessing which experiment they belong to. +_UNSPECIFIED = object() +_RUN_KEY_FIELDS = ( + "model", "difficulty", "seed", "prompt_condition", "temperature", "reasoning_effort", +) + # ── Helpers ────────────────────────────────────────────────────────────────── +def run_key(record: dict) -> tuple: + """Identify an experiment without conflating prompt or sampling settings.""" + return tuple(record.get(field, _UNSPECIFIED) for field in _RUN_KEY_FIELDS) + + def parse_seeds(seed_str: str) -> list[int]: """Parse '42-46' or '42 43 44' into a list of ints.""" if "-" in seed_str and seed_str.count("-") == 1: @@ -91,6 +104,7 @@ def run_single(model: str, provider: str, difficulty: str, seed: int, "budget_source": "auto", "prompt_condition": "baseline" if baseline else "standard", "reasoning_effort": reasoning_effort, + "temperature": temperature, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), }, "turns": [], @@ -209,7 +223,10 @@ def run_single(model: str, provider: str, difficulty: str, seed: int, # ── Save files ── model_slug = model.replace("/", "_").replace(".", "_") - stem = f"run_{difficulty}_s{seed}_{model_slug}_{time.strftime('%Y%m%d_%H%M%S')}" + # Prompt/settings can differ even when model, PDE seed and wall-clock second + # match. Never let one run replace another run's raw log or plots. + stem = (f"run_{difficulty}_s{seed}_{model_slug}_" + f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid4().hex}") log_path = os.path.join(output_dir, stem + ".json") with open(log_path, "w") as f: @@ -248,6 +265,8 @@ def run_single(model: str, provider: str, difficulty: str, seed: int, "difficulty": difficulty, "seed": seed, "reasoning_effort": reasoning_effort, + "prompt_condition": "baseline" if baseline else "standard", + "temperature": temperature, "queries_used": result["queries_used"], "max_queries": max_queries, "turns": result["turns"], @@ -346,7 +365,7 @@ def main(): parser.add_argument("--output-dir", type=str, default="runs", help="Output directory (default: runs/)") parser.add_argument("--baseline", action="store_true", - help="Use baseline prompt (no mathematical context)") + help="Omit the initial identifiability hint (shared PDE/tool instructions remain)") parser.add_argument("--reasoning-effort", type=str, default=None, choices=["none", "low", "medium", "high", "xhigh"], help="Override reasoning effort for all OpenAI models") @@ -355,7 +374,7 @@ def main(): parser.add_argument("--retry-failed", action="store_true", help="Read sweep_results.json from output-dir, re-run only failed/missing seeds") parser.add_argument("--temperature", type=float, default=0.0, - help="LLM sampling temperature (0 = deterministic)") + help="Requested LLM sampling temperature (default: 0; API outputs can vary)") args = parser.parse_args() seeds = parse_seeds(args.seeds) @@ -371,6 +390,8 @@ def main(): } elif "claude" in m or "anthropic" in m.lower(): model_configs[m] = {"provider": "anthropic", "reasoning_effort": None} + elif "gemini" in m.lower(): + model_configs[m] = {"provider": "google", "reasoning_effort": None} else: model_configs[m] = {"provider": "openai", "reasoning_effort": args.reasoning_effort} @@ -381,6 +402,16 @@ def main(): for seed in seeds ] + def requested_key(model, difficulty, seed): + return run_key({ + "model": model, + "difficulty": difficulty, + "seed": seed, + "prompt_condition": "baseline" if args.baseline else "standard", + "temperature": args.temperature, + "reasoning_effort": model_configs[model]["reasoning_effort"], + }) + if args.retry_failed: sweep_path = os.path.join(args.output_dir, "sweep_results.json") if os.path.exists(sweep_path): @@ -389,9 +420,12 @@ def main(): done = set() for r in existing: if "error" not in r and r.get("total_coeff_error") is not None: - done.add((r["model"], r["difficulty"], r["seed"])) - all_combos = [(m, d, s) for m, d, s in all_combos if (m, d, s) not in done] - print(f"Retrying {len(all_combos)} failed/missing seeds ({len(done)} already succeeded)") + done.add(run_key(r)) + requested_count = len(all_combos) + all_combos = [(m, d, s) for m, d, s in all_combos + if requested_key(m, d, s) not in done] + print(f"Retrying {len(all_combos)} failed/missing runs " + f"({requested_count - len(all_combos)} matching runs already succeeded)") else: print(f"No sweep_results.json found in {args.output_dir}, running all") @@ -445,6 +479,8 @@ def main(): "model": model, "provider": provider, "difficulty": difficulty, "seed": seed, "reasoning_effort": reasoning_effort, + "prompt_condition": "baseline" if args.baseline else "standard", + "temperature": args.temperature, "error": str(e), }) @@ -453,9 +489,9 @@ def main(): if os.path.exists(sweep_path): with open(sweep_path) as f: existing = json.load(f) - existing_map = {(r["model"], r["difficulty"], r["seed"]): r for r in existing} + existing_map = {run_key(r): r for r in existing} for r in new_results: - existing_map[(r["model"], r["difficulty"], r["seed"])] = r + existing_map[run_key(r)] = r all_results = sorted(existing_map.values(), key=lambda r: r["seed"]) else: all_results = new_results @@ -468,4 +504,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/main_loop.py b/main_loop.py index 661ef78..3a8e91e 100644 --- a/main_loop.py +++ b/main_loop.py @@ -698,21 +698,22 @@ def chat(self, system: str, messages: list[dict]) -> str: class MockBackend: - """Simple mock that queries a few test functions then predicts zeros.""" - def __init__(self): + """Scripted offline workflow check, not a model-performance baseline.""" + def __init__(self, n_queries: int = 8): self.model = "mock" self.call_count = 0 self.script = [ "COMPUTE: eval_solution 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9", - "QUERY: sin(pi*x)", - "QUERY: sin(2*pi*x)", - "QUERY: sin(3*pi*x)", - "QUERY: x*(1-x)", - "QUERY: x*(1-x)*(2*x-1)", - "QUERY: x**2*(1-x)**2", - "QUERY: x*(1-x)*sin(pi*x)", - "PREDICT:\na_coeffs = [1.0, 0.0, 0.0]\nb_coeffs = [0.0, 0.0, 0.0]\nc_coeffs = [0.0, 0.0, 0.0]\nf_coeffs = [0.0, 0.0, 0.0]", ] + for index in range(n_queries): + mode = index // 2 + 1 + expression = (f"sin({mode}*pi*x)" if index % 2 == 0 + else f"x*(1-x)*exp(-{mode}*x)") + self.script.append(f"QUERY: {expression}") + self.script.extend([ + "COMPUTE: solve", + "PREDICT:\nConfidence: a=0%, b=0%, c=0%, f=0%", + ]) def chat(self, system, messages): idx = min(self.call_count, len(self.script) - 1) @@ -1525,12 +1526,12 @@ def main(): parser.add_argument("--model", default=None, help="Model name (default: claude-haiku-4-5-20251001 for anthropic, gpt-4o for openai)") parser.add_argument("--max-turns", type=int, default=None, - help="Override auto-scaled turn limit (default: 2x budget)") + help="Override auto-scaled turn limit (default: 3x budget)") parser.add_argument("--save-transcript", type=str, default=None) parser.add_argument("--save-plot", type=str, default=None, - help="Save results plot to file (default: probe__.png)") + help="Save dashboard to this path (default: alongside the timestamped JSON)") parser.add_argument("--baseline", action="store_true", - help="Use minimal prompt without mathematical context (for ablation)") + help="Omit the initial identifiability hint (shared PDE/tool instructions remain)") parser.add_argument("--reasoning-effort", type=str, default=None, choices=["none", "low", "medium", "high", "xhigh"], help="Reasoning effort for OpenAI GPT-5+ models " @@ -1542,7 +1543,7 @@ def main(): parser.add_argument("--no-plot", action="store_true", help="Skip generating dashboard PNG (JSON only)") parser.add_argument("--temperature", type=float, default=0.0, - help="LLM sampling temperature (0 = deterministic)") + help="Requested LLM sampling temperature; API responses may vary") args = parser.parse_args() if args.model is None: @@ -1553,12 +1554,19 @@ def main(): config = DIFFICULTY_CONFIG[args.difficulty] max_queries = args.max_queries if args.max_queries is not None else config["budget"] max_turns = args.max_turns if args.max_turns is not None else 3 * max_queries + if max_queries <= 0 or max_turns <= 0: + parser.error("--max-queries and --max-turns must be positive") + mock_queries = max(config["unknowns"], args.min_queries or 0) + if args.mock and (max_queries < mock_queries or max_turns < mock_queries + 3): + parser.error(f"--mock needs at least {mock_queries} queries and {mock_queries + 3} turns") os.makedirs(args.output_dir, exist_ok=True) session = ProbeSession.from_difficulty(args.difficulty, args.seed, max_queries) if args.mock: - backend = MockBackend() + backend = MockBackend(n_queries=mock_queries) + args.model = "mock" + args.provider = "mock" elif args.provider == "openai": backend = OpenAIBackend(model=args.model, reasoning_effort=args.reasoning_effort, temperature=args.temperature) @@ -1580,6 +1588,8 @@ def main(): "budget_source": "override" if args.max_queries is not None else "auto", "prompt_condition": "baseline" if args.baseline else "standard", "reasoning_effort": args.reasoning_effort, + "temperature": args.temperature, + "min_queries": args.min_queries, "timestamp": datetime.now().isoformat(), }, "turns": [], @@ -1699,7 +1709,7 @@ def main(): re_tag = f"_re{args.reasoning_effort}" if args.reasoning_effort else "" run_stem = ( f"run_{args.difficulty}_s{args.seed}_{model_slug}{re_tag}" - f"_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + f"_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" ) log_path = os.path.join(args.output_dir, run_stem + ".json") with open(log_path, "w") as f: @@ -1744,4 +1754,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/paper.pdf b/paper.pdf index e403ddc..9f44cfc 100644 Binary files a/paper.pdf and b/paper.pdf differ diff --git a/paper.tex b/paper.tex index db0fb44..7d6b1bc 100644 --- a/paper.tex +++ b/paper.tex @@ -33,14 +33,17 @@ metacognitive control, executive planning, cognitive flexibility, in-context learning, attention, and goal maintenance. Each faculty is measured by a dedicated, mathematically grounded metric with -computable ground truth. Across three OpenAI models (GPT-4o, GPT-5.4-mini, -GPT-5.4) and three difficulty levels, we find a clear capability hierarchy: -GPT-5.4 achieves near-perfect coefficient recovery at medium difficulty -(error $< 10^{-4}$) while GPT-4o fails at basic tool use on hard problems. +computable ground truth. A preliminary reported comparison covers three +OpenAI models (GPT-4o, GPT-5.4-mini, GPT-5.4), three difficulty levels, +and two seeds per configuration. In that comparison, GPT-5.4 achieves +total coefficient error below $0.002$ on both medium-difficulty instances, +while GPT-4o fails to produce a scored run on the two hard instances. The benchmark reveals distinct behavioral failure modes---test function homogeneity, premature termination, and over-reliance on consistency -checks---that map onto specific cognitive deficits. All code, logs, and -data are publicly available. +checks---that map onto specific cognitive deficits. These observations +are exploratory. The implementation and selected notebook outputs are +publicly available; the underlying run-log archive for the manuscript's +tables is not included in the repository. \end{abstract} %---------------------------------------------------------------------- @@ -130,10 +133,10 @@ \subsection{Difficulty Levels} \toprule \textbf{Difficulty} & $N$ & \textbf{Unknowns} & \textbf{Budget} & \textbf{Turn limit} \\ \midrule -Easy & 2 & 8 & 12 & 24 \\ -Medium & 3 & 12 & 18 & 36 \\ -Hard & 4 & 16 & 24 & 48 \\ -Extreme & 8 & 32 & 48 & 96 \\ +Easy & 2 & 8 & 12 & 36 \\ +Medium & 3 & 12 & 18 & 54 \\ +Hard & 4 & 16 & 24 & 72 \\ +Extreme & 8 & 32 & 48 & 144 \\ \bottomrule \end{tabular} \end{center} @@ -141,7 +144,9 @@ \subsection{Difficulty Levels} \noindent The budget is set to $1.5\times$ the number of unknowns, providing enough headroom for the model to solve the system, diagnose problems, and iterate---but not so much that brute-force data collection -suffices. The turn limit is $2\times$ the budget. +suffices. These are the current runner's defaults: its turn limit is +$3\times$ the budget. Historical results must be compared using their +recorded run settings, which are not recoverable from the tables alone. \subsection{Tools Available to the Model} @@ -455,7 +460,9 @@ \section{Experimental Design} \subsection{Models} -We evaluate six models spanning three providers and two capability tiers: +The preliminary comparison reported in Table~\ref{tab:sweep} covers +GPT-4o, GPT-5.4-mini, and GPT-5.4. An expanded comparison is proposed +across six models spanning three providers and two capability tiers: \begin{center} \small @@ -463,37 +470,49 @@ \subsection{Models} \toprule \textbf{Provider} & \textbf{Large} & \textbf{Small} \\ \midrule -Anthropic & Claude Opus 4.6 & Claude Sonnet 4.6 \\ +Anthropic & Claude Opus 4.6 & Claude Sonnet 4 (2025-05-14) \\ OpenAI & GPT-5.4 & GPT-5.4-mini \\ Google & Gemini 3.1 Pro & Gemini 3 Flash \\ \bottomrule \end{tabular} \end{center} -\noindent All models are accessed through the same benchmark infrastructure, -ensuring identical tool interfaces, system prompts, and parsing logic across -providers. OpenAI models use \texttt{reasoning\_effort: medium} (see -Limitations). All models use the standard prompt with mathematical context. +\noindent Provider backends share tool interfaces, prompt construction, +and parsing logic. The proposed model names reflect the +runner's model registry; this is distinct from the three-model table. The draft +reports \texttt{reasoning\_effort: medium} for GPT-5.4 and GPT-5.4-mini; +GPT-4o has no reasoning-effort override in the registry. The preliminary +comparison uses the standard prompt with mathematical context. \subsection{Protocol} -Each model is evaluated at three difficulty levels (easy, medium, hard) -across five random seeds, with and without the mathematical context prompt -(baseline ablation), for a total of $6 \times 3 \times 5 \times 2 = 180$ -runs. Budgets and turn limits are auto-scaled from difficulty. Each run +The preliminary table reports $3 \times 3 \times 2 = 18$ attempted runs: +three models at three difficulty levels (easy, medium, hard), using seeds +42 and 137. Fifteen attempts have reported scores and three have no +score. This matches the batch runner's default model, difficulty, and +seed selection; it is not evidence that the table has been regenerated +with the current code. + +The proposed expanded study would evaluate six models across five seeds, +with and without the mathematical context prompt (baseline ablation), for +a total of $6 \times 3 \times 5 \times 2 = 180$ runs. This expanded sweep +is not reported as completed here. The runner selects one prompt condition +per invocation via \texttt{--baseline}; both conditions require separate +invocations. Budgets and turn limits are auto-scaled from difficulty. Each run produces a JSON log containing: the full turn-by-turn transcript, every query and its result, every solve with coefficient errors against ground truth, every consistency check, every term integral call, the post-hoc efficiency curve (error at each query count), and the metacognitive metrics ($\sigma$-curves, monitoring reports, control efficiency). -The benchmark is available as a Kaggle Community Benchmark. The -\texttt{kaggle.py} adapter wraps the core benchmark loop using the -\texttt{kaggle-benchmarks} SDK, importing \texttt{dispatch\_turn()} from -the main benchmark code to ensure identical tool dispatch logic across the -command-line and notebook interfaces. The full sweep costs approximately -\$25 at standard API pricing (calibrated from Opus 4.6 runs at \$0.85/run), -making reproduction accessible. +Kaggle integration is provided in \texttt{kaggle-benchmark.ipynb} and +\texttt{kaggle.ipynb}, using the \texttt{kaggle-benchmarks} SDK and importing +\texttt{dispatch\_turn()} from the main benchmark code. Saved notebook +outputs document additional exploratory runs; they are separate from the +18-attempt table and do not establish completion of the proposed study. +The original draft estimated approximately \$25 for the expanded sweep +(calibrated from Opus 4.6 runs at \$0.85/run); this estimate has not been +revalidated and actual cost depends on the selected models and usage. %---------------------------------------------------------------------- \section{Results} @@ -502,14 +521,27 @@ \section{Results} \subsection{Cross-Model Comparison} -Table~\ref{tab:sweep} shows the primary results. The identifiability +\paragraph{Evidence status.} +The tables below retain the historical values reported in this draft. +The repository does not include their underlying per-run JSON logs or +sweep summaries, so these values and aggregates cannot be recomputed from +the checkout alone. The checked-in figures likewise do not include a +complete source-run archive. In particular, the checked-in success-rate +figures label 180 scored runs across six models, with ten runs per +model--difficulty combination. That aggregate has a different design +from the proposed five-seed, two-prompt study above. Saved Kaggle notebook +outputs provide additional exploratory evidence under their own recorded settings. +No numerical results have been regenerated for this revision. + +Table~\ref{tab:sweep} shows the preliminary reported results. The identifiability hierarchy $f \gg a > b > c$ holds across all models and difficulties: $f$ is recovered to machine precision, $a$ is consistently accurate, and $c$ is the limiting factor. \begin{table}[h] \centering -\caption{Coefficient error across models and difficulty levels (2 seeds each). +\caption{Historical reported coefficient error across three models and three +difficulty levels (2 seeds each; 18 attempts, 15 scored). ``---'' indicates the model failed to produce a scored run.}\label{tab:sweep} \small \begin{tabular}{llrrrrrrrr} @@ -745,15 +777,19 @@ \subsection{Limitations} mathematical inadequacy. Future versions could use structured tool calling to separate these concerns. -The current evaluation uses only two seeds per difficulty level. Hard +The manuscript's preliminary comparison uses only two seeds per difficulty +level and does not report the proposed cross-provider prompt ablation. Its +underlying run archive is not included, limiting independent verification +of the numerical tables and behavioral aggregates. Hard difficulty shows high variance (GPT-5.4 achieves 0.29 on one seed and 3.68 on the other), suggesting that some PDE instances are inherently easier to identify. A larger seed sweep would provide more robust estimates. GPT-5.2 and newer OpenAI models default to \texttt{reasoning\_effort: -none}, which disables chain-of-thought reasoning. All results reported -here use \texttt{reasoning\_effort: medium}, set explicitly via the API. +none}, which disables chain-of-thought reasoning. The draft reports +\texttt{reasoning\_effort: medium} for GPT-5.4 and GPT-5.4-mini; GPT-4o +has no reasoning-effort override in the runner. Runs without this override would likely perform significantly worse, particularly GPT-5.4-mini, and the results should not be compared to evaluations that use the default API setting. @@ -784,8 +820,10 @@ \section{Conclusion} This addresses a gap identified by Burnell et al.\ in the current landscape of cognitive evaluations for AI systems. -All code, run logs, and analysis tools are publicly available at -\texttt{[repository URL]}. +The code, analysis tools, and selected notebook outputs are available at +\url{https://github.com/Hydrodynamical/PDE_Learning}. The complete +source-run archive for the manuscript's tables and checked-in figures +remains to be published. \bibliographystyle{plainnat} @@ -848,4 +886,4 @@ \section{Conclusion} \end{thebibliography} -\end{document} \ No newline at end of file +\end{document} diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..a89fafe --- /dev/null +++ b/requirements-api.txt @@ -0,0 +1,35 @@ +# Optional provider SDKs and their resolved dependencies (Python 3.12). +-r requirements.txt +annotated-types==0.8.0 +anthropic==1.17.0 +anyio==4.15.1 +certifi==2026.10.19 +cffi==2.1.1 +charset-normalizer==3.5.2 +cryptography==50.0.2 +distro==1.9.0 +docstring_parser==0.18.0 +google-auth==2.62.0 +google-genai==2.31.0 +h11==0.16.0 +httpcore2==2.15.1 +httpcore==1.0.9 +httpx2==2.15.1 +httpx==0.28.1 +idna==3.20 +jiter==0.17.0 +openai==3.35.1 +opentelemetry-api==1.45.1 +pyasn1==0.6.4 +pyasn1_modules==0.4.2 +pycparser==3.11 +pydantic==2.14.1 +pydantic_core==2.50.1 +requests==2.34.2 +sniffio==1.3.1 +tenacity==9.1.4 +truststore==0.10.5 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +urllib3==2.8.0 +websockets==16.1.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e5b465c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +contourpy==1.4.0 +cycler==0.12.1 +fonttools==4.67.0 +kiwisolver==1.5.1 +matplotlib==3.11.2 +numpy==2.5.4 +packaging==26.3 +pillow==13.0.0 +pyparsing==3.3.3 +python-dateutil==2.9.0.post0 +scipy==1.18.1 +six==1.17.0 diff --git a/test_cli.py b/test_cli.py new file mode 100644 index 0000000..3912d9a --- /dev/null +++ b/test_cli.py @@ -0,0 +1,55 @@ +"""End-to-end checks of the documented offline command; never call a provider.""" + +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parent + + +class TestOfflineCLI(unittest.TestCase): + def test_mock_produces_a_scored_log_for_each_difficulty(self): + for difficulty, expected_queries in (("easy", 8), ("medium", 12), ("hard", 16), ("extreme", 32)): + with self.subTest(difficulty=difficulty), tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + [sys.executable, str(ROOT / "main_loop.py"), "--mock", "--difficulty", difficulty, + "--seed", "42", "--no-plot", "--output-dir", directory], + cwd=ROOT, text=True, capture_output=True, timeout=60, + env={**os.environ, "MPLBACKEND": "Agg"}, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + logs = list(Path(directory).glob("*.json")) + self.assertEqual(len(logs), 1) + log = json.loads(logs[0].read_text()) + self.assertEqual(log["config"]["model"], "mock") + self.assertEqual(log["config"]["provider"], "mock") + self.assertEqual(log["config"]["seed"], 42) + self.assertIn("temperature", log["config"]) + self.assertIn("min_queries", log["config"]) + self.assertEqual(len(log["queries"]), expected_queries) + self.assertEqual(len(log["solves"]), 1) + self.assertFalse(log["behavioral_metrics"]["auto_submitted"]) + self.assertTrue(math.isfinite(log["results"]["coefficient_errors"]["total"])) + self.assertIn("predict", log["turns"][-1]["parsed_actions"]) + + def test_mock_rejects_limits_that_cannot_complete_the_protocol(self): + for limits in (("--max-queries", "7"), ("--max-turns", "10")): + with self.subTest(limits=limits), tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + [sys.executable, str(ROOT / "main_loop.py"), "--mock", "--difficulty", "easy", + "--no-plot", "--output-dir", directory, *limits], + cwd=ROOT, text=True, capture_output=True, timeout=30, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--mock needs at least", result.stderr) + self.assertEqual(list(Path(directory).iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_parser.py b/test_parser.py index 7009634..1612861 100644 --- a/test_parser.py +++ b/test_parser.py @@ -1,139 +1,128 @@ -"""Tests for the LLM response parser.""" +"""Offline tests for the response protocol used by ``main_loop.py``. + +PREDICT submits the session's most recent COMPUTE: solve result. Coefficient +arrays and the legacy DECOMPOSE action belong to the archived protocol. +""" import unittest -from llm_loop import parse_llm_response, _parse_prediction +from main_loop import parse_probe_response -class TestParser(unittest.TestCase): +class TestProbeResponseParser(unittest.TestCase): def test_simple_query(self): - actions = parse_llm_response("QUERY: sin(pi*x)") - self.assertEqual(len(actions), 1) - self.assertEqual(actions[0]["action"], "query") - self.assertEqual(actions[0]["spec"], "sin(pi*x)") - - def test_query_with_quotes(self): - actions = parse_llm_response('QUERY: "x*(1-x)*sin(pi*x)"') - self.assertEqual(actions[0]["spec"], "x*(1-x)*sin(pi*x)") - - def test_query_with_backticks(self): - actions = parse_llm_response("QUERY: `sin(2*pi*x)`") - self.assertEqual(actions[0]["spec"], "sin(2*pi*x)") - - def test_query_case_insensitive(self): - actions = parse_llm_response("query: sin(pi*x)") - self.assertEqual(actions[0]["action"], "query") - - def test_multiple_queries(self): - text = "Let me try two.\n\nQUERY: sin(pi*x)\nQUERY: sin(2*pi*x)" - actions = parse_llm_response(text) - queries = [a for a in actions if a["action"] == "query"] - self.assertEqual(len(queries), 2) - self.assertEqual(queries[0]["spec"], "sin(pi*x)") - self.assertEqual(queries[1]["spec"], "sin(2*pi*x)") - - def test_reasoning_plus_query(self): - text = "I think sine modes will be informative.\n\nQUERY: sin(pi*x)" - actions = parse_llm_response(text) - reasoning = [a for a in actions if a["action"] == "reasoning"] - queries = [a for a in actions if a["action"] == "query"] - self.assertGreater(len(reasoning), 0) - self.assertEqual(len(queries), 1) - - def test_prediction(self): - text = ( - "PREDICT:\n" - "a_coeffs = [1.0, 0.5, -0.3]\n" - "b_coeffs = [0.0, 0.1, 0.2]\n" - "c_coeffs = [-0.5, 0.0, 0.3]\n" - "f_coeffs = [1.0, -0.5, 0.0]" + self.assertEqual( + parse_probe_response("QUERY: sin(pi*x)"), + [{"action": "query", "spec": "sin(pi*x)"}], + ) + + def test_quoted_queries(self): + for quote in ('"', "'", "`"): + with self.subTest(quote=quote): + actions = parse_probe_response(f"QUERY: {quote}x*(1-x){quote}") + self.assertEqual(actions, [{"action": "query", "spec": "x*(1-x)"}]) + + def test_commands_are_case_insensitive(self): + self.assertEqual( + parse_probe_response("query: sin(pi*x)\ncompute: solve\npredict:"), + [ + {"action": "query", "spec": "sin(pi*x)"}, + {"action": "compute", "command": "solve"}, + {"action": "predict"}, + ], + ) + + def test_query_sanitizes_latex_and_implicit_multiplication(self): + self.assertEqual( + parse_probe_response(r"QUERY: $\sin(2\pi x)$"), + [{"action": "query", "spec": "sin(2*pi*x)"}], + ) + + def test_multiple_queries_preserve_order(self): + self.assertEqual( + parse_probe_response("QUERY: sin(pi*x)\nQUERY: sin(2*pi*x)"), + [ + {"action": "query", "spec": "sin(pi*x)"}, + {"action": "query", "spec": "sin(2*pi*x)"}, + ], + ) + + def test_compute_commands_preserve_arguments(self): + commands = [ + "eval_solution 0.1 0.5 0.9", + "solve", + "check x*(1-x)", + "term_integrals sin(2*pi*x)", + ] + for command in commands: + with self.subTest(command=command): + self.assertEqual( + parse_probe_response(f"COMPUTE: {command}"), + [{"action": "compute", "command": command}], + ) + + def test_predict_does_not_require_coefficient_arrays(self): + self.assertEqual(parse_probe_response("PREDICT:"), [{"action": "predict"}]) + + def test_predict_preserves_confidence_report_as_reasoning(self): + confidence = "Confidence: a=90%, b=80%, c=70%, f=95%" + self.assertEqual( + parse_probe_response(f"PREDICT:\n{confidence}"), + [{"action": "predict"}, {"action": "reasoning", "text": confidence}], + ) + + def test_legacy_arrays_do_not_override_solved_coefficients(self): + actions = parse_probe_response("PREDICT:\na_coeffs = [99.0, 99.0]") + self.assertEqual( + actions, + [ + {"action": "predict"}, + {"action": "reasoning", "text": "a_coeffs = [99.0, 99.0]"}, + ], + ) + + def test_legacy_decompose_is_reasoning(self): + self.assertEqual( + parse_probe_response("DECOMPOSE: sin(pi*x)"), + [{"action": "reasoning", "text": "DECOMPOSE: sin(pi*x)"}], ) - actions = parse_llm_response(text) - preds = [a for a in actions if a["action"] == "predict"] - self.assertEqual(len(preds), 1) - self.assertEqual(preds[0]["a"], [1.0, 0.5, -0.3]) - self.assertEqual(preds[0]["f"], [1.0, -0.5, 0.0]) - - def test_prediction_short_form(self): - text = ( - "PREDICT:\n" - "a = [1.0, 0.5]\n" - "b = [0.0, 0.1]\n" - "c = [-0.5, 0.0]\n" - "f = [1.0, -0.5]" + + def test_mixed_actions_and_reasoning_preserve_order(self): + self.assertEqual( + parse_probe_response( + "First inspect the solution.\n\n" + "COMPUTE: eval_solution 0.25 0.75\n" + "QUERY: sin(pi*x)\n" + "I will examine the result next." + ), + [ + {"action": "reasoning", "text": "First inspect the solution."}, + {"action": "compute", "command": "eval_solution 0.25 0.75"}, + {"action": "query", "spec": "sin(pi*x)"}, + {"action": "reasoning", "text": "I will examine the result next."}, + ], ) - actions = parse_llm_response(text) - preds = [a for a in actions if a["action"] == "predict"] - self.assertEqual(len(preds), 1) - self.assertEqual(preds[0]["a"], [1.0, 0.5]) - - def test_reasoning_only(self): - text = "I need to think about this problem more carefully." - actions = parse_llm_response(text) - self.assertTrue(all(a["action"] == "reasoning" for a in actions)) - - def test_empty_string(self): - actions = parse_llm_response("") - self.assertEqual(len(actions), 0) - - def test_prediction_with_reasoning(self): - text = ( - "Based on my analysis, f is approximately constant and negative.\n" - "The diffusion coefficient is roughly 0.5.\n\n" - "PREDICT:\n" - "a_coeffs = [0.5, 0.0, 0.0]\n" - "b_coeffs = [0.0, 0.0, 0.0]\n" - "c_coeffs = [0.0, 0.0, 0.0]\n" - "f_coeffs = [-0.1, 0.0, 0.0]" + + def test_empty_and_whitespace_responses(self): + for text in ("", " \n\t\n "): + with self.subTest(text=text): + self.assertEqual(parse_probe_response(text), []) + + def test_empty_query_and_compute_are_not_dispatched(self): + self.assertEqual( + parse_probe_response("QUERY:\nCOMPUTE:"), + [ + {"action": "reasoning", "text": "QUERY:"}, + {"action": "reasoning", "text": "COMPUTE:"}, + ], ) - actions = parse_llm_response(text) - reasoning = [a for a in actions if a["action"] == "reasoning"] - preds = [a for a in actions if a["action"] == "predict"] - self.assertGreater(len(reasoning), 0) - self.assertEqual(len(preds), 1) - - def test_decompose(self): - actions = parse_llm_response("DECOMPOSE: sin(pi*x)") - self.assertEqual(len(actions), 1) - self.assertEqual(actions[0]["action"], "decompose") - self.assertEqual(actions[0]["spec"], "sin(pi*x)") - - def test_decompose_case_insensitive(self): - actions = parse_llm_response("decompose: x*(1-x)") - self.assertEqual(actions[0]["action"], "decompose") - - def test_compute(self): - actions = parse_llm_response("COMPUTE: basis_info") - self.assertEqual(len(actions), 1) - self.assertEqual(actions[0]["action"], "compute") - self.assertEqual(actions[0]["command"], "basis_info") - - def test_compute_with_args(self): - actions = parse_llm_response("COMPUTE: eval_basis 0.1 0.5 0.9") - self.assertEqual(actions[0]["action"], "compute") - self.assertEqual(actions[0]["command"], "eval_basis 0.1 0.5 0.9") - - def test_compute_solve(self): - actions = parse_llm_response("COMPUTE: solve") - self.assertEqual(len(actions), 1) - self.assertEqual(actions[0]["action"], "compute") - self.assertEqual(actions[0]["command"], "solve") - - def test_mixed_actions(self): - text = ( - "Let me get basis info and then decompose.\n\n" - "COMPUTE: basis_info\n" - "DECOMPOSE: sin(pi*x)\n" - "QUERY: sin(2*pi*x)" + + def test_commands_must_start_the_line(self): + text = "I considered QUERY: sin(pi*x), but have not selected a probe." + self.assertEqual( + parse_probe_response(text), [{"action": "reasoning", "text": text}] ) - actions = parse_llm_response(text) - computes = [a for a in actions if a["action"] == "compute"] - decomps = [a for a in actions if a["action"] == "decompose"] - queries = [a for a in actions if a["action"] == "query"] - self.assertEqual(len(computes), 1) - self.assertEqual(len(decomps), 1) - self.assertEqual(len(queries), 1) if __name__ == "__main__": - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) diff --git a/test_sweep.py b/test_sweep.py new file mode 100644 index 0000000..07813d0 --- /dev/null +++ b/test_sweep.py @@ -0,0 +1,172 @@ +"""Regression tests for sweep provenance and resumption; never call an API.""" + +import contextlib +import io +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +import generate_transcripts as sweep + + +def successful_record(**overrides): + record = { + "model": "gpt-4o", "provider": "openai", "difficulty": "easy", "seed": 42, + "prompt_condition": "standard", "temperature": 0.0, "reasoning_effort": None, + "total_coeff_error": 1.0, "queries_used": 8, + } + record.update(overrides) + return record + + +class TestSweepResumption(unittest.TestCase): + def run_sweep(self, existing, *options, fail=False): + """Exercise the actual CLI's resume/merge logic with a fake single run.""" + with tempfile.TemporaryDirectory() as directory: + summary_path = Path(directory) / "sweep_results.json" + summary_path.write_text(json.dumps(existing)) + + def fake_run(**kwargs): + return successful_record( + model=kwargs["model"], provider=kwargs["provider"], + difficulty=kwargs["difficulty"], seed=kwargs["seed"], + prompt_condition="baseline" if kwargs["baseline"] else "standard", + temperature=kwargs["temperature"], + reasoning_effort=kwargs["reasoning_effort"], total_coeff_error=0.25, + ) + + argv = [ + "generate_transcripts.py", "--models", "gpt-4o", + "--difficulties", "easy", "--seeds", "42", "--no-plot", + "--output-dir", directory, *options, + ] + with patch("sys.argv", argv), patch.object( + sweep, "run_single", side_effect=RuntimeError("fake failure") if fail else fake_run + ) as runner, patch.object(sweep, "print_summary_table"), contextlib.redirect_stdout(io.StringIO()): + sweep.main() + + return json.loads(summary_path.read_text()), runner.call_args_list + + def test_retry_skips_exact_success_including_zero_error(self): + records, calls = self.run_sweep( + [successful_record(total_coeff_error=0.0)], "--retry-failed" + ) + self.assertEqual(calls, []) + self.assertEqual(records[0]["total_coeff_error"], 0.0) + + def test_retry_does_not_reuse_other_prompt_temperature_or_reasoning(self): + for options in (("--baseline",), ("--temperature", "0.5"), ("--reasoning-effort", "low")): + with self.subTest(options=options): + records, calls = self.run_sweep( + [successful_record()], "--retry-failed", *options + ) + self.assertEqual(len(calls), 1) + self.assertEqual(len(records), 2) + self.assertNotEqual(sweep.run_key(records[0]), sweep.run_key(records[1])) + + def test_legacy_summary_is_preserved_and_does_not_skip_current_run(self): + for missing in ("prompt_condition", "temperature", "reasoning_effort"): + with self.subTest(missing=missing): + legacy = successful_record() + del legacy[missing] + records, calls = self.run_sweep([legacy], "--retry-failed") + self.assertEqual(len(calls), 1) + self.assertEqual(len(records), 2) + self.assertIn(legacy, records) + + def test_retry_replaces_matching_failure(self): + failed = successful_record(total_coeff_error=None, error="previous failure") + records, calls = self.run_sweep([failed], "--retry-failed") + self.assertEqual(len(calls), 1) + self.assertEqual(len(records), 1) + self.assertEqual(records[0]["total_coeff_error"], 0.25) + self.assertNotIn("error", records[0]) + + def test_merge_replaces_only_matching_configuration(self): + old = [ + successful_record(), + successful_record(prompt_condition="baseline"), + successful_record(temperature=0.5), + successful_record(reasoning_effort="low"), + ] + records, calls = self.run_sweep(old) + self.assertEqual(len(calls), 1) + self.assertEqual(len(records), 4) + matching = [r for r in records if sweep.run_key(r) == sweep.run_key(old[0])] + self.assertEqual(matching[0]["total_coeff_error"], 0.25) + for unchanged in old[1:]: + self.assertIn(unchanged, records) + + def test_failed_run_keeps_configuration_metadata(self): + records, _ = self.run_sweep( + [], "--baseline", "--temperature", "0.5", "--reasoning-effort", "low", fail=True + ) + self.assertEqual(records[0]["prompt_condition"], "baseline") + self.assertEqual(records[0]["temperature"], 0.5) + self.assertEqual(records[0]["reasoning_effort"], "low") + self.assertEqual(records[0]["error"], "fake failure") + + def test_unregistered_gemini_model_uses_google_backend(self): + _, calls = self.run_sweep([], "--models", "gemini-example") + self.assertEqual(calls[0].kwargs["provider"], "google") + self.assertIsNone(calls[0].kwargs["reasoning_effort"]) + + +class TestSingleRunMetadata(unittest.TestCase): + def make_session(self): + coefficient = SimpleNamespace(coeffs=SimpleNamespace(tolist=lambda: [1.0])) + return SimpleNamespace(pde=SimpleNamespace( + a=coefficient, b=coefficient, c=coefficient, f=coefficient, + describe=lambda: "synthetic test PDE", + )) + + def test_json_and_summary_record_requested_configuration(self): + result = {"turns": 1, "queries_used": 0, "prediction_submitted": False} + with tempfile.TemporaryDirectory() as directory, patch.object( + sweep.ProbeSession, "from_difficulty", return_value=self.make_session() + ), patch.object(sweep, "make_backend"), patch.object( + sweep, "run_probe_session", return_value=result + ), patch.object(sweep, "compute_extended_metrics", return_value={}): + summary = sweep.run_single( + "example-model", "openai", "easy", 42, directory, + baseline=True, temperature=0.5, reasoning_effort="low", no_plot=True, + ) + log = json.loads(Path(summary["log_path"]).read_text()) + + for record in (summary, log["config"]): + self.assertEqual(record["prompt_condition"], "baseline") + self.assertEqual(record["temperature"], 0.5) + self.assertEqual(record["reasoning_effort"], "low") + + def test_runs_in_same_second_keep_both_raw_logs(self): + result = {"turns": 1, "queries_used": 0, "prediction_submitted": False} + with tempfile.TemporaryDirectory() as directory, patch.object( + sweep.ProbeSession, "from_difficulty", return_value=self.make_session() + ), patch.object(sweep, "make_backend"), patch.object( + sweep, "run_probe_session", return_value=result + ), patch.object(sweep, "compute_extended_metrics", return_value={}), patch.object( + sweep.time, "strftime", return_value="20261020_120000" + ): + standard = sweep.run_single( + "example-model", "openai", "easy", 42, directory, no_plot=True, + ) + baseline = sweep.run_single( + "example-model", "openai", "easy", 42, directory, baseline=True, + temperature=0.5, no_plot=True, + ) + self.assertNotEqual(standard["log_path"], baseline["log_path"]) + self.assertEqual(len(list(Path(directory).glob("run_*.json"))), 2) + standard_log = json.loads(Path(standard["log_path"]).read_text()) + baseline_log = json.loads(Path(baseline["log_path"]).read_text()) + + self.assertEqual(standard_log["config"]["prompt_condition"], "standard") + self.assertEqual(standard_log["config"]["temperature"], 0.0) + self.assertEqual(baseline_log["config"]["prompt_condition"], "baseline") + self.assertEqual(baseline_log["config"]["temperature"], 0.5) + + +if __name__ == "__main__": + unittest.main()