From 39aceafa528036871dccbbdf078196f3be999bfe Mon Sep 17 00:00:00 2001 From: Dustin <6962246+djdarcy@users.noreply.github.com> Date: Wed, 11 Feb 2026 03:24:35 -0500 Subject: [PATCH 1/2] v0.8.2: Fix trisum base-b, unified --min/--max, CLI UX, .github setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix trisum() base-interpretation bug, replace --max-n/--max-m with unified --max VAR:VALUE / --min VAR:VALUE syntax, add CLI help improvements, and set up GitHub project infrastructure. Bug fix: - trisum() Horner evaluation: was converting digits via string concatenation (base-10 only), now uses proper base-b Horner's method. trisum(10)=666, trisum(6)=51 now correct for all bases. - Remove sqrt_triangular_base() (dead code after Horner fix) CLI — unified bounds syntax (#62): - Add --max VAR:VALUE and --min VAR:VALUE (e.g., --max n:1000000) - Remove legacy --max-n, --max-m, --max-p flags - Short aliases: -min, -max for convenience - build_bounds_from_args() rewritten for VAR:VALUE parsing CLI — dynamic help and argument groups (#63): - Argument groups: "variable & iteration options" and "algorithm & performance options" for organized --help output - Dynamic --max example shows resolved default from config cascade - resolve_effective_bounds(): merges DEFAULT_BOUNDS + config.json + equation defaults (same pattern as resolve_effective_defaults) - Parser description updated to reflect general-purpose tool scope - Short aliases: -var, -eq, -op, -fmt, -lim, -V CLI — primesieve warning cleanup: - Custom warnings.formatwarning suppresses source-line echo - "Silence" hint gated behind -v (not shown at default verbosity) Error messages: - grammar.py: bounds suggestion updated from --max-{v} to --max v:VALUE Documentation: - README: update all --max-n/--max-m to --max n:VALUE/--min n:VALUE - docs/expressions.md: 16 parameter notation updates, rewrite "Variables and Bounds" section with new syntax - docs/equations.md: update --max-n example GitHub project setup: - .github/workflows/ci.yml: conda job (Python 3.10 + primesieve) and pip fallback job (Python 3.12), ubuntu + windows matrix - .github/ISSUE_TEMPLATE/: bug-report.md, feature-request.md, config.yml (blank issues enabled) - .github/CODEOWNERS, FUNDING.yml, pull_request_template.md, release-template.md, stale.yml, dependabot.yml Tests: 852 passed, 7 skipped Closes #61 Closes #62 Closes #63 Refs #58 (resolve_effective_bounds contributes to bounds transparency) Design: - 2026-02-10__12-44-14__full-postmortem_trisum-fix-and-min-max-cli.md - 2026-02-10__06-18-55__dev-workflow_issue62-min-max-syntax.md - 2026-02-11__02-34-51__full-postmortem_cli-ux-improvements-issue-63-and-beyond.md --- .github/CODEOWNERS | 15 ++ .github/FUNDING.yml | 15 ++ .github/ISSUE_TEMPLATE/bug-report.md | 30 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature-request.md | 19 ++ .github/dependabot.yml | 21 ++ .github/pull_request_template.md | 31 +++ .github/release-template.md | 34 ++++ .github/stale.yml | 37 ++++ .github/workflows/ci.yml | 65 +++++++ README.md | 42 +++- VERSION | 2 +- docs/equations.md | 2 +- docs/expressions.md | 43 +++-- prime-square-sum.py | 169 ++++++++++------- ...026-02-10__argparse-prefix-collision-v2.py | 179 ++++++++++++++++++ .../2026-02-10__argparse-prefix-collision.py | 147 ++++++++++++++ tests/test_cli.py | 109 ++++++++++- tests/test_cli_integration.py | 24 +-- tests/test_grammar.py | 2 +- tests/test_number_theory.py | 20 +- utils/cli.py | 69 ++++++- utils/grammar.py | 2 +- utils/number_theory.py | 105 ++-------- utils/sieve.py | 23 ++- 25 files changed, 987 insertions(+), 223 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/release-template.md create mode 100644 .github/stale.yml create mode 100644 .github/workflows/ci.yml create mode 100644 tests/one-offs/2026-02-10__argparse-prefix-collision-v2.py create mode 100644 tests/one-offs/2026-02-10__argparse-prefix-collision.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..42e6f4f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,15 @@ +# Default owner for everything +* @djdarcy + +# Python source +*.py @djdarcy + +# Lean proofs +proofs/ @djdarcy + +# Documentation +docs/ @djdarcy +*.md @djdarcy + +# GitHub configuration +/.github/ @djdarcy diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2740466 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: djdarcy # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +ko_fi: djdarcy # Replace with a single Ko-fi username +buy_me_a_coffee: djdarcy +#patreon: dustinjd # Replace with a single Patreon username + +open_collective: # Replace with a single Open Collective username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +#custom: ['https://buymeacoffee.com/djdarcy'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..f51871b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help improve Prime-Square-Sum +title: "[BUG] " +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Command used** +```bash +python prime-square-sum.py --expr "..." +``` + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Actual behavior** +What actually happened, including any error messages. + +**Environment:** +- OS: [e.g., Windows 11, Ubuntu 22.04] +- Python version: [e.g., 3.12] +- Prime-Square-Sum version: [e.g., 0.8.2] +- Installation method: [conda / pip / manual] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..81df20f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/djdarcy/Prime-Square-Sum/tree/main/docs + about: Browse the expression syntax, equations, and function reference docs diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..3d121e5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for Prime-Square-Sum +title: '' +labels: enhancement +assignees: '' +--- + +**Is your feature request related to a problem?** +A clear and concise description of what the problem is. E.g., "I'm frustrated when..." + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..df68923 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + # Python dependencies (requirements.txt) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + versioning-strategy: increase-if-necessary + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..25ae6b5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +## Description + + +## Type of change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring (no functional changes) +- [ ] Performance improvement +- [ ] Lean proof addition/modification + +## Components affected +- [ ] CLI / Expression engine +- [ ] Number theory utilities +- [ ] Lean 4 proofs +- [ ] Documentation +- [ ] Tests +- [ ] Infrastructure (scripts, CI, etc.) + +## How Has This Been Tested? + + + +## Checklist: +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] New and existing tests pass (`python -m pytest tests/ -v`) +- [ ] I have updated the CHANGELOG (for user-facing changes) diff --git a/.github/release-template.md b/.github/release-template.md new file mode 100644 index 0000000..440722e --- /dev/null +++ b/.github/release-template.md @@ -0,0 +1,34 @@ +# Release v$NEXT_VERSION + +## What's Changed + +### New Features +- + +### Bug Fixes +- + +### Lean Proofs +- + +### Documentation +- + +### Maintenance +- + +### Breaking Changes +- + +## Installation + +```bash +git clone https://github.com/djdarcy/Prime-Square-Sum.git +cd Prime-Square-Sum +conda create -n prime-square-sum python=3.12 +conda activate prime-square-sum +conda install -c conda-forge numpy primesieve +``` + +## Full Changelog +$CHANGES diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..a39a1a7 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,37 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 90 + +# Number of days of inactivity before a stale Issue or Pull Request is closed +daysUntilClose: 14 + +# Issues or Pull Requests with these labels will never be considered stale +exemptLabels: + - pinned + - security + - enhancement + - bug + - "in progress" + - "help wanted" + - "lean-proofs" + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale +markComment: > + This issue/PR has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs within 14 days. + Thank you for your contributions. + +# Comment to post when closing a stale Issue or Pull Request +closeComment: > + This issue/PR has been automatically closed due to inactivity. + Please feel free to reopen if this is still relevant. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Set to true to ignore issues with an assignee +exemptAssignees: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af38c68 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + # Primary test: conda with primesieve (the recommended install path) + test-conda: + name: conda (Python 3.10, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + defaults: + run: + shell: bash -el {0} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Miniconda + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: prime-square-sum + environment-file: environment.yml + auto-activate-base: false + + - name: Verify primesieve available + run: | + python -c "import primesieve; print(f'primesieve {primesieve.primesieve_version()} available')" + + - name: Run tests + run: | + python -m pytest tests/ -v --tb=short + + # Compatibility test: pip-only, newer Python (no primesieve, tests fallback sieve) + test-pip: + name: pip (Python ${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install numpy pytest lark + + - name: Run tests + run: | + python -m pytest tests/ -v --tb=short diff --git a/README.md b/README.md index ade2f03..a5a3e4d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 📓 **[View the Mathematica Notebook](https://github.com/djdarcy/Prime-Square-Sum/blob/main/paper%20and%20notes/2010%20-%20Recurrence%20relation%20between%20triangular%20numbers%20and%20squared%20primes%20-%20D.%20Darcy.nb)** *(Requires [Mathematica](https://www.wolfram.com/mathematica/) or [Wolfram Player](https://www.wolfram.com/player/))* -The python program is a computational platform for validating mathematical relationships. It checks LHS expressions against RHS targets and stores sequences for future analysis. The main focus of `prime-square-sum` is on summing triangular row values together (where `stf()` is an acronym for sum triangular factors) to determine if: +The python program is a computational platform for validating mathematical relationships. It checks LHS expressions against RHS targets and stores sequences for future analysis. The main focus of `prime-square-sum` is on summing triangular row values together (where `stf()` below is an acronym for "sum triangular factors") to determine if: ![stf(b) = sum_(z=1)^qg(b) tf(b,z);]( "stf defined") @@ -18,7 +18,7 @@ r = qg(b) = size of the base row of the triangular number; //qg(b) = 1/2(-1+sqrt z = row in the triangular number; //ex. tf(10,4)=0123; tf(10,3)=456; tf(10,2)=78, etc.) ``` -Where `tf()`, the individual triangular row factors, are defined to be: +Where `tf()`, the individual "triangular row factors", are defined to be: ![tf(b,z) = (-2 + 2b - 2b^2 + z - bz - z^2 + bz^2 + b^z(2 + 2b^2 + z + z^2 - b(2 + z + z^2))) / (2(-1 + b)^2)]( "tf defined") @@ -41,7 +41,7 @@ Due to the huge size of the 98-digits I've further adapted `prime-square-sum` to ## Usage ```bash -# Find n where sum of first n squared primes equals 666 +# Find n where sum of first n squared primes equals 666 (this is the known result: stf(10)) python prime-square-sum.py --expr "does_exist primesum(n,2) == 666" # Output: Found: n=7 (because 2² + 3² + 5² + 7² + 11² + 13² + 17² = 666) @@ -49,11 +49,35 @@ python prime-square-sum.py --expr "does_exist primesum(n,2) == 666" python prime-square-sum.py --target 666 ``` +### The Open Question + +The core investigation: does the sum of triangular row factors as `stf(666)`, the 98-digit number, equal a sum of squared, cubed, or some p-th power primes? + +```bash +# The main search — does stf(666) = primesum(n, 2) for some n? +python prime-square-sum.py --expr "does_exist primesum(n,2) == trisum(666)" \ + --algorithm sieve:primesieve --max n:1000000000 + +# The pattern may continue at a higher power — search p = 3, 4, 5 +python prime-square-sum.py --expr "does_exist primesum(n,3) == trisum(666)" \ + --algorithm sieve:primesieve --max n:50000000 +python prime-square-sum.py --expr "does_exist primesum(n,4) == trisum(666)" \ + --algorithm sieve:primesieve --max n:10000000 +python prime-square-sum.py --expr "does_exist primesum(n,5) == trisum(666)" \ + --algorithm sieve:primesieve --max n:5000000 + +# Skip early values that are too small to match a 98-digit target +python prime-square-sum.py --expr "does_exist primesum(n,p) == trisum(666)" \ + --algorithm sieve:primesieve -min n:50000000 -max n:1000000000 --max p:5 +``` + +This search requires billions of prime sums and benefits from `--algorithm sieve:primesieve` (C++ [primesieve](https://github.com/kimwalisch/primesieve) library via conda). See [Installation](#recommended-conda) for setup. The answer to this question would establish whether the chain `primesum(3,1) → stf(10) → primesum(7,2) → stf(666) → primesum(?,?)` continues — and whether it may continues indefinitely. The formal [proofs](proofs/README.md) provide the algebraic framework (closed-form `stf(b)`, verified in Lean 4), while the numerical search here would supply the concrete witness needed for induction. + The `--target` flag searches against the **default expression** `primesum(n,2)` (sum of squared primes). This default is defined in `equations.json` and can be customized via `config.json`. See [docs/equations.md](docs/equations.md) for details. ```bash # Find matches between prime sums and triangular numbers -python prime-square-sum.py --expr "for_any primesum(n,2) == tri(m)" --max-n 100 --max-m 50 +python prime-square-sum.py --expr "for_any primesum(n,2) == tri(m)" --max n:100 --max m:50 # List available functions python prime-square-sum.py --list functions @@ -66,7 +90,7 @@ Use standard arithmetic operators directly in expressions: ```bash # Arithmetic in expressions python prime-square-sum.py --expr "does_exist n**2 == 25" # n=5 -python prime-square-sum.py --expr "does_exist tri(n) + 1 == 11" --max-n 10 # n=4 +python prime-square-sum.py --expr "does_exist tri(n) + 1 == 11" --max n:10 # n=4 python prime-square-sum.py --expr "verify (2 + 3) * 4 == 20" # true # Operators: + - * / // % ** ^ (unary: -x, +x) @@ -77,14 +101,14 @@ python prime-square-sum.py --expr "verify (2 + 3) * 4 == 20" # ```bash # Boolean logic with short-circuit evaluation -python prime-square-sum.py --expr "does_exist n > 0 and n < 10 and tri(n) == 28" --max-n 20 +python prime-square-sum.py --expr "does_exist n > 0 and n < 10 and tri(n) == 28" --max n:20 # Bitwise operations via keyword operators python prime-square-sum.py --expr "verify 5 xor 3 == 6" python prime-square-sum.py --expr "verify 5 band 3 == 1" # Chained comparisons -python prime-square-sum.py --expr "does_exist 1 < n < 10 and tri(n) == 28" --max-n 20 +python prime-square-sum.py --expr "does_exist 1 < n < 10 and tri(n) == 28" --max n:20 # Context blocks for operator disambiguation python prime-square-sum.py --expr "verify bit[2^3] == 1" # ^ is XOR in bit context @@ -103,8 +127,8 @@ python prime-square-sum.py --expr "solve tri(36)" # 666 python prime-square-sum.py --expr "tri(36)" # 666 (implicit) # Enumerate sequence values -python prime-square-sum.py --expr "solve tri(n)" --max-n 10 # n=1: 1, n=2: 3, ... -python prime-square-sum.py --expr "for_any primesum(n,2)" --max-n 7 # tabulate squared prime sums +python prime-square-sum.py --expr "solve tri(n)" --max n:10 # n=1: 1, n=2: 3, ... +python prime-square-sum.py --expr "for_any primesum(n,2)" --max n:7 # tabulate squared prime sums ``` ### Verify Mode (v0.7.6+) diff --git a/VERSION b/VERSION index 6f4eebd..100435b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 diff --git a/docs/equations.md b/docs/equations.md index 9fde69a..2bee587 100644 --- a/docs/equations.md +++ b/docs/equations.md @@ -152,7 +152,7 @@ Set default variable bounds in the equation: CLI flags override equation bounds: ```bash -python prime-square-sum.py --equation tri-match --max-n 500 +python prime-square-sum.py --equation tri-match --max n:500 ``` ## Built-in Equations diff --git a/docs/expressions.md b/docs/expressions.md index d264c12..614b69e 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -14,7 +14,7 @@ python prime-square-sum.py --expr "verify primesum(7,2) == 666" # Output: true # Find all matches between two sequences -python prime-square-sum.py --expr "for_any primesum(n,2) == tri(m)" --max-n 100 --max-m 1000 +python prime-square-sum.py --expr "for_any primesum(n,2) == tri(m)" --max n:100 --max m:1000 # Output: Found: n=7, m=36 ``` @@ -51,7 +51,7 @@ An expression consists of an optional **directive** and a **comparison**: # Output: 666 # solve - value enumeration (free variables, no comparison) ---expr "solve tri(n)" --max-n 10 +--expr "solve tri(n)" --max n:10 # Output: n=1: 1, n=2: 3, n=3: 6, ... ``` @@ -79,7 +79,7 @@ When you omit the directive, the system auto-detects the mode: # Output: n=36 # Implicit for_any — free variables, no comparison ---expr "tri(n)" --max-n 5 +--expr "tri(n)" --max n:5 # Output: n=1: 1, n=2: 3, n=3: 6, n=4: 10, n=5: 15 ``` @@ -101,13 +101,13 @@ The `solve` directive has three modes depending on the expression: **Value enumeration** — free variables, no comparison (tabulate values): ```bash ---expr "solve tri(n)" --max-n 10 +--expr "solve tri(n)" --max n:10 # n=1: 1 # n=2: 3 # n=3: 6 # ... ---expr "solve primesum(n,2)" --max-n 7 +--expr "solve primesum(n,2)" --max n:7 # n=1: 4 # n=2: 13 # ... @@ -122,13 +122,13 @@ Bare-term expressions (no comparison operator) with `for_any` or `solve` enumera ```bash # Enumerate triangular numbers ---expr "for_any tri(n)" --max-n 10 +--expr "for_any tri(n)" --max n:10 # n=1: 1 # n=2: 3 # ... # Multi-variable enumeration ---expr "for_any tri(n) + tri(m)" --max-n 5 --max-m 5 +--expr "for_any tri(n) + tri(m)" --max n:5 --max m:5 # n=1, m=1: 2 # n=1, m=2: 4 # ... @@ -171,8 +171,8 @@ Full operator precedence from lowest to highest binding power: --expr "does_exist n**2 == 25" # n=5 --expr "verify 2 + 3 * 4 == 14" # true (precedence) --expr "verify (2 + 3) * 4 == 20" # true (parens) ---expr "does_exist tri(n) + 1 == 11" --max-n 10 # n=4 ---expr "does_exist primesum(n,2) - 1 == 665" --max-n 10 # n=7 +--expr "does_exist tri(n) + 1 == 11" --max n:10 # n=4 +--expr "does_exist primesum(n,2) - 1 == 665" --max n:10 # n=7 ``` | Operator | Operation | Example | Result | @@ -212,7 +212,7 @@ Arithmetic expressions can be used inside function arguments: Logical operators with short-circuit evaluation: ```bash ---expr "does_exist n > 0 and n < 10 and tri(n) == 28" --max-n 20 # n=7 +--expr "does_exist n > 0 and n < 10 and tri(n) == 28" --max n:20 # n=7 --expr "verify 2 > 1 and 3 > 2" # true --expr "verify not 1 > 2" # true ``` @@ -261,7 +261,7 @@ Comparisons can be chained, Python-style: ```bash --expr "verify 1 < 2 < 3" # true ---expr "does_exist 1 < n < 10 and tri(n) == 28" --max-n 20 # n=7 +--expr "does_exist 1 < n < 10 and tri(n) == 28" --max n:20 # n=7 --expr "verify 1 <= 2 < 3" # true ``` @@ -348,13 +348,16 @@ Available namespaces: `pss` (tool-specific), `math` (Python math module), `user` Free variables (like `n`, `m`) are iterated over search ranges: ```bash -# Set bounds for variables (legacy syntax) ---expr "for_any primesum(n,2) == tri(m)" --max-n 1000 --max-m 5000 +# Set bounds for variables +--expr "for_any primesum(n,2) == tri(m)" --max n:1000 --max m:5000 + +# Set minimum start value (skip early iterations) +--expr "does_exist primesum(n,2) == stf(666)" --min n:50000000 ``` -Default bounds: -- `--max-n`: 1,000,000 -- `--max-m`: 10,000 +Default bounds (when no `--max` is specified): +- `n`: 1,000,000 +- `m`: 10,000 ### Iterator Syntax (v0.7.7+) @@ -423,11 +426,11 @@ For simpler queries, you can use decomposed flags instead of `--expr`: --target 666 # Custom left-hand side: ---lhs "tri(n)" --target 666 --max-n 100 +--lhs "tri(n)" --target 666 --max n:100 # Finds: n=36 # Different operator: ---lhs "primesum(n,2)" --operator ">=" --target 600 --max-n 10 +--lhs "primesum(n,2)" --operator ">=" --target 600 --max n:10 # Finds first n where primesum(n,2) >= 600 ``` @@ -480,7 +483,7 @@ python prime-square-sum.py --target 666 -v `--quiet` / `-Q` suppresses all non-error output (hints, progress, timing): ```bash -python prime-square-sum.py --expr "solve tri(n)" --max-n 5 -Q +python prime-square-sum.py --expr "solve tri(n)" --max n:5 -Q # n=1: 1 # n=2: 3 # ...only data on stdout, nothing else @@ -491,7 +494,7 @@ python prime-square-sum.py --expr "solve tri(n)" --max-n 5 -Q `--limit N` caps the number of results for enumeration modes: ```bash -python prime-square-sum.py --expr "for_any tri(n)" --max-n 1000 --limit 5 +python prime-square-sum.py --expr "for_any tri(n)" --max n:1000 --limit 5 # n=1: 1 # n=2: 3 # n=3: 6 diff --git a/prime-square-sum.py b/prime-square-sum.py index de79a73..3589f7a 100644 --- a/prime-square-sum.py +++ b/prime-square-sum.py @@ -51,6 +51,7 @@ ) from utils.function_registry import FunctionRegistry from utils.cli import ( + ExpressionComponents, build_expression_from_args, build_bounds_from_args, build_iterator_factories_from_args, @@ -58,6 +59,9 @@ format_no_match, # Issue #22: Configuration load_config, + # Issue #63: Dynamic help text + resolve_effective_defaults, + resolve_effective_bounds, ) from utils.list_commands import handle_list from utils.sieve import ( @@ -71,21 +75,59 @@ # Argument Parser # ============================================================================= -def create_parser(): - """Create argument parser with all CLI options.""" +_MAX_HELP_LEN = 80 + + +def _truncate_for_help(text: str) -> str: + """Truncate text for argparse help display, adding '...' if needed.""" + if len(text) > _MAX_HELP_LEN: + return text[:_MAX_HELP_LEN - 3] + "..." + return text + + +def create_parser(effective_defaults: ExpressionComponents = None, + effective_bounds: dict = None): + """Create argument parser with all CLI options. + + Args: + effective_defaults: Resolved default expression components for help text. + When None, uses hardcoded defaults. Caller typically passes + resolve_effective_defaults() to show the user's configured default. + effective_bounds: Resolved default bounds for help text examples. + When None, uses {'n': 1000000}. Caller typically passes + resolve_effective_bounds() to show the user's configured bounds. + """ + if effective_defaults is None: + effective_defaults = ExpressionComponents() + if effective_bounds is None: + effective_bounds = {'n': 1000000} + + # Build display strings for help text (Issue #63) + lhs_display = _truncate_for_help(effective_defaults.lhs) + rhs_for_example = effective_defaults.rhs or "666" + expr_example = _truncate_for_help( + f"{effective_defaults.quantifier} {effective_defaults.lhs} " + f"{effective_defaults.operator} {rhs_for_example}" + ) + + # Build max example from resolved bounds (prefer 'n' as the common case) + max_example_var = 'n' if 'n' in effective_bounds else next(iter(sorted(effective_bounds)), 'n') + max_example_val = effective_bounds.get(max_example_var, 1000000) + max_example = f"--max {max_example_var}:{max_example_val}" + parser = argparse.ArgumentParser( - description='Mathematical expression evaluator for prime sequences', + description='Evaluate and search mathematical sequence relationships - built around the conjecture that prime p-th power sums equal triangular-base totals, extensible to arbitrary expressions.', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Find n where sum of squared primes equals 666 %(prog)s --expr "does_exist primesum(n,2) == 666" - # Same query using shorthand + # Same query using shorthand, when default "equations.json" is "does_exist primesum(n,2)" %(prog)s --target 666 # Find where prime sums equal triangular numbers - %(prog)s --expr "for_any primesum(n,2) == tri(m)" --max-n 100 --max-m 50 + %(prog)s --expr "for_any primesum(n,2) == tri(m)" --max n:100 --max m:50 # Use cubed primes instead of squared %(prog)s --lhs "primesum(n,3)" --target 666 @@ -115,14 +157,14 @@ def create_parser(): parser.add_argument( '--expr', '-e', metavar='EXPRESSION', - help='Full expression (e.g., "does_exist primesum(n,2) == 666")' + help=f'Full expression (e.g., "{expr_example}")' ) # === Tier 2: Decomposed Flags === parser.add_argument( '--lhs', metavar='EXPR', - help='Left-hand side expression (default: primesum(n,2))' + help=f'Left-hand side expression (default: {lhs_display})' ) parser.add_argument( '--rhs', '--target', '-t', @@ -131,85 +173,72 @@ def create_parser(): help='Right-hand side value (required unless using --expr)' ) parser.add_argument( - '--operator', '--op', + '--operator', '-op', metavar='OP', choices=['==', '!=', '<', '>', '<=', '>='], - help='Comparison operator (default: ==)' + help=f'Comparison operator (default: {effective_defaults.operator})' ) parser.add_argument( '--quantifier', '-q', metavar='Q', choices=['does_exist', 'for_any'], - help='Quantifier (default: does_exist)' + help=f'Quantifier (default: {effective_defaults.quantifier})' ) # === Tier 3: Saved Equations (Issue #21) === parser.add_argument( - '--equation', + '--equation', "-eq", metavar='ID', help='Load saved equation by ID or name' ) - parser.add_argument( - '--var', + + # === Variable & Iterator Configuration (Issue #37, #62) === + iter_group = parser.add_argument_group('variable & iteration options') + iter_group.add_argument( + '--var', '-var', action='append', metavar='NAME=VALUE', help='Set equation parameter (e.g., --var a=3 or --var a=3,b=4)' ) - - # === Variable Bounds === - parser.add_argument( - '--max-n', - type=int, - default=1000000, - metavar='N', - help='Maximum value for variable n (default: 1000000)' - ) - parser.add_argument( - '--max-m', - type=int, - default=10000, - metavar='M', - help='Maximum value for variable m (default: 10000)' - ) - - # === Iterator Configuration (Issue #37) === - parser.add_argument( + iter_group.add_argument( '--iter-var', action='append', metavar='VAR:START:STOP[:STEP][:DTYPE]', help='Define iterator for variable (e.g., n:1:1000:2:uint64)' ) - parser.add_argument( - '--iter-type', + iter_group.add_argument( + '--min', '-min', '--iter-start', + dest='iter_start', action='append', - metavar='VAR:TYPE', - help='Set iterator type for variable (int or float)' + metavar='VAR:VALUE', + help='Set minimum (start) value for variable (e.g., --min n:50000000)' ) - parser.add_argument( - '--iter-start', + iter_group.add_argument( + '--max', '-max', '--iter-stop', + dest='iter_stop', action='append', metavar='VAR:VALUE', - help='Set iterator start value for variable' + help=f'Set maximum (stop) value for variable (e.g., {max_example})' ) - parser.add_argument( - '--iter-stop', + iter_group.add_argument( + '--iter-type', action='append', - metavar='VAR:VALUE', - help='Set iterator stop value for variable' + metavar='VAR:TYPE', + help='Set iterator type for variable (int or float)' ) - parser.add_argument( + iter_group.add_argument( '--iter-step', action='append', metavar='VAR:VALUE', help='Set iterator step for variable' ) - parser.add_argument( + iter_group.add_argument( '--iter-num-steps', action='append', metavar='VAR:COUNT', help='Set number of steps for float iterator (linspace-style)' ) - parser.add_argument( + iter_group.add_argument( '--iter-dtype', action='append', metavar='VAR:DTYPE', @@ -218,7 +247,7 @@ def create_parser(): # === Output Control === parser.add_argument( - '--format', '-f', + '--format', '-fmt', choices=['text', 'json', 'csv'], default='text', help='Output format (default: text)' @@ -235,7 +264,7 @@ def create_parser(): help='Suppress all non-error output (hints, progress, timing)' ) parser.add_argument( - '--limit', + '--limit', "-lim", type=int, metavar='N', help='Maximum number of results for enumeration (for_any/solve)' @@ -261,25 +290,24 @@ def create_parser(): help='Load user-defined functions from Python file' ) - # === GPU Control === - parser.add_argument( + # === Algorithm & Performance (Issue #29) === + perf_group = parser.add_argument_group('algorithm & performance options') + perf_group.add_argument( '--no-gpu', action='store_true', help='Disable GPU acceleration' ) - - # === Algorithm Selection (Issue #29) === - parser.add_argument( + perf_group.add_argument( '--algorithm', metavar='CLASS:VARIANT', help='Algorithm selection (e.g., sieve:segmented, sieve:basic, sieve:individual)' ) - parser.add_argument( + perf_group.add_argument( '--prefer', choices=['cpu', 'gpu', 'memory', 'minimal'], help='Resource preference hint for auto-selection' ) - parser.add_argument( + perf_group.add_argument( '--max-memory', type=int, metavar='MB', @@ -288,7 +316,7 @@ def create_parser(): # === Version === parser.add_argument( - '--version', + '--version', '-V', action='version', version=f'%(prog)s {__version__}' ) @@ -359,7 +387,7 @@ def handle_expression(args, registry: FunctionRegistry, config=None) -> int: } if default_bounds: bound_str = ', '.join(f'{v}={val:,}' for v, val in default_bounds.items()) - flag_str = ', '.join(f'--max-{v}' for v in default_bounds) + flag_str = ', '.join(f'--max {v}:VALUE' for v in default_bounds) out.hint('bounds.implicit_defaults', 'verbose', bounds=bound_str, flags=flag_str) @@ -436,17 +464,17 @@ def handle_expression(args, registry: FunctionRegistry, config=None) -> int: def _has_explicit_bound(args, var_name: str) -> bool: """Check if a variable's bound was explicitly set via CLI flags.""" - # Check --max-{var} style flags - attr = f'max_{var_name}' - if hasattr(args, attr): - # argparse sets defaults; check if user provided it - # Default values: n=1000000, m=10000 - defaults = {'max_n': 1000000, 'max_m': 10000} - val = getattr(args, attr) - if attr in defaults and val == defaults[attr]: - return False # Still at default - return True - # Check --iter-var overrides + # Check --max / --iter-stop + if args.iter_stop: + for spec in args.iter_stop: + if spec.startswith(f'{var_name}:'): + return True + # Check --min / --iter-start + if args.iter_start: + for spec in args.iter_start: + if spec.startswith(f'{var_name}:'): + return True + # Check --iter-var compact syntax if args.iter_var: for spec in args.iter_var: if spec.startswith(f'{var_name}:'): @@ -482,7 +510,10 @@ def parse_algorithm_arg(algo_str: str) -> tuple: def main(): - parser = create_parser() + parser = create_parser( + effective_defaults=resolve_effective_defaults(), + effective_bounds=resolve_effective_bounds(), + ) args = parser.parse_args() # Initialize output manager (#31, #57) diff --git a/tests/one-offs/2026-02-10__argparse-prefix-collision-v2.py b/tests/one-offs/2026-02-10__argparse-prefix-collision-v2.py new file mode 100644 index 0000000..9db6b0d --- /dev/null +++ b/tests/one-offs/2026-02-10__argparse-prefix-collision-v2.py @@ -0,0 +1,179 @@ +""" +Test: Can -min, -max, and -m all coexist without collision? + +Verifies that argparse exact-matching prevents -m from being +swallowed by -min or -max, leaving -m free for other purposes. +""" +import argparse +import sys + + +def build_parser(): + """Build a parser mimicking our planned CLI layout.""" + p = argparse.ArgumentParser() + + # New unified bounds: -min/-max as short forms of --iter-start/--iter-stop + p.add_argument('-min', '--min', '--iter-start', dest='iter_start', + action='append', metavar='VAR:VALUE', + help='Set minimum (start) value for variable') + p.add_argument('-max', '--max', '--iter-stop', dest='iter_stop', + action='append', metavar='VAR:VALUE', + help='Set maximum (stop) value for variable') + + # Legacy convenience flags (to be deprecated later) + p.add_argument('--max-n', type=int, default=1000000) + p.add_argument('--max-m', type=int, default=10000) + + # -m reserved for something else (using --version-string as dummy) + p.add_argument('-m', '--mock-flag', type=str, default=None, + help='Dummy flag to prove -m is independent') + + # -e for --expr (already exists in real CLI) + p.add_argument('-e', '--expr', type=str, default=None) + + return p + + +def run_test(label, argv, checks): + """Run a parse test and validate expectations.""" + p = build_parser() + try: + args = p.parse_args(argv) + results = [] + all_pass = True + for desc, actual, expected in checks(args): + ok = actual == expected + status = "PASS" if ok else "FAIL" + results.append(f" {status}: {desc} (got {actual!r}, expected {expected!r})") + if not ok: + all_pass = False + print(f" [{('PASS' if all_pass else 'FAIL')}] {label}") + for r in results: + print(r) + return all_pass + except SystemExit: + print(f" [FAIL] {label}") + print(f" argparse rejected: {' '.join(argv)}") + return False + + +def main(): + print("=" * 65) + print("Argparse coexistence: -min, -max, -m, --max-n, --max-m") + print("=" * 65) + print() + + results = [] + + # 1. -min works + results.append(run_test( + "-min n:100 sets iter_start", + ['-min', 'n:100'], + lambda a: [("iter_start", a.iter_start, ['n:100']), + ("mock_flag", a.mock_flag, None)] + )) + + # 2. -max works + results.append(run_test( + "-max n:5000 sets iter_stop", + ['-max', 'n:5000'], + lambda a: [("iter_stop", a.iter_stop, ['n:5000']), + ("mock_flag", a.mock_flag, None)] + )) + + # 3. -m is independent (does NOT collide with -min or -max) + results.append(run_test( + "-m 'hello' sets mock_flag, not iter_start/stop", + ['-m', 'hello'], + lambda a: [("mock_flag", a.mock_flag, 'hello'), + ("iter_start", a.iter_start, None), + ("iter_stop", a.iter_stop, None)] + )) + + # 4. All three simultaneously + results.append(run_test( + "-min n:100 -max n:5000 -m 'test' all coexist", + ['-min', 'n:100', '-max', 'n:5000', '-m', 'test'], + lambda a: [("iter_start", a.iter_start, ['n:100']), + ("iter_stop", a.iter_stop, ['n:5000']), + ("mock_flag", a.mock_flag, 'test')] + )) + + # 5. --min long form also works + results.append(run_test( + "--min n:100 (double-dash) same dest as -min", + ['--min', 'n:100'], + lambda a: [("iter_start", a.iter_start, ['n:100'])] + )) + + # 6. --iter-start long form also works + results.append(run_test( + "--iter-start n:100 (original long form) same dest", + ['--iter-start', 'n:100'], + lambda a: [("iter_start", a.iter_start, ['n:100'])] + )) + + # 7. Mix -min and --iter-start (append to same list) + results.append(run_test( + "-min n:100 --iter-start m:50 both append to iter_start", + ['-min', 'n:100', '--iter-start', 'm:50'], + lambda a: [("iter_start", a.iter_start, ['n:100', 'm:50'])] + )) + + # 8. --max-n coexists with -max + results.append(run_test( + "--max-n 999 and -max n:5000 coexist (different dests)", + ['--max-n', '999', '-max', 'n:5000'], + lambda a: [("max_n", a.max_n, 999), + ("iter_stop", a.iter_stop, ['n:5000'])] + )) + + # 9. Multiple -min appends + results.append(run_test( + "-min n:100 -min m:50 appends both", + ['-min', 'n:100', '-min', 'm:50'], + lambda a: [("iter_start", a.iter_start, ['n:100', 'm:50'])] + )) + + # 10. -e for --expr still works alongside everything + results.append(run_test( + "-e 'does_exist tri(n)==666' -min n:1 -max n:100 -m debug", + ['-e', 'does_exist tri(n)==666', '-min', 'n:1', '-max', 'n:100', '-m', 'debug'], + lambda a: [("expr", a.expr, 'does_exist tri(n)==666'), + ("iter_start", a.iter_start, ['n:1']), + ("iter_stop", a.iter_stop, ['n:100']), + ("mock_flag", a.mock_flag, 'debug')] + )) + + # 11. Abbreviation -ma -- should be AMBIGUOUS + print() + print(" Edge case: abbreviation -ma (expected: ambiguous)") + p = build_parser() + try: + p.parse_args(['-ma', 'n:5000']) + print(" [INFO] -ma was accepted (matched something)") + except SystemExit: + print(" [INFO] -ma was rejected (ambiguous, as expected)") + + # 12. Abbreviation -mi -- should match -min or be ambiguous? + print() + print(" Edge case: abbreviation -mi (does it match -min?)") + p = build_parser() + try: + args = p.parse_args(['-mi', 'n:100']) + print(f" [INFO] -mi was accepted: iter_start={args.iter_start}") + except SystemExit: + print(" [INFO] -mi was rejected (ambiguous or unrecognized)") + + print() + passed = sum(results) + total = len(results) + print(f"Results: {passed}/{total} passed") + if passed == total: + print("ALL CLEAR: -min, -max, and -m coexist without collision.") + else: + print("ISSUES FOUND - see failures above.") + + +if __name__ == '__main__': + main() diff --git a/tests/one-offs/2026-02-10__argparse-prefix-collision.py b/tests/one-offs/2026-02-10__argparse-prefix-collision.py new file mode 100644 index 0000000..c307d5f --- /dev/null +++ b/tests/one-offs/2026-02-10__argparse-prefix-collision.py @@ -0,0 +1,147 @@ +""" +Test: Does argparse's prefix matching cause `-m` to collide with `--min`? + +Scenarios: +1. --min, --max alongside --max-n, --max-m +2. Single-dash -min, -max alongside -m, -n +3. Abbreviation: does --ma match --max or --max-n? +""" +import argparse +import sys + +def test_double_dash_aliases(): + """Test --min/--max as aliases for --iter-start/--iter-stop.""" + print("=" * 60) + print("TEST 1: --min/--max as aliases (double-dash)") + print("=" * 60) + + p = argparse.ArgumentParser() + p.add_argument('--min', '--iter-start', dest='iter_start', action='append', + metavar='VAR:VALUE') + p.add_argument('--max', '--iter-stop', dest='iter_stop', action='append', + metavar='VAR:VALUE') + p.add_argument('--max-n', type=int, default=1000000) + p.add_argument('--max-m', type=int, default=10000) + + # Test: --min and --max work + args = p.parse_args(['--min', 'n:100', '--max', 'n:5000']) + print(f" --min n:100 --max n:5000 => iter_start={args.iter_start}, iter_stop={args.iter_stop}") + assert args.iter_start == ['n:100'] + assert args.iter_stop == ['n:5000'] + print(" PASS") + + # Test: --iter-start and --iter-stop still work (same dest) + args = p.parse_args(['--iter-start', 'n:100', '--iter-stop', 'n:5000']) + print(f" --iter-start/stop => iter_start={args.iter_start}, iter_stop={args.iter_stop}") + assert args.iter_start == ['n:100'] + assert args.iter_stop == ['n:5000'] + print(" PASS") + + # Test: --max-n still works alongside --max + args = p.parse_args(['--max', 'n:5000', '--max-n', '999']) + print(f" --max n:5000 --max-n 999 => iter_stop={args.iter_stop}, max_n={args.max_n}") + assert args.iter_stop == ['n:5000'] + assert args.max_n == 999 + print(" PASS") + + # Test: mixing --min and --iter-start appends to same list + args = p.parse_args(['--min', 'n:100', '--iter-start', 'm:50']) + print(f" --min n:100 --iter-start m:50 => iter_start={args.iter_start}") + assert args.iter_start == ['n:100', 'm:50'] + print(" PASS") + + # Test: does --ma cause ambiguity? + print("\n Testing abbreviation --ma ...") + try: + args = p.parse_args(['--ma', 'n:5000']) + print(f" --ma n:5000 => MATCHED (iter_stop={args.iter_stop}, max_n={args.max_n}, max_m={args.max_m})") + except SystemExit: + print(" --ma n:5000 => AMBIGUOUS (argparse error, as expected)") + + print() + + +def test_single_dash_collision(): + """Test whether -m collides with -min (single-dash).""" + print("=" * 60) + print("TEST 2: Single-dash -min/-max collision with -m") + print("=" * 60) + + p = argparse.ArgumentParser() + p.add_argument('-min', '--iter-start', dest='iter_start', action='append') + p.add_argument('-max', '--iter-stop', dest='iter_stop', action='append') + p.add_argument('-m', '--max-m', type=int, default=10000) + p.add_argument('-n', '--max-n', type=int, default=1000000) + + # Test: -min works? + print("\n Testing -min n:100 ...") + try: + args = p.parse_args(['-min', 'n:100']) + print(f" -min n:100 => iter_start={args.iter_start}") + print(" PASS") + except SystemExit as e: + print(f" -min n:100 => FAILED (argparse error)") + + # Test: -m 500 — does it match -m (max_m) or -min? + print("\n Testing -m 500 (should match --max-m, not -min)...") + try: + args = p.parse_args(['-m', '500']) + print(f" -m 500 => max_m={args.max_m}, iter_start={args.iter_start}") + if args.max_m == 500: + print(" PASS: -m correctly matched --max-m") + else: + print(" UNEXPECTED: -m matched something else") + except SystemExit: + print(" -m 500 => AMBIGUOUS or ERROR") + + # Test: -ma — does it collide? + print("\n Testing -ma n:5000 ...") + try: + args = p.parse_args(['-ma', 'n:5000']) + print(f" -ma n:5000 => iter_stop={args.iter_stop}") + except SystemExit: + print(" -ma n:5000 => FAILED/AMBIGUOUS") + + print() + + +def test_two_letter_short_flags(): + """Test two-letter short flags as a safe middle ground.""" + print("=" * 60) + print("TEST 3: Two-letter short flags (-mn, -mx)") + print("=" * 60) + + p = argparse.ArgumentParser() + p.add_argument('-mn', '--min', '--iter-start', dest='iter_start', action='append') + p.add_argument('-mx', '--max', '--iter-stop', dest='iter_stop', action='append') + p.add_argument('--max-n', type=int, default=1000000) + p.add_argument('--max-m', type=int, default=10000) + + args = p.parse_args(['-mn', 'n:100', '-mx', 'n:5000']) + print(f" -mn n:100 -mx n:5000 => iter_start={args.iter_start}, iter_stop={args.iter_stop}") + assert args.iter_start == ['n:100'] + assert args.iter_stop == ['n:5000'] + print(" PASS") + + # --min and --max long forms + args = p.parse_args(['--min', 'n:100', '--max', 'n:5000']) + print(f" --min n:100 --max n:5000 => iter_start={args.iter_start}, iter_stop={args.iter_stop}") + assert args.iter_start == ['n:100'] + assert args.iter_stop == ['n:5000'] + print(" PASS") + + # --max-n still works + args = p.parse_args(['--max-n', '999', '--max', 'n:5000']) + print(f" --max-n 999 --max n:5000 => max_n={args.max_n}, iter_stop={args.iter_stop}") + assert args.max_n == 999 + assert args.iter_stop == ['n:5000'] + print(" PASS") + + print() + + +if __name__ == '__main__': + test_double_dash_aliases() + test_single_dash_collision() + test_two_letter_short_flags() + print("All tests complete.") diff --git a/tests/test_cli.py b/tests/test_cli.py index abd917b..403f2f4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,6 +28,8 @@ Config, load_config, resolve_default_equation, + # Issue #63: Dynamic help text + resolve_effective_defaults, # Issue #37: Iterator definitions IteratorDef, parse_iterator_def, @@ -188,26 +190,39 @@ def test_defaults_applied(self): assert bounds['m'] == DEFAULT_BOUNDS['m'] def test_explicit_max_n(self): - """Explicit --max-n overrides default.""" - args = Namespace(max_n=5000, max_m=None) + """--max n:VALUE overrides default.""" + args = Namespace(iter_stop=['n:5000']) bounds = build_bounds_from_args(args, "does_exist primesum(n,2) == 666") assert bounds['n'] == 5000 assert bounds['m'] == DEFAULT_BOUNDS['m'] def test_explicit_max_m(self): - """Explicit --max-m overrides default.""" - args = Namespace(max_n=None, max_m=500) + """--max m:VALUE overrides default.""" + args = Namespace(iter_stop=['m:500']) bounds = build_bounds_from_args(args, "does_exist primesum(n,2) == 666") assert bounds['n'] == DEFAULT_BOUNDS['n'] assert bounds['m'] == 500 def test_both_explicit(self): """Both bounds can be explicit.""" - args = Namespace(max_n=1000, max_m=100) + args = Namespace(iter_stop=['n:1000', 'm:100']) bounds = build_bounds_from_args(args, "for_any primesum(n,2) == tri(m)") assert bounds['n'] == 1000 assert bounds['m'] == 100 + def test_max_arbitrary_variable(self): + """--max VAR:VALUE works for variables beyond n and m.""" + args = Namespace(iter_stop=['k:500']) + bounds = build_bounds_from_args(args, "does_exist f(k) == 42") + assert bounds['k'] == 500 + + def test_max_multiple(self): + """Multiple --max flags append correctly.""" + args = Namespace(iter_stop=['n:5000', 'm:200']) + bounds = build_bounds_from_args(args, "for_any primesum(n,2) == tri(m)") + assert bounds['n'] == 5000 + assert bounds['m'] == 200 + # ============================================================================= # Output Formatting Tests @@ -452,6 +467,90 @@ def test_hardcoded_when_no_files(self): assert source == "hardcoded" +class TestResolveEffectiveDefaults: + """Test dynamic default resolution for help text (Issue #63).""" + + def test_returns_expression_components(self): + """Always returns an ExpressionComponents instance.""" + result = resolve_effective_defaults() + assert isinstance(result, ExpressionComponents) + assert len(result.lhs) > 0 + assert result.quantifier is not None + assert result.operator is not None + + def test_stock_equations_resolves_correctly(self): + """With stock equations.json, resolves to primesum(n,2) components.""" + # Stock equations.json has equation "1" as default with lhs="primesum(n,a)", a=2 + result = resolve_effective_defaults() + assert result.lhs == "primesum(n,2)" + assert result.quantifier == "does_exist" + assert result.operator == "==" + + def test_fallback_when_no_files(self, tmp_path, monkeypatch): + """Falls back to hardcoded defaults when no config files found.""" + monkeypatch.chdir(tmp_path) + result = resolve_effective_defaults() + assert result.lhs == DEFAULTS.lhs + assert result.quantifier == DEFAULTS.quantifier + assert result.operator == DEFAULTS.operator + + def test_handles_malformed_equations_gracefully(self, tmp_path, monkeypatch): + """Malformed equations.json doesn't crash, returns hardcoded defaults.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "equations.json").write_text("{invalid json") + result = resolve_effective_defaults() + assert result.lhs == DEFAULTS.lhs + + def test_expr_and_lhs_stay_consistent(self): + """The LHS in components matches what would appear in a full expression.""" + result = resolve_effective_defaults() + # The LHS should appear in any expression built from these components + result.rhs = "666" + expr = result.to_expression() + assert result.lhs in expr + + def test_quantifier_matches_equations_json(self): + """Resolved quantifier comes from equations.json, not hardcoded.""" + # Load the actual equations.json default and verify it matches + ef = load_equations_file() + assert ef is not None + default_eq = ef.get_default() + assert default_eq is not None + + result = resolve_effective_defaults() + assert result.quantifier == default_eq.quantifier + + def test_operator_matches_equations_json(self): + """Resolved operator comes from equations.json, not hardcoded.""" + ef = load_equations_file() + assert ef is not None + default_eq = ef.get_default() + assert default_eq is not None + + result = resolve_effective_defaults() + assert result.operator == default_eq.operator + + def test_custom_equation_quantifier_propagates(self, tmp_path, monkeypatch): + """Custom equations.json with for_any default propagates to resolved defaults.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "equations.json").write_text(json.dumps({ + "version": "1.0", + "equations": { + "1": { + "name": "custom", + "default": True, + "lhs": "tri(n)", + "operator": ">=", + "quantifier": "for_any" + } + } + })) + result = resolve_effective_defaults() + assert result.quantifier == "for_any" + assert result.operator == ">=" + assert result.lhs == "tri(n)" + + class TestAlgorithmConfig: """Test algorithm configuration loading (Issue #29).""" diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index d22ea20..53348ba 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -165,7 +165,7 @@ def test_expr_does_exist_no_match(self): """--expr returns no match for impossible value.""" code, stdout, stderr = run_cli( "--expr", "does_exist primesum(n,2) == 12345", - "--max-n", "100" + "--max", "n:100" ) assert code == 1 # No match returns exit code 1 assert "No match" in stdout or "not found" in stdout.lower() @@ -174,8 +174,8 @@ def test_expr_for_any_finds_match(self): """--expr with for_any finds matches.""" code, stdout, stderr = run_cli( "--expr", "for_any primesum(n,2) == tri(m)", - "--max-n", "10", - "--max-m", "50" + "--max", "n:10", + "--max", "m:50" ) assert code == 0 assert "n=7" in stdout @@ -206,7 +206,7 @@ def test_custom_lhs(self): code, stdout, stderr = run_cli( "--lhs", "tri(n)", "--target", "666", - "--max-n", "100" + "--max", "n:100" ) assert code == 0 assert "n=36" in stdout # tri(36) = 666 @@ -217,7 +217,7 @@ def test_custom_operator(self): "--lhs", "tri(n)", "--operator", ">=", "--target", "100", - "--max-n", "20" + "--max", "n:20" ) assert code == 0 # tri(13) = 91, tri(14) = 105 @@ -229,8 +229,8 @@ def test_quantifier_for_any(self): "--quantifier", "for_any", "--lhs", "tri(n)", "--target", "tri(m)", - "--max-n", "5", - "--max-m", "5" + "--max", "n:5", + "--max", "m:5" ) assert code == 0 # Should find matches where tri(n) == tri(m), i.e., n == m @@ -411,7 +411,7 @@ def test_limit_caps_for_any(self): """--limit 3 with for_any returns at most 3 results.""" code, stdout, stderr = run_cli( "--expr", "for_any tri(n)", - "--max-n", "100", + "--max", "n:100", "--limit", "3" ) assert code == 0 @@ -422,7 +422,7 @@ def test_limit_caps_solve_enumeration(self): """--limit works with solve enumeration too.""" code, stdout, stderr = run_cli( "--expr", "solve tri(n)", - "--max-n", "100", + "--max", "n:100", "--limit", "5" ) assert code == 0 @@ -433,7 +433,7 @@ def test_no_limit_returns_all(self): """Without --limit, all matches are returned.""" code, stdout, stderr = run_cli( "--expr", "for_any tri(n)", - "--max-n", "10" + "--max", "n:10" ) assert code == 0 lines = [l for l in stdout.strip().splitlines() if l.strip()] @@ -443,7 +443,7 @@ def test_limit_with_does_exist_shows_hint(self): """--limit with does_exist shows hint to use for_any instead.""" code, stdout, stderr = run_cli( "--expr", "does_exist tri(n) == 666", - "--max-n", "1000", + "--max", "n:1000", "--limit", "5" ) assert code == 0 @@ -470,7 +470,7 @@ def test_unknown_function(self): """Unknown function results in clear error message.""" code, stdout, stderr = run_cli( "--expr", "does_exist unknown_func(n) == 666", - "--max-n", "10" + "--max", "n:10" ) assert code == 1 # Should report unknown function error, not silently fail diff --git a/tests/test_grammar.py b/tests/test_grammar.py index b4a07b7..d61c155 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -498,7 +498,7 @@ def test_missing_bound_raises(self, parser, evaluator): assert "Missing bounds" in str(exc.value) assert "m" in str(exc.value) - assert "--max-m" in str(exc.value) + assert "--max m:VALUE" in str(exc.value) def test_no_variables_true(self, parser, evaluator): """Expression with no variables evaluates once.""" diff --git a/tests/test_number_theory.py b/tests/test_number_theory.py index e848284..f2e13e4 100644 --- a/tests/test_number_theory.py +++ b/tests/test_number_theory.py @@ -371,13 +371,21 @@ def test_trisum_invalid_base_raises(self): trisum(-1) def test_trisum_triangular_bases(self): - """Test trisum for triangular number bases (complete triangles).""" - # tri(2) = 3 digits: 0,1,2 → arranged as: 2 / 01 → 01+2 = 1+2 = 3 - # tri(3) = 6 digits: 0,1,2,3,4,5 → arranged as: 5 / 34 / 012 → 12+34+5 = 51 + """Test trisum for triangular number bases (complete triangles). + + Row digits are evaluated as base-b numbers (Horner's method), + not base-10. This matters for b != 10. + """ + # tri(2) = 3 digits: 0,1,2 → arranged as: 2 / 01 → (0*3+1)+(2) = 1+2 = 3 + # tri(3) = 6 digits: 0-5 → arranged as: 5 / 34 / 012 + # 012 in base 6 = 0*36 + 1*6 + 2 = 8 + # 34 in base 6 = 3*6 + 4 = 22 + # 5 in base 6 = 5 + # Total: 8 + 22 + 5 = 35 # tri(4) = 10 digits: 0-9 → arranged as: 9 / 78 / 456 / 0123 → 123+456+78+9 = 666 - assert trisum(3) == 3 # 01+2 = 1+2 = 3 - assert trisum(6) == 51 # 012+34+5 = 12+34+5 = 51 - assert trisum(10) == 666 + assert trisum(3) == 3 # base-3: (0*3+1) + 2 = 3 + assert trisum(6) == 35 # base-6: 8 + 22 + 5 = 35 + assert trisum(10) == 666 # base-10: 123 + 456 + 78 + 9 = 666 class TestTriangularRelationships: diff --git a/utils/cli.py b/utils/cli.py index d04dc8d..484448c 100644 --- a/utils/cli.py +++ b/utils/cli.py @@ -156,11 +156,12 @@ def build_bounds_from_args(args, expr_str: str) -> Dict[str, int]: """ bounds = {} - # Add explicit bounds from args - if hasattr(args, 'max_n') and args.max_n is not None: - bounds['n'] = args.max_n - if hasattr(args, 'max_m') and args.max_m is not None: - bounds['m'] = args.max_m + # Add bounds from --max / --iter-stop + if hasattr(args, 'iter_stop') and args.iter_stop: + for spec in args.iter_stop: + if ':' in spec: + var_name, value = spec.split(':', 1) + bounds[var_name.strip()] = int(value.strip()) # Apply defaults for missing bounds for var, default in DEFAULT_BOUNDS.items(): @@ -831,6 +832,56 @@ def resolve_default_equation( return (None, "hardcoded") +def resolve_effective_bounds() -> Dict[str, int]: + """ + Resolve the effective default bounds for display. + + Uses the same precedence as runtime: + 1. Equation-specific defaults (from default equation) + 2. config.json "default_bounds" + 3. Hardcoded DEFAULT_BOUNDS (lowest) + + Returns DEFAULT_BOUNDS on any error, so this is always safe to call. + """ + try: + bounds = dict(DEFAULT_BOUNDS) + config = load_config() + bounds.update(config.default_bounds) + equations_file = load_equations_file() + default_eq, _source = resolve_default_equation(equations_file, config) + if default_eq and default_eq.defaults: + bounds.update(default_eq.defaults) + return bounds + except Exception: + pass + return dict(DEFAULT_BOUNDS) + + +def resolve_effective_defaults() -> ExpressionComponents: + """ + Resolve the effective default expression components for display. + + Uses the same three-tier precedence as runtime: + 1. config.json "default_equation" (highest) + 2. equations.json "default": true + 3. Hardcoded built-in (lowest) + + Returns hardcoded defaults on any error, so this is always safe to call. + + Returns: + ExpressionComponents with the effective defaults (parameter-substituted). + """ + try: + equations_file = load_equations_file() + config = load_config() + default_eq, _source = resolve_default_equation(equations_file, config) + if default_eq and default_eq.lhs: + return default_eq.to_components() + except Exception: + pass + return ExpressionComponents() + + def show_config( equations_file: Optional[EquationsFile] = None, config: Optional[Config] = None @@ -1014,9 +1065,9 @@ def build_iterator_factories_from_args( This function bridges CLI arguments to the grammar's iterator_factories parameter. It handles: - - --var VAR:START:STOP[:STEP][:TYPE] compact syntax (new) - - --iter-type, --iter-start, etc. individual flags (new) - - --max-n, --max-m legacy bounds (backwards compat) + - --iter-var VAR:START:STOP[:STEP][:TYPE] compact syntax + - --min/--max (aliases for --iter-start/--iter-stop) + - --iter-type, --iter-step, etc. individual flags Args: args: Parsed argparse namespace @@ -1091,7 +1142,7 @@ def make_factory(idef): factories[var_name] = make_factory(iter_def) # 4. For variables in bounds but not in factories, create default factories - # (This maintains backwards compatibility with --max-n) + # (Default factories for variables with bounds but no explicit iterator) for var_name, max_val in bounds.items(): if var_name not in factories: # Create a factory that uses the bound diff --git a/utils/grammar.py b/utils/grammar.py index 4ee3223..f93092d 100644 --- a/utils/grammar.py +++ b/utils/grammar.py @@ -918,7 +918,7 @@ def find_matches( missing = free_vars - covered_vars if missing: missing_list = sorted(missing) - suggestions = ', '.join(f'--max-{v}' for v in missing_list) + suggestions = ', '.join(f'--max {v}:VALUE' for v in missing_list) raise ValueError( f"Missing bounds for variable(s): {', '.join(missing_list)}. " f"Use {suggestions} to specify." diff --git a/utils/number_theory.py b/utils/number_theory.py index 9eabf09..32bc82d 100644 --- a/utils/number_theory.py +++ b/utils/number_theory.py @@ -444,99 +444,36 @@ def trisum(b: int) -> int: if b == 1: return 0 # Only digit 0 - # Find how many complete rows we can form with b digits - # Row k (1-indexed from bottom) has k digits - # Total digits in rows 1..r = tri(r) - # We need tri(r) <= b - - # Find the number of complete rows - r = qtri(b - 1) # b-1 because we have digits 0 to b-1 - if r is None: - # b-1 is not triangular, find largest r where tri(r) <= b-1 - r = 0 - while tri(r + 1) <= b - 1: - r += 1 - - # Build the triangular arrangement - # Digits fill from bottom-left, going right, then up - # Bottom row (row 1) has 1 digit, row 2 has 2 digits, etc. - # But the pattern shows bottom row is longest... - - # Re-reading the example: - # 9 (row 4, 1 digit: 9) - # 78 (row 3, 2 digits: 7,8) - # 456 (row 2, 3 digits: 4,5,6) - # 0123 (row 1, 4 digits: 0,1,2,3) - # - # So row 1 (bottom) has the most digits, row r (top) has 1 digit - # For b=10 digits (0-9), we have r=4 rows - # Row k has (r - k + 1) digits... no wait - # Row 1: 4 digits, Row 2: 3 digits, Row 3: 2 digits, Row 4: 1 digit - # So row k has (r - k + 1) digits from bottom up - - # Total digits used = 4 + 3 + 2 + 1 = 10 = tri(4) - # So r = qtri(b) when b is triangular - - # Actually let's compute directly: - # Find r such that tri(r) = b (if b is triangular) or tri(r) < b < tri(r+1) - - # For b = 10: tri(4) = 10, so r = 4 - - # Simpler approach: compute directly - total = 0 - digit = 0 # Current digit to place (0, 1, 2, ...) - - # Find number of rows + # Find number of rows: largest r where tri(r) <= b + # For b=10: tri(4)=10, so r=4 rows + # Row sizes from bottom: r, r-1, ..., 2, 1 num_rows = 1 - while tri(num_rows) < b: + while tri(num_rows + 1) <= b: num_rows += 1 - if tri(num_rows) > b: - num_rows -= 1 - - # Now num_rows is such that tri(num_rows) <= b - # But we may have extra digits that don't fit - # For exact triangular numbers, tri(num_rows) = b - digits_used = 0 - row_values = [] - - for row in range(num_rows, 0, -1): - # Row 'row' has 'row' digits when counting from top - # But in our arrangement, bottom has most digits - # Row index from bottom: num_rows - row + 1 (has that many spaces) - # Actually, let me re-think... - - # Looking at example again: - # Row 1 (bottom): 0123 - 4 digits, forms number 0123 = 123 - # Row 2: 456 - 3 digits, forms number 456 - # Row 3: 78 - 2 digits, forms number 78 - # Row 4 (top): 9 - 1 digit, forms number 9 - - # So row k from bottom has (num_rows - k + 1) digits - pass - - # Let me implement this more directly - # Digits 0 to b-1 arranged bottom to top, left to right - # Row sizes from bottom: num_rows, num_rows-1, ..., 2, 1 - - digits = list(range(b)) # [0, 1, 2, ..., b-1] + # Arrange digits 0..b-1 into rows, bottom to top, left to right + # Each row is interpreted as a base-b number (Horner evaluation) + # + # Example b=10, r=4: + # 9 = 9 (1 digit) + # 78 = 7×10 + 8 = 78 (2 digits) + # 456 = 4×100 + 5×10 + 6 = 456 (3 digits) + # 0123 = 0×1000 + 1×100 + 2×10 + 3 = 123 (4 digits) + # + # Total: 123 + 456 + 78 + 9 = 666 idx = 0 total = 0 for row_size in range(num_rows, 0, -1): - if idx >= len(digits): - break - # Take row_size digits - row_digits = digits[idx:idx + row_size] - if len(row_digits) < row_size: - # Partial row - include if any digits - if row_digits: - row_value = int(''.join(map(str, row_digits))) - total += row_value + if idx >= b: break + # Evaluate row digits as a base-b number using Horner's method + row_value = 0 + for i in range(row_size): + if idx + i >= b: + break + row_value = row_value * b + (idx + i) idx += row_size - # Form number from these digits - row_value = int(''.join(map(str, row_digits))) total += row_value return total diff --git a/utils/sieve.py b/utils/sieve.py index 7923662..f4f2c13 100644 --- a/utils/sieve.py +++ b/utils/sieve.py @@ -111,12 +111,25 @@ def _warn_no_primesieve(): if _primesieve_warned or os.environ.get('PRIME_SQUARE_SUM_QUIET'): return _primesieve_warned = True - warnings.warn( - "primesieve not available - using slower Python fallback.\n" - " Install: pip install primesieve\n" - " conda: conda install -c conda-forge python-primesieve\n" - " Silence: set PRIME_SQUARE_SUM_QUIET=1" + # Temporarily suppress source-line echo in warnings output + _orig_fmt = warnings.formatwarning + warnings.formatwarning = ( + lambda msg, cat, fn, ln, line=None: + f"{fn}:{ln}: {cat.__name__}: {msg}\n" ) + try: + warnings.warn( + "primesieve not available - using slower Python fallback.\n" + " Install: pip install primesieve\n" + " conda: conda install -c conda-forge python-primesieve", + stacklevel=2, + ) + finally: + warnings.formatwarning = _orig_fmt + # Show silence hint only at -v (avoids clutter for casual users) + from utils.output import get_output + out = get_output() + out.emit(1, " Silence: set PRIME_SQUARE_SUM_QUIET=1", channel='config') def _get_available_memory_mb() -> int: From 780c51c1064febf35224a27a35b6861d8adb0174 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:25:57 +0000 Subject: [PATCH 2/2] build(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af38c68..70e06b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: shell: bash -el {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Miniconda uses: conda-incubator/setup-miniconda@v3 @@ -48,7 +48,7 @@ jobs: python-version: ['3.12'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5